feat: organizer Android app and token-gated upload API
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:
Frank Schwenk
2026-08-27 21:38:50 +02:00
parent 5dcc3717c7
commit c03f17488c
58 changed files with 3134 additions and 186 deletions
+25 -45
View File
@@ -3,9 +3,7 @@
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Query, Request
@@ -13,40 +11,35 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import config, images, pipeline, remix
from . import api, config, images, jobs, pipeline, remix
from .logging_config import configure_logging
configure_logging()
logger = logging.getLogger("livef12.web")
app = FastAPI(title=config.SITE_TITLE)
app.include_router(api.router)
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.globals["img_attrs"] = images.img_attrs
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
templates.env.globals["status_label"] = jobs.status_label
templates.env.globals["stage_label"] = jobs.stage_label
templates.env.globals["ACTIVE_STATUSES"] = jobs.ACTIVE_STATUSES
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# Job id = sanitized source stem (may include dots).
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _validate_job_id(job_id: str) -> str:
raw = job_id or ""
if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw:
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
stem = pipeline.sanitize_stem(raw)
if not _JOB_ID_RE.match(stem):
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
return stem
try:
return jobs.validate_job_id(job_id)
except jobs.JobIdError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _job_exists(job_id: str) -> str:
stem = _validate_job_id(job_id)
if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists():
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return stem
return jobs.require_job_id(job_id)
def _safe_job_file(job_id: str, filename: str) -> Path:
@@ -54,9 +47,9 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
stem = _validate_job_id(job_id)
name = Path(filename).name
if name != filename or "/" in filename or "\\" in filename or name.startswith("."):
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
raise HTTPException(status_code=400, detail="Ungültiger Pfad")
if not name.startswith(f"{stem}_"):
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
raise HTTPException(status_code=400, detail="Ungültiger Pfad")
for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR):
candidate = (directory / name).resolve()
@@ -67,30 +60,17 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
def list_jobs() -> list[dict[str, Any]]:
jobs = []
for job_id in pipeline.list_job_ids():
data = pipeline.read_meta(job_id) or {}
variants = data.get("variants") or []
jobs.append(
{
"job_id": job_id,
"status": data.get("status", "unknown"),
"created_at": data.get("created_at") or "",
"variant_count": len(variants),
"thumbnail": variants[-1].get("file") if variants else None,
}
)
jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True)
return jobs
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
job_list = jobs.list_jobs()
return templates.TemplateResponse(
request,
"index.html",
{"jobs": list_jobs(), "site_title": config.SITE_TITLE},
{
"jobs": job_list,
"site_title": config.SITE_TITLE,
"has_active": any(j["status"] in jobs.ACTIVE_STATUSES for j in job_list),
},
)
@@ -100,11 +80,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
paths = pipeline.job_paths(stem)
status = pipeline.read_status(stem) or {}
manifest = pipeline.read_manifest(stem) or {"variants": []}
intermediates = (
sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
if paths.intermediates.exists()
else []
)
intermediates = jobs.list_intermediates(stem)
return templates.TemplateResponse(
request,
@@ -118,6 +94,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
"rembg_name": paths.rembg.name if paths.rembg.exists() else None,
"has_rembg": paths.rembg.exists(),
"intermediates": intermediates,
"live": (status.get("status") or "") in jobs.ACTIVE_STATUSES,
},
)
@@ -134,7 +111,7 @@ def job_file(
if w not in images.SRCSET_WIDTHS:
raise HTTPException(
status_code=400,
detail=f"Ungueltige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
detail=f"Ungültige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
)
try:
cached = images.get_or_create_resized(path, w)
@@ -154,7 +131,10 @@ def remix_form(
stem = _job_exists(job_id)
paths = pipeline.job_paths(stem)
if not paths.rembg.exists():
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
raise HTTPException(
status_code=409,
detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht möglich.",
)
assets = pipeline.load_assets()
options = remix.build_remix_options(assets)