"""JSON API for the organizer Android app (upload + job status).""" from __future__ import annotations import hashlib import hmac import logging import uuid from pathlib import Path from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile from fastapi.responses import JSONResponse from . import config, jobs, pipeline logger = logging.getLogger("livef12.api") router = APIRouter(prefix="/api") _CHUNK = 1024 * 1024 _JSON_HEADERS = {"Cache-Control": "no-store"} def _provided_token( authorization: str | None, x_upload_token: str | None, ) -> str: if x_upload_token and x_upload_token.strip(): return x_upload_token.strip() if authorization: scheme, _, rest = authorization.partition(" ") if scheme.lower() == "bearer" and rest.strip(): return rest.strip() return "" def _token_ok(provided: str) -> bool: expected = config.UPLOAD_TOKEN if not expected or not provided: return False # Hash first so compare_digest never sees unequal lengths. left = hashlib.sha256(provided.encode("utf-8")).digest() right = hashlib.sha256(expected.encode("utf-8")).digest() return hmac.compare_digest(left, right) def require_upload_token( authorization: str | None, x_upload_token: str | None, ) -> None: if not config.UPLOAD_TOKEN: raise HTTPException(status_code=503, detail="Upload ist nicht konfiguriert") if not _token_ok(_provided_token(authorization, x_upload_token)): raise HTTPException(status_code=401, detail="Ungültiger Upload-Token") @router.get("/health") def health() -> JSONResponse: return JSONResponse( {"ok": True, "upload": bool(config.UPLOAD_TOKEN)}, headers=_JSON_HEADERS, ) @router.get("/jobs") def api_list_jobs() -> JSONResponse: items = jobs.list_jobs() return JSONResponse( { "jobs": items, "active": any(j["status"] in jobs.ACTIVE_STATUSES for j in items), }, headers=_JSON_HEADERS, ) @router.get("/jobs/{job_id}") def api_job_detail(job_id: str) -> JSONResponse: return JSONResponse(jobs.job_detail_payload(job_id), headers=_JSON_HEADERS) @router.post("/jobs", status_code=202) async def api_upload_job( request: Request, file: UploadFile = File(...), authorization: str | None = Header(default=None), x_upload_token: str | None = Header(default=None), ) -> JSONResponse: require_upload_token(authorization, x_upload_token) original_name = file.filename or "photo.jpg" dest_name = jobs.make_inbox_filename(original_name) suffix = Path(dest_name).suffix.lower() if suffix not in config.SUPPORTED_EXTENSIONS: raise HTTPException(status_code=400, detail="Dateityp nicht unterstützt") config.INBOX_DIR.mkdir(parents=True, exist_ok=True) config.META_DIR.mkdir(parents=True, exist_ok=True) tmp = config.INBOX_DIR / f".upload_{uuid.uuid4().hex}" dest = config.INBOX_DIR / dest_name written = 0 try: with tmp.open("wb") as out: while True: chunk = await file.read(_CHUNK) if not chunk: break written += len(chunk) if written > config.MAX_UPLOAD_BYTES: raise HTTPException( status_code=413, detail=f"Datei zu groß (max {config.MAX_UPLOAD_BYTES // (1024 * 1024)} MB)", ) out.write(chunk) if written == 0: raise HTTPException(status_code=400, detail="Leere Datei") tmp.replace(dest) except HTTPException: tmp.unlink(missing_ok=True) raise except OSError as exc: tmp.unlink(missing_ok=True) logger.exception("upload write failed") raise HTTPException(status_code=500, detail="Upload konnte nicht gespeichert werden") from exc finally: await file.close() stem = pipeline.sanitize_stem(dest.stem) paths = pipeline.job_paths(stem, suffix) pipeline.write_status( paths, "queued", source_file=dest.name, source_stem=stem, stage="queued", progress={"current": 0, "total": config.OUTPUT_COUNT}, error=None, uploaded_from=request.client.host if request.client else None, ) logger.info("queued upload %s -> job %s (%d bytes)", dest.name, stem, written) payload = jobs.summarize_job(stem) payload["status"] = "queued" return JSONResponse(payload, status_code=202, headers=_JSON_HEADERS)