# retoor import logging from pathlib import Path from typing import Annotated from fastapi import Depends, APIRouter, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from devplacepy import config from devplacepy.models import BackupRunForm, BackupScheduleForm from devplacepy.responses import action_result, respond, wants_json from devplacepy.schemas import BackupDashboardOut, BackupJobOut, BackupOut from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.services.audit import record as audit from devplacepy.services.backup import store from devplacepy.services.devii.tasks.schedule import cron_next, next_run, now_utc, to_iso from devplacepy.services.jobs import queue from devplacepy.utils import is_primary_admin, not_found, require_admin from fastapi import Depends, HTTPException from devplacepy.dependencies import json_or_form logger = logging.getLogger(__name__) router = APIRouter() RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60 def _download_url(row: dict, can_download: bool) -> str | None: if ( can_download and row.get("status") == store.STATUS_DONE and row.get("local_path") ): return f"/admin/backups/{row['uid']}/download" return None def _backup_payload(row: dict, can_download: bool) -> dict: return { **row, "size_human": store.human_bytes(int(row.get("size_bytes") or 0)), "download_url": _download_url(row, can_download), } def _metrics(backups: list[dict]) -> dict: done = [b for b in backups if b["status"] == store.STATUS_DONE] return { "total": len(backups), "done": len(done), "running": len([b for b in backups if b["status"] == store.STATUS_RUNNING]), "pending": len([b for b in backups if b["status"] == store.STATUS_PENDING]), "failed": len([b for b in backups if b["status"] == store.STATUS_FAILED]), "size_bytes": sum(int(b.get("size_bytes") or 0) for b in done), "size_human": store.human_bytes( sum(int(b.get("size_bytes") or 0) for b in done) ), } def _targets() -> list[dict]: return [ {"key": key, "label": meta["label"], "description": meta["description"]} for key, meta in store.BACKUP_TARGETS.items() ] def _dashboard(can_download: bool) -> dict: backups = [_backup_payload(row, can_download) for row in store.list_backups()] schedules = store.list_schedules() return { "storage": store.compute_storage_stats(), "backups": backups, "schedules": schedules, "targets": _targets(), "metrics": _metrics(backups), "generated_at": store.now_iso(), "can_download_backups": can_download, } @router.get("/backups", response_class=HTMLResponse) async def admin_backups(request: Request): admin = require_admin(request) data = _dashboard(is_primary_admin(admin)) base = site_url(request) seo_ctx = base_seo_context( request, title="Backups - Admin", description="Create, schedule, and manage encrypted-at-rest data backups.", breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Admin", "url": "/admin"}, {"name": "Backups", "url": "/admin/backups"}, ], schemas=[website_schema(base)], ) return respond( request, "admin_backups.html", { **seo_ctx, **data, "request": request, "user": admin, "admin_section": "backups", }, model=BackupDashboardOut, ) @router.get("/backups/data") async def admin_backups_data(request: Request): admin = require_admin(request) data = _dashboard(is_primary_admin(admin)) return JSONResponse(BackupDashboardOut.model_validate(data).model_dump(mode="json")) @router.post("/backups/run") async def admin_backups_run( request: Request, data: Annotated[BackupRunForm, Depends(json_or_form(BackupRunForm))] ): admin = require_admin(request) job_uid = queue.enqueue( "backup", {"target": data.target, "schedule_uid": "", "created_by": admin["uid"]}, owner_kind="user", owner_id=admin["uid"], preferred_name=f"{store.target_label(data.target)} (manual)", ) backup_uid = store.create_backup( target=data.target, created_by=admin["uid"], job_uid=job_uid ) audit.record( request, "admin.backup.run", target_type="backup", target_uid=backup_uid, metadata={"target": data.target}, summary=f"started manual backup of {data.target}", links=[audit.job(job_uid)], ) if wants_json(request): return JSONResponse( { "ok": True, "uid": job_uid, "backup_uid": backup_uid, "status_url": f"/admin/backups/jobs/{job_uid}", } ) return action_result(request, "/admin/backups") def _job_payload(job: dict, can_download: bool) -> dict: result = job.get("result", {}) done = job.get("status") == queue.DONE return { "uid": job.get("uid", ""), "kind": job.get("kind", ""), "status": job.get("status", ""), "target": (job.get("payload") or {}).get("target"), "backup_uid": result.get("backup_uid") if done else None, "download_url": ( f"/admin/backups/{result.get('backup_uid')}/download" if can_download and done and result.get("backup_uid") else None ), "error": job.get("error") or None, "bytes_out": int(job.get("bytes_out") or 0), "file_count": int(job.get("item_count") or 0), "sha256": result.get("sha256"), "created_at": job.get("created_at"), "completed_at": job.get("completed_at") or None, } @router.get("/backups/jobs/{uid}") async def admin_backups_job(request: Request, uid: str): admin = require_admin(request) job = queue.get_job(uid) if not job or job.get("kind") != "backup": raise not_found("Backup job not found") return JSONResponse( BackupJobOut.model_validate( _job_payload(job, is_primary_admin(admin)) ).model_dump(mode="json") ) def _first_run(form: BackupScheduleForm) -> str: reference = now_utc() if form.kind == "cron": return to_iso(cron_next(form.cron.strip(), reference)) moment = next_run("interval", form.every_seconds, None, reference) return to_iso(moment) if moment else "" @router.post("/backups/schedules/create") async def admin_backups_schedule_create( request: Request, data: Annotated[BackupScheduleForm, Depends(json_or_form(BackupScheduleForm))] ): admin = require_admin(request) schedule_uid = store.create_schedule( name=data.name, target=data.target, kind=data.kind, every_seconds=data.every_seconds, cron=data.cron.strip(), keep_last=data.keep_last, created_by=admin["uid"], next_run_at=_first_run(data), ) audit.record( request, "admin.backup_schedule.create", target_type="backup_schedule", target_uid=schedule_uid, new_value=f"{data.name} -> {data.target}", metadata={"target": data.target, "kind": data.kind}, summary=f"created backup schedule {data.name}", ) return action_result(request, "/admin/backups") @router.post("/backups/schedules/{uid}/edit") async def admin_backups_schedule_edit( request: Request, uid: str, data: Annotated[BackupScheduleForm, Depends(json_or_form(BackupScheduleForm))] ): require_admin(request) if not store.get_schedule(uid): raise not_found("Schedule not found") store.update_schedule( uid, { "name": data.name, "target": data.target, "kind": data.kind, "every_seconds": data.every_seconds, "cron": data.cron.strip(), "keep_last": data.keep_last, "next_run_at": _first_run(data), }, ) audit.record( request, "admin.backup_schedule.update", target_type="backup_schedule", target_uid=uid, new_value=f"{data.name} -> {data.target}", summary=f"updated backup schedule {data.name}", ) return action_result(request, "/admin/backups") @router.post("/backups/schedules/{uid}/toggle") async def admin_backups_schedule_toggle(request: Request, uid: str): require_admin(request) schedule = store.get_schedule(uid) if not schedule: raise not_found("Schedule not found") enabled = 0 if int(schedule.get("enabled") or 0) else 1 store.update_schedule(uid, {"enabled": enabled}) audit.record( request, "admin.backup_schedule.toggle", target_type="backup_schedule", target_uid=uid, new_value="enabled" if enabled else "disabled", summary=f"{'enabled' if enabled else 'disabled'} backup schedule {schedule.get('name')}", ) return action_result(request, "/admin/backups") @router.post("/backups/schedules/{uid}/run") async def admin_backups_schedule_run(request: Request, uid: str): admin = require_admin(request) schedule = store.get_schedule(uid) if not schedule: raise not_found("Schedule not found") job_uid = queue.enqueue( "backup", { "target": schedule["target"], "schedule_uid": uid, "created_by": admin["uid"], "keep_last": int(schedule.get("keep_last") or 0), }, owner_kind="user", owner_id=admin["uid"], preferred_name=f"{schedule.get('name')} (manual run)", ) store.create_backup( target=schedule["target"], created_by=admin["uid"], job_uid=job_uid, schedule_uid=uid, ) audit.record( request, "admin.backup.run", target_type="backup_schedule", target_uid=uid, metadata={"target": schedule["target"], "schedule_uid": uid}, summary=f"manually ran backup schedule {schedule.get('name')}", links=[audit.job(job_uid)], ) return action_result(request, "/admin/backups") @router.post("/backups/schedules/{uid}/delete") async def admin_backups_schedule_delete(request: Request, uid: str): admin = require_admin(request) schedule = store.get_schedule(uid) if not schedule: raise not_found("Schedule not found") store.delete_schedule(uid, admin["uid"]) audit.record( request, "admin.backup_schedule.delete", target_type="backup_schedule", target_uid=uid, old_value=schedule.get("name"), summary=f"deleted backup schedule {schedule.get('name')}", ) return action_result(request, "/admin/backups") @router.get("/backups/{uid}/download") async def admin_backups_download(request: Request, uid: str): admin = require_admin(request) if not is_primary_admin(admin): audit.record( request, "security.authz.denied", user=admin, result="denied", summary=f"non-primary admin {admin['username']} denied backup download", ) raise HTTPException( status_code=403, detail="Only the primary administrator may download backups", ) row = store.get_backup(uid) if not row or row.get("status") != store.STATUS_DONE: raise not_found("Backup not available") local_path = row.get("local_path") or "" resolved = Path(local_path).resolve() if ( not local_path or not resolved.is_relative_to(config.BACKUPS_DIR.resolve()) or not resolved.is_file() ): raise not_found("Backup not available") return FileResponse( resolved, filename=row.get("filename", "backup.tar.gz"), media_type="application/gzip", ) @router.post("/backups/{uid}/delete") async def admin_backups_delete(request: Request, uid: str): require_admin(request) row = store.get_backup(uid) if not row: raise not_found("Backup not found") store.delete_backup(uid) audit.record( request, "admin.backup.delete", target_type="backup", target_uid=uid, old_value=row.get("filename"), metadata={"target": row.get("target"), "size_bytes": row.get("size_bytes")}, summary=f"deleted backup {row.get('filename')}", ) return action_result(request, "/admin/backups") @router.get("/backups/{uid}") async def admin_backups_detail(request: Request, uid: str): admin = require_admin(request) row = store.get_backup(uid) if not row: raise not_found("Backup not found") return JSONResponse( BackupOut.model_validate( _backup_payload(row, is_primary_admin(admin)) ).model_dump(mode="json") )