|
# retoor <retoor@molodetz.nl>
|
|
|
|
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(request: Request, 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(request, 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(request, 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")
|
|
)
|