diff --git a/README.md b/README.md index fe1fe2c..139d4a2 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/TODO.md b/TODO.md index 6045b93..2007301 100644 --- a/TODO.md +++ b/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 diff --git a/backend/main.py b/backend/main.py index 7a6acc6..42fd53e 100644 --- a/backend/main.py +++ b/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(): diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 82c4ab9..327cbbd 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -308,6 +308,7 @@ function AdminPage({ const [form, setForm] = useState(emptyForm()); const [file, setFile] = useState(null); const [editingId, setEditingId] = useState(null); + const [restoreText, setRestoreText] = useState(''); const startEdit = (link: LinkItem) => { setEditingId(link.id); @@ -330,29 +331,92 @@ function AdminPage({ return (
-
{ - event.preventDefault(); - await onSave(form, file, editingId ?? undefined); - reset(); - }} - > - setForm({ ...form, name: e.target.value })} required /> - setForm({ ...form, url: e.target.value })} required /> - setForm({ ...form, description: e.target.value })} /> - setForm({ ...form, category: e.target.value })} /> - setForm({ ...form, icon_url: e.target.value })} /> - setFile(e.target.files?.[0] ?? null)} /> - -
- - {editingId ? : null} +
+ { + event.preventDefault(); + await onSave(form, file, editingId ?? undefined); + reset(); + }} + > + setForm({ ...form, name: e.target.value })} required /> + setForm({ ...form, url: e.target.value })} required /> + setForm({ ...form, description: e.target.value })} /> + setForm({ ...form, category: e.target.value })} /> + setForm({ ...form, icon_url: e.target.value })} /> + setFile(e.target.files?.[0] ?? null)} /> + +
+ + {editingId ? : null} +
+ +
+
Backup / Restore
+
+ + { + const f = e.target.files?.[0]; + if (!f) return; + setRestoreText(await f.text()); + }} + /> +
+