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

0
web/__init__.py Normal file
View File

55
web/app.py Normal file
View File

@@ -0,0 +1,55 @@
"""
web/app.py — FastAPI application factory.
Usage:
from web.app import create_app
app = create_app()
uvicorn.run(app, host=host, port=port)
The app is created fresh per uvicorn startup (no module-level state).
Templates and static files are mounted from web/templates/ and web/static/.
"""
from contextlib import asynccontextmanager
from pathlib import Path
import jinja2
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from web import db as webdb
from web.routes import auth, dashboard, config_routes, users
_WEB_DIR = Path(__file__).parent
@asynccontextmanager
async def _lifespan(app: FastAPI):
webdb.init_db()
yield
def create_app() -> FastAPI:
app = FastAPI(title="ULPgrammer", lifespan=_lifespan)
# Use a custom Environment with caching disabled.
# Jinja2's LRUCache has a Python 3.14 hashability issue with its cache key;
# cache_size=0 disables the LRUCache code path entirely.
_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(_WEB_DIR / "templates")),
autoescape=jinja2.select_autoescape(),
cache_size=0,
)
app.state.templates = Jinja2Templates(env=_env)
# Static files
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
# Routers
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(config_routes.router)
app.include_router(users.router)
return app

76
web/auth.py Normal file
View File

@@ -0,0 +1,76 @@
"""
web/auth.py — JWT signing/verification and bcrypt password helpers.
Tokens:
access — HS256, 15 min TTL, payload: {sub, role, type:"access"}
refresh — HS256, 7 day TTL, payload: {sub, jti, type:"refresh"}
Both tokens live in httpOnly SameSite=Strict cookies.
The `type` claim prevents an access token being used as a refresh token.
Secret: WEB_SECRET_KEY env var (required; no hardcoded default).
"""
import os
import uuid
from datetime import datetime, timedelta, timezone
import bcrypt as _bcrypt_lib
from jose import JWTError, jwt
_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "")
_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
def _secret() -> str:
if not _SECRET_KEY:
raise RuntimeError("WEB_SECRET_KEY env var is required.")
return _SECRET_KEY
def hash_password(plain: str) -> str:
return _bcrypt_lib.hashpw(plain.encode("utf-8"), _bcrypt_lib.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
return _bcrypt_lib.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": user_id, "role": role, "type": "access", "exp": expire}
return jwt.encode(payload, _secret(), algorithm=_ALGORITHM)
def create_refresh_token(user_id: str) -> tuple[str, str, datetime]:
"""Returns (encoded_token, jti, expires_at)."""
jti = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": user_id, "jti": jti, "type": "refresh", "exp": expires_at}
token = jwt.encode(payload, _secret(), algorithm=_ALGORITHM)
return token, jti, expires_at
def decode_access_token(token: str) -> dict | None:
"""Returns payload dict or None if invalid/expired."""
try:
payload = jwt.decode(token, _secret(), algorithms=[_ALGORITHM])
if payload.get("type") != "access":
return None
return payload
except JWTError:
return None
def decode_refresh_token(token: str) -> dict | None:
"""Returns payload dict or None if invalid/expired."""
try:
payload = jwt.decode(token, _secret(), algorithms=[_ALGORITHM])
if payload.get("type") != "refresh":
return None
return payload
except JWTError:
return None

156
web/db.py Normal file
View File

@@ -0,0 +1,156 @@
"""
web/db.py — SQLite user store for the web frontend.
Tables:
users — credentials + role + active flag
refresh_tokens — JTI-indexed refresh token revocation list
Bootstrap: on first init, creates a superadmin from WEB_ADMIN_USER / WEB_ADMIN_PASS
env vars (required only on first run if the DB doesn't exist yet).
"""
import os
import sqlite3
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from web.auth import hash_password
DB_FILE = Path("./data/web.db")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('superadmin','admin','reader')),
created_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
jti TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
"""
@contextmanager
def get_conn():
DB_FILE.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
def init_db() -> None:
"""Create schema and bootstrap superadmin on first run."""
with get_conn() as conn:
conn.executescript(_SCHEMA)
# Bootstrap superadmin only if the users table is empty.
row = conn.execute("SELECT COUNT(*) FROM users").fetchone()
if row[0] == 0:
admin_user = os.environ.get("WEB_ADMIN_USER", "admin")
admin_pass = os.environ.get("WEB_ADMIN_PASS")
if not admin_pass:
raise RuntimeError(
"WEB_ADMIN_PASS env var is required on first run to create the superadmin."
)
conn.execute(
"INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?,?,?,?,?)",
(
str(uuid.uuid4()),
admin_user,
hash_password(admin_pass),
"superadmin",
datetime.now(timezone.utc).isoformat(),
),
)
# ─── User queries ─────────────────────────────────────────────────────────────
def get_user_by_username(username: str) -> sqlite3.Row | None:
with get_conn() as conn:
return conn.execute(
"SELECT * FROM users WHERE username = ? AND is_active = 1", (username,)
).fetchone()
def get_user_by_id(user_id: str) -> sqlite3.Row | None:
with get_conn() as conn:
return conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
def list_users() -> list[sqlite3.Row]:
with get_conn() as conn:
return conn.execute("SELECT * FROM users ORDER BY created_at").fetchall()
def create_user(username: str, password: str, role: str) -> str:
user_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
with get_conn() as conn:
conn.execute(
"INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?,?,?,?,?)",
(user_id, username, hash_password(password), role, now),
)
return user_id
def update_user(user_id: str, **fields) -> None:
"""Update arbitrary user fields. Hashes password if provided."""
if "password" in fields:
fields["password_hash"] = hash_password(fields.pop("password"))
if not fields:
return
cols = ", ".join(f"{k} = ?" for k in fields)
with get_conn() as conn:
conn.execute(
f"UPDATE users SET {cols} WHERE id = ?",
(*fields.values(), user_id),
)
def deactivate_user(user_id: str) -> None:
with get_conn() as conn:
conn.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user_id,))
# ─── Refresh token queries ────────────────────────────────────────────────────
def store_refresh_token(jti: str, user_id: str, expires_at: datetime) -> None:
with get_conn() as conn:
conn.execute(
"INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?,?,?)",
(jti, user_id, expires_at.isoformat()),
)
def is_refresh_token_valid(jti: str) -> bool:
with get_conn() as conn:
row = conn.execute(
"SELECT revoked, expires_at FROM refresh_tokens WHERE jti = ?", (jti,)
).fetchone()
if row is None:
return False
if row["revoked"]:
return False
expires = datetime.fromisoformat(row["expires_at"])
return datetime.now(timezone.utc) < expires
def revoke_refresh_token(jti: str) -> None:
with get_conn() as conn:
conn.execute(
"UPDATE refresh_tokens SET revoked = 1 WHERE jti = ?", (jti,)
)

52
web/dependencies.py Normal file
View File

@@ -0,0 +1,52 @@
"""
web/dependencies.py — FastAPI dependency functions.
get_current_user: reads the access_token cookie, decodes + validates it,
loads the user row from web.db. Raises 401 if anything fails.
require_role(min_role): returns a dependency that enforces a minimum RBAC level.
"""
from fastapi import Cookie, Depends, HTTPException, status
from web import auth, db
_ROLE_ORDER = ["reader", "admin", "superadmin"]
def _role_rank(role: str) -> int:
try:
return _ROLE_ORDER.index(role)
except ValueError:
return -1
async def get_current_user(
access_token: str | None = Cookie(default=None),
) -> db.sqlite3.Row:
exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if not access_token:
raise exc
payload = auth.decode_access_token(access_token)
if payload is None:
raise exc
user = db.get_user_by_id(payload["sub"])
if user is None or not user["is_active"]:
raise exc
return user
def require_role(min_role: str):
"""FastAPI dependency factory: ensures user role >= min_role."""
async def _dep(user=Depends(get_current_user)):
if _role_rank(user["role"]) < _role_rank(min_role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires role: {min_role}",
)
return user
return _dep

64
web/models.py Normal file
View File

@@ -0,0 +1,64 @@
"""
web/models.py — Pydantic request/response schemas.
"""
import re
from typing import Literal
from pydantic import BaseModel, field_validator
# ─── Auth ─────────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
# ─── Keyword groups ───────────────────────────────────────────────────────────
class PatternEntry(BaseModel):
regex: str
label: str
@field_validator("regex")
@classmethod
def regex_must_compile(cls, v: str) -> str:
try:
re.compile(v, re.IGNORECASE)
except re.error as e:
raise ValueError(f"Invalid regex: {e}") from e
return v
class KeywordGroup(BaseModel):
id: str
name: str
patterns: list[PatternEntry]
class KeywordGroupsPayload(BaseModel):
groups: list[KeywordGroup]
# ─── Channels ─────────────────────────────────────────────────────────────────
class ChannelsPayload(BaseModel):
channels: list[str | int]
# ─── Users ───────────────────────────────────────────────────────────────────
Role = Literal["superadmin", "admin", "reader"]
class CreateUserRequest(BaseModel):
username: str
password: str
role: Role
class UpdateUserRequest(BaseModel):
password: str | None = None
role: Role | None = None
is_active: bool | None = None

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

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

@@ -0,0 +1,106 @@
"""
web/routes/auth.py — Login, logout, token refresh.
POST /login — form submit; sets access_token + refresh_token cookies
POST /logout — revokes refresh token, clears cookies
POST /refresh — exchanges refresh_token cookie for a new access_token
"""
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from web import auth as auth_lib
from web import db
router = APIRouter()
def _set_auth_cookies(response: Response, access_token: str, refresh_token: str) -> None:
response.set_cookie(
"access_token", access_token,
httponly=True, samesite="strict", max_age=auth_lib.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
response.set_cookie(
"refresh_token", refresh_token,
httponly=True, samesite="strict", max_age=auth_lib.REFRESH_TOKEN_EXPIRE_DAYS * 86400,
)
def _clear_auth_cookies(response: Response) -> None:
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
def _templates(request: Request) -> Jinja2Templates:
return request.app.state.templates
@router.get("/login")
async def login_page(request: Request):
return _templates(request).TemplateResponse(request, "login.html")
@router.post("/login")
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
user = db.get_user_by_username(username)
if user is None or not auth_lib.verify_password(password, user["password_hash"]):
return _templates(request).TemplateResponse(
request, "login.html",
{"error": "Invalid username or password"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access_token = auth_lib.create_access_token(user["id"], user["role"])
refresh_token, jti, expires_at = auth_lib.create_refresh_token(user["id"])
db.store_refresh_token(jti, user["id"], expires_at)
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_303_SEE_OTHER)
_set_auth_cookies(response, access_token, refresh_token)
return response
@router.post("/logout")
async def logout(request: Request):
refresh_token = request.cookies.get("refresh_token")
if refresh_token:
payload = auth_lib.decode_refresh_token(refresh_token)
if payload and payload.get("jti"):
db.revoke_refresh_token(payload["jti"])
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
_clear_auth_cookies(response)
return response
@router.post("/refresh")
async def refresh(request: Request):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token")
payload = auth_lib.decode_refresh_token(refresh_token)
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
jti = payload.get("jti")
if not jti or not db.is_refresh_token_valid(jti):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired")
user = db.get_user_by_id(payload["sub"])
if user is None or not user["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
# Rotate: revoke old, issue new
db.revoke_refresh_token(jti)
new_access = auth_lib.create_access_token(user["id"], user["role"])
new_refresh, new_jti, expires_at = auth_lib.create_refresh_token(user["id"])
db.store_refresh_token(new_jti, user["id"], expires_at)
response = Response(status_code=status.HTTP_204_NO_CONTENT)
_set_auth_cookies(response, new_access, new_refresh)
return response

View File

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

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

@@ -0,0 +1,108 @@
"""
web/routes/dashboard.py — Dashboard views and SSE live stream.
GET / → redirect to /dashboard
GET /dashboard → overview: all groups, stats, live hit feed
GET /dashboard/groups/{group_id} → per-group hits + filtered SSE
GET /api/stream → SSE event stream (one hit per event)
GET /api/stats → JSON severity counts (for hx-trigger refresh)
"""
import asyncio
import json
import queue
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
import config
from tui import events as bus
from utils import database as hitdb
from web.dependencies import require_role
router = APIRouter()
def _templates(request: Request):
return request.app.state.templates
def _hit_to_dict(hit) -> dict:
reasons_raw = hit["reasons"] or ""
reasons = [r.strip() for r in reasons_raw.split("|") if r.strip()]
return {
"severity": hit["severity"],
"score": hit["score"],
"raw": hit["raw"],
"source": hit["source"],
"filename": hit["filename"],
"reasons": reasons,
"timestamp": hit["timestamp"],
}
@router.get("/")
async def root():
return RedirectResponse(url="/dashboard")
@router.get("/dashboard")
async def dashboard(request: Request, user=Depends(require_role("reader"))):
hits = [_hit_to_dict(h) for h in hitdb.recent(limit=50)]
counts = hitdb.count_by_severity()
groups = config.KEYWORD_GROUPS
return _templates(request).TemplateResponse(
request, "dashboard.html",
{"user": dict(user), "hits": hits, "counts": counts, "groups": groups},
)
@router.get("/dashboard/groups/{group_id}")
async def group_detail(request: Request, group_id: str, user=Depends(require_role("reader"))):
groups = config.KEYWORD_GROUPS
group = next((g for g in groups if g["id"] == group_id), None)
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
patterns = [p["regex"] for p in group.get("patterns", [])]
hits = [_hit_to_dict(h) for h in hitdb.recent_for_domains(patterns, limit=100)]
counts = hitdb.count_by_severity_for_domains(patterns)
return _templates(request).TemplateResponse(
request, "group_detail.html",
{"user": dict(user), "group": group, "hits": hits, "counts": counts},
)
@router.get("/api/stream")
async def event_stream(request: Request, _user=Depends(require_role("reader"))):
"""Server-Sent Events: one data frame per EvHit."""
q = bus.subscribe()
async def generate():
try:
while True:
if await request.is_disconnected():
break
try:
ev = q.get_nowait()
if isinstance(ev, bus.EvHit):
payload = json.dumps({
"severity": ev.severity,
"raw": ev.raw,
"source": ev.source,
"filename": ev.filename,
"reasons": ev.reasons,
})
yield f"data: {payload}\n\n"
except queue.Empty:
await asyncio.sleep(0.1)
finally:
bus.unsubscribe(q)
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/api/stats")
async def stats(_user=Depends(require_role("reader"))):
return hitdb.count_by_severity()

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

@@ -0,0 +1,82 @@
"""
web/routes/users.py — User CRUD (superadmin only).
GET /users → list all users
POST /users → create a new user
PATCH /users/{id} → update role / password / active flag
DELETE /users/{id} → deactivate (cannot delete self or other superadmins)
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from web import db
from web.dependencies import require_role
from web.models import CreateUserRequest, UpdateUserRequest
router = APIRouter()
def _templates(request: Request):
return request.app.state.templates
@router.get("/users")
async def list_users(request: Request, user=Depends(require_role("superadmin"))):
users = db.list_users()
return _templates(request).TemplateResponse(
request, "users.html",
{"user": dict(user), "users": [dict(u) for u in users]},
)
@router.post("/users")
async def create_user(
payload: CreateUserRequest,
_user=Depends(require_role("superadmin")),
):
try:
user_id = db.create_user(payload.username, payload.password, payload.role)
except Exception as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return {"id": user_id}
@router.patch("/users/{user_id}")
async def update_user(
user_id: str,
payload: UpdateUserRequest,
acting_user=Depends(require_role("superadmin")),
):
target = db.get_user_by_id(user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
# Cannot demote another superadmin via role patch
if target["role"] == "superadmin" and payload.role and payload.role != "superadmin":
raise HTTPException(status_code=403, detail="Cannot demote another superadmin")
updates: dict = {}
if payload.password is not None:
updates["password"] = payload.password
if payload.role is not None:
updates["role"] = payload.role
if payload.is_active is not None:
updates["is_active"] = 1 if payload.is_active else 0
db.update_user(user_id, **updates)
return {"status": "ok"}
@router.delete("/users/{user_id}")
async def deactivate_user(
user_id: str,
acting_user=Depends(require_role("superadmin")),
):
if user_id == acting_user["id"]:
raise HTTPException(status_code=403, detail="Cannot deactivate yourself")
target = db.get_user_by_id(user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
db.deactivate_user(user_id)
return {"status": "ok"}

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

@@ -0,0 +1,162 @@
/* ULPgrammer web UI — minimal, dark-ish */
:root {
--bg: #1a1a1a;
--surface: #252525;
--border: #3a3a3a;
--text: #e0e0e0;
--muted: #888;
--critical:#ff4444;
--high: #ff8800;
--medium: #ffcc00;
--low: #44bb44;
--accent: #4a90d9;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav */
nav {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.6rem 1.2rem;
}
.nav-brand { font-weight: 700; font-size: 1rem; color: var(--text); }
.nav-links { display: flex; gap: 1rem; align-items: center; }
main { padding: 1.2rem 1.4rem; max-width: 1200px; }
h2 { margin: 0.8rem 0 0.6rem; }
h3 { margin: 0.6rem 0 0.4rem; }
/* Stats bar */
.stats-bar {
display: flex;
gap: 1rem;
margin: 0.8rem 0;
padding: 0.5rem 0.8rem;
background: var(--surface);
border-radius: 6px;
border: 1px solid var(--border);
}
.stat { font-weight: 600; }
.stat.critical { color: var(--critical); }
.stat.high { color: var(--high); }
.stat.medium { color: var(--medium); }
.stat.low { color: var(--low); }
/* Groups bar */
.groups-bar { margin: 0.5rem 0 1rem; }
.group-pill {
display: inline-block;
margin: 0 0.3rem 0.3rem 0;
padding: 0.2rem 0.6rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 0.85rem;
}
/* Hit cards */
.hit-card {
padding: 0.5rem 0.8rem;
margin: 0.4rem 0;
border-left: 4px solid var(--border);
background: var(--surface);
border-radius: 0 4px 4px 0;
}
.hit-card.sev-critical { border-color: var(--critical); }
.hit-card.sev-high { border-color: var(--high); }
.hit-card.sev-medium { border-color: var(--medium); }
.hit-card.sev-low { border-color: var(--low); }
.sev-badge {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-right: 0.5rem;
background: var(--border);
}
.sev-critical .sev-badge { background: var(--critical); color: #fff; }
.sev-high .sev-badge { background: var(--high); color: #000; }
.sev-medium .sev-badge { background: var(--medium); color: #000; }
.sev-low .sev-badge { background: var(--low); color: #000; }
code.raw { font-family: monospace; font-size: 0.9rem; word-break: break-all; }
.meta { color: var(--muted); font-size: 0.8rem; margin-left: 0.5rem; }
.reasons { margin: 0.2rem 0 0 1.5rem; color: var(--muted); font-size: 0.8rem; }
/* Tables */
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.8rem;
}
.data-table th, .data-table td {
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
text-align: left;
}
.data-table th { background: var(--surface); }
/* Forms */
label { display: block; margin: 0.4rem 0; }
label input, label select { display: block; width: 100%; margin-top: 0.2rem; }
input, select, textarea {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.3rem 0.5rem;
border-radius: 4px;
}
input:focus, select:focus { outline: 1px solid var(--accent); }
button { cursor: pointer; padding: 0.3rem 0.7rem; border-radius: 4px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-danger { background: var(--critical); color: #fff; border-color: var(--critical); }
.btn-add { margin-top: 0.3rem; }
.btn-link { background: none; border: none; color: var(--accent); padding: 0; }
/* Tabs */
.tabs { display: flex; gap: 0.5rem; margin: 0.6rem 0; }
.tab { padding: 0.3rem 0.8rem; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Config fieldsets */
.group-fieldset { border: 1px solid var(--border); padding: 0.7rem; margin: 0.6rem 0; border-radius: 4px; }
.group-fieldset legend input { background: transparent; border: none; font-size: 1rem; font-weight: 600; color: var(--text); }
.patterns-table { width: 100%; border-collapse: collapse; }
.patterns-table td { padding: 0.2rem 0.4rem; }
.patterns-table input { width: 100%; }
/* Login */
.login-body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { width: 320px; padding: 2rem; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); }
.login-box h1 { margin-bottom: 1rem; text-align: center; }
.login-box button { width: 100%; margin-top: 0.8rem; }
/* Misc */
.error { color: var(--critical); margin: 0.4rem 0; }
.hint { color: var(--muted); font-size: 0.85rem; margin: 0.3rem 0; }
.empty { color: var(--muted); font-style: italic; }
dialog { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; min-width: 340px; }
dialog::backdrop { background: rgba(0,0,0,0.6); }
.dialog-actions { display: flex; gap: 0.5rem; margin-top: 0.8rem; }
.patterns-list { margin: 0.5rem 0; }
details summary { cursor: pointer; }

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

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

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

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

View File

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

View File

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

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

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

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

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