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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,7 +22,3 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Claude things
|
|
||||||
CLAUDE.md
|
|
||||||
.claude/*
|
|
||||||
|
|||||||
61
config.py
61
config.py
@@ -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")
|
||||||
|
|||||||
56
main.py
56
main.py
@@ -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__":
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
115
tests/test_events.py
Normal 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
63
tests/test_web_auth.py
Normal 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
108
tests/test_web_db.py
Normal 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
191
tests/test_web_rbac.py
Normal 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
|
||||||
@@ -1,28 +1,43 @@
|
|||||||
# tui/events.py
|
# tui/events.py
|
||||||
|
|
||||||
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
0
web/__init__.py
Normal file
55
web/app.py
Normal file
55
web/app.py
Normal 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
76
web/auth.py
Normal 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
156
web/db.py
Normal 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
52
web/dependencies.py
Normal 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
64
web/models.py
Normal 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
0
web/routes/__init__.py
Normal file
106
web/routes/auth.py
Normal file
106
web/routes/auth.py
Normal 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
|
||||||
66
web/routes/config_routes.py
Normal file
66
web/routes/config_routes.py
Normal 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
108
web/routes/dashboard.py
Normal 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
82
web/routes/users.py
Normal 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
162
web/static/style.css
Normal 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
32
web/templates/base.html
Normal 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
145
web/templates/config.html
Normal 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 %}
|
||||||
64
web/templates/dashboard.html
Normal file
64
web/templates/dashboard.html
Normal 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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
37
web/templates/group_detail.html
Normal file
37
web/templates/group_detail.html
Normal 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
21
web/templates/login.html
Normal 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
129
web/templates/users.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user