feat: enhance face-lock service with improved upload handling and response structure
python / test (push) Failing after 8s

- Updated README.md to reflect new features and API changes.
- Introduced versioning in app initialization.
- Enhanced configuration management in app/config.py with new validation functions.
- Refactored main.py to improve request handling and response generation.
- Added new models in app/models.py for structured API responses.
- Implemented a dedicated UI rendering function in app/ui.py.
- Improved Docker configuration for better security and health checks.
- Updated tests to cover new validation rules and response formats.
- Added CLAUDE.md for project guidelines and working rules.
This commit is contained in:
Space-Banane
2026-06-20 11:23:12 +02:00
parent f1072cb7b0
commit 3cf4a9a40a
17 changed files with 759 additions and 222 deletions
+140 -158
View File
@@ -1,185 +1,167 @@
from io import BytesIO
from __future__ import annotations
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from io import BytesIO
import logging
from pathlib import Path
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse
from app import __version__
from app.config import settings
from app.models import Detector, FocusResponse, HealthResponse
from app.ui import render_index_html
app = FastAPI(title="face-lock", version="0.3.0")
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("face_lock")
app = FastAPI(
title="face-lock",
version=__version__,
docs_url="/docs" if settings.docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.docs_enabled else None,
)
def require_auth(request: Request) -> None:
if not settings.auth_enabled:
return
header_name = settings.auth_header_name.lower()
provided = request.headers.get(header_name)
if not provided and request.headers.get("authorization", "").lower().startswith("bearer "):
provided = request.headers.get("authorization", "")[7:].strip()
if provided != settings.auth_token:
provided = (request.headers.get(settings.auth_header_name) or "").strip()
if settings.auth_header_name.lower() == "authorization" and provided.lower().startswith("bearer "):
provided = provided[7:].strip()
if not provided:
authorization = request.headers.get("authorization", "").strip()
if authorization.lower().startswith("bearer "):
provided = authorization[7:].strip()
if not provided or not secrets.compare_digest(provided, settings.auth_token):
raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/health")
def health():
return {
"ok": True,
"env": settings.env,
"test_ui_enabled": settings.test_ui_enabled,
"auth_enabled": settings.auth_enabled,
"auth_header": settings.auth_header_name if settings.auth_enabled else None,
}
def _safe_filename(filename: str | None) -> str:
name = Path(filename or "upload").name.strip()
return name or "upload"
@app.get("/", response_class=HTMLResponse)
def index():
if not settings.test_ui_enabled:
return HTMLResponse(
"<!doctype html><html><body style='font-family:sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem'><h1>face-lock</h1><p>Test UI is disabled.</p><p><a href='/docs' style='color:#67e8f9'>Open API docs</a></p></body></html>"
def _validate_upload(file: UploadFile, payload: bytes) -> None:
if not payload:
raise HTTPException(status_code=400, detail="empty upload")
if len(payload) > settings.max_upload_bytes:
raise HTTPException(
status_code=413,
detail=f"upload exceeds FACE_LOCK_MAX_UPLOAD_BYTES ({settings.max_upload_bytes} bytes)",
)
return HTMLResponse(
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
<title>face-lock</title>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<main class="mx-auto max-w-6xl p-6">
<div class="mb-6 flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold">face-lock</h1>
<p class="text-slate-400">Square the subject, crop it, and keep the raw blobs out of sight.</p>
</div>
<a class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-cyan-300 hover:bg-slate-900" href="/docs" target="_blank">Docs</a>
</div>
<div class="grid gap-6 md:grid-cols-2">
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<label class="block text-sm text-slate-400">Image</label>
<input id="file" type="file" accept="image/*" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm text-slate-400">Detector</label>
<select id="detector" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3">
<option value="face">Face</option>
<option value="animal">Animal</option>
<option value="person">Person</option>
<option value="subject" selected>Subject</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400">Buffer ratio</label>
<input id="buffer_ratio" type="number" step="0.05" min="0" max="0.6" value="0.20" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
</div>
</div>
<div class="mt-4">
<label class="block text-sm text-slate-400">Auth token (only if enabled)</label>
<input id="auth_token" type="password" placeholder="paste token here" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
</div>
<button id="go" class="mt-4 rounded-lg bg-cyan-500 px-4 py-2 font-semibold text-slate-950">Process</button>
<pre id="meta" class="mt-4 whitespace-pre-wrap rounded-lg bg-slate-950 p-3 text-xs text-slate-300"></pre>
</section>
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<div class="mb-3 text-sm font-semibold text-slate-400">Result</div>
<div class="grid gap-4">
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Crop</div>
<img id="crop" class="hidden w-full rounded-xl border border-slate-800" />
</div>
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Annotated source</div>
<img id="annotated" class="hidden w-full rounded-xl border border-slate-800" />
</div>
</div>
</section>
</div>
</main>
<script>
const file = document.getElementById('file');
const go = document.getElementById('go');
const crop = document.getElementById('crop');
const annotated = document.getElementById('annotated');
const meta = document.getElementById('meta');
go.onclick = async () => {
if (!file.files.length) return;
const form = new FormData();
form.append('file', file.files[0]);
form.append('detector', document.getElementById('detector').value);
form.append('buffer_ratio', document.getElementById('buffer_ratio').value);
meta.textContent = 'Working...';
const headers = {};
const token = document.getElementById('auth_token').value.trim();
if (token) headers['__AUTH_HEADER_NAME__'] = token;
const resp = await fetch('/api/focus', { method: 'POST', body: form, headers });
const data = await resp.json();
if (!resp.ok) {
meta.textContent = JSON.stringify(data, null, 2);
return;
}
meta.textContent = JSON.stringify({
filename: data.filename,
detector: data.detector,
method: data.method,
buffer_ratio: data.buffer_ratio,
detected_bbox: data.detected_bbox,
square_bbox: data.square_bbox,
source_size: data.source_size,
}, null, 2);
crop.src = data.crop_data_url;
annotated.src = data.annotated_data_url;
crop.classList.remove('hidden');
annotated.classList.remove('hidden');
};
</script>
</body>
</html>
""".replace("__AUTH_HEADER_NAME__", settings.auth_header_name)
content_type = (file.content_type or "").strip().lower()
if content_type not in settings.allowed_mime_types:
raise HTTPException(
status_code=415,
detail=f"unsupported content type: {content_type or 'unknown'}",
)
async def _process_upload(file: UploadFile, *, buffer_ratio: float, detector: Detector) -> dict[str, object]:
from app.vision import process_image
payload = await file.read()
_validate_upload(file, payload)
try:
return process_image(
payload,
_safe_filename(file.filename),
buffer_ratio=buffer_ratio,
detector=detector.value,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.middleware("http")
async def add_response_headers(request: Request, call_next) -> Response:
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
return response
@app.get("/health", response_model=HealthResponse, tags=["meta"])
def health() -> HealthResponse:
return HealthResponse(
env=settings.env,
version=__version__,
docs_enabled=settings.docs_enabled,
test_ui_enabled=settings.test_ui_enabled,
auth_enabled=settings.auth_enabled,
auth_header=settings.auth_header_name if settings.auth_enabled else None,
max_upload_bytes=settings.max_upload_bytes,
allowed_mime_types=list(settings.allowed_mime_types),
)
@app.post("/api/focus")
@app.get("/", response_class=HTMLResponse, tags=["meta"])
def index() -> HTMLResponse:
if not settings.test_ui_enabled:
docs_link = "<p><a href='/docs' style='color:#67e8f9'>Open API docs</a></p>" if settings.docs_enabled else ""
return HTMLResponse(
(
"<!doctype html><html><body style='font-family:sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem'>"
"<h1>face-lock</h1><p>Test UI is disabled.</p>"
f"{docs_link}</body></html>"
),
headers={"Cache-Control": "no-store"},
)
return HTMLResponse(
render_index_html(auth_header_name=settings.auth_header_name),
headers={"Cache-Control": "no-store"},
)
@app.post("/api/focus", response_model=FocusResponse, tags=["focus"])
async def focus(
request: Request,
file: UploadFile = File(...),
buffer_ratio: float = Form(0.15),
detector: str = Form("subject"),
buffer_ratio: Annotated[float, Form(ge=0.0, le=0.6)] = 0.15,
detector: Annotated[Detector, Form()] = Detector.SUBJECT,
_auth: None = Depends(require_auth),
):
from app.vision import process_image
try:
payload = await file.read()
result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector)
return {
"filename": result["filename"],
"detector": result["detector"],
"method": result["method"],
"buffer_ratio": result["buffer_ratio"],
"detected_bbox": result["detected_bbox"],
"square_bbox": result["square_bbox"],
"source_size": result["source_size"],
"crop_data_url": result["crop_data_url"],
"annotated_data_url": result["annotated_data_url"],
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
) -> dict[str, object]:
result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector)
return {
"filename": result["filename"],
"detector": result["detector"],
"method": result["method"],
"buffer_ratio": result["buffer_ratio"],
"detected_bbox": result["detected_bbox"],
"square_bbox": result["square_bbox"],
"source_size": result["source_size"],
"mime_type": result["mime_type"],
"crop_data_url": result["crop_data_url"],
"annotated_data_url": result["annotated_data_url"],
}
@app.post("/api/focus/image")
@app.post("/api/focus/image", tags=["focus"])
async def focus_image(
request: Request,
file: UploadFile = File(...),
buffer_ratio: float = Form(0.15),
detector: str = Form("subject"),
buffer_ratio: Annotated[float, Form(ge=0.0, le=0.6)] = 0.15,
detector: Annotated[Detector, Form()] = Detector.SUBJECT,
_auth: None = Depends(require_auth),
):
from app.vision import process_image
try:
payload = await file.read()
result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector)
return StreamingResponse(BytesIO(result["crop_bytes"]), media_type="image/jpeg")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
) -> StreamingResponse:
result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector)
filename_stem = Path(str(result["filename"])).stem or "focus"
return StreamingResponse(
BytesIO(result["crop_bytes"]),
media_type=str(result["mime_type"]),
headers={"Content-Disposition": f'inline; filename="{filename_stem}-crop.jpg"'},
)