"""GET /api/history; GET /api/history/{prompt_id}/images; GET /api/history/{prompt_id}/file/{filename}; POST /api/history/{prompt_id}/share; DELETE /api/history/{prompt_id}/share""" from __future__ import annotations import base64 from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi.responses import Response from pydantic import BaseModel from web.auth import require_admin, require_auth router = APIRouter() def _assert_owns(prompt_id: str, user: dict) -> None: """Raise 404 if the generation doesn't exist or doesn't belong to the user. Returning the same 404 for both cases prevents leaking whether a prompt_id exists to users who don't own it. Admins bypass this check. """ if user.get("admin"): return from generation_db import get_generation gen = get_generation(prompt_id) if gen is None or gen["user_label"] != user["sub"]: raise HTTPException(404, "Not found") @router.get("") async def get_history( user: dict = Depends(require_auth), q: Optional[str] = Query(None, description="Keyword to search in overrides JSON"), persons: list[str] = Query(default=[], alias="persons", description="Filter by person name/alias substring (repeatable)"), ): """Return generation history. Admins see all; regular users see only their own. Pass ?q=keyword to filter by prompt text or any override field. Pass ?persons=name (repeatable) to filter by persons detected in output images.""" import face_db as face_db_mod from generation_db import ( get_history as db_get_history, get_history_for_user, search_history, search_history_for_user, get_generation_ids_for_file_ids, get_file_ids_for_generation_ids, ) # Person filter: OR union across all named persons, then filter rows gen_id_filter: list[int] | None = None all_file_ids: set[int] = set() active_persons = [p.strip() for p in persons if p.strip()] if active_persons: for p in active_persons: ids = face_db_mod.get_source_ids_for_person_query(p, "output") all_file_ids.update(ids) if not all_file_ids: return {"history": []} gen_ids = get_generation_ids_for_file_ids(list(all_file_ids)) if not gen_ids: return {"history": []} gen_id_filter = gen_ids if q and q.strip(): if user.get("admin"): rows = search_history(q.strip(), limit=50) else: rows = search_history_for_user(user["sub"], q.strip(), limit=50) else: if user.get("admin"): rows = db_get_history(limit=50) else: rows = get_history_for_user(user["sub"], limit=50) if gen_id_filter is not None: gen_id_set = set(gen_id_filter) rows = [r for r in rows if r["id"] in gen_id_set] # Annotate each row with detected_persons when person filtering is active if active_persons and rows: row_ids = [r["id"] for r in rows] file_ids_per_gen = get_file_ids_for_generation_ids(row_ids) all_annotate_ids = [fid for fids in file_ids_per_gen.values() for fid in fids] person_map = face_db_mod.get_persons_for_source_id_map(all_annotate_ids, "output") if all_annotate_ids else {} for row in rows: row["detected_persons"] = list({ name for fid in file_ids_per_gen.get(row["id"], []) for name in person_map.get(fid, []) }) return {"history": rows} @router.get("/{prompt_id}/persons") async def get_generation_persons(prompt_id: str, user: dict = Depends(require_auth)): """Return persons detected in the output images of a generation.""" _assert_owns(prompt_id, user) import generation_db as gen_db import face_db as face_db_mod file_ids = gen_db.get_file_ids_for_prompt(prompt_id) persons = face_db_mod.get_persons_for_source_ids(file_ids, "output") return {"persons": persons} class _AddPersonRequest(BaseModel): name: str @router.post("/{prompt_id}/persons") async def add_generation_person( prompt_id: str, body: _AddPersonRequest, _: dict = Depends(require_admin), ): """Manually tag a person to a generation's output files.""" import generation_db as gen_db import face_db as face_db_mod file_ids = gen_db.get_file_ids_for_prompt(prompt_id) if not file_ids: raise HTTPException(404, f"Generation {prompt_id!r} not found") name = body.name.strip() if not name: raise HTTPException(400, "Name cannot be empty") if len(name) > 100: raise HTTPException(400, "Name too long (max 100 chars)") person_id, _ = face_db_mod.get_or_create_person(name) # Only add if not already tagged on any file of this generation existing = face_db_mod.get_persons_for_source_ids(file_ids, "output") if not any(p["id"] == person_id for p in existing): face_db_mod.insert_detection( source_type="output", source_id=file_ids[0], embedding=None, bbox={}, face_index=0, person_id=person_id, ) return {"person_id": person_id, "name": name} @router.delete("/{prompt_id}/persons/{person_id}") async def remove_generation_person( prompt_id: str, person_id: int, _: dict = Depends(require_admin), ): """Remove all face detections linking a person to this generation's output files.""" import generation_db as gen_db import face_db as face_db_mod file_ids = gen_db.get_file_ids_for_prompt(prompt_id) if not file_ids: raise HTTPException(404, f"Generation {prompt_id!r} not found") face_db_mod.remove_person_from_source_ids(file_ids, person_id, "output") return {"ok": True} @router.get("/{prompt_id}/images") async def get_history_images(prompt_id: str, user: dict = Depends(require_auth)): """ Fetch output files for a past generation. Returns base64-encoded blobs from the local SQLite DB — works even after ``flush_pending`` has deleted the files from disk. """ _assert_owns(prompt_id, user) from generation_db import get_files files = get_files(prompt_id) if not files: raise HTTPException(404, f"No files found for prompt_id {prompt_id!r}") return { "prompt_id": prompt_id, "images": [ { "filename": f["filename"], "data": base64.b64encode(f["data"]).decode() if not f["mime_type"].startswith("video/") else None, "mime_type": f["mime_type"], } for f in files ], } @router.get("/{prompt_id}/file/{filename}") async def get_history_file( prompt_id: str, filename: str, request: Request, user: dict = Depends(require_auth), ): """Stream a single output file, with HTTP range request support for video seeking.""" _assert_owns(prompt_id, user) from generation_db import get_files files = get_files(prompt_id) matched = next((f for f in files if f["filename"] == filename), None) if matched is None: raise HTTPException(404, f"File {filename!r} not found for prompt_id {prompt_id!r}") data: bytes = matched["data"] mime: str = matched["mime_type"] total = len(data) range_header = request.headers.get("range") if range_header: range_val = range_header.replace("bytes=", "") start_str, _, end_str = range_val.partition("-") start = int(start_str) if start_str else 0 end = int(end_str) if end_str else total - 1 end = min(end, total - 1) if start < 0 or start > end: return Response( status_code=416, headers={"Content-Range": f"bytes */{total}"}, ) chunk = data[start : end + 1] return Response( content=chunk, status_code=206, media_type=mime, headers={ "Content-Range": f"bytes {start}-{end}/{total}", "Accept-Ranges": "bytes", "Content-Length": str(len(chunk)), "Cache-Control": "private", }, ) return Response( content=data, media_type=mime, headers={ "Accept-Ranges": "bytes", "Content-Length": str(total), "Cache-Control": "private", }, ) class _ShareRequest(BaseModel): is_public: bool = False expires_in_hours: Optional[float] = None max_views: Optional[int] = None @router.post("/{prompt_id}/share") async def create_generation_share( prompt_id: str, body: _ShareRequest = Body(default_factory=_ShareRequest), user: dict = Depends(require_auth), ): """Create a share token for a generation. Only the owner may share.""" _assert_owns(prompt_id, user) from generation_db import ( create_share as db_create_share, get_active_share_for_prompt as db_get_active_share, ) existing = db_get_active_share(prompt_id, user["sub"]) if existing: raise HTTPException(409, "A share already exists — revoke it first") if body.is_public and body.expires_in_hours is None and body.max_views is None: raise HTTPException(400, "Public shares must have at least one expiry condition (time or view limit)") if body.max_views is not None and body.max_views < 1: raise HTTPException(400, "max_views must be >= 1") if body.expires_in_hours is not None and body.expires_in_hours <= 0: raise HTTPException(400, "expires_in_hours must be > 0") expires_at = None if body.expires_in_hours is not None: expires_at = (datetime.now(timezone.utc) + timedelta(hours=body.expires_in_hours)).isoformat() share = db_create_share( prompt_id, user["sub"], is_public=body.is_public, expires_at=expires_at, max_views=body.max_views, ) return { "share_token": share["share_token"], "is_public": bool(share["is_public"]), "expires_at": share["expires_at"], "max_views": share["max_views"], } @router.delete("/{prompt_id}/share") async def revoke_generation_share(prompt_id: str, user: dict = Depends(require_auth)): """Revoke a share token for a generation. Admins can revoke any share.""" from generation_db import revoke_share as db_revoke_share # Admins pass None for owner_label to delete by prompt_id alone owner_label = None if user.get("admin") else user["sub"] deleted = db_revoke_share(prompt_id, owner_label) if not deleted: raise HTTPException(404, "No active share found for this generation") return {"ok": True}