Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor

- FastAPI + htmx + Jinja2 web frontend, started with --web flag
- JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) +
  refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db
- RBAC: superadmin > admin > reader enforced per route
- Live SSE dashboard fed by tui/events broadcast queue
- Config editor: keyword groups and channel list saved to data/runtime_config.json
  and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed)
- config.py migrated to load groups/channels from runtime_config.json;
  falls back to hardcoded defaults when file absent
- tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed
- utils/scorer.py: import config as _config (fixes local binding); reload_from_config()
- utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains
- 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement,
  config round-trip); total 141 passing
This commit is contained in:
2026-04-02 11:41:46 -03:00
parent b28168c846
commit 4c104cddd2
32 changed files with 2093 additions and 47 deletions

View File

@@ -0,0 +1,66 @@
"""
web/routes/config_routes.py — Keyword groups and channel list management.
GET /config/keywords → render groups editor
PUT /config/keywords → validate + save groups, reload scorer
GET /config/channels → render channel list
PUT /config/channels → save channels, signal bot to re-watch
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
import config
import utils.scorer as scorer
from tui import events as bus
from web.dependencies import require_role
from web.models import ChannelsPayload, KeywordGroupsPayload
router = APIRouter()
def _templates(request: Request):
return request.app.state.templates
@router.get("/config/keywords")
async def keywords_page(request: Request, user=Depends(require_role("admin"))):
return _templates(request).TemplateResponse(
request, "config.html",
{
"user": dict(user),
"groups": config.KEYWORD_GROUPS,
"channels": config.WATCHED_CHANNELS,
"active_tab": "keywords",
},
)
@router.put("/config/keywords")
async def update_keywords(payload: KeywordGroupsPayload, _user=Depends(require_role("admin"))):
groups = [g.model_dump() for g in payload.groups]
config.save_runtime_config(groups, config.WATCHED_CHANNELS)
scorer.reload_from_config()
return {"status": "ok", "groups": len(groups)}
@router.get("/config/channels")
async def channels_page(request: Request, user=Depends(require_role("admin"))):
return _templates(request).TemplateResponse(
request, "config.html",
{
"user": dict(user),
"groups": config.KEYWORD_GROUPS,
"channels": config.WATCHED_CHANNELS,
"active_tab": "channels",
},
)
@router.put("/config/channels")
async def update_channels(payload: ChannelsPayload, _user=Depends(require_role("admin"))):
config.save_runtime_config(config.KEYWORD_GROUPS, payload.channels)
bus.signal_channel_changed()
return {"status": "ok", "channels": len(payload.channels)}