Files
devplacepy/devplacepy/routers/admin/backups.py
T
retoor 6ceca3d0d4 docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API
- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
2026-06-18 22:09:34 +00:00

374 lines
13 KiB
Python

# 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(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")
)