"""Admin: File locations (Gitea issue 7). The archive folder under ARCHIVE_ROOT, its check, and the archive copies. Every route needs the admin role and a password session. Writes answer 409 `archive_off` while ARCHIVE_ROOT is unset. The check and the save are audited (`archive.checked`, `admin.file_locations_updated`); "Retry now" is audited as `admin.archive_retry` through the job request. """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Request from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, ConfigDict, Field from starlette.concurrency import run_in_threadpool from starlette.responses import JSONResponse from ..domain import archive from .deps import Db, Principal, admin_principal, get_db, settings_of from .errors import ApiError from .jobs import request_run from .packets import WRITE router = APIRouter(prefix="/api/admin/file-locations", tags=["admin"]) class CheckBody(BaseModel): model_config = ConfigDict(extra="forbid") archive_dir: str = Field(max_length=archive.DIR_MAX + 100) class PutBody(BaseModel): model_config = ConfigDict(extra="forbid") archive_dir: str = Field(max_length=archive.DIR_MAX + 100) version: int = Field(ge=0) def _out(conn: Any, request: Request) -> dict[str, Any]: root = archive.root_of(settings_of(request)) archive_dir, version, _, _ = archive.load(conn) resolved = None if root is not None and archive_dir is not None: try: resolved = str(archive.resolve_dir(root, archive_dir)[1]) except archive.PathRefused: resolved = None last = archive.last_check(conn) return { "enabled": root is not None, "root": str(root) if root is not None else None, "archive_dir": archive_dir, "version": version, "resolved": resolved, "last_check": {k: last[k] for k in ("ok", "at", "by", "message")} if last else None, "copies": archive.copies_status(conn), } def _root_or_409(request: Request) -> Any: root = archive.root_of(settings_of(request)) if root is None: raise ApiError(409, "archive_off", archive.OFF_MESSAGE) return root @router.get("") def file_locations( request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: return _out(d.conn, request) @router.post("/check", dependencies=WRITE) async def file_locations_check( body: CheckBody, request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: root = _root_or_409(request) rel, result = await run_in_threadpool(archive.check, root, body.archive_dir) await run_in_threadpool( archive.audit_check, d.conn, result, archive_dir=rel or body.archive_dir, source="admin", who=p ) if rel is None: # The path rules refused it (not a failed probe): 422 with the reason, still audited above. raise ApiError(422, "bad_path", result.message) return result.as_dict() @router.put("", dependencies=WRITE) async def file_locations_put( body: PutBody, request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> Any: root = _root_or_409(request) conn = d.conn rel, result = await run_in_threadpool(archive.check, root, body.archive_dir) await run_in_threadpool( archive.audit_check, conn, result, archive_dir=rel or body.archive_dir, source="save", who=p ) if rel is None: raise ApiError(422, "bad_path", result.message) if not result.ok: raise ApiError(422, "check_failed", result.message, extra={"check": result.as_dict()}) def save() -> dict[str, Any]: try: archive.save(conn, who=p, archive_dir=rel, version=body.version) except archive.VersionConflict: raise ApiError( 409, "version_conflict", "Someone else changed the file locations. Reload and try again.", extra={"current": jsonable_encoder(_out(conn, request))}, ) from None with conn.transaction(): archive.request_copy(conn, p.user_id, "archive folder saved") return _out(conn, request) return await run_in_threadpool(save) @router.post("/retry", dependencies=WRITE) def file_locations_retry( request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> JSONResponse: _root_or_409(request) conn = d.conn with conn.transaction(): reset = archive.reset_backoff(conn) out = request_run(conn, p, archive.JOB, note="Retry now (File locations)", action="admin.archive_retry") return JSONResponse(status_code=202, content={**out, "reset": reset})