feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s
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>
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user