c03f17488c
CI / lint-and-test (push) Failing after 1m18s
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>
144 lines
4.5 KiB
Python
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)
|