admin: add backup/restore flow and structured request logging
All checks were successful
docker / test (push) Successful in 14s
docker / build-and-push (push) Successful in 1m36s

This commit is contained in:
Space-Banane
2026-05-20 22:44:02 +02:00
parent 791126cdd0
commit fd874c9499
5 changed files with 311 additions and 28 deletions

View File

@@ -14,6 +14,8 @@ Dark dashboard for Arr* services and custom links.
- Icon blobs stored in the database - Icon blobs stored in the database
- Containerized app deployment (requires MariaDB) - Containerized app deployment (requires MariaDB)
- Admin-managed service links - Admin-managed service links
- Admin backup/export and restore with dry-run validation
- Structured JSON logs with request IDs (`x-request-id`)
## Local Dev ## Local Dev
@@ -59,6 +61,12 @@ The app expects a MariaDB instance configured through environment variables.
- `USERNAME_MAX_LEN` (default: `64`) - `USERNAME_MAX_LEN` (default: `64`)
- `PASSWORD_MIN_LEN` (default: `12`) - `PASSWORD_MIN_LEN` (default: `12`)
### Backup / Restore API
- `GET /api/admin/backup` exports users and links as JSON
- `POST /api/admin/restore?dry_run=true` validates a backup payload without applying
- `POST /api/admin/restore?dry_run=false` applies restore when body includes `"confirm": true`
## Gitea CI/CD ## Gitea CI/CD
Add these secrets in Gitea: Add these secrets in Gitea:

12
TODO.md
View File

@@ -34,12 +34,12 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
- [x] Update read query to order by `enabled desc`, `sort_order`, `name`. - [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
- [x] Remove duplicate connection pattern in create flow. - [x] Remove duplicate connection pattern in create flow.
- [x] Use one DB transaction/connection per request path where possible. - [x] Use one DB transaction/connection per request path where possible.
- Add backup and restore flow in admin API/UI. - [x] Add backup and restore flow in admin API/UI.
- Download full export. - [x] Download full export.
- Upload validated import with explicit confirmation. - [x] Upload validated import with explicit confirmation.
- Add dry-run validation mode before apply. - [x] Add dry-run validation mode before apply.
- Add structured logging. - [x] Add structured logging.
- Log auth attempts, CRUD actions, and restore events with request IDs. - [x] Log auth attempts, CRUD actions, and restore events with request IDs.
## P2 - UX and Product Improvements ## P2 - UX and Product Improvements

View File

@@ -3,6 +3,10 @@ from __future__ import annotations
import secrets import secrets
import os import os
import time import time
import json
import logging
import uuid
import base64
from datetime import datetime, timezone from datetime import datetime, timezone
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
@@ -45,6 +49,9 @@ app = FastAPI(title="Jellomator")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
login_attempts: dict[str, list[float]] = {} login_attempts: dict[str, list[float]] = {}
login_lockouts: dict[str, float] = {} login_lockouts: dict[str, float] = {}
logger = logging.getLogger("jellomator")
if not logger.handlers:
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(message)s")
@app.get("/healthz") @app.get("/healthz")
@@ -186,6 +193,11 @@ class LoginIn(BaseModel):
password: str password: str
class RestoreIn(BaseModel):
data: dict
confirm: bool = False
def utc_now_iso() -> str: def utc_now_iso() -> str:
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
@@ -194,6 +206,10 @@ def utc_now_db() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
def utc_now_iso_z() -> str:
return datetime.now(timezone.utc).isoformat()
def expires_at_iso() -> str: def expires_at_iso() -> str:
now = datetime.utcnow().timestamp() now = datetime.utcnow().timestamp()
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat() return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
@@ -300,6 +316,66 @@ def require_csrf(request: Request):
raise HTTPException(403, "Invalid CSRF token") raise HTTPException(403, "Invalid CSRF token")
def log_event(request: Request | None, event: str, **fields):
payload = {"event": event, "ts": utc_now_iso_z(), **fields}
if request is not None:
payload["request_id"] = getattr(request.state, "request_id", None)
payload["method"] = request.method
payload["path"] = request.url.path
payload["client_ip"] = client_ip(request)
logger.info(json.dumps(payload, separators=(",", ":")))
def parse_backup_payload(data: dict) -> tuple[list[dict], list[dict]]:
if not isinstance(data, dict):
raise HTTPException(422, "backup payload must be an object")
users = data.get("users")
links = data.get("links")
if not isinstance(users, list) or not isinstance(links, list):
raise HTTPException(422, "backup payload must include users[] and links[]")
parsed_users: list[dict] = []
parsed_links: list[dict] = []
for idx, user in enumerate(users):
if not isinstance(user, dict):
raise HTTPException(422, f"users[{idx}] must be an object")
username = str(user.get("username", "")).strip()
role = str(user.get("role", "")).strip() or "admin"
password_hash_b64 = str(user.get("password_hash_b64", "")).strip()
if not username or not password_hash_b64:
raise HTTPException(422, f"users[{idx}] must include username and password_hash_b64")
try:
password_hash = base64.b64decode(password_hash_b64.encode("ascii"), validate=True)
except Exception as exc:
raise HTTPException(422, f"users[{idx}] has invalid password_hash_b64") from exc
parsed_users.append({"username": username, "role": role, "password_hash": password_hash})
for idx, link in enumerate(links):
if not isinstance(link, dict):
raise HTTPException(422, f"links[{idx}] must be an object")
name = str(link.get("name", "")).strip()
url = str(link.get("url", "")).strip()
description = str(link.get("description", "") or "")
category = str(link.get("category", "") or "General")
icon_url = link.get("icon_url")
sort_order = int(link.get("sort_order", 0) or 0)
enabled = bool(link.get("enabled", True))
validate_link_payload(name, url, description, category, icon_url)
parsed_links.append(
{
"name": name,
"url": url,
"description": description,
"category": category,
"icon_url": icon_url,
"sort_order": sort_order,
"enabled": enabled,
}
)
lower_names = [l["name"].lower() for l in parsed_links]
if len(lower_names) != len(set(lower_names)):
raise HTTPException(422, "backup has duplicate link names")
return parsed_users, parsed_links
def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool: def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool:
with conn.cursor() as cur: with conn.cursor() as cur:
if exclude_id is None: if exclude_id is None:
@@ -350,6 +426,15 @@ def read_icon_blob(icon: UploadFile | None) -> tuple[bytes | None, str | None]:
return blob, icon.content_type return blob, icon.content_type
@app.middleware("http")
async def request_context(request: Request, call_next):
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
request.state.request_id = request_id
response = await call_next(request)
response.headers["x-request-id"] = request_id
return response
@app.get("/api/me") @app.get("/api/me")
def me(request: Request, response: Response): def me(request: Request, response: Response):
current = current_user(request, response) current = current_user(request, response)
@@ -370,6 +455,7 @@ def setup(inp: SetupIn):
raise HTTPException(400, "Setup already complete") raise HTTPException(400, "Setup already complete")
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt()) pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin")) cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin"))
log_event(None, "auth.setup_complete", username=inp.username)
return {"ok": True} return {"ok": True}
@@ -381,6 +467,7 @@ def login(request: Request, inp: LoginIn):
key = login_key(request, inp.username) key = login_key(request, inp.username)
locked_until = login_lockouts.get(key) locked_until = login_lockouts.get(key)
if locked_until and locked_until > now_ts: if locked_until and locked_until > now_ts:
log_event(request, "auth.login_blocked", username=inp.username, locked_until=locked_until)
raise HTTPException(429, "Too many login attempts. Try again later.") raise HTTPException(429, "Too many login attempts. Try again later.")
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
@@ -392,6 +479,7 @@ def login(request: Request, inp: LoginIn):
login_attempts[key] = [t for t in attempts if t >= now_ts - LOGIN_WINDOW_SECONDS] login_attempts[key] = [t for t in attempts if t >= now_ts - LOGIN_WINDOW_SECONDS]
if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS: if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS:
login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS
log_event(request, "auth.login_failed", username=inp.username)
raise HTTPException(401, "Invalid credentials") raise HTTPException(401, "Invalid credentials")
login_attempts.pop(key, None) login_attempts.pop(key, None)
login_lockouts.pop(key, None) login_lockouts.pop(key, None)
@@ -416,6 +504,7 @@ def login(request: Request, inp: LoginIn):
max_age=SESSION_TTL_SECONDS, max_age=SESSION_TTL_SECONDS,
path="/", path="/",
) )
log_event(request, "auth.login_success", username=inp.username)
return response return response
@@ -429,6 +518,7 @@ def logout(request: Request):
cur.execute("delete from sessions where token=%s", (token,)) cur.execute("delete from sessions where token=%s", (token,))
resp = JSONResponse({"ok": True}) resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/") resp.delete_cookie(SESSION_COOKIE, path="/")
log_event(request, "auth.logout")
return resp return resp
@@ -486,6 +576,7 @@ def create_link(
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now), (name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now),
) )
log_event(request, "links.create", name=name)
return {"ok": True} return {"ok": True}
@@ -507,6 +598,7 @@ def delete_link(request: Request, link_id: int):
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("delete from links where id=%s", (link_id,)) cur.execute("delete from links where id=%s", (link_id,))
log_event(request, "links.delete", link_id=link_id)
return {"ok": True} return {"ok": True}
@@ -541,9 +633,102 @@ def update_link(
"""update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", """update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""",
(name, url, description, category, icon_url, int(enabled), now, link_id), (name, url, description, category, icon_url, int(enabled), now, link_id),
) )
log_event(request, "links.update", link_id=link_id, name=name)
return {"ok": True} return {"ok": True}
@app.get("/api/admin/backup")
def backup(request: Request):
require_admin(request)
with db() as c:
with c.cursor() as cur:
cur.execute("select username, role, password_hash from users order by id asc")
users = cur.fetchall()
cur.execute(
"select name,url,description,category,sort_order,icon_url,enabled,created_at,updated_at from links order by id asc"
)
links_rows = cur.fetchall()
users_out = [
{
"username": u["username"],
"role": u["role"],
"password_hash_b64": base64.b64encode(u["password_hash"]).decode("ascii"),
}
for u in users
]
links_out = []
for link in links_rows:
links_out.append(
{
"name": link["name"],
"url": link["url"],
"description": link["description"],
"category": link["category"],
"sort_order": link["sort_order"],
"icon_url": link["icon_url"],
"enabled": bool(link["enabled"]),
"created_at": link["created_at"].replace(tzinfo=timezone.utc).isoformat() if link["created_at"] else None,
"updated_at": link["updated_at"].replace(tzinfo=timezone.utc).isoformat() if link["updated_at"] else None,
}
)
out = {"version": 1, "exported_at": utc_now_iso_z(), "users": users_out, "links": links_out}
log_event(request, "backup.export", users=len(users_out), links=len(links_out))
return out
@app.post("/api/admin/restore")
def restore(request: Request, inp: RestoreIn, dry_run: bool = True):
require_admin(request)
require_csrf(request)
parsed_users, parsed_links = parse_backup_payload(inp.data)
if dry_run:
log_event(request, "backup.restore_dry_run", users=len(parsed_users), links=len(parsed_links))
return {"ok": True, "dry_run": True, "users": len(parsed_users), "links": len(parsed_links)}
if not inp.confirm:
raise HTTPException(400, "confirm=true is required to apply restore")
now = utc_now_db()
with db() as c:
c.begin()
try:
with c.cursor() as cur:
cur.execute("set foreign_key_checks=0")
cur.execute("delete from sessions")
cur.execute("delete from users")
cur.execute("delete from links")
for user in parsed_users:
cur.execute(
"insert into users(username,password_hash,role) values (%s,%s,%s)",
(user["username"], user["password_hash"], user["role"]),
)
for link in parsed_links:
cur.execute(
"""
insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
link["name"],
link["url"],
link["description"],
link["category"],
link["sort_order"],
None,
None,
link["icon_url"],
int(link["enabled"]),
now,
now,
),
)
cur.execute("set foreign_key_checks=1")
c.commit()
except Exception:
c.rollback()
raise
log_event(request, "backup.restore_apply", users=len(parsed_users), links=len(parsed_links))
return {"ok": True, "dry_run": False, "users": len(parsed_users), "links": len(parsed_links)}
if STATIC_DIR.exists(): if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
if PUBLIC_DIR.exists(): if PUBLIC_DIR.exists():

View File

@@ -308,6 +308,7 @@ function AdminPage({
const [form, setForm] = useState<LinkForm>(emptyForm()); const [form, setForm] = useState<LinkForm>(emptyForm());
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [restoreText, setRestoreText] = useState('');
const startEdit = (link: LinkItem) => { const startEdit = (link: LinkItem) => {
setEditingId(link.id); setEditingId(link.id);
@@ -330,6 +331,7 @@ function AdminPage({
return ( return (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]"> <div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
<div className="space-y-4">
<form <form
className="panel space-y-2" className="panel space-y-2"
onSubmit={async (event) => { onSubmit={async (event) => {
@@ -353,6 +355,68 @@ function AdminPage({
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null} {editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
</div> </div>
</form> </form>
<div className="panel space-y-2">
<div className="text-xs text-slate-400">Backup / Restore</div>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const backup = await api.request<Record<string, unknown>>('/api/admin/backup');
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `jellomator-backup-${new Date().toISOString().replaceAll(':', '-')}.json`;
a.click();
URL.revokeObjectURL(a.href);
}}
>
Export backup
</button>
<input
type="file"
accept="application/json"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setRestoreText(await f.text());
}}
/>
</div>
<textarea
className="input min-h-32"
placeholder="Paste backup JSON here"
value={restoreText}
onChange={(e) => setRestoreText(e.target.value)}
/>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) });
alert('Restore dry-run passed.');
}}
>
Validate (dry-run)
</button>
<button
type="button"
className="btn-subtle"
onClick={async () => {
if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return;
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) });
alert('Restore applied.');
window.location.reload();
}}
>
Apply restore
</button>
</div>
</div>
</div>
<div className="panel"> <div className="panel">
<ul className="admin-list"> <ul className="admin-list">

View File

@@ -129,3 +129,29 @@ def test_login_rate_limit_lockout(client: TestClient):
assert resp.status_code == 401 assert resp.status_code == 401
locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"}) locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
assert locked.status_code == 429 assert locked.status_code == 429
def test_backup_restore_dry_run_and_apply(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
client.post("/api/login", json={"username": "admin", "password": "123456789012"})
client.post(
"/api/links",
data={
"name": "Sonarr",
"url": "https://sonarr.example.com",
"description": "TV",
"category": "Arr",
"enabled": "true",
},
)
backup_resp = client.get("/api/admin/backup")
assert backup_resp.status_code == 200
backup = backup_resp.json()
assert isinstance(backup.get("users"), list)
assert isinstance(backup.get("links"), list)
dry = client.post("/api/admin/restore?dry_run=true", json={"data": backup, "confirm": False})
assert dry.status_code == 200
assert dry.json()["dry_run"] is True
apply_resp = client.post("/api/admin/restore?dry_run=false", json={"data": backup, "confirm": True})
assert apply_resp.status_code == 200
assert apply_resp.json()["ok"] is True