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
This commit is contained in:
2026-04-02 11:41:46 -03:00
parent b28168c846
commit 4c104cddd2
32 changed files with 2093 additions and 47 deletions

4
.gitignore vendored
View File

@@ -22,7 +22,3 @@ __pycache__/
*.pyo *.pyo
.venv/ .venv/
venv/ venv/
# Claude things
CLAUDE.md
.claude/*

View File

@@ -2,12 +2,16 @@
config.py — Loads and validates all settings from .env config.py — Loads and validates all settings from .env
""" """
import json
import logging
import os import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
log = logging.getLogger(__name__)
# -- Timeouts -- # -- Timeouts --
BOT_REPLY_TIMEOUT = 10 BOT_REPLY_TIMEOUT = 10
@@ -18,19 +22,21 @@ BOT_TOKEN = os.environ["BOT_TOKEN"]
NOTIFY_CHAT_ID = int(os.environ["NOTIFY_CHAT_ID"]) NOTIFY_CHAT_ID = int(os.environ["NOTIFY_CHAT_ID"])
SESSION_NAME = os.getenv("SESSION_NAME", "monitor_session") 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. # Add your org's domains, email patterns, IP ranges, known usernames, etc.
# All patterns are case-insensitive regex. # All patterns are case-insensitive regex.
TARGET_KEYWORDS: list[str] = [ _DEFAULT_KEYWORDS: list[str] = [
r"sanatorioaleman\.cl", r"sanatorioaleman\.cl",
r"@sanatorioaleman\.cl", r"@sanatorioaleman\.cl",
# r"192\.168\.10\.", # internal IP range example # r"192\.168\.10\.", # internal IP range example
# r"specificuser", # known internal usernames # r"specificuser", # known internal usernames
] ]
# ─── Channels to watch ───────────────────────────────────────────────────────
# Use usernames (without @) or numeric channel IDs (-100xxxxxxxxxx) # Use usernames (without @) or numeric channel IDs (-100xxxxxxxxxx)
WATCHED_CHANNELS: list[str | int] = [ _DEFAULT_CHANNELS: list[str | int] = [
#-1002230225603, #-1002230225603,
"cloudxlog", "cloudxlog",
#-1001967030016, # daisycloud #-1001967030016, # daisycloud
@@ -50,6 +56,53 @@ WATCHED_CHANNELS: list[str | int] = [
#-1001234567890, # private channel by ID #-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 ─────────────────────────────────────────────────────────── # ─── File handling ───────────────────────────────────────────────────────────
TEMP_DIR = Path("./tmp") TEMP_DIR = Path("./tmp")
HITS_FILE = Path("./hits.txt") HITS_FILE = Path("./hits.txt")

54
main.py
View File

@@ -2,11 +2,10 @@
main.py — Entry point for the ULP credential monitor. main.py — Entry point for the ULP credential monitor.
Usage: Usage:
python main.py # TUI mode (default, requires textual) python main.py # TUI mode (default)
python main.py --no-tui # Plain CLI mode python main.py --no-tui # Plain CLI mode
python main.py --web # TUI + web frontend (port 8080)
First run will prompt for your Telegram phone number and 2FA code python main.py --no-tui --web # CLI + web frontend
to create a session file. Subsequent runs are fully automatic.
""" """
import asyncio import asyncio
@@ -14,6 +13,7 @@ import logging
import sys import sys
import shutil import shutil
import argparse import argparse
import threading
import config import config
from utils.database import init_db from utils.database import init_db
@@ -36,6 +36,22 @@ log = logging.getLogger(__name__)
init_db() 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 ─────────────────────────────────────────────────────────── # ─── Plain CLI mode ───────────────────────────────────────────────────────────
async def _cli_main(): async def _cli_main():
@@ -96,24 +112,29 @@ async def _cli_main():
def main(): def main():
parser = argparse.ArgumentParser(description="ULP Credential Monitor") parser = argparse.ArgumentParser(description="ULP Credential Monitor")
parser.add_argument( parser.add_argument("--no-tui", action="store_true", help="Run in plain CLI mode (no Textual TUI)")
"--no-tui", parser.add_argument("--web", action="store_true", help="Start web frontend")
action="store_true", parser.add_argument("--web-host", default="127.0.0.1", help="Web frontend bind host (default: 127.0.0.1)")
help="Run in plain CLI mode (no Textual TUI)", parser.add_argument("--web-port", type=int, default=8080, help="Web frontend port (default: 8080)")
)
args = parser.parse_args() 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: if args.no_tui:
try: try:
asyncio.run(_cli_main()) asyncio.run(_cli_main())
except KeyboardInterrupt: except KeyboardInterrupt:
log.info("Interrupted by user.") log.info("Interrupted by user.")
finally: finally:
log.info("Cleaning up tmp/...") _cleanup()
if config.TEMP_DIR.exists():
shutil.rmtree(config.TEMP_DIR, ignore_errors=True)
config.TEMP_DIR.mkdir()
log.info("Done.")
else: else:
try: try:
from tui.app import run_tui from tui.app import run_tui
@@ -132,10 +153,7 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
log.info("Cleaning up tmp/...") _cleanup()
if config.TEMP_DIR.exists():
shutil.rmtree(config.TEMP_DIR, ignore_errors=True)
config.TEMP_DIR.mkdir()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -14,3 +14,11 @@ tqdm
# Archive extraction # Archive extraction
py7zr py7zr
rarfile rarfile
# Web frontend (optional — only needed with --web)
fastapi
uvicorn[standard]
jinja2
python-multipart
bcrypt
python-jose[cryptography]

View File

@@ -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("BOT_TOKEN", "0:dummy_bot_token")
os.environ.setdefault("NOTIFY_CHAT_ID", "99999") 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 pytest
import config import config
import utils.scorer as scorer 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 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'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) 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, "EMPLOYEE_DOMAINS", scorer._build_employee_domains())
monkeypatch.setattr(scorer, "ORG_DOMAINS", scorer._build_org_domains()) monkeypatch.setattr(scorer, "ORG_DOMAINS", scorer._build_org_domains())

115
tests/test_events.py Normal file
View File

@@ -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())

63
tests/test_web_auth.py Normal file
View File

@@ -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

108
tests/test_web_db.py Normal file
View File

@@ -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")

191
tests/test_web_rbac.py Normal file
View File

@@ -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

View File

@@ -2,27 +2,42 @@
Thread-safe event bus between the bot backend thread and the Textual TUI. 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 ## Public API
```python ```python
from tui import events as bus # from core/ and tui/app.py 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 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` ### `init_bus() -> queue.Queue`
Creates the `queue.Queue`. Called inside `MonitorApp.on_mount()`**must run on Textual's event loop**, not before `App.run()`. Creates the `queue.Queue`. Called inside `MonitorApp.on_mount()`**must run on Textual's event loop**, not before `App.run()`.
### `post(event: Any) -> None` ### `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. Uses `queue.Queue.put_nowait()` — never blocks.
### `get_bus() -> queue.Queue | None` ### `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` ### `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. 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 ## 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) Bot thread (own asyncio loop)
└─ bus.post(event) ← queue.Queue.put_nowait() [thread-safe] └─ bus.post(event) ← queue.Queue.put_nowait() [thread-safe]
queue.Queue _queue (TUI) + _subscribers[0..n] (web SSE)
Textual thread (Textual's loop) Textual thread Web thread (uvicorn)
└─ _drain_bus() [set_interval 100ms] _drain_bus() SSE generator: q.get_nowait()
└─ q.get_nowait() loop
└─ dispatch to widgets [safe, same thread as Textual]
``` ```
Channel changes flow the other way: Channel changes from TUI:
``` ```
_drain_bus sees EvChannelAdded/Removed _drain_bus sees EvChannelAdded/Removed
→ _signal_channel_changed() → _signal_channel_changed()
→ loop.call_soon_threadsafe(asyncio.Event.set) → loop.call_soon_threadsafe(asyncio.Event.set)
→ bot thread's _watch_channels() wakes → 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
```

View File

@@ -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. using asyncio.get_event_loop().run_in_executor() bridging.
post() is safe to call from any thread or any asyncio loop. 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 queue
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -22,6 +26,49 @@ _queue_lock = threading.Lock()
# writing directly to the terminal. # writing directly to the terminal.
tui_active: bool = False 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: def init_bus() -> queue.Queue:
"""Call once from MonitorApp.on_mount() to create the 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: 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: if _queue is not None:
try: try:
_queue.put_nowait(event) _queue.put_nowait(event)
except queue.Full: except queue.Full:
pass pass
with _subscribers_lock:
subs = list(_subscribers)
for q in subs:
try:
q.put_nowait(event)
except queue.Full:
pass
# ─── Event types ────────────────────────────────────────────────────────────── # ─── Event types ──────────────────────────────────────────────────────────────

View File

@@ -144,6 +144,51 @@ def by_severity(severity: str) -> list[sqlite3.Row]:
""", (severity,)).fetchall() """, (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: def stats() -> dict:
"""Return summary statistics.""" """Return summary statistics."""
with _connect() as conn: with _connect() as conn:

View File

@@ -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 | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| `EMPLOYEE_DOMAINS` | `list[tuple[str, Pattern]]` | `(domain_str, anchored_pattern)` for `@`-keywords | | `EMPLOYEE_DOMAINS` | `list[tuple[str, Pattern]]` | `(domain_str, anchored_pattern)` for `@`-keywords |
| `ORG_DOMAINS` | `list[Pattern]` | Plain domain patterns for all 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: To rebuild after editing `config.TARGET_KEYWORDS` at runtime:
```python ```python
import utils.scorer as scorer import utils.scorer as scorer
scorer.EMPLOYEE_DOMAINS = scorer._build_employee_domains() scorer.reload_from_config()
scorer.ORG_DOMAINS = scorer._build_org_domains()
``` ```
### `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.

View File

@@ -30,7 +30,7 @@ Each scored hit gets a dict with:
import re import re
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from config import TARGET_KEYWORDS import config as _config
log = logging.getLogger(__name__) 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. Returns list of (domain_str, compiled_pattern) tuples.
""" """
patterns = [] patterns = []
for kw in TARGET_KEYWORDS: for kw in _config.TARGET_KEYWORDS:
if "@" in kw: if "@" in kw:
domain = _kw_to_domain(kw) domain = _kw_to_domain(kw)
if domain: if domain:
@@ -144,7 +144,7 @@ def _build_org_domains() -> list[re.Pattern]:
Checks that the org domain appears anywhere in the line. Checks that the org domain appears anywhere in the line.
""" """
patterns = [] patterns = []
for kw in TARGET_KEYWORDS: for kw in _config.TARGET_KEYWORDS:
domain = _kw_to_domain(kw) domain = _kw_to_domain(kw)
if domain: if domain:
patterns.append(re.compile(re.escape(domain), re.IGNORECASE)) 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() 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 ──────────────────────────────────────────────────────────── # ─── Scoring logic ────────────────────────────────────────────────────────────

0
web/__init__.py Normal file
View File

55
web/app.py Normal file
View File

@@ -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

76
web/auth.py Normal file
View File

@@ -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

156
web/db.py Normal file
View File

@@ -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,)
)

52
web/dependencies.py Normal file
View File

@@ -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

64
web/models.py Normal file
View File

@@ -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

0
web/routes/__init__.py Normal file
View File

106
web/routes/auth.py Normal file
View File

@@ -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

View File

@@ -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)}

108
web/routes/dashboard.py Normal file
View File

@@ -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()

82
web/routes/users.py Normal file
View File

@@ -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"}

162
web/static/style.css Normal file
View File

@@ -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; }

32
web/templates/base.html Normal file
View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ULPgrammer{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js" defer></script>
</head>
<body>
{% if user is defined %}
<nav>
<a href="/dashboard" class="nav-brand">ULPgrammer</a>
<span class="nav-links">
<a href="/dashboard">Dashboard</a>
<a href="/config/keywords">Config</a>
{% if user.role == 'superadmin' %}
<a href="/users">Users</a>
{% endif %}
<form method="post" action="/logout" style="display:inline">
<button type="submit" class="btn-link">Logout ({{ user.username }})</button>
</form>
</span>
</nav>
{% endif %}
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

145
web/templates/config.html Normal file
View File

@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Config — ULPgrammer{% endblock %}
{% block content %}
<h2>Configuration</h2>
<div class="tabs">
<a href="/config/keywords" class="tab {% if active_tab == 'keywords' %}active{% endif %}">Keywords</a>
<a href="/config/channels" class="tab {% if active_tab == 'channels' %}active{% endif %}">Channels</a>
</div>
{% if active_tab == 'keywords' %}
<section>
<h3>Keyword Groups</h3>
<p class="hint">Each group can have multiple regex patterns. Patterns containing <code>@</code> trigger CRITICAL on matching email usernames.</p>
<form id="kw-form">
<div id="groups-container">
{% for g in groups %}
<fieldset class="group-fieldset">
<legend>
<input name="group_name" value="{{ g.name }}" placeholder="Group name" required>
<button type="button" class="btn-danger" onclick="this.closest('fieldset').remove()">Remove group</button>
</legend>
<input type="hidden" name="group_id" value="{{ g.id }}">
<table class="patterns-table">
<thead><tr><th>Regex</th><th>Label</th><th></th></tr></thead>
<tbody>
{% for p in g.patterns %}
<tr>
<td><input name="regex" value="{{ p.regex }}" required></td>
<td><input name="label" value="{{ p.label }}"></td>
<td><button type="button" onclick="this.closest('tr').remove()"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="button" class="btn-add" onclick="addPatternRow(this)">+ Add pattern</button>
</fieldset>
{% endfor %}
</div>
<button type="button" onclick="addGroup()">+ Add group</button>
<button type="submit" class="btn-primary">Save keywords</button>
<span id="kw-status"></span>
</form>
</section>
{% elif active_tab == 'channels' %}
<section>
<h3>Watched Channels</h3>
<p class="hint">Username (without @) or numeric channel ID (e.g. <code>-1002748707556</code>).</p>
<form id="ch-form">
<ul id="channels-list">
{% for ch in channels %}
<li>
<input name="channel" value="{{ ch }}" required>
<button type="button" onclick="this.closest('li').remove()"></button>
</li>
{% endfor %}
</ul>
<button type="button" onclick="addChannel()">+ Add channel</button>
<button type="submit" class="btn-primary">Save channels</button>
<span id="ch-status"></span>
</form>
</section>
{% endif %}
<script>
function addPatternRow(btn) {
const tbody = btn.previousElementSibling.querySelector("tbody");
const tr = document.createElement("tr");
tr.innerHTML = '<td><input name="regex" required></td><td><input name="label"></td><td><button type="button" onclick="this.closest(\'tr\').remove()">✕</button></td>';
tbody.appendChild(tr);
}
function addGroup() {
const id = "group_" + Date.now();
const fs = document.createElement("fieldset");
fs.className = "group-fieldset";
fs.innerHTML = `
<legend>
<input name="group_name" placeholder="Group name" required>
<button type="button" class="btn-danger" onclick="this.closest('fieldset').remove()">Remove group</button>
</legend>
<input type="hidden" name="group_id" value="${id}">
<table class="patterns-table">
<thead><tr><th>Regex</th><th>Label</th><th></th></tr></thead>
<tbody></tbody>
</table>
<button type="button" class="btn-add" onclick="addPatternRow(this)">+ Add pattern</button>
`;
document.getElementById("groups-container").appendChild(fs);
}
function addChannel() {
const li = document.createElement("li");
li.innerHTML = '<input name="channel" required><button type="button" onclick="this.closest(\'li\').remove()">✕</button>';
document.getElementById("channels-list").appendChild(li);
}
// Keyword form submit
document.getElementById("kw-form")?.addEventListener("submit", async e => {
e.preventDefault();
const groups = collectGroups();
const res = await fetch("/config/keywords", {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({groups}),
});
document.getElementById("kw-status").textContent = res.ok ? "Saved." : "Error: " + await res.text();
});
// Channel form submit
document.getElementById("ch-form")?.addEventListener("submit", async e => {
e.preventDefault();
const inputs = document.querySelectorAll("#channels-list input[name=channel]");
const channels = [...inputs].map(i => {
const v = i.value.trim();
return /^-?\d+$/.test(v) ? parseInt(v, 10) : v;
});
const res = await fetch("/config/channels", {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({channels}),
});
document.getElementById("ch-status").textContent = res.ok ? "Saved." : "Error: " + await res.text();
});
function collectGroups() {
const fieldsets = document.querySelectorAll(".group-fieldset");
return [...fieldsets].map(fs => {
const id = fs.querySelector("input[name=group_id]").value;
const name = fs.querySelector("input[name=group_name]").value;
const rows = fs.querySelectorAll("tbody tr");
const patterns = [...rows].map(tr => ({
regex: tr.querySelector("input[name=regex]").value,
label: tr.querySelector("input[name=label]").value,
}));
return {id, name, patterns};
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Dashboard — ULPgrammer{% endblock %}
{% block content %}
<div class="stats-bar"
hx-get="/api/stats"
hx-trigger="every 10s"
hx-swap="outerHTML">
<span class="stat critical">🔴 CRITICAL: {{ counts.CRITICAL }}</span>
<span class="stat high">🟠 HIGH: {{ counts.HIGH }}</span>
<span class="stat medium">🟡 MEDIUM: {{ counts.MEDIUM }}</span>
<span class="stat low">🟢 LOW: {{ counts.LOW }}</span>
</div>
{% if groups %}
<div class="groups-bar">
<strong>Groups:</strong>
{% for g in groups %}
<a href="/dashboard/groups/{{ g.id }}" class="group-pill">{{ g.name }}</a>
{% endfor %}
</div>
{% endif %}
<h2>Live feed</h2>
<div id="hit-feed"
hx-ext="sse"
sse-connect="/api/stream"
sse-swap="hit"
hx-swap="afterbegin">
{% for hit in hits %}
<div class="hit-card sev-{{ hit.severity|lower }}">
<span class="sev-badge">{{ hit.severity }}</span>
<code class="raw">{{ hit.raw }}</code>
<span class="meta">{{ hit.source }} / {{ hit.filename }} — {{ hit.timestamp }}</span>
{% if hit.reasons %}
<ul class="reasons">{% for r in hit.reasons %}<li>{{ r }}</li>{% endfor %}</ul>
{% endif %}
</div>
{% endfor %}
</div>
<script>
// SSE: inject new hit cards into #hit-feed
document.body.addEventListener("htmx:sseMessage", function(e) {
if (e.detail.type !== "hit") return;
const data = JSON.parse(e.detail.data);
const feed = document.getElementById("hit-feed");
const card = document.createElement("div");
card.className = "hit-card sev-" + data.severity.toLowerCase();
card.innerHTML = `
<span class="sev-badge">${data.severity}</span>
<code class="raw">${escHtml(data.raw)}</code>
<span class="meta">${escHtml(data.source)} / ${escHtml(data.filename)}</span>
<ul class="reasons">${data.reasons.map(r => `<li>${escHtml(r)}</li>`).join("")}</ul>
`;
feed.prepend(card);
});
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}{{ group.name }} — ULPgrammer{% endblock %}
{% block content %}
<h2>{{ group.name }}</h2>
<div class="stats-bar">
<span class="stat critical">🔴 CRITICAL: {{ counts.CRITICAL }}</span>
<span class="stat high">🟠 HIGH: {{ counts.HIGH }}</span>
<span class="stat medium">🟡 MEDIUM: {{ counts.MEDIUM }}</span>
<span class="stat low">🟢 LOW: {{ counts.LOW }}</span>
</div>
<details class="patterns-list">
<summary>Patterns ({{ group.patterns|length }})</summary>
<ul>
{% for p in group.patterns %}
<li><code>{{ p.regex }}</code> — {{ p.label }}</li>
{% endfor %}
</ul>
</details>
<h3>Recent hits</h3>
{% for hit in hits %}
<div class="hit-card sev-{{ hit.severity|lower }}">
<span class="sev-badge">{{ hit.severity }}</span>
<code class="raw">{{ hit.raw }}</code>
<span class="meta">{{ hit.source }} / {{ hit.filename }} — {{ hit.timestamp }}</span>
{% if hit.reasons %}
<ul class="reasons">{% for r in hit.reasons %}<li>{{ r }}</li>{% endfor %}</ul>
{% endif %}
</div>
{% else %}
<p class="empty">No hits yet for this group.</p>
{% endfor %}
{% endblock %}

21
web/templates/login.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login — ULPgrammer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="login-box">
<h1>ULPgrammer</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label>Username<input type="text" name="username" autofocus required></label>
<label>Password<input type="password" name="password" required></label>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>

129
web/templates/users.html Normal file
View File

@@ -0,0 +1,129 @@
{% extends "base.html" %}
{% block title %}Users — ULPgrammer{% endblock %}
{% block content %}
<h2>Users</h2>
<button class="btn-primary" onclick="document.getElementById('create-modal').showModal()">+ New user</button>
<table class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr id="user-{{ u.id }}">
<td>{{ u.username }}</td>
<td>{{ u.role }}</td>
<td>{{ u.created_at[:10] }}</td>
<td>{{ "Yes" if u.is_active else "No" }}</td>
<td>
{% if u.id != user.id %}
<button onclick="openEdit('{{ u.id }}', '{{ u.role }}', {{ u.is_active }})">Edit</button>
<button class="btn-danger" onclick="deactivate('{{ u.id }}')">Deactivate</button>
{% else %}
<em>(you)</em>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Create user modal -->
<dialog id="create-modal">
<form id="create-form">
<h3>New user</h3>
<label>Username<input name="username" required></label>
<label>Password<input type="password" name="password" required></label>
<label>Role
<select name="role">
<option>reader</option>
<option>admin</option>
<option>superadmin</option>
</select>
</label>
<div class="dialog-actions">
<button type="submit" class="btn-primary">Create</button>
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
</div>
<p id="create-error" class="error"></p>
</form>
</dialog>
<!-- Edit user modal -->
<dialog id="edit-modal">
<form id="edit-form">
<h3>Edit user</h3>
<input type="hidden" id="edit-user-id">
<label>New password (leave blank to keep)<input type="password" id="edit-password"></label>
<label>Role
<select id="edit-role">
<option>reader</option>
<option>admin</option>
<option>superadmin</option>
</select>
</label>
<label><input type="checkbox" id="edit-active"> Active</label>
<div class="dialog-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
</div>
<p id="edit-error" class="error"></p>
</form>
</dialog>
<script>
document.getElementById("create-form").addEventListener("submit", async e => {
e.preventDefault();
const fd = new FormData(e.target);
const res = await fetch("/users", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({username: fd.get("username"), password: fd.get("password"), role: fd.get("role")}),
});
if (res.ok) { location.reload(); }
else { document.getElementById("create-error").textContent = await res.text(); }
});
function openEdit(id, role, isActive) {
document.getElementById("edit-user-id").value = id;
document.getElementById("edit-role").value = role;
document.getElementById("edit-active").checked = !!isActive;
document.getElementById("edit-password").value = "";
document.getElementById("edit-modal").showModal();
}
document.getElementById("edit-form").addEventListener("submit", async e => {
e.preventDefault();
const id = document.getElementById("edit-user-id").value;
const body = {
role: document.getElementById("edit-role").value,
is_active: document.getElementById("edit-active").checked,
};
const pw = document.getElementById("edit-password").value;
if (pw) body.password = pw;
const res = await fetch(`/users/${id}`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
if (res.ok) { location.reload(); }
else { document.getElementById("edit-error").textContent = await res.text(); }
});
async function deactivate(id) {
if (!confirm("Deactivate this user?")) return;
const res = await fetch(`/users/${id}`, {method: "DELETE"});
if (res.ok) { location.reload(); }
else { alert(await res.text()); }
}
</script>
{% endblock %}