Files
Frank Schwenk c03f17488c
CI / lint-and-test (push) Failing after 1m18s
feat: organizer Android app and token-gated upload API
Replace Syncthing as the product upload path with a share-target app
and POST /api/jobs. Web UI live-polls job progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 21:38:50 +02:00

144 lines
4.5 KiB
Python

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