From 4c104cddd291a5e27c69e0f848e15d48743b10ff Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 2 Apr 2026 11:41:46 -0300 Subject: [PATCH] Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor - FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing --- .gitignore | 4 - config.py | 61 +++++++++- main.py | 56 ++++++---- requirements.txt | 8 ++ tests/conftest.py | 11 +- tests/test_events.py | 115 +++++++++++++++++++ tests/test_web_auth.py | 63 +++++++++++ tests/test_web_db.py | 108 ++++++++++++++++++ tests/test_web_rbac.py | 191 ++++++++++++++++++++++++++++++++ tui/events.md | 41 +++++-- tui/events.py | 56 +++++++++- utils/database.py | 45 ++++++++ utils/scorer.md | 10 +- utils/scorer.py | 16 ++- web/__init__.py | 0 web/app.py | 55 +++++++++ web/auth.py | 76 +++++++++++++ web/db.py | 156 ++++++++++++++++++++++++++ web/dependencies.py | 52 +++++++++ web/models.py | 64 +++++++++++ web/routes/__init__.py | 0 web/routes/auth.py | 106 ++++++++++++++++++ web/routes/config_routes.py | 66 +++++++++++ web/routes/dashboard.py | 108 ++++++++++++++++++ web/routes/users.py | 82 ++++++++++++++ web/static/style.css | 162 +++++++++++++++++++++++++++ web/templates/base.html | 32 ++++++ web/templates/config.html | 145 ++++++++++++++++++++++++ web/templates/dashboard.html | 64 +++++++++++ web/templates/group_detail.html | 37 +++++++ web/templates/login.html | 21 ++++ web/templates/users.html | 129 +++++++++++++++++++++ 32 files changed, 2093 insertions(+), 47 deletions(-) create mode 100644 tests/test_events.py create mode 100644 tests/test_web_auth.py create mode 100644 tests/test_web_db.py create mode 100644 tests/test_web_rbac.py create mode 100644 web/__init__.py create mode 100644 web/app.py create mode 100644 web/auth.py create mode 100644 web/db.py create mode 100644 web/dependencies.py create mode 100644 web/models.py create mode 100644 web/routes/__init__.py create mode 100644 web/routes/auth.py create mode 100644 web/routes/config_routes.py create mode 100644 web/routes/dashboard.py create mode 100644 web/routes/users.py create mode 100644 web/static/style.css create mode 100644 web/templates/base.html create mode 100644 web/templates/config.html create mode 100644 web/templates/dashboard.html create mode 100644 web/templates/group_detail.html create mode 100644 web/templates/login.html create mode 100644 web/templates/users.html diff --git a/.gitignore b/.gitignore index 79805e2..7f3ec6a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,3 @@ __pycache__/ *.pyo .venv/ venv/ - -# Claude things -CLAUDE.md -.claude/* diff --git a/config.py b/config.py index 260c822..ecb2e7b 100644 --- a/config.py +++ b/config.py @@ -2,12 +2,16 @@ config.py — Loads and validates all settings from .env """ +import json +import logging import os from pathlib import Path from dotenv import load_dotenv load_dotenv() +log = logging.getLogger(__name__) + # -- Timeouts -- BOT_REPLY_TIMEOUT = 10 @@ -18,19 +22,21 @@ BOT_TOKEN = os.environ["BOT_TOKEN"] NOTIFY_CHAT_ID = int(os.environ["NOTIFY_CHAT_ID"]) SESSION_NAME = os.getenv("SESSION_NAME", "monitor_session") -# ─── Target keywords ───────────────────────────────────────────────────────── +# ─── Runtime config path ───────────────────────────────────────────────────── +RUNTIME_CONFIG_PATH = Path("./data/runtime_config.json") + +# ─── Hardcoded defaults (used when runtime_config.json is absent) ───────────── # Add your org's domains, email patterns, IP ranges, known usernames, etc. # All patterns are case-insensitive regex. -TARGET_KEYWORDS: list[str] = [ +_DEFAULT_KEYWORDS: list[str] = [ r"sanatorioaleman\.cl", r"@sanatorioaleman\.cl", # r"192\.168\.10\.", # internal IP range example # r"specificuser", # known internal usernames ] -# ─── Channels to watch ─────────────────────────────────────────────────────── # Use usernames (without @) or numeric channel IDs (-100xxxxxxxxxx) -WATCHED_CHANNELS: list[str | int] = [ +_DEFAULT_CHANNELS: list[str | int] = [ #-1002230225603, "cloudxlog", #-1001967030016, # daisycloud @@ -50,6 +56,53 @@ WATCHED_CHANNELS: list[str | int] = [ #-1001234567890, # private channel by ID ] +# ─── Runtime config helpers ─────────────────────────────────────────────────── + +def _load_runtime_config() -> dict: + """Load runtime_config.json; return empty dict if absent or malformed.""" + if not RUNTIME_CONFIG_PATH.exists(): + return {} + try: + with open(RUNTIME_CONFIG_PATH) as f: + return json.load(f) + except Exception as e: + log.warning("Failed to load %s: %s", RUNTIME_CONFIG_PATH, e) + return {} + + +def _keywords_from_groups(groups: list[dict]) -> list[str]: + """Flatten all group patterns into a single keyword list.""" + return [p["regex"] for g in groups for p in g.get("patterns", [])] + + +# ─── Live config ────────────────────────────────────────────────────────────── +# Populated from runtime_config.json at import; falls back to hardcoded defaults. + +_cfg = _load_runtime_config() + +KEYWORD_GROUPS: list[dict] = _cfg.get("groups", []) +TARGET_KEYWORDS: list[str] = ( + _keywords_from_groups(KEYWORD_GROUPS) if KEYWORD_GROUPS else _DEFAULT_KEYWORDS +) +WATCHED_CHANNELS: list[str | int] = _cfg.get("channels", _DEFAULT_CHANNELS) + + +def save_runtime_config(groups: list[dict], channels: list[str | int]) -> None: + """ + Persist keyword groups + channel list to runtime_config.json. + Updates module globals so the running process sees the new values immediately. + Called by web config routes after validating input. + """ + global KEYWORD_GROUPS, TARGET_KEYWORDS, WATCHED_CHANNELS + data = {"groups": groups, "channels": channels} + RUNTIME_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(RUNTIME_CONFIG_PATH, "w") as f: + json.dump(data, f, indent=2) + KEYWORD_GROUPS = groups + TARGET_KEYWORDS = _keywords_from_groups(groups) if groups else _DEFAULT_KEYWORDS + WATCHED_CHANNELS = channels + + # ─── File handling ─────────────────────────────────────────────────────────── TEMP_DIR = Path("./tmp") HITS_FILE = Path("./hits.txt") diff --git a/main.py b/main.py index 04393e9..f05c809 100644 --- a/main.py +++ b/main.py @@ -2,11 +2,10 @@ main.py — Entry point for the ULP credential monitor. Usage: - python main.py # TUI mode (default, requires textual) - python main.py --no-tui # Plain CLI mode - -First run will prompt for your Telegram phone number and 2FA code -to create a session file. Subsequent runs are fully automatic. + python main.py # TUI mode (default) + python main.py --no-tui # Plain CLI mode + python main.py --web # TUI + web frontend (port 8080) + python main.py --no-tui --web # CLI + web frontend """ import asyncio @@ -14,6 +13,7 @@ import logging import sys import shutil import argparse +import threading import config from utils.database import init_db @@ -36,6 +36,22 @@ log = logging.getLogger(__name__) init_db() +# ─── Web thread ─────────────────────────────────────────────────────────────── + +def _run_web(host: str, port: int) -> None: + """Start uvicorn in its own thread with its own asyncio loop.""" + import uvicorn + from web.app import create_app + uvicorn.run(create_app(), host=host, port=port, log_level="warning") + + +def _start_web_thread(host: str, port: int) -> threading.Thread: + t = threading.Thread(target=_run_web, args=(host, port), daemon=True, name="web") + t.start() + log.info(f"Web frontend started at http://{host}:{port}") + return t + + # ─── Plain CLI mode ─────────────────────────────────────────────────────────── async def _cli_main(): @@ -96,24 +112,29 @@ async def _cli_main(): def main(): parser = argparse.ArgumentParser(description="ULP Credential Monitor") - parser.add_argument( - "--no-tui", - action="store_true", - help="Run in plain CLI mode (no Textual TUI)", - ) + parser.add_argument("--no-tui", action="store_true", help="Run in plain CLI mode (no Textual TUI)") + parser.add_argument("--web", action="store_true", help="Start web frontend") + parser.add_argument("--web-host", default="127.0.0.1", help="Web frontend bind host (default: 127.0.0.1)") + parser.add_argument("--web-port", type=int, default=8080, help="Web frontend port (default: 8080)") args = parser.parse_args() + if args.web: + _start_web_thread(args.web_host, args.web_port) + + def _cleanup(): + log.info("Cleaning up tmp/...") + if config.TEMP_DIR.exists(): + shutil.rmtree(config.TEMP_DIR, ignore_errors=True) + config.TEMP_DIR.mkdir() + log.info("Done.") + if args.no_tui: try: asyncio.run(_cli_main()) except KeyboardInterrupt: log.info("Interrupted by user.") finally: - log.info("Cleaning up tmp/...") - if config.TEMP_DIR.exists(): - shutil.rmtree(config.TEMP_DIR, ignore_errors=True) - config.TEMP_DIR.mkdir() - log.info("Done.") + _cleanup() else: try: from tui.app import run_tui @@ -132,10 +153,7 @@ def main(): except KeyboardInterrupt: pass finally: - log.info("Cleaning up tmp/...") - if config.TEMP_DIR.exists(): - shutil.rmtree(config.TEMP_DIR, ignore_errors=True) - config.TEMP_DIR.mkdir() + _cleanup() if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 9fdadb0..33ea811 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,11 @@ tqdm # Archive extraction py7zr rarfile + +# Web frontend (optional — only needed with --web) +fastapi +uvicorn[standard] +jinja2 +python-multipart +bcrypt +python-jose[cryptography] diff --git a/tests/conftest.py b/tests/conftest.py index f2d8d56..8fa3c72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,11 @@ os.environ.setdefault("API_HASH", "dummy_hash_for_tests") os.environ.setdefault("BOT_TOKEN", "0:dummy_bot_token") os.environ.setdefault("NOTIFY_CHAT_ID", "99999") +# Web frontend test defaults — set once here so all web test files see the same values. +os.environ.setdefault("WEB_SECRET_KEY", "test-secret-key-for-pytest") +os.environ.setdefault("WEB_ADMIN_USER", "superadmin") +os.environ.setdefault("WEB_ADMIN_PASS", "superpass") + import pytest import config import utils.scorer as scorer @@ -22,10 +27,10 @@ def patched_keywords(monkeypatch): """ Override TARGET_KEYWORDS for the duration of a test and rebuild the scorer's module-level globals so scoring logic uses known test patterns. + + scorer.py now reads _config.TARGET_KEYWORDS at call time via `import config as _config`, + so patching config.TARGET_KEYWORDS is sufficient — no direct scorer patch needed. """ monkeypatch.setattr(config, "TARGET_KEYWORDS", TEST_KEYWORDS) - # scorer.py uses `from config import TARGET_KEYWORDS` — a local binding that - # doesn't update when config.TARGET_KEYWORDS is patched. Patch it directly. - monkeypatch.setattr(scorer, "TARGET_KEYWORDS", TEST_KEYWORDS) monkeypatch.setattr(scorer, "EMPLOYEE_DOMAINS", scorer._build_employee_domains()) monkeypatch.setattr(scorer, "ORG_DOMAINS", scorer._build_org_domains()) diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..0340876 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,115 @@ +""" +Tests for tui/events.py — subscribe/unsubscribe broadcast, signal_channel_changed. +""" + +import queue +import pytest +from tui import events as bus + + +@pytest.fixture(autouse=True) +def reset_bus(): + """Reset all bus state between tests.""" + bus._queue = None + bus.tui_active = False + bus._subscribers.clear() + bus._bot_loop = None + bus._bot_ch_ev = None + yield + bus._queue = None + bus.tui_active = False + bus._subscribers.clear() + bus._bot_loop = None + bus._bot_ch_ev = None + + +class TestInitBus: + def test_init_creates_queue(self): + q = bus.init_bus() + assert q is not None + assert bus.tui_active is True + + def test_get_bus_returns_same_queue(self): + q = bus.init_bus() + assert bus.get_bus() is q + + +class TestPost: + def test_post_before_init_is_silent(self): + bus.post("event") # should not raise + + def test_post_reaches_tui_queue(self): + q = bus.init_bus() + bus.post("hello") + assert q.get_nowait() == "hello" + + def test_post_reaches_subscriber(self): + bus.init_bus() + sub = bus.subscribe() + bus.post("world") + assert sub.get_nowait() == "world" + + def test_post_reaches_multiple_subscribers(self): + bus.init_bus() + s1 = bus.subscribe() + s2 = bus.subscribe() + bus.post(42) + assert s1.get_nowait() == 42 + assert s2.get_nowait() == 42 + + +class TestSubscribeUnsubscribe: + def test_subscribe_returns_queue(self): + q = bus.subscribe() + assert isinstance(q, queue.Queue) + assert q in bus._subscribers + + def test_unsubscribe_removes_queue(self): + q = bus.subscribe() + bus.unsubscribe(q) + assert q not in bus._subscribers + + def test_unsubscribe_twice_is_safe(self): + q = bus.subscribe() + bus.unsubscribe(q) + bus.unsubscribe(q) # should not raise + + def test_unsubscribed_does_not_receive(self): + bus.init_bus() + sub = bus.subscribe() + bus.unsubscribe(sub) + bus.post("gone") + with pytest.raises(queue.Empty): + sub.get_nowait() + + +class TestSignalChannelChanged: + def test_signal_without_context_is_safe(self): + bus.signal_channel_changed() # should not raise + + def test_set_bot_context_stores_refs(self): + import asyncio + + async def _inner(): + loop = asyncio.get_event_loop() + ev = asyncio.Event() + bus.set_bot_context(loop, ev) + assert bus._bot_loop is loop + assert bus._bot_ch_ev is ev + + asyncio.run(_inner()) + + def test_signal_sets_event(self): + import asyncio + + async def _inner(): + loop = asyncio.get_event_loop() + ev = asyncio.Event() + bus.set_bot_context(loop, ev) + assert not ev.is_set() + bus.signal_channel_changed() + # give the call_soon_threadsafe a chance to fire + await asyncio.sleep(0) + assert ev.is_set() + + asyncio.run(_inner()) diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py new file mode 100644 index 0000000..8194682 --- /dev/null +++ b/tests/test_web_auth.py @@ -0,0 +1,63 @@ +""" +Tests for web/auth.py — JWT token lifecycle, bcrypt helpers. +""" + +import pytest +from datetime import datetime, timedelta, timezone + +from web import auth + + +class TestPasswordHashing: + def test_hash_and_verify(self): + h = auth.hash_password("hunter2") + assert auth.verify_password("hunter2", h) + + def test_wrong_password_fails(self): + h = auth.hash_password("hunter2") + assert not auth.verify_password("wrong", h) + + +class TestAccessToken: + def test_encode_decode_roundtrip(self): + token = auth.create_access_token("user-1", "admin") + payload = auth.decode_access_token(token) + assert payload is not None + assert payload["sub"] == "user-1" + assert payload["role"] == "admin" + assert payload["type"] == "access" + + def test_invalid_token_returns_none(self): + assert auth.decode_access_token("not.a.token") is None + + def test_tampered_token_returns_none(self): + token = auth.create_access_token("user-1", "admin") + # Flip last character + tampered = token[:-1] + ("A" if token[-1] != "A" else "B") + assert auth.decode_access_token(tampered) is None + + def test_refresh_token_rejected_as_access(self): + token, _jti, _exp = auth.create_refresh_token("user-1") + # A refresh token must NOT be accepted as an access token + assert auth.decode_access_token(token) is None + + +class TestRefreshToken: + def test_encode_decode_roundtrip(self): + token, jti, expires_at = auth.create_refresh_token("user-2") + payload = auth.decode_refresh_token(token) + assert payload is not None + assert payload["sub"] == "user-2" + assert payload["jti"] == jti + assert payload["type"] == "refresh" + + def test_expires_at_in_future(self): + _token, _jti, expires_at = auth.create_refresh_token("user-2") + assert expires_at > datetime.now(timezone.utc) + + def test_access_token_rejected_as_refresh(self): + token = auth.create_access_token("user-1", "reader") + assert auth.decode_refresh_token(token) is None + + def test_invalid_token_returns_none(self): + assert auth.decode_refresh_token("garbage") is None diff --git a/tests/test_web_db.py b/tests/test_web_db.py new file mode 100644 index 0000000..19aba26 --- /dev/null +++ b/tests/test_web_db.py @@ -0,0 +1,108 @@ +""" +Tests for web/db.py — user store and refresh token management. +""" + +import pytest +from pathlib import Path + + +@pytest.fixture +def tmp_db(tmp_path, monkeypatch): + """Point web.db at a temp file for each test.""" + import web.db as db_mod + db_path = tmp_path / "test_web.db" + monkeypatch.setattr(db_mod, "DB_FILE", db_path) + db_mod.init_db() + return db_mod + + +class TestInitDb: + def test_creates_superadmin_on_first_run(self, tmp_db): + import os + admin_user = os.environ["WEB_ADMIN_USER"] + user = tmp_db.get_user_by_username(admin_user) + assert user is not None + assert user["role"] == "superadmin" + assert user["is_active"] == 1 + + def test_second_init_does_not_duplicate(self, tmp_db): + import os + admin_user = os.environ["WEB_ADMIN_USER"] + tmp_db.init_db() # second call + users = tmp_db.list_users() + assert len([u for u in users if u["username"] == admin_user]) == 1 + + +class TestUserCRUD: + def test_create_and_get_user(self, tmp_db): + uid = tmp_db.create_user("alice", "pass1", "reader") + user = tmp_db.get_user_by_id(uid) + assert user["username"] == "alice" + assert user["role"] == "reader" + + def test_get_by_username(self, tmp_db): + tmp_db.create_user("bob", "pass2", "admin") + user = tmp_db.get_user_by_username("bob") + assert user is not None + assert user["role"] == "admin" + + def test_update_role(self, tmp_db): + uid = tmp_db.create_user("carol", "pass3", "reader") + tmp_db.update_user(uid, role="admin") + user = tmp_db.get_user_by_id(uid) + assert user["role"] == "admin" + + def test_update_password_is_hashed(self, tmp_db): + from web.auth import verify_password + uid = tmp_db.create_user("dave", "oldpass", "reader") + tmp_db.update_user(uid, password="newpass") + user = tmp_db.get_user_by_id(uid) + assert verify_password("newpass", user["password_hash"]) + assert not verify_password("oldpass", user["password_hash"]) + + def test_deactivate_user(self, tmp_db): + uid = tmp_db.create_user("eve", "pass4", "reader") + tmp_db.deactivate_user(uid) + # get_user_by_username filters is_active=1 + assert tmp_db.get_user_by_username("eve") is None + # get_user_by_id still returns the row + user = tmp_db.get_user_by_id(uid) + assert user["is_active"] == 0 + + def test_list_users(self, tmp_db): + tmp_db.create_user("u1", "p", "reader") + tmp_db.create_user("u2", "p", "admin") + users = tmp_db.list_users() + usernames = [u["username"] for u in users] + assert "u1" in usernames + assert "u2" in usernames + + +class TestRefreshTokens: + def test_store_and_validate(self, tmp_db): + from datetime import datetime, timedelta, timezone + import uuid + jti = str(uuid.uuid4()) + expires_at = datetime.now(timezone.utc) + timedelta(days=7) + tmp_db.store_refresh_token(jti, "user-x", expires_at) + assert tmp_db.is_refresh_token_valid(jti) + + def test_revoked_token_invalid(self, tmp_db): + from datetime import datetime, timedelta, timezone + import uuid + jti = str(uuid.uuid4()) + expires_at = datetime.now(timezone.utc) + timedelta(days=7) + tmp_db.store_refresh_token(jti, "user-x", expires_at) + tmp_db.revoke_refresh_token(jti) + assert not tmp_db.is_refresh_token_valid(jti) + + def test_expired_token_invalid(self, tmp_db): + from datetime import datetime, timedelta, timezone + import uuid + jti = str(uuid.uuid4()) + expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + tmp_db.store_refresh_token(jti, "user-x", expires_at) + assert not tmp_db.is_refresh_token_valid(jti) + + def test_unknown_jti_invalid(self, tmp_db): + assert not tmp_db.is_refresh_token_valid("nonexistent-jti") diff --git a/tests/test_web_rbac.py b/tests/test_web_rbac.py new file mode 100644 index 0000000..17e55a8 --- /dev/null +++ b/tests/test_web_rbac.py @@ -0,0 +1,191 @@ +""" +Tests for web RBAC enforcement and config JSON round-trip. + +Uses FastAPI TestClient with a temporary web.db. +""" + +import json +import pytest + + +@pytest.fixture(autouse=True) +def isolated_web(tmp_path, monkeypatch): + """Redirect web.db and runtime_config.json to tmp dirs for each test.""" + from pathlib import Path + import web.db as db_mod + import config as cfg_mod + + db_path = tmp_path / "web.db" + cfg_path = tmp_path / "runtime_config.json" + + monkeypatch.setattr(db_mod, "DB_FILE", db_path) + monkeypatch.setattr(cfg_mod, "RUNTIME_CONFIG_PATH", cfg_path) + + db_mod.init_db() + + +@pytest.fixture +def client(): + from fastapi.testclient import TestClient + from web.app import create_app + app = create_app() + return TestClient(app, raise_server_exceptions=True) + + +def _login(client, username="superadmin", password="superpass") -> dict: + """Log in and return the cookies dict.""" + r = client.post("/login", data={"username": username, "password": password}, + follow_redirects=False) + assert r.status_code == 303, f"Login failed: {r.text}" + return client.cookies + + +# ─── Auth flow ──────────────────────────────────────────────────────────────── + +class TestAuthFlow: + def test_login_sets_cookies(self, client): + r = client.post("/login", data={"username": "superadmin", "password": "superpass"}, + follow_redirects=False) + assert r.status_code == 303 + assert "access_token" in r.cookies + assert "refresh_token" in r.cookies + + def test_invalid_password_returns_401(self, client): + r = client.post("/login", data={"username": "superadmin", "password": "wrong"}, + follow_redirects=False) + assert r.status_code == 401 + + def test_unauthenticated_dashboard_redirected_or_401(self, client): + r = client.get("/dashboard", follow_redirects=False) + assert r.status_code in (401, 302, 303) + + def test_logout_clears_cookies(self, client): + _login(client) + r = client.post("/logout", follow_redirects=False) + assert r.status_code == 303 + + def test_authenticated_dashboard_ok(self, client): + _login(client) + r = client.get("/dashboard") + assert r.status_code == 200 + + +# ─── RBAC ───────────────────────────────────────────────────────────────────── + +class TestRBAC: + def test_reader_cannot_access_config(self, client): + import web.db as db_mod + db_mod.create_user("reader1", "pass", "reader") + _login(client, "reader1", "pass") + r = client.get("/config/keywords") + assert r.status_code == 403 + + def test_admin_can_access_config(self, client): + import web.db as db_mod + db_mod.create_user("admin1", "pass", "admin") + _login(client, "admin1", "pass") + r = client.get("/config/keywords") + assert r.status_code == 200 + + def test_reader_cannot_access_users(self, client): + import web.db as db_mod + db_mod.create_user("reader2", "pass", "reader") + _login(client, "reader2", "pass") + r = client.get("/users") + assert r.status_code == 403 + + def test_admin_cannot_access_users(self, client): + import web.db as db_mod + db_mod.create_user("admin2", "pass", "admin") + _login(client, "admin2", "pass") + r = client.get("/users") + assert r.status_code == 403 + + def test_superadmin_can_access_users(self, client): + _login(client) + r = client.get("/users") + assert r.status_code == 200 + + +# ─── Config round-trip ──────────────────────────────────────────────────────── + +class TestConfigRoundTrip: + def test_put_keywords_saves_and_reloads(self, client): + import config + _login(client) + + groups = [ + { + "id": "testorg", + "name": "Test Org", + "patterns": [ + {"regex": r"testorg\.com", "label": "Domain"}, + {"regex": r"@testorg\.com", "label": "Employees"}, + ], + } + ] + r = client.put("/config/keywords", json={"groups": groups}) + assert r.status_code == 200 + assert r.json()["groups"] == 1 + + # Config module globals updated in-process + assert any("testorg" in kw for kw in config.TARGET_KEYWORDS) + + def test_put_keywords_invalid_regex_rejected(self, client): + _login(client) + groups = [ + { + "id": "bad", + "name": "Bad", + "patterns": [{"regex": "[invalid(regex", "label": "oops"}], + } + ] + r = client.put("/config/keywords", json={"groups": groups}) + assert r.status_code == 422 + + def test_put_channels_saves(self, client): + import config + _login(client) + channels = ["testchannel", -1002748707556] + r = client.put("/config/channels", json={"channels": channels}) + assert r.status_code == 200 + assert config.WATCHED_CHANNELS == channels + + +# ─── User management ───────────────────────────────────────────────────────── + +class TestUserManagement: + def test_create_user(self, client): + _login(client) + r = client.post("/users", json={"username": "newuser", "password": "pass", "role": "reader"}) + assert r.status_code == 200 + assert "id" in r.json() + + def test_cannot_create_duplicate_username(self, client): + _login(client) + client.post("/users", json={"username": "dup", "password": "p", "role": "reader"}) + r = client.post("/users", json={"username": "dup", "password": "p", "role": "reader"}) + assert r.status_code == 409 + + def test_update_user_role(self, client): + import web.db as db_mod + _login(client) + uid = db_mod.create_user("patchme", "p", "reader") + r = client.patch(f"/users/{uid}", json={"role": "admin"}) + assert r.status_code == 200 + assert db_mod.get_user_by_id(uid)["role"] == "admin" + + def test_deactivate_user(self, client): + import web.db as db_mod + _login(client) + uid = db_mod.create_user("byebye", "p", "reader") + r = client.delete(f"/users/{uid}") + assert r.status_code == 200 + assert db_mod.get_user_by_id(uid)["is_active"] == 0 + + def test_cannot_deactivate_self(self, client): + import web.db as db_mod + _login(client) + me = db_mod.get_user_by_username("superadmin") + r = client.delete(f"/users/{me['id']}") + assert r.status_code == 403 diff --git a/tui/events.md b/tui/events.md index 674117e..9bcadeb 100644 --- a/tui/events.md +++ b/tui/events.md @@ -1,28 +1,43 @@ # tui/events.py Thread-safe event bus between the bot backend thread and the Textual TUI. -The bot thread calls `post()`. The TUI drains the queue every 100ms via `_drain_bus()`. +The bot thread calls `post()`. The TUI drains the queue every 100ms via `_drain_bus()`. +Web SSE consumers call `subscribe()` / `unsubscribe()` to get their own broadcast queue. ## Public API ```python from tui import events as bus # from core/ and tui/app.py from tui.events import post, init_bus, get_bus, tui_active +from tui.events import subscribe, unsubscribe +from tui.events import set_bot_context, signal_channel_changed ``` ### `init_bus() -> queue.Queue` Creates the `queue.Queue`. Called inside `MonitorApp.on_mount()` — **must run on Textual's event loop**, not before `App.run()`. ### `post(event: Any) -> None` -Fire-and-forget from any thread. Silently drops if bus not initialised. +Fire-and-forget from any thread. Delivers to the TUI queue **and** all subscriber queues. Uses `queue.Queue.put_nowait()` — never blocks. ### `get_bus() -> queue.Queue | None` -Returns the queue for the TUI consumer to drain. +Returns the TUI queue for `_drain_bus()` to consume. ### `tui_active: bool` Set to `True` by `init_bus()`. Checked by `core/tdl_downloader.py` to decide whether to pipe tdl output or inherit the terminal. +### `subscribe() -> queue.Queue` +Register a new subscriber. Returns a private `queue.Queue` that receives every future `post()`. Thread-safe. Call once per SSE connection. + +### `unsubscribe(q: queue.Queue) -> None` +Remove a subscriber queue. Safe to call if already removed. Call on SSE disconnect. + +### `set_bot_context(loop, event) -> None` +Called by `_bot_main()` once the bot asyncio loop and `_ch_changed` event exist. Enables `signal_channel_changed()`. + +### `signal_channel_changed() -> None` +Wake the bot's `_watch_channels()` coroutine from any thread. Used by web config routes after channel list is updated. + --- ## Event types @@ -49,18 +64,24 @@ Set to `True` by `init_bus()`. Checked by `core/tdl_downloader.py` to decide whe Bot thread (own asyncio loop) └─ bus.post(event) ← queue.Queue.put_nowait() [thread-safe] ↓ - queue.Queue - ↓ -Textual thread (Textual's loop) - └─ _drain_bus() [set_interval 100ms] - └─ q.get_nowait() loop - └─ dispatch to widgets [safe, same thread as Textual] + _queue (TUI) + _subscribers[0..n] (web SSE) + ↓ ↓ +Textual thread Web thread (uvicorn) + _drain_bus() SSE generator: q.get_nowait() ``` -Channel changes flow the other way: +Channel changes from TUI: ``` _drain_bus sees EvChannelAdded/Removed → _signal_channel_changed() → loop.call_soon_threadsafe(asyncio.Event.set) → bot thread's _watch_channels() wakes ``` + +Channel changes from web: +``` +PUT /config/channels + → config.save_runtime_config() + → bus.signal_channel_changed() ← uses stored loop + event + → bot thread's _watch_channels() wakes +``` diff --git a/tui/events.py b/tui/events.py index ff0cd27..5b61f07 100644 --- a/tui/events.py +++ b/tui/events.py @@ -7,8 +7,12 @@ queue.Queue (thread-safe), and the TUI consumer polls it from Textual's loop using asyncio.get_event_loop().run_in_executor() bridging. post() is safe to call from any thread or any asyncio loop. + +Web frontend: call subscribe() to get a private queue that receives every +event post() delivers. Call unsubscribe() on disconnect. """ +import asyncio import queue import threading from dataclasses import dataclass, field @@ -22,6 +26,49 @@ _queue_lock = threading.Lock() # writing directly to the terminal. tui_active: bool = False +# ─── Web subscriber broadcast ───────────────────────────────────────────────── + +_subscribers: list[queue.Queue] = [] +_subscribers_lock = threading.Lock() + + +def subscribe() -> queue.Queue: + """Return a new Queue that receives every future post(). Thread-safe.""" + q: queue.Queue = queue.Queue() + with _subscribers_lock: + _subscribers.append(q) + return q + + +def unsubscribe(q: queue.Queue) -> None: + """Remove a subscriber queue. Safe to call even if already removed.""" + with _subscribers_lock: + try: + _subscribers.remove(q) + except ValueError: + pass + + +# ─── Bot-loop channel-change signal (for web routes) ───────────────────────── +# The TUI sets this via set_bot_context() after the bot asyncio loop starts. +# Web config routes call signal_channel_changed() to wake _watch_channels(). + +_bot_loop: asyncio.AbstractEventLoop | None = None +_bot_ch_ev: asyncio.Event | None = None + + +def set_bot_context(loop: asyncio.AbstractEventLoop, event: asyncio.Event) -> None: + """Called by _bot_main() once the bot loop and channel event exist.""" + global _bot_loop, _bot_ch_ev + _bot_loop = loop + _bot_ch_ev = event + + +def signal_channel_changed() -> None: + """Wake the bot's _watch_channels() coroutine from any thread.""" + if _bot_loop is not None and _bot_ch_ev is not None: + _bot_loop.call_soon_threadsafe(_bot_ch_ev.set) + def init_bus() -> queue.Queue: """Call once from MonitorApp.on_mount() to create the queue.""" @@ -36,12 +83,19 @@ def get_bus() -> queue.Queue | None: def post(event: Any) -> None: - """Fire-and-forget from any thread. Silently drops if bus not up.""" + """Fire-and-forget from any thread. Broadcasts to TUI queue + all subscribers.""" if _queue is not None: try: _queue.put_nowait(event) except queue.Full: pass + with _subscribers_lock: + subs = list(_subscribers) + for q in subs: + try: + q.put_nowait(event) + except queue.Full: + pass # ─── Event types ────────────────────────────────────────────────────────────── diff --git a/utils/database.py b/utils/database.py index 589acb7..6745fce 100644 --- a/utils/database.py +++ b/utils/database.py @@ -144,6 +144,51 @@ def by_severity(severity: str) -> list[sqlite3.Row]: """, (severity,)).fetchall() +def recent_for_domains(patterns: list[str], limit: int = 100) -> list[sqlite3.Row]: + """Return recent hits whose `raw` field matches any of the given regex-like patterns.""" + if not patterns: + return [] + conditions = " OR ".join("raw LIKE ?" for _ in patterns) + args = [f"%{p.replace(r'\.','.').replace('@','').replace('^','').replace('$','')}%" for p in patterns] + args.append(limit) + with _connect() as conn: + return conn.execute( + f"SELECT * FROM hits WHERE ({conditions}) ORDER BY timestamp DESC LIMIT ?", + args, + ).fetchall() + + +def count_by_severity_for_domains(patterns: list[str]) -> dict: + """Severity counts filtered to hits matching any of the given patterns.""" + if not patterns: + return {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + conditions = " OR ".join("raw LIKE ?" for _ in patterns) + args = [f"%{p.replace(r'\.','.').replace('@','').replace('^','').replace('$','')}%" for p in patterns] + with _connect() as conn: + rows = conn.execute( + f"SELECT severity, COUNT(*) FROM hits WHERE ({conditions}) GROUP BY severity", + args, + ).fetchall() + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for row in rows: + if row[0] in counts: + counts[row[0]] = row[1] + return counts + + +def count_by_severity() -> dict: + """Overall severity counts (unique hits only).""" + with _connect() as conn: + rows = conn.execute( + "SELECT severity, COUNT(*) FROM hits WHERE seen_before=0 GROUP BY severity" + ).fetchall() + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for row in rows: + if row[0] in counts: + counts[row[0]] = row[1] + return counts + + def stats() -> dict: """Return summary statistics.""" with _connect() as conn: diff --git a/utils/scorer.md b/utils/scorer.md index 50df937..9d9e59c 100644 --- a/utils/scorer.md +++ b/utils/scorer.md @@ -72,16 +72,20 @@ The URL field handles two common stealer-log complications: --- -## Module-level globals (rebuilt on import + via KeywordsScreen) +## Module-level globals (rebuilt on import + via reload_from_config) | Name | Type | Description | |------|------|-------------| | `EMPLOYEE_DOMAINS` | `list[tuple[str, Pattern]]` | `(domain_str, anchored_pattern)` for `@`-keywords | | `ORG_DOMAINS` | `list[Pattern]` | Plain domain patterns for all keywords | +scorer uses `import config as _config` (not `from config import TARGET_KEYWORDS`), so patching `config.TARGET_KEYWORDS` at runtime is sufficient — `_build_*` reads the live module attribute. + To rebuild after editing `config.TARGET_KEYWORDS` at runtime: ```python import utils.scorer as scorer -scorer.EMPLOYEE_DOMAINS = scorer._build_employee_domains() -scorer.ORG_DOMAINS = scorer._build_org_domains() +scorer.reload_from_config() ``` + +### `reload_from_config() -> None` +Rebuilds `EMPLOYEE_DOMAINS` and `ORG_DOMAINS` from the current `config.TARGET_KEYWORDS`. Called by web config routes after `config.save_runtime_config()` writes new keyword groups. diff --git a/utils/scorer.py b/utils/scorer.py index 9f1a3a8..079bb44 100644 --- a/utils/scorer.py +++ b/utils/scorer.py @@ -30,7 +30,7 @@ Each scored hit gets a dict with: import re import logging from dataclasses import dataclass, field -from config import TARGET_KEYWORDS +import config as _config log = logging.getLogger(__name__) @@ -124,7 +124,7 @@ def _build_employee_domains() -> list[tuple[str, re.Pattern]]: Returns list of (domain_str, compiled_pattern) tuples. """ patterns = [] - for kw in TARGET_KEYWORDS: + for kw in _config.TARGET_KEYWORDS: if "@" in kw: domain = _kw_to_domain(kw) if domain: @@ -144,7 +144,7 @@ def _build_org_domains() -> list[re.Pattern]: Checks that the org domain appears anywhere in the line. """ patterns = [] - for kw in TARGET_KEYWORDS: + for kw in _config.TARGET_KEYWORDS: domain = _kw_to_domain(kw) if domain: patterns.append(re.compile(re.escape(domain), re.IGNORECASE)) @@ -153,6 +153,16 @@ def _build_org_domains() -> list[re.Pattern]: ORG_DOMAINS = _build_org_domains() +def reload_from_config() -> None: + """ + Rebuild EMPLOYEE_DOMAINS and ORG_DOMAINS from the current config.TARGET_KEYWORDS. + Call after save_runtime_config() updates the keyword list. + """ + global EMPLOYEE_DOMAINS, ORG_DOMAINS + EMPLOYEE_DOMAINS = _build_employee_domains() + ORG_DOMAINS = _build_org_domains() + + # ─── Scoring logic ──────────────────────────────────────────────────────────── diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..f2e5406 --- /dev/null +++ b/web/app.py @@ -0,0 +1,55 @@ +""" +web/app.py — FastAPI application factory. + +Usage: + from web.app import create_app + app = create_app() + uvicorn.run(app, host=host, port=port) + +The app is created fresh per uvicorn startup (no module-level state). +Templates and static files are mounted from web/templates/ and web/static/. +""" + +from contextlib import asynccontextmanager +from pathlib import Path + +import jinja2 +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from web import db as webdb +from web.routes import auth, dashboard, config_routes, users + +_WEB_DIR = Path(__file__).parent + + +@asynccontextmanager +async def _lifespan(app: FastAPI): + webdb.init_db() + yield + + +def create_app() -> FastAPI: + app = FastAPI(title="ULPgrammer", lifespan=_lifespan) + + # Use a custom Environment with caching disabled. + # Jinja2's LRUCache has a Python 3.14 hashability issue with its cache key; + # cache_size=0 disables the LRUCache code path entirely. + _env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(_WEB_DIR / "templates")), + autoescape=jinja2.select_autoescape(), + cache_size=0, + ) + app.state.templates = Jinja2Templates(env=_env) + + # Static files + app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static") + + # Routers + app.include_router(auth.router) + app.include_router(dashboard.router) + app.include_router(config_routes.router) + app.include_router(users.router) + + return app diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..4fe206b --- /dev/null +++ b/web/auth.py @@ -0,0 +1,76 @@ +""" +web/auth.py — JWT signing/verification and bcrypt password helpers. + +Tokens: + access — HS256, 15 min TTL, payload: {sub, role, type:"access"} + refresh — HS256, 7 day TTL, payload: {sub, jti, type:"refresh"} + +Both tokens live in httpOnly SameSite=Strict cookies. +The `type` claim prevents an access token being used as a refresh token. + +Secret: WEB_SECRET_KEY env var (required; no hardcoded default). +""" + +import os +import uuid +from datetime import datetime, timedelta, timezone + +import bcrypt as _bcrypt_lib +from jose import JWTError, jwt + +_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") +_ALGORITHM = "HS256" + +ACCESS_TOKEN_EXPIRE_MINUTES = 15 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + + +def _secret() -> str: + if not _SECRET_KEY: + raise RuntimeError("WEB_SECRET_KEY env var is required.") + return _SECRET_KEY + + +def hash_password(plain: str) -> str: + return _bcrypt_lib.hashpw(plain.encode("utf-8"), _bcrypt_lib.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + return _bcrypt_lib.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + + +def create_access_token(user_id: str, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + payload = {"sub": user_id, "role": role, "type": "access", "exp": expire} + return jwt.encode(payload, _secret(), algorithm=_ALGORITHM) + + +def create_refresh_token(user_id: str) -> tuple[str, str, datetime]: + """Returns (encoded_token, jti, expires_at).""" + jti = str(uuid.uuid4()) + expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + payload = {"sub": user_id, "jti": jti, "type": "refresh", "exp": expires_at} + token = jwt.encode(payload, _secret(), algorithm=_ALGORITHM) + return token, jti, expires_at + + +def decode_access_token(token: str) -> dict | None: + """Returns payload dict or None if invalid/expired.""" + try: + payload = jwt.decode(token, _secret(), algorithms=[_ALGORITHM]) + if payload.get("type") != "access": + return None + return payload + except JWTError: + return None + + +def decode_refresh_token(token: str) -> dict | None: + """Returns payload dict or None if invalid/expired.""" + try: + payload = jwt.decode(token, _secret(), algorithms=[_ALGORITHM]) + if payload.get("type") != "refresh": + return None + return payload + except JWTError: + return None diff --git a/web/db.py b/web/db.py new file mode 100644 index 0000000..f1ffceb --- /dev/null +++ b/web/db.py @@ -0,0 +1,156 @@ +""" +web/db.py — SQLite user store for the web frontend. + +Tables: + users — credentials + role + active flag + refresh_tokens — JTI-indexed refresh token revocation list + +Bootstrap: on first init, creates a superadmin from WEB_ADMIN_USER / WEB_ADMIN_PASS +env vars (required only on first run if the DB doesn't exist yet). +""" + +import os +import sqlite3 +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +from web.auth import hash_password + +DB_FILE = Path("./data/web.db") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('superadmin','admin','reader')), + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + jti TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); +""" + + +@contextmanager +def get_conn(): + DB_FILE.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_FILE, check_same_thread=False) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init_db() -> None: + """Create schema and bootstrap superadmin on first run.""" + with get_conn() as conn: + conn.executescript(_SCHEMA) + + # Bootstrap superadmin only if the users table is empty. + row = conn.execute("SELECT COUNT(*) FROM users").fetchone() + if row[0] == 0: + admin_user = os.environ.get("WEB_ADMIN_USER", "admin") + admin_pass = os.environ.get("WEB_ADMIN_PASS") + if not admin_pass: + raise RuntimeError( + "WEB_ADMIN_PASS env var is required on first run to create the superadmin." + ) + conn.execute( + "INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?,?,?,?,?)", + ( + str(uuid.uuid4()), + admin_user, + hash_password(admin_pass), + "superadmin", + datetime.now(timezone.utc).isoformat(), + ), + ) + + +# ─── User queries ───────────────────────────────────────────────────────────── + +def get_user_by_username(username: str) -> sqlite3.Row | None: + with get_conn() as conn: + return conn.execute( + "SELECT * FROM users WHERE username = ? AND is_active = 1", (username,) + ).fetchone() + + +def get_user_by_id(user_id: str) -> sqlite3.Row | None: + with get_conn() as conn: + return conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + + +def list_users() -> list[sqlite3.Row]: + with get_conn() as conn: + return conn.execute("SELECT * FROM users ORDER BY created_at").fetchall() + + +def create_user(username: str, password: str, role: str) -> str: + user_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + with get_conn() as conn: + conn.execute( + "INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?,?,?,?,?)", + (user_id, username, hash_password(password), role, now), + ) + return user_id + + +def update_user(user_id: str, **fields) -> None: + """Update arbitrary user fields. Hashes password if provided.""" + if "password" in fields: + fields["password_hash"] = hash_password(fields.pop("password")) + if not fields: + return + cols = ", ".join(f"{k} = ?" for k in fields) + with get_conn() as conn: + conn.execute( + f"UPDATE users SET {cols} WHERE id = ?", + (*fields.values(), user_id), + ) + + +def deactivate_user(user_id: str) -> None: + with get_conn() as conn: + conn.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user_id,)) + + +# ─── Refresh token queries ──────────────────────────────────────────────────── + +def store_refresh_token(jti: str, user_id: str, expires_at: datetime) -> None: + with get_conn() as conn: + conn.execute( + "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?,?,?)", + (jti, user_id, expires_at.isoformat()), + ) + + +def is_refresh_token_valid(jti: str) -> bool: + with get_conn() as conn: + row = conn.execute( + "SELECT revoked, expires_at FROM refresh_tokens WHERE jti = ?", (jti,) + ).fetchone() + if row is None: + return False + if row["revoked"]: + return False + expires = datetime.fromisoformat(row["expires_at"]) + return datetime.now(timezone.utc) < expires + + +def revoke_refresh_token(jti: str) -> None: + with get_conn() as conn: + conn.execute( + "UPDATE refresh_tokens SET revoked = 1 WHERE jti = ?", (jti,) + ) diff --git a/web/dependencies.py b/web/dependencies.py new file mode 100644 index 0000000..4cd41b9 --- /dev/null +++ b/web/dependencies.py @@ -0,0 +1,52 @@ +""" +web/dependencies.py — FastAPI dependency functions. + +get_current_user: reads the access_token cookie, decodes + validates it, + loads the user row from web.db. Raises 401 if anything fails. + +require_role(min_role): returns a dependency that enforces a minimum RBAC level. +""" + +from fastapi import Cookie, Depends, HTTPException, status + +from web import auth, db + +_ROLE_ORDER = ["reader", "admin", "superadmin"] + + +def _role_rank(role: str) -> int: + try: + return _ROLE_ORDER.index(role) + except ValueError: + return -1 + + +async def get_current_user( + access_token: str | None = Cookie(default=None), +) -> db.sqlite3.Row: + exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not access_token: + raise exc + payload = auth.decode_access_token(access_token) + if payload is None: + raise exc + user = db.get_user_by_id(payload["sub"]) + if user is None or not user["is_active"]: + raise exc + return user + + +def require_role(min_role: str): + """FastAPI dependency factory: ensures user role >= min_role.""" + async def _dep(user=Depends(get_current_user)): + if _role_rank(user["role"]) < _role_rank(min_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires role: {min_role}", + ) + return user + return _dep diff --git a/web/models.py b/web/models.py new file mode 100644 index 0000000..bb23b70 --- /dev/null +++ b/web/models.py @@ -0,0 +1,64 @@ +""" +web/models.py — Pydantic request/response schemas. +""" + +import re +from typing import Literal + +from pydantic import BaseModel, field_validator + + +# ─── Auth ───────────────────────────────────────────────────────────────────── + +class LoginRequest(BaseModel): + username: str + password: str + + +# ─── Keyword groups ─────────────────────────────────────────────────────────── + +class PatternEntry(BaseModel): + regex: str + label: str + + @field_validator("regex") + @classmethod + def regex_must_compile(cls, v: str) -> str: + try: + re.compile(v, re.IGNORECASE) + except re.error as e: + raise ValueError(f"Invalid regex: {e}") from e + return v + + +class KeywordGroup(BaseModel): + id: str + name: str + patterns: list[PatternEntry] + + +class KeywordGroupsPayload(BaseModel): + groups: list[KeywordGroup] + + +# ─── Channels ───────────────────────────────────────────────────────────────── + +class ChannelsPayload(BaseModel): + channels: list[str | int] + + +# ─── Users ─────────────────────────────────────────────────────────────────── + +Role = Literal["superadmin", "admin", "reader"] + + +class CreateUserRequest(BaseModel): + username: str + password: str + role: Role + + +class UpdateUserRequest(BaseModel): + password: str | None = None + role: Role | None = None + is_active: bool | None = None diff --git a/web/routes/__init__.py b/web/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/routes/auth.py b/web/routes/auth.py new file mode 100644 index 0000000..131bbc4 --- /dev/null +++ b/web/routes/auth.py @@ -0,0 +1,106 @@ +""" +web/routes/auth.py — Login, logout, token refresh. + +POST /login — form submit; sets access_token + refresh_token cookies +POST /logout — revokes refresh token, clears cookies +POST /refresh — exchanges refresh_token cookie for a new access_token +""" + +from fastapi import APIRouter, Form, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from web import auth as auth_lib +from web import db + +router = APIRouter() + + +def _set_auth_cookies(response: Response, access_token: str, refresh_token: str) -> None: + response.set_cookie( + "access_token", access_token, + httponly=True, samesite="strict", max_age=auth_lib.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + response.set_cookie( + "refresh_token", refresh_token, + httponly=True, samesite="strict", max_age=auth_lib.REFRESH_TOKEN_EXPIRE_DAYS * 86400, + ) + + +def _clear_auth_cookies(response: Response) -> None: + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + + +def _templates(request: Request) -> Jinja2Templates: + return request.app.state.templates + + +@router.get("/login") +async def login_page(request: Request): + return _templates(request).TemplateResponse(request, "login.html") + + +@router.post("/login") +async def login( + request: Request, + username: str = Form(...), + password: str = Form(...), +): + user = db.get_user_by_username(username) + if user is None or not auth_lib.verify_password(password, user["password_hash"]): + return _templates(request).TemplateResponse( + request, "login.html", + {"error": "Invalid username or password"}, + status_code=status.HTTP_401_UNAUTHORIZED, + ) + + access_token = auth_lib.create_access_token(user["id"], user["role"]) + refresh_token, jti, expires_at = auth_lib.create_refresh_token(user["id"]) + db.store_refresh_token(jti, user["id"], expires_at) + + response = RedirectResponse(url="/dashboard", status_code=status.HTTP_303_SEE_OTHER) + _set_auth_cookies(response, access_token, refresh_token) + return response + + +@router.post("/logout") +async def logout(request: Request): + refresh_token = request.cookies.get("refresh_token") + if refresh_token: + payload = auth_lib.decode_refresh_token(refresh_token) + if payload and payload.get("jti"): + db.revoke_refresh_token(payload["jti"]) + + response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + _clear_auth_cookies(response) + return response + + +@router.post("/refresh") +async def refresh(request: Request): + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token") + + payload = auth_lib.decode_refresh_token(refresh_token) + if payload is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") + + jti = payload.get("jti") + if not jti or not db.is_refresh_token_valid(jti): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired") + + user = db.get_user_by_id(payload["sub"]) + if user is None or not user["is_active"]: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + + # Rotate: revoke old, issue new + db.revoke_refresh_token(jti) + new_access = auth_lib.create_access_token(user["id"], user["role"]) + new_refresh, new_jti, expires_at = auth_lib.create_refresh_token(user["id"]) + db.store_refresh_token(new_jti, user["id"], expires_at) + + response = Response(status_code=status.HTTP_204_NO_CONTENT) + _set_auth_cookies(response, new_access, new_refresh) + return response diff --git a/web/routes/config_routes.py b/web/routes/config_routes.py new file mode 100644 index 0000000..800a10e --- /dev/null +++ b/web/routes/config_routes.py @@ -0,0 +1,66 @@ +""" +web/routes/config_routes.py — Keyword groups and channel list management. + +GET /config/keywords → render groups editor +PUT /config/keywords → validate + save groups, reload scorer +GET /config/channels → render channel list +PUT /config/channels → save channels, signal bot to re-watch +""" + +import re + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse + +import config +import utils.scorer as scorer +from tui import events as bus +from web.dependencies import require_role +from web.models import ChannelsPayload, KeywordGroupsPayload + +router = APIRouter() + + +def _templates(request: Request): + return request.app.state.templates + + +@router.get("/config/keywords") +async def keywords_page(request: Request, user=Depends(require_role("admin"))): + return _templates(request).TemplateResponse( + request, "config.html", + { + "user": dict(user), + "groups": config.KEYWORD_GROUPS, + "channels": config.WATCHED_CHANNELS, + "active_tab": "keywords", + }, + ) + + +@router.put("/config/keywords") +async def update_keywords(payload: KeywordGroupsPayload, _user=Depends(require_role("admin"))): + groups = [g.model_dump() for g in payload.groups] + config.save_runtime_config(groups, config.WATCHED_CHANNELS) + scorer.reload_from_config() + return {"status": "ok", "groups": len(groups)} + + +@router.get("/config/channels") +async def channels_page(request: Request, user=Depends(require_role("admin"))): + return _templates(request).TemplateResponse( + request, "config.html", + { + "user": dict(user), + "groups": config.KEYWORD_GROUPS, + "channels": config.WATCHED_CHANNELS, + "active_tab": "channels", + }, + ) + + +@router.put("/config/channels") +async def update_channels(payload: ChannelsPayload, _user=Depends(require_role("admin"))): + config.save_runtime_config(config.KEYWORD_GROUPS, payload.channels) + bus.signal_channel_changed() + return {"status": "ok", "channels": len(payload.channels)} diff --git a/web/routes/dashboard.py b/web/routes/dashboard.py new file mode 100644 index 0000000..47dd0da --- /dev/null +++ b/web/routes/dashboard.py @@ -0,0 +1,108 @@ +""" +web/routes/dashboard.py — Dashboard views and SSE live stream. + +GET / → redirect to /dashboard +GET /dashboard → overview: all groups, stats, live hit feed +GET /dashboard/groups/{group_id} → per-group hits + filtered SSE +GET /api/stream → SSE event stream (one hit per event) +GET /api/stats → JSON severity counts (for hx-trigger refresh) +""" + +import asyncio +import json +import queue + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse, StreamingResponse + +import config +from tui import events as bus +from utils import database as hitdb +from web.dependencies import require_role + +router = APIRouter() + + +def _templates(request: Request): + return request.app.state.templates + + +def _hit_to_dict(hit) -> dict: + reasons_raw = hit["reasons"] or "" + reasons = [r.strip() for r in reasons_raw.split("|") if r.strip()] + return { + "severity": hit["severity"], + "score": hit["score"], + "raw": hit["raw"], + "source": hit["source"], + "filename": hit["filename"], + "reasons": reasons, + "timestamp": hit["timestamp"], + } + + +@router.get("/") +async def root(): + return RedirectResponse(url="/dashboard") + + +@router.get("/dashboard") +async def dashboard(request: Request, user=Depends(require_role("reader"))): + hits = [_hit_to_dict(h) for h in hitdb.recent(limit=50)] + counts = hitdb.count_by_severity() + groups = config.KEYWORD_GROUPS + return _templates(request).TemplateResponse( + request, "dashboard.html", + {"user": dict(user), "hits": hits, "counts": counts, "groups": groups}, + ) + + +@router.get("/dashboard/groups/{group_id}") +async def group_detail(request: Request, group_id: str, user=Depends(require_role("reader"))): + groups = config.KEYWORD_GROUPS + group = next((g for g in groups if g["id"] == group_id), None) + if group is None: + raise HTTPException(status_code=404, detail="Group not found") + + patterns = [p["regex"] for p in group.get("patterns", [])] + hits = [_hit_to_dict(h) for h in hitdb.recent_for_domains(patterns, limit=100)] + counts = hitdb.count_by_severity_for_domains(patterns) + + return _templates(request).TemplateResponse( + request, "group_detail.html", + {"user": dict(user), "group": group, "hits": hits, "counts": counts}, + ) + + +@router.get("/api/stream") +async def event_stream(request: Request, _user=Depends(require_role("reader"))): + """Server-Sent Events: one data frame per EvHit.""" + q = bus.subscribe() + + async def generate(): + try: + while True: + if await request.is_disconnected(): + break + try: + ev = q.get_nowait() + if isinstance(ev, bus.EvHit): + payload = json.dumps({ + "severity": ev.severity, + "raw": ev.raw, + "source": ev.source, + "filename": ev.filename, + "reasons": ev.reasons, + }) + yield f"data: {payload}\n\n" + except queue.Empty: + await asyncio.sleep(0.1) + finally: + bus.unsubscribe(q) + + return StreamingResponse(generate(), media_type="text/event-stream") + + +@router.get("/api/stats") +async def stats(_user=Depends(require_role("reader"))): + return hitdb.count_by_severity() diff --git a/web/routes/users.py b/web/routes/users.py new file mode 100644 index 0000000..44240c8 --- /dev/null +++ b/web/routes/users.py @@ -0,0 +1,82 @@ +""" +web/routes/users.py — User CRUD (superadmin only). + +GET /users → list all users +POST /users → create a new user +PATCH /users/{id} → update role / password / active flag +DELETE /users/{id} → deactivate (cannot delete self or other superadmins) +""" + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse + +from web import db +from web.dependencies import require_role +from web.models import CreateUserRequest, UpdateUserRequest + +router = APIRouter() + + +def _templates(request: Request): + return request.app.state.templates + + +@router.get("/users") +async def list_users(request: Request, user=Depends(require_role("superadmin"))): + users = db.list_users() + return _templates(request).TemplateResponse( + request, "users.html", + {"user": dict(user), "users": [dict(u) for u in users]}, + ) + + +@router.post("/users") +async def create_user( + payload: CreateUserRequest, + _user=Depends(require_role("superadmin")), +): + try: + user_id = db.create_user(payload.username, payload.password, payload.role) + except Exception as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + return {"id": user_id} + + +@router.patch("/users/{user_id}") +async def update_user( + user_id: str, + payload: UpdateUserRequest, + acting_user=Depends(require_role("superadmin")), +): + target = db.get_user_by_id(user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + # Cannot demote another superadmin via role patch + if target["role"] == "superadmin" and payload.role and payload.role != "superadmin": + raise HTTPException(status_code=403, detail="Cannot demote another superadmin") + + updates: dict = {} + if payload.password is not None: + updates["password"] = payload.password + if payload.role is not None: + updates["role"] = payload.role + if payload.is_active is not None: + updates["is_active"] = 1 if payload.is_active else 0 + + db.update_user(user_id, **updates) + return {"status": "ok"} + + +@router.delete("/users/{user_id}") +async def deactivate_user( + user_id: str, + acting_user=Depends(require_role("superadmin")), +): + if user_id == acting_user["id"]: + raise HTTPException(status_code=403, detail="Cannot deactivate yourself") + target = db.get_user_by_id(user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + db.deactivate_user(user_id) + return {"status": "ok"} diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..b0ee3ae --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,162 @@ +/* ULPgrammer web UI — minimal, dark-ish */ + +:root { + --bg: #1a1a1a; + --surface: #252525; + --border: #3a3a3a; + --text: #e0e0e0; + --muted: #888; + --critical:#ff4444; + --high: #ff8800; + --medium: #ffcc00; + --low: #44bb44; + --accent: #4a90d9; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* Nav */ +nav { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.6rem 1.2rem; +} +.nav-brand { font-weight: 700; font-size: 1rem; color: var(--text); } +.nav-links { display: flex; gap: 1rem; align-items: center; } + +main { padding: 1.2rem 1.4rem; max-width: 1200px; } + +h2 { margin: 0.8rem 0 0.6rem; } +h3 { margin: 0.6rem 0 0.4rem; } + +/* Stats bar */ +.stats-bar { + display: flex; + gap: 1rem; + margin: 0.8rem 0; + padding: 0.5rem 0.8rem; + background: var(--surface); + border-radius: 6px; + border: 1px solid var(--border); +} +.stat { font-weight: 600; } +.stat.critical { color: var(--critical); } +.stat.high { color: var(--high); } +.stat.medium { color: var(--medium); } +.stat.low { color: var(--low); } + +/* Groups bar */ +.groups-bar { margin: 0.5rem 0 1rem; } +.group-pill { + display: inline-block; + margin: 0 0.3rem 0.3rem 0; + padding: 0.2rem 0.6rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + font-size: 0.85rem; +} + +/* Hit cards */ +.hit-card { + padding: 0.5rem 0.8rem; + margin: 0.4rem 0; + border-left: 4px solid var(--border); + background: var(--surface); + border-radius: 0 4px 4px 0; +} +.hit-card.sev-critical { border-color: var(--critical); } +.hit-card.sev-high { border-color: var(--high); } +.hit-card.sev-medium { border-color: var(--medium); } +.hit-card.sev-low { border-color: var(--low); } + +.sev-badge { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.1rem 0.4rem; + border-radius: 3px; + margin-right: 0.5rem; + background: var(--border); +} +.sev-critical .sev-badge { background: var(--critical); color: #fff; } +.sev-high .sev-badge { background: var(--high); color: #000; } +.sev-medium .sev-badge { background: var(--medium); color: #000; } +.sev-low .sev-badge { background: var(--low); color: #000; } + +code.raw { font-family: monospace; font-size: 0.9rem; word-break: break-all; } +.meta { color: var(--muted); font-size: 0.8rem; margin-left: 0.5rem; } +.reasons { margin: 0.2rem 0 0 1.5rem; color: var(--muted); font-size: 0.8rem; } + +/* Tables */ +.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.8rem; +} +.data-table th, .data-table td { + padding: 0.4rem 0.6rem; + border: 1px solid var(--border); + text-align: left; +} +.data-table th { background: var(--surface); } + +/* Forms */ +label { display: block; margin: 0.4rem 0; } +label input, label select { display: block; width: 100%; margin-top: 0.2rem; } +input, select, textarea { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.3rem 0.5rem; + border-radius: 4px; +} +input:focus, select:focus { outline: 1px solid var(--accent); } + +button { cursor: pointer; padding: 0.3rem 0.7rem; border-radius: 4px; border: 1px solid var(--border); background: var(--surface); color: var(--text); } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-danger { background: var(--critical); color: #fff; border-color: var(--critical); } +.btn-add { margin-top: 0.3rem; } +.btn-link { background: none; border: none; color: var(--accent); padding: 0; } + +/* Tabs */ +.tabs { display: flex; gap: 0.5rem; margin: 0.6rem 0; } +.tab { padding: 0.3rem 0.8rem; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); } +.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* Config fieldsets */ +.group-fieldset { border: 1px solid var(--border); padding: 0.7rem; margin: 0.6rem 0; border-radius: 4px; } +.group-fieldset legend input { background: transparent; border: none; font-size: 1rem; font-weight: 600; color: var(--text); } +.patterns-table { width: 100%; border-collapse: collapse; } +.patterns-table td { padding: 0.2rem 0.4rem; } +.patterns-table input { width: 100%; } + +/* Login */ +.login-body { display: flex; align-items: center; justify-content: center; min-height: 100vh; } +.login-box { width: 320px; padding: 2rem; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); } +.login-box h1 { margin-bottom: 1rem; text-align: center; } +.login-box button { width: 100%; margin-top: 0.8rem; } + +/* Misc */ +.error { color: var(--critical); margin: 0.4rem 0; } +.hint { color: var(--muted); font-size: 0.85rem; margin: 0.3rem 0; } +.empty { color: var(--muted); font-style: italic; } +dialog { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; min-width: 340px; } +dialog::backdrop { background: rgba(0,0,0,0.6); } +.dialog-actions { display: flex; gap: 0.5rem; margin-top: 0.8rem; } +.patterns-list { margin: 0.5rem 0; } +details summary { cursor: pointer; } diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..bd7438f --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,32 @@ + + + + + + {% block title %}ULPgrammer{% endblock %} + + + + + +{% if user is defined %} + +{% endif %} + +
+ {% block content %}{% endblock %} +
+ + diff --git a/web/templates/config.html b/web/templates/config.html new file mode 100644 index 0000000..6c08d8e --- /dev/null +++ b/web/templates/config.html @@ -0,0 +1,145 @@ +{% extends "base.html" %} +{% block title %}Config — ULPgrammer{% endblock %} +{% block content %} + +

Configuration

+ +
+ Keywords + Channels +
+ +{% if active_tab == 'keywords' %} +
+

Keyword Groups

+

Each group can have multiple regex patterns. Patterns containing @ trigger CRITICAL on matching email usernames.

+ +
+
+ {% for g in groups %} +
+ + + + + + + + + {% for p in g.patterns %} + + + + + + {% endfor %} + +
RegexLabel
+ +
+ {% endfor %} +
+ + + +
+
+ +{% elif active_tab == 'channels' %} +
+

Watched Channels

+

Username (without @) or numeric channel ID (e.g. -1002748707556).

+ +
+
    + {% for ch in channels %} +
  • + + +
  • + {% endfor %} +
+ + + +
+
+{% endif %} + + + +{% endblock %} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..e3e7d14 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Dashboard — ULPgrammer{% endblock %} +{% block content %} + +
+ 🔴 CRITICAL: {{ counts.CRITICAL }} + 🟠 HIGH: {{ counts.HIGH }} + 🟡 MEDIUM: {{ counts.MEDIUM }} + 🟢 LOW: {{ counts.LOW }} +
+ +{% if groups %} +
+ Groups: + {% for g in groups %} + {{ g.name }} + {% endfor %} +
+{% endif %} + +

Live feed

+
+ {% for hit in hits %} +
+ {{ hit.severity }} + {{ hit.raw }} + {{ hit.source }} / {{ hit.filename }} — {{ hit.timestamp }} + {% if hit.reasons %} +
    {% for r in hit.reasons %}
  • {{ r }}
  • {% endfor %}
+ {% endif %} +
+ {% endfor %} +
+ + + +{% endblock %} diff --git a/web/templates/group_detail.html b/web/templates/group_detail.html new file mode 100644 index 0000000..9527c6c --- /dev/null +++ b/web/templates/group_detail.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}{{ group.name }} — ULPgrammer{% endblock %} +{% block content %} + +

{{ group.name }}

+ +
+ 🔴 CRITICAL: {{ counts.CRITICAL }} + 🟠 HIGH: {{ counts.HIGH }} + 🟡 MEDIUM: {{ counts.MEDIUM }} + 🟢 LOW: {{ counts.LOW }} +
+ +
+ Patterns ({{ group.patterns|length }}) + +
+ +

Recent hits

+{% for hit in hits %} +
+ {{ hit.severity }} + {{ hit.raw }} + {{ hit.source }} / {{ hit.filename }} — {{ hit.timestamp }} + {% if hit.reasons %} + + {% endif %} +
+{% else %} +

No hits yet for this group.

+{% endfor %} + +{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..4691d50 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,21 @@ + + + + + Login — ULPgrammer + + + +
+

ULPgrammer

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + +
+
+ + diff --git a/web/templates/users.html b/web/templates/users.html new file mode 100644 index 0000000..9484312 --- /dev/null +++ b/web/templates/users.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}Users — ULPgrammer{% endblock %} +{% block content %} + +

Users

+ + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
UsernameRoleCreatedActiveActions
{{ u.username }}{{ u.role }}{{ u.created_at[:10] }}{{ "Yes" if u.is_active else "No" }} + {% if u.id != user.id %} + + + {% else %} + (you) + {% endif %} +
+ + + +
+

New user

+ + + +
+ + +
+

+
+
+ + + +
+

Edit user

+ + + + +
+ + +
+

+
+
+ + + +{% endblock %}