admin: add backup/restore flow and structured request logging
This commit is contained in:
@@ -14,6 +14,8 @@ Dark dashboard for Arr* services and custom links.
|
||||
- Icon blobs stored in the database
|
||||
- Containerized app deployment (requires MariaDB)
|
||||
- Admin-managed service links
|
||||
- Admin backup/export and restore with dry-run validation
|
||||
- Structured JSON logs with request IDs (`x-request-id`)
|
||||
|
||||
## Local Dev
|
||||
|
||||
@@ -59,6 +61,12 @@ The app expects a MariaDB instance configured through environment variables.
|
||||
- `USERNAME_MAX_LEN` (default: `64`)
|
||||
- `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
|
||||
|
||||
Add these secrets in Gitea:
|
||||
|
||||
12
TODO.md
12
TODO.md
@@ -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] Remove duplicate connection pattern in create flow.
|
||||
- [x] Use one DB transaction/connection per request path where possible.
|
||||
- Add backup and restore flow in admin API/UI.
|
||||
- Download full export.
|
||||
- Upload validated import with explicit confirmation.
|
||||
- Add dry-run validation mode before apply.
|
||||
- Add structured logging.
|
||||
- Log auth attempts, CRUD actions, and restore events with request IDs.
|
||||
- [x] Add backup and restore flow in admin API/UI.
|
||||
- [x] Download full export.
|
||||
- [x] Upload validated import with explicit confirmation.
|
||||
- [x] Add dry-run validation mode before apply.
|
||||
- [x] Add structured logging.
|
||||
- [x] Log auth attempts, CRUD actions, and restore events with request IDs.
|
||||
|
||||
## P2 - UX and Product Improvements
|
||||
|
||||
|
||||
185
backend/main.py
185
backend/main.py
@@ -3,6 +3,10 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import contextmanager
|
||||
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=["*"])
|
||||
login_attempts: dict[str, list[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")
|
||||
@@ -186,6 +193,11 @@ class LoginIn(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class RestoreIn(BaseModel):
|
||||
data: dict
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.utcnow().isoformat()
|
||||
|
||||
@@ -194,6 +206,10 @@ def utc_now_db() -> datetime:
|
||||
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:
|
||||
now = datetime.utcnow().timestamp()
|
||||
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
|
||||
@@ -300,6 +316,66 @@ def require_csrf(request: Request):
|
||||
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:
|
||||
with conn.cursor() as cur:
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
def me(request: Request, response: Response):
|
||||
current = current_user(request, response)
|
||||
@@ -370,6 +455,7 @@ def setup(inp: SetupIn):
|
||||
raise HTTPException(400, "Setup already complete")
|
||||
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"))
|
||||
log_event(None, "auth.setup_complete", username=inp.username)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -381,6 +467,7 @@ def login(request: Request, inp: LoginIn):
|
||||
key = login_key(request, inp.username)
|
||||
locked_until = login_lockouts.get(key)
|
||||
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.")
|
||||
with db() as c:
|
||||
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]
|
||||
if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS:
|
||||
login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS
|
||||
log_event(request, "auth.login_failed", username=inp.username)
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
login_attempts.pop(key, None)
|
||||
login_lockouts.pop(key, None)
|
||||
@@ -416,6 +504,7 @@ def login(request: Request, inp: LoginIn):
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
path="/",
|
||||
)
|
||||
log_event(request, "auth.login_success", username=inp.username)
|
||||
return response
|
||||
|
||||
|
||||
@@ -429,6 +518,7 @@ def logout(request: Request):
|
||||
cur.execute("delete from sessions where token=%s", (token,))
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
log_event(request, "auth.logout")
|
||||
return resp
|
||||
|
||||
|
||||
@@ -486,6 +576,7 @@ def create_link(
|
||||
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),
|
||||
)
|
||||
log_event(request, "links.create", name=name)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -507,6 +598,7 @@ def delete_link(request: Request, link_id: int):
|
||||
with db() as c:
|
||||
with c.cursor() as cur:
|
||||
cur.execute("delete from links where id=%s", (link_id,))
|
||||
log_event(request, "links.delete", link_id=link_id)
|
||||
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""",
|
||||
(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}
|
||||
|
||||
|
||||
@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():
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
if PUBLIC_DIR.exists():
|
||||
|
||||
@@ -308,6 +308,7 @@ function AdminPage({
|
||||
const [form, setForm] = useState<LinkForm>(emptyForm());
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [restoreText, setRestoreText] = useState('');
|
||||
|
||||
const startEdit = (link: LinkItem) => {
|
||||
setEditingId(link.id);
|
||||
@@ -330,29 +331,92 @@ function AdminPage({
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<form
|
||||
className="panel space-y-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await onSave(form, file, editingId ?? undefined);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
|
||||
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
||||
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
|
||||
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="panel space-y-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await onSave(form, file, editingId ?? undefined);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
|
||||
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
|
||||
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
|
||||
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<ul className="admin-list">
|
||||
|
||||
@@ -129,3 +129,29 @@ def test_login_rate_limit_lockout(client: TestClient):
|
||||
assert resp.status_code == 401
|
||||
locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user