Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import os | |
| import threading | |
| import time | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from typing import Any | |
| from fastapi import FastAPI, Header, HTTPException, Query, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response | |
| from pydantic import BaseModel, ConfigDict, Field, ValidationError | |
| from sse_starlette.sse import EventSourceResponse | |
| from backend import VALID_RATINGS, Backend | |
| from backend.hf_bootstrap import ensure_media_cache, ensure_store_db | |
| from backend.media_remote import remote_media_health_payload, remote_media_url | |
| _app_dir = Path(__file__).resolve().parent | |
| _data_dir = _app_dir / "data" | |
| # App uses the slim DB (local: scripts/build_slim_store.py). Override with KINK_STORE_PATH. | |
| # On HF: point KINK_STORE_PATH at a mounted bucket path; Dockerfile defaults to bundled seed (set KINK_HF_DATASET_* for Hub catalog). | |
| _default_store = Path(os.environ["KINK_STORE_PATH"]) if os.environ.get("KINK_STORE_PATH") else _data_dir / "store_slim.db" | |
| FRONTEND_DIR = _app_dir / "frontend" | |
| FRONTEND_DIST_DIR = FRONTEND_DIR / "dist" | |
| FRONTEND_PATH = FRONTEND_DIST_DIR / "index.html" | |
| MEDIA_ROOT = _app_dir / "data" / "cached_assets" | |
| ADMIN_SECRET = os.environ.get("KINK_ADMIN_SECRET", "") | |
| _MEDIA_CACHE = "public, max-age=86400, immutable" | |
| _MEDIA_FALLBACK_CACHE = "public, max-age=3600" | |
| def _frontend_static_cache_control(target: Path) -> str: | |
| """Browser e2e and local iteration can disable static caching for built assets.""" | |
| if os.environ.get("KINK_FRONTEND_NO_CACHE") == "1": | |
| return "no-store" | |
| if target.suffix in {".js", ".css"} and target.parent.name == "assets": | |
| return "public, max-age=31536000, immutable" | |
| return "no-store" | |
| def _require_frontend_build() -> None: | |
| if not FRONTEND_PATH.is_file(): | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Frontend build missing. Run `npm ci && npm run build` before serving the app.", | |
| ) | |
| assets_dir = FRONTEND_DIST_DIR / "assets" | |
| if not assets_dir.is_dir() or not any(assets_dir.iterdir()): | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Frontend build incomplete (no bundled assets). Run `npm ci && npm run build` to refresh.", | |
| ) | |
| def _hf_space_published_image() -> bool: | |
| """True in the Hugging Face Space Docker image (``Dockerfile`` sets ``KINK_HF_SPACE_IMAGE=1``). | |
| Seed deployments can serve bundled JPEG fallbacks for missing catalog tiles. Full-catalog | |
| deployments must expose missing bytes as 404s so the UI can use a neutral placeholder instead of | |
| repeating an unrelated demo image. | |
| """ | |
| return _env_truthy("KINK_HF_SPACE_IMAGE") | |
| def _media_strict_missing() -> bool: | |
| """If true, missing ``/media/...`` bytes return 404 instead of a same-origin placeholder image.""" | |
| if _env_is_set("KINK_MEDIA_STRICT"): | |
| return _env_truthy("KINK_MEDIA_STRICT") | |
| return _env_truthy("KINK_HF_REQUIRE_FULL_CATALOG") | |
| def _media_fallback_path() -> Path | None: | |
| """Bundled JPEG used when a catalog ``/media/...`` path has no on-disk mirror (typical on HF).""" | |
| override = (os.environ.get("KINK_MEDIA_FALLBACK_PATH") or "").strip() | |
| if override: | |
| cand = Path(override).expanduser() | |
| resolved = cand.resolve() | |
| if resolved.is_file(): | |
| return resolved | |
| # Invalid override must not disable the bundled default (common HF foot‑gun: stale path in Space vars). | |
| default = (MEDIA_ROOT / "hf_seed" / "demo_kink_1.jpg").resolve() | |
| if MEDIA_ROOT.resolve() not in default.parents: | |
| return None | |
| return default if default.is_file() else None | |
| def _media_health_payload() -> dict[str, Any]: | |
| fb = _media_fallback_path() | |
| payload = { | |
| "hf_space_image": _hf_space_published_image(), | |
| "strict_env": _env_truthy("KINK_MEDIA_STRICT"), | |
| "strict_missing_assets": _media_strict_missing(), | |
| "fallback_ready": fb is not None, | |
| "fallback_path": str(fb) if fb is not None else None, | |
| } | |
| payload.update(remote_media_health_payload()) | |
| return payload | |
| _backend_impl: Backend | None = None | |
| _backend_lock = threading.Lock() | |
| def _env_truthy(name: str) -> bool: | |
| return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") | |
| def _env_is_set(name: str) -> bool: | |
| return os.environ.get(name, "").strip() != "" | |
| def _store_path_is_ephemeral(path: Path) -> bool: | |
| resolved = path.resolve() | |
| prefixes = ("/tmp", "/var/tmp", "/dev/shm") | |
| return any(str(resolved) == prefix or str(resolved).startswith(f"{prefix}/") for prefix in prefixes) | |
| def _store_storage_mode(path: Path) -> str: | |
| return "ephemeral" if _store_path_is_ephemeral(path) else "persistent" | |
| def _warn_or_fail_ephemeral_store(path: Path) -> None: | |
| if not _store_path_is_ephemeral(path): | |
| return | |
| msg = ( | |
| f"[kink_cli] WARNING: store path {path} is ephemeral; profiles, ratings, and partner state " | |
| "will be lost on restart unless KINK_STORE_PATH points at persistent storage." | |
| ) | |
| print(msg, flush=True) | |
| # Hard-fail only when explicitly requested. Requiring a full catalog download must not imply | |
| # refusing /tmp on Hugging Face Spaces (default KINK_STORE_PATH): that combination left the | |
| # backend uninitialized forever (health: starting, all API routes 500). | |
| if _env_truthy("KINK_FAIL_ON_EPHEMERAL_STORE"): | |
| raise RuntimeError(msg) | |
| def _boot_phase(name: str) -> "_BootTimer": | |
| return _BootTimer(name) | |
| class _BootTimer: | |
| def __init__(self, name: str) -> None: | |
| self.name = name | |
| self._t0 = 0.0 | |
| def __enter__(self) -> "_BootTimer": | |
| self._t0 = time.monotonic() | |
| return self | |
| def __exit__(self, exc_type, exc, tb) -> None: | |
| dt = time.monotonic() - self._t0 | |
| status = "err" if exc_type else "ok" | |
| print(f"[kink_cli boot] phase={self.name} seconds={dt:.2f} status={status}", flush=True) | |
| def _get_backend() -> Backend: | |
| """Open SQLite + warm caches on first use so uvicorn can bind before a multi‑GB Hub download finishes.""" | |
| global _backend_impl | |
| if _backend_impl is not None: | |
| return _backend_impl | |
| with _backend_lock: | |
| if _backend_impl is not None: | |
| return _backend_impl | |
| total_t0 = time.monotonic() | |
| # ensure_media_cache pulls + extracts a Hub media archive — slow (~50-75s on cpu-basic), | |
| # and the catalog/SQLite/request paths don't depend on it. Defer to a daemon thread so | |
| # cold boot returns in seconds; individual media URLs fall through to the bundled | |
| # placeholder via the strict-missing path until the archive finishes hydrating. | |
| import threading as _threading | |
| def _media_warm() -> None: | |
| t0 = time.monotonic() | |
| try: | |
| ensure_media_cache(MEDIA_ROOT) | |
| print(f"[kink_cli boot] phase=media_cache_bg seconds={time.monotonic()-t0:.2f} status=ok", flush=True) | |
| except BaseException as exc: # noqa: BLE001 | |
| print(f"[kink_cli boot] phase=media_cache_bg seconds={time.monotonic()-t0:.2f} status=err exc={exc!r}", flush=True) | |
| _threading.Thread(target=_media_warm, name="kink-media-warm", daemon=True).start() | |
| with _boot_phase("ensure_store_db"): | |
| path = ensure_store_db(_default_store) | |
| _warn_or_fail_ephemeral_store(path) | |
| with _boot_phase("user_snapshot_restore"): | |
| _restore_user_snapshot_on_boot(path) | |
| with _boot_phase("backend_ctor"): | |
| b = Backend(path) | |
| # Catalog must be ready before the first recommendations request. Build it once here: | |
| # starting a warm thread and then blocking can double-build on slow cpu-basic Spaces. | |
| with _boot_phase("catalog_build"): | |
| b._catalog() | |
| # PPR / full similarity graph warm is RAM-heavy on multi‑GB catalogs; skip only that on | |
| # small Spaces (see Dockerfile KINK_SKIP_HEAVY_WARM). | |
| if os.environ.get("KINK_SKIP_HEAVY_WARM", "").strip().lower() not in ("1", "true", "yes", "on"): | |
| with _boot_phase("ppr_warm"): | |
| from backend.recsys_graph import warm_ppr_caches | |
| warm_ppr_caches(b) | |
| _backend_impl = b | |
| if os.environ.get("KINK_BACKGROUND_PPR_WARM", "").strip().lower() in ("1", "true", "yes", "on"): | |
| from backend.recsys_graph import warm_ppr_caches_in_background | |
| warm_ppr_caches_in_background(b) | |
| print(f"[kink_cli boot] phase=total seconds={time.monotonic() - total_t0:.2f} status=ok", flush=True) | |
| return b | |
| class _BackendProxy: | |
| def __getattr__(self, name: str): | |
| return getattr(_get_backend(), name) | |
| backend = _BackendProxy() | |
| _LAST_RESTORE: dict[str, Any] = {"status": "unattempted", "applied": 0, "at": None, "detail": None} | |
| def _restore_user_snapshot_on_boot(store_path: Path) -> None: | |
| """Pull the latest user-state snapshot from the configured Hub dataset when local user tables are empty. | |
| No-op when KINK_USER_SNAPSHOT_REPO is unset. Failures are logged and swallowed so a Hub outage | |
| cannot prevent the Space from booting (catalog still serves; periodic push will retry). | |
| """ | |
| import datetime as _dt | |
| def _record(status: str, *, applied: int = 0, detail: str | None = None) -> None: | |
| _LAST_RESTORE["status"] = status | |
| _LAST_RESTORE["applied"] = applied | |
| _LAST_RESTORE["at"] = _dt.datetime.now(_dt.timezone.utc).isoformat() | |
| _LAST_RESTORE["detail"] = detail | |
| try: | |
| from backend import user_snapshot, user_snapshot_hub | |
| except ImportError: | |
| _record("import_error") | |
| return | |
| if not user_snapshot_hub.snapshot_enabled(): | |
| _record("disabled") | |
| return | |
| # Always merge the Hub snapshot into the local store on boot. ``restore_user_state`` uses | |
| # ``INSERT OR REPLACE`` so this is idempotent — the Hub copy wins on conflicting primary | |
| # keys. We previously gated this on "user tables empty", but HF Spaces sometimes preserves | |
| # ``/tmp`` across soft restarts; that left stale rows that the empty-check treated as | |
| # authoritative, silently dropping more recently pushed users (the exact persistence bug | |
| # this subsystem exists to prevent). | |
| try: | |
| tmp = store_path.parent / ".user_state_snapshot.db" | |
| if not user_snapshot_hub.pull_user_snapshot(tmp): | |
| _record("pull_returned_false") | |
| return | |
| counts = user_snapshot.restore_user_state(store_path, tmp) | |
| try: | |
| tmp.unlink() | |
| except OSError: | |
| pass | |
| applied = sum(counts.values()) | |
| users = counts.get("user", 0) | |
| _record("ok", applied=applied, detail=f"users={users}") | |
| print( | |
| f"[kink_cli] restored {applied} user-state rows from snapshot " | |
| f"({user_snapshot_hub.snapshot_repo()}/{user_snapshot_hub.snapshot_filename()})" | |
| ) | |
| except Exception as exc: # noqa: BLE001 — degrade gracefully on any restore failure | |
| _record("error", detail=f"{type(exc).__name__}: {exc}") | |
| print(f"[kink_cli] user-state snapshot restore failed: {exc}") | |
| async def _user_snapshot_flusher(store_path: Path, interval_s: float) -> None: | |
| """Periodic push: dump user tables and upload to Hub when fingerprint changes. | |
| Initialises ``last_fingerprint`` to a sentinel so the FIRST tick always pushes — otherwise | |
| a user who signed up between catalog warm and the flusher's baseline-capture would never | |
| see their state pushed, and the next container restart would silently lose them. | |
| """ | |
| from backend import user_snapshot, user_snapshot_hub | |
| last_fingerprint = "<never-pushed>" | |
| while True: | |
| try: | |
| await asyncio.sleep(interval_s) | |
| fp = await asyncio.to_thread(user_snapshot.compute_user_state_fingerprint, store_path) | |
| if fp == last_fingerprint: | |
| continue | |
| if user_snapshot.user_state_is_empty(store_path): | |
| # Nothing to push yet; remember the empty fingerprint so we don't keep retrying | |
| # on every tick when no user has signed up. | |
| last_fingerprint = fp | |
| continue | |
| tmp = store_path.parent / ".user_state_snapshot_out.db" | |
| await asyncio.to_thread(user_snapshot.dump_user_state, store_path, tmp) | |
| sent = await asyncio.to_thread(user_snapshot_hub.push_user_snapshot, tmp) | |
| try: | |
| tmp.unlink() | |
| except OSError: | |
| pass | |
| if sent: | |
| last_fingerprint = fp | |
| print(f"[kink_cli] user-state snapshot pushed (fp={fp[:80]}…)") | |
| except asyncio.CancelledError: | |
| raise | |
| except Exception as exc: # noqa: BLE001 — never let the flusher die | |
| print(f"[kink_cli] user-state snapshot flush failed: {exc}") | |
| async def lifespan(_app: FastAPI): | |
| """Begin Hub download + DB open in a worker thread so the server binds to PORT immediately (HF Spaces timeout).""" | |
| loop = asyncio.get_running_loop() | |
| loop.run_in_executor(None, _get_backend) | |
| flusher_task: asyncio.Task | None = None | |
| try: | |
| from backend import user_snapshot_hub | |
| except ImportError: | |
| user_snapshot_hub = None # type: ignore[assignment] | |
| if user_snapshot_hub is not None and user_snapshot_hub.snapshot_enabled(): | |
| interval_s = float(os.environ.get("KINK_USER_SNAPSHOT_INTERVAL_S", "60") or "60") | |
| print( | |
| f"[kink_cli] user-state snapshot ENABLED — repo={user_snapshot_hub.snapshot_repo()} " | |
| f"file={user_snapshot_hub.snapshot_filename()} interval={interval_s:.0f}s" | |
| ) | |
| async def _start_when_ready() -> None: | |
| while _backend_impl is None: | |
| await asyncio.sleep(1.0) | |
| await _user_snapshot_flusher(_backend_impl.path, interval_s) | |
| flusher_task = asyncio.create_task(_start_when_ready()) | |
| else: | |
| repo = user_snapshot_hub.snapshot_repo() if user_snapshot_hub is not None else "<no module>" | |
| has_token = bool((os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()) | |
| print( | |
| f"[kink_cli] user-state snapshot DISABLED — repo={repo!r} HF_TOKEN={'set' if has_token else 'missing'}; " | |
| "users will NOT persist across container restarts. Set HF_TOKEN (and KINK_USER_SNAPSHOT_REPO if needed) to enable." | |
| ) | |
| try: | |
| yield | |
| finally: | |
| if flusher_task is not None: | |
| flusher_task.cancel() | |
| try: | |
| await flusher_task | |
| except (asyncio.CancelledError, Exception): # noqa: BLE001 | |
| pass | |
| if _backend_impl is not None: | |
| try: | |
| from backend import user_snapshot, user_snapshot_hub as _hub | |
| tmp = _backend_impl.path.parent / ".user_state_snapshot_shutdown.db" | |
| user_snapshot.dump_user_state(_backend_impl.path, tmp) | |
| _hub.push_user_snapshot(tmp, commit_message="user-state snapshot (shutdown)") | |
| try: | |
| tmp.unlink() | |
| except OSError: | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[kink_cli] final user-state snapshot push failed: {exc}") | |
| app = FastAPI(title="Kink Discovery API", version="0.1.0", lifespan=lifespan) | |
| cors_origins = os.environ.get("KINK_CORS_ORIGINS", "http://localhost:8011,http://127.0.0.1:8011,http://localhost:8012,http://127.0.0.1:8012").split(",") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[o.strip() for o in cors_origins], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| async def add_security_headers(request: Request, call_next): | |
| response = await call_next(request) | |
| # connect-src covers fetch/beacon/WS. Frontend dependencies are bundled by Vite and served same-origin. | |
| connect_src = " ".join( | |
| sorted( | |
| { | |
| "'self'", | |
| *(origin.strip() for origin in cors_origins if origin.strip()), | |
| } | |
| ) | |
| ) | |
| csp = ( | |
| "default-src 'self' data:; " | |
| "base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'none'; object-src 'none'; " | |
| "script-src 'self'; script-src-attr 'none'; " | |
| "style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; " | |
| "img-src 'self' data: https:; media-src 'self' data: https:; font-src 'self' data: https:; " | |
| f"connect-src {connect_src}; " | |
| "manifest-src 'self'; worker-src 'self' blob:" | |
| ) | |
| response.headers.setdefault("Content-Security-Policy", csp) | |
| response.headers.setdefault("X-Frame-Options", "DENY") | |
| response.headers.setdefault("X-Content-Type-Options", "nosniff") | |
| response.headers.setdefault("Referrer-Policy", "same-origin") | |
| response.headers.setdefault("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()") | |
| # credentialless: cross-origin images (e.g. CDN asset URLs in catalog) load without CORP on the image host; | |
| # require-corp blocked many <img> sources under COEP. Assign (not setdefault): HF README custom_headers / inner | |
| # layers may set require-corp first; we must win. Keep README custom_headers in sync (see repo README frontmatter). | |
| response.headers["Cross-Origin-Embedder-Policy"] = "credentialless" | |
| response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") | |
| response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin") | |
| # Personalized GETs must not be cached by intermediaries or the browser (Playwright e2e saw stale ``/users/…`` payloads). | |
| if request.method == "GET" and request.url.path.startswith("/users/"): | |
| response.headers["Cache-Control"] = "private, no-store" | |
| return response | |
| class CreateUserRequest(BaseModel): | |
| pass | |
| class LoginRequest(BaseModel): | |
| """Strip accidental whitespace from pasted credentials (common on mobile).""" | |
| model_config = ConfigDict(str_strip_whitespace=True) | |
| user_id: str | |
| private_token: str | |
| class PlaySaveRequest(BaseModel): | |
| kink_id: str | |
| interest_state: str = Field(pattern="^(love|like|curious|not_interested|hard_no)$") | |
| directions: list[str] = Field(default_factory=list) | |
| class ScenarioPreferenceRequest(BaseModel): | |
| parent_kink_id: str | |
| scenario_kink_id: str | |
| interest_state: str = Field(pattern="^(love|like|curious|not_interested|hard_no)$") | |
| directions: list[str] = Field(default_factory=list) | |
| class LinkPartnerRequest(BaseModel): | |
| partner_id: str | |
| class PartnerAcceptBody(BaseModel): | |
| from_user_id: str | |
| class PartnerDeclineBody(BaseModel): | |
| from_user_id: str | |
| class PartnerCancelBody(BaseModel): | |
| to_user_id: str | |
| class PartnerGroupCreateRequest(BaseModel): | |
| name: str | |
| member_ids: list[str] = Field(default_factory=list) | |
| class PartnerGroupMembersRequest(BaseModel): | |
| member_ids: list[str] = Field(default_factory=list) | |
| class ShareToggleRequest(BaseModel): | |
| share: bool | |
| class RoleSaveRequest(BaseModel): | |
| kink_id: str | |
| selected: bool = True | |
| class PreferenceRequest(BaseModel): | |
| show_images: bool | |
| class KinkBatchRequest(BaseModel): | |
| ids: list[str] | |
| class PromptRespondRequest(BaseModel): | |
| interest_state: str | None = Field(default=None, pattern="^(love|like|curious|not_interested|hard_no)$") | |
| directions: list[str] = Field(default_factory=list) | |
| dismiss: bool = False | |
| def require_user(user_id: str) -> dict: | |
| user = backend.get_user(user_id) | |
| if not user: | |
| raise HTTPException(status_code=404, detail="Unknown user") | |
| return user | |
| def sanitize_user(user: dict) -> dict: | |
| return { | |
| "id": user["id"], | |
| "plays": user.get("plays", {}), | |
| "scenario_preferences": user.get("scenario_preferences", {}), | |
| "roles": user.get("roles", []), | |
| "partners": user["partners"], | |
| "incoming_partner_requests": user.get("incoming_partner_requests", []), | |
| "outgoing_partner_requests": user.get("outgoing_partner_requests", []), | |
| "partner_groups": user.get("partner_groups", []), | |
| "preferences": user.get("preferences", {"show_images": False}), | |
| } | |
| def require_user_auth(user_id: str, private_token: str | None) -> dict: | |
| user_id = (user_id or "").strip() | |
| private_token = private_token.strip() if private_token else None | |
| if not private_token: | |
| raise HTTPException(status_code=401, detail="Missing private token") | |
| user = backend.get_user(user_id) | |
| if not user: | |
| raise HTTPException( | |
| status_code=404, | |
| detail=( | |
| "Unknown profile — this user id is not on this server. " | |
| "If you are on a Hugging Face Space, the database may have reset (ephemeral storage); create a new profile." | |
| ), | |
| ) | |
| if user["private_token"] != private_token: | |
| raise HTTPException(status_code=401, detail="Invalid credentials") | |
| return user | |
| def require_kink(kink_id: str) -> dict: | |
| kink = backend.get_kink(kink_id) | |
| if not kink: | |
| raise HTTPException(status_code=404, detail="Unknown kink") | |
| return kink | |
| def require_partner_group_access(user_id: str, group_id: str) -> None: | |
| if not backend.can_access_partner_group(user_id, group_id): | |
| raise HTTPException(status_code=403, detail="Unknown or inaccessible partner group") | |
| def require_admin(x_admin_secret: str | None) -> None: | |
| if not ADMIN_SECRET: | |
| raise HTTPException(status_code=403, detail="Admin access disabled (KINK_ADMIN_SECRET not configured)") | |
| if not x_admin_secret or x_admin_secret != ADMIN_SECRET: | |
| raise HTTPException(status_code=403, detail="Invalid admin secret") | |
| async def parse_payload(request: Request, model: type[BaseModel]) -> BaseModel: | |
| raw = await request.body() | |
| if not raw: | |
| raw = b"{}" | |
| try: | |
| return model.model_validate_json(raw) | |
| except ValidationError as exc: | |
| raise HTTPException(status_code=422, detail=exc.errors()) from exc | |
| except json.JSONDecodeError as exc: | |
| raise HTTPException(status_code=400, detail="Invalid JSON body") from exc | |
| async def run_blocking(func, /, *args, **kwargs): | |
| """Run sync backend / SQLite work away from the asyncio event loop.""" | |
| return await asyncio.to_thread(func, *args, **kwargs) | |
| def frontend() -> FileResponse: | |
| _require_frontend_build() | |
| return FileResponse(FRONTEND_PATH, headers={"Cache-Control": "no-store"}) | |
| def frontend_static(filepath: str) -> FileResponse: | |
| _require_frontend_build() | |
| target = (FRONTEND_DIST_DIR / filepath).resolve() | |
| if FRONTEND_DIST_DIR.resolve() not in target.parents and target != FRONTEND_DIST_DIR.resolve(): | |
| raise HTTPException(status_code=404, detail="Not found") | |
| if not target.exists() or not target.is_file(): | |
| raise HTTPException(status_code=404, detail="Not found") | |
| cache_control = _frontend_static_cache_control(target) | |
| return FileResponse(target, headers={"Cache-Control": cache_control}) | |
| def favicon() -> FileResponse: | |
| """Browsers request this by default; serve a small bundled tile so the console stays clean on HF.""" | |
| fb = _media_fallback_path() | |
| if fb is not None: | |
| return FileResponse( | |
| fb, | |
| media_type="image/jpeg", | |
| headers={"Cache-Control": _MEDIA_FALLBACK_CACHE}, | |
| ) | |
| raise HTTPException(status_code=404, detail="Not found") | |
| def media(bucket: str, filename: str) -> Any: | |
| target = (MEDIA_ROOT / bucket / filename).resolve() | |
| if MEDIA_ROOT.resolve() not in target.parents: | |
| raise HTTPException(status_code=404, detail="Unknown asset") | |
| if target.is_file(): | |
| return FileResponse(target, headers={"Cache-Control": _MEDIA_CACHE}) | |
| remote_url = remote_media_url(f"{bucket}/{filename}") | |
| if remote_url: | |
| return RedirectResponse( | |
| remote_url, | |
| status_code=307, | |
| headers={"Cache-Control": "private, max-age=300"}, | |
| ) | |
| if not _media_strict_missing(): | |
| fb = _media_fallback_path() | |
| if fb is not None: | |
| return FileResponse( | |
| fb, | |
| media_type="image/jpeg", | |
| headers={"Cache-Control": _MEDIA_FALLBACK_CACHE, "X-Kink-Media-Fallback": "1"}, | |
| ) | |
| raise HTTPException(status_code=404, detail="Unknown asset") | |
| def health() -> dict: | |
| store_path = _default_store.resolve() | |
| warnings: list[str] = [] | |
| if _store_path_is_ephemeral(store_path): | |
| warnings.append("Store path is ephemeral; user data will not survive restarts.") | |
| if _backend_impl is None: | |
| return { | |
| "ok": False, | |
| "starting": True, | |
| "store_path": str(store_path), | |
| "storage_mode": _store_storage_mode(store_path), | |
| "store_path_persistent": not _store_path_is_ephemeral(store_path), | |
| "warnings": warnings, | |
| "media": _media_health_payload(), | |
| } | |
| b = _backend_impl | |
| store_path = b.path.resolve() | |
| warnings = [] | |
| if _store_path_is_ephemeral(store_path): | |
| warnings.append("Store path is ephemeral; user data will not survive restarts.") | |
| cand = b.recsys_settings.candidates_path | |
| if cand is None: | |
| cand = b.path.parent / "recsys" / "candidates.json" | |
| settings_path = b.path.parent / "recsys" / "settings.json" | |
| cache_exists = cand.is_file() | |
| cache_age_s = int(max(0, time.time() - cand.stat().st_mtime)) if cache_exists else None | |
| cache_payload = b._load_ots_candidate_cache() if cache_exists else None | |
| cache_user_count = ( | |
| len(cache_payload.get("users", {})) | |
| if isinstance(cache_payload, dict) and isinstance(cache_payload.get("users", {}), dict) | |
| else None | |
| ) | |
| snapshot_payload: dict[str, Any] = {"enabled": False} | |
| try: | |
| from backend import user_snapshot_hub as _hub | |
| snapshot_payload = { | |
| "enabled": _hub.snapshot_enabled(), | |
| "repo": _hub.snapshot_repo() or None, | |
| "filename": _hub.snapshot_filename(), | |
| "has_hf_token": bool((os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()), | |
| "last_restore": dict(_LAST_RESTORE), | |
| } | |
| except Exception: # noqa: BLE001 | |
| pass | |
| return { | |
| "ok": True, | |
| "store_path": str(store_path), | |
| "storage_mode": _store_storage_mode(store_path), | |
| "store_path_persistent": not _store_path_is_ephemeral(store_path), | |
| "warnings": warnings, | |
| "media": _media_health_payload(), | |
| "stats": b.catalog_stats(), | |
| "user_snapshot": snapshot_payload, | |
| "recsys": { | |
| "source": b.recsys_settings.source, | |
| "settings_path": str(settings_path), | |
| "settings_file_exists": settings_path.is_file(), | |
| "candidates_path": str(cand), | |
| "candidates_cache_exists": cache_exists, | |
| "recsys_candidate_cache_exists": cache_exists, | |
| "recsys_candidate_cache_age_s": cache_age_s, | |
| "recsys_candidate_user_count": cache_user_count, | |
| }, | |
| } | |
| def health_catalog_sample() -> dict: | |
| """Cheap proof the catalog table is readable (one ``LIMIT 1`` id; no full list).""" | |
| if _backend_impl is None: | |
| return {"ok": False, "starting": True} | |
| kid = _backend_impl.peek_catalog_kink_id() | |
| return {"ok": True, "sample_kink_id": kid} | |
| def stats() -> dict: | |
| return backend.catalog_stats() | |
| def fetlife_jobs(limit: int = Query(default=100, ge=1, le=500), state: str | None = None, x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return {"items": backend.fetlife_jobs(limit=limit, state=state), "stats": backend.fetlife_job_stats()} | |
| def fetlife_coverage(limit: int = Query(default=50, ge=1, le=200), x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return { | |
| "items": backend.fetlife_source_coverage(limit=limit), | |
| "debt": backend.fetlife_coverage_debt(limit=min(20, limit)), | |
| } | |
| def fetlife_data_health(limit: int = Query(default=20, ge=1, le=100), x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return backend.fetlife_data_health(limit=limit) | |
| def fetlife_supervisor_status(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return backend.fetlife_supervisor_status() | |
| def queue_fetlife_priority_debt(limit: int = Query(default=20, ge=1, le=100), x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return backend.queue_fetlife_priority_debt(limit=limit) | |
| def recommendation_debug( | |
| user_id: str, | |
| limit: int = Query(default=10, ge=1, le=50), | |
| group_id: str | None = None, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| x_admin_secret: str | None = Header(default=None, alias="x-admin-secret"), | |
| ) -> dict: | |
| require_admin(x_admin_secret) | |
| require_user_auth(user_id, x_private_token) | |
| return backend.recommendation_debug(user_id, limit=limit, group_id=group_id) | |
| def type_coverage(limit: int = Query(default=20, ge=1, le=200), x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return backend.content_type_coverage(limit=limit) | |
| def fetlife_samples(limit: int = Query(default=25, ge=1, le=200), x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| return backend.fetlife_sample_coverage(limit=limit) | |
| def admin_reload(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| require_admin(x_admin_secret) | |
| backend.refresh_cache() | |
| return {"ok": True, "stats": backend.catalog_stats()} | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Admin: user-state surface for ops + safe deploy workflow. | |
| # All endpoints below require ``x-admin-secret`` matching ``KINK_ADMIN_SECRET``. | |
| # Designed so ``scripts/admin_deploy.py`` can: | |
| # • back up every profile to a local SQLite before a risky deploy | |
| # • restore it after if the snapshot subsystem hiccups | |
| # • fix individual user issues (lost token, stuck state, bad rating) without redeploying | |
| # Coverage: tests/test_admin_endpoints.py exercises every route in this block including | |
| # auth gate sweeps, INSERT-OR-REPLACE roundtrip on /admin/import, and Hub mocks for push/pull. | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def admin_list_users( | |
| limit: int = Query(default=200, ge=1, le=2000), | |
| offset: int = Query(default=0, ge=0), | |
| x_admin_secret: str | None = Header(default=None, alias="x-admin-secret"), | |
| ) -> dict: | |
| """List every user with summary counts. Sorted by id; paginate via limit/offset.""" | |
| require_admin(x_admin_secret) | |
| from sqlalchemy import text as _sql_text | |
| with backend.engine.connect() as conn: | |
| users = [r[0] for r in conn.execute(_sql_text('SELECT id FROM "user" ORDER BY id'))] | |
| plays_by_user: dict[str, int] = dict( | |
| conn.execute(_sql_text('SELECT user_id, COUNT(*) FROM playpreference GROUP BY user_id')).all() | |
| ) | |
| scen_by_user: dict[str, int] = dict( | |
| conn.execute(_sql_text('SELECT user_id, COUNT(*) FROM scenariopreference GROUP BY user_id')).all() | |
| ) | |
| roles_by_user: dict[str, int] = dict( | |
| conn.execute(_sql_text('SELECT user_id, COUNT(*) FROM rolepreference GROUP BY user_id')).all() | |
| ) | |
| groups_by_user: dict[str, int] = dict( | |
| conn.execute(_sql_text('SELECT member_user_id, COUNT(*) FROM partnergroupmember GROUP BY member_user_id')).all() | |
| ) | |
| links_rows = conn.execute(_sql_text('SELECT left_user_id, right_user_id FROM partnerlink')).all() | |
| links_by_user: dict[str, int] = {} | |
| for left, right in links_rows: | |
| links_by_user[left] = links_by_user.get(left, 0) + 1 | |
| links_by_user[right] = links_by_user.get(right, 0) + 1 | |
| items = [ | |
| { | |
| "user_id": uid, | |
| "plays": int(plays_by_user.get(uid, 0)), | |
| "scenarios": int(scen_by_user.get(uid, 0)), | |
| "roles": int(roles_by_user.get(uid, 0)), | |
| "partner_links": int(links_by_user.get(uid, 0)), | |
| "group_memberships": int(groups_by_user.get(uid, 0)), | |
| } | |
| for uid in users | |
| ] | |
| return {"total": len(items), "limit": limit, "offset": offset, "items": items[offset : offset + limit]} | |
| def admin_get_user(user_id: str, x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Full user state INCLUDING ``private_token`` (don't ship to clients). For helping users | |
| who lost their pass or for debugging stuck profiles.""" | |
| require_admin(x_admin_secret) | |
| user = backend.get_user(user_id) | |
| if not user: | |
| raise HTTPException(status_code=404, detail=f"Unknown user {user_id!r}") | |
| return user | |
| def admin_regenerate_token(user_id: str, x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Issue a new ``private_token`` for a user who has lost theirs. Old token stops working.""" | |
| require_admin(x_admin_secret) | |
| from sqlmodel import Session | |
| from models import User | |
| new_token = backend._make_memorable_pass() | |
| with Session(backend.engine) as session: | |
| user = session.get(User, user_id) | |
| if not user: | |
| raise HTTPException(status_code=404, detail=f"Unknown user {user_id!r}") | |
| user.private_token = new_token | |
| session.add(user) | |
| session.commit() | |
| return {"user_id": user_id, "private_token": new_token} | |
| async def admin_delete_user(user_id: str, x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Hard-delete a profile + all its plays / scenarios / partner state. Idempotent.""" | |
| require_admin(x_admin_secret) | |
| backend.delete_user(user_id) | |
| return {"ok": True, "user_id": user_id} | |
| async def admin_scrub_plays(user_id: str, x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Wipe every PlayPreference for one user (keeps profile, scenarios, roles).""" | |
| require_admin(x_admin_secret) | |
| from sqlmodel import Session, delete as sql_delete | |
| from models import PlayPreference | |
| with Session(backend.engine) as session: | |
| result = session.exec(sql_delete(PlayPreference).where(PlayPreference.user_id == user_id)) | |
| deleted = getattr(result, "rowcount", 0) or 0 | |
| session.commit() | |
| return {"ok": True, "user_id": user_id, "deleted": int(deleted)} | |
| def admin_delete_play( | |
| user_id: str, | |
| kink_id: str, | |
| x_admin_secret: str | None = Header(default=None, alias="x-admin-secret"), | |
| ) -> dict: | |
| """Remove ONE play preference for a user (no token required).""" | |
| require_admin(x_admin_secret) | |
| backend.remove_play_preference(user_id, kink_id) | |
| return {"ok": True, "user_id": user_id, "kink_id": kink_id} | |
| def admin_export(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> Response: | |
| """Stream the user-state SQLite (USER_TABLES only — no catalog). Suitable as a pre-deploy | |
| backup. Identical schema/format to what the snapshot flusher pushes to Hub. | |
| """ | |
| require_admin(x_admin_secret) | |
| import tempfile | |
| from backend import user_snapshot | |
| store_path = _default_store.resolve() | |
| tmp = Path(tempfile.mkdtemp()) / "user_state.db" | |
| user_snapshot.dump_user_state(store_path, tmp) | |
| data = tmp.read_bytes() | |
| try: | |
| tmp.unlink() | |
| tmp.parent.rmdir() | |
| except OSError: | |
| pass | |
| headers = {"Content-Disposition": 'attachment; filename="user_state_export.db"'} | |
| return Response(content=data, media_type="application/octet-stream", headers=headers) | |
| async def admin_import( | |
| request: Request, | |
| x_admin_secret: str | None = Header(default=None, alias="x-admin-secret"), | |
| ) -> dict: | |
| """Upload a user-state SQLite (raw body, octet-stream) and apply via INSERT OR REPLACE. | |
| Existing rows with conflicting primary keys are overwritten by the snapshot's values; rows | |
| only in the live store are kept. Catalog tables are untouched. | |
| """ | |
| require_admin(x_admin_secret) | |
| import tempfile | |
| from backend import user_snapshot | |
| body = await request.body() | |
| if not body: | |
| raise HTTPException(status_code=400, detail="empty body — POST raw user_state.db bytes") | |
| tmp = Path(tempfile.mkdtemp()) / "import.db" | |
| tmp.write_bytes(body) | |
| try: | |
| counts = user_snapshot.restore_user_state(_default_store.resolve(), tmp) | |
| finally: | |
| try: | |
| tmp.unlink() | |
| tmp.parent.rmdir() | |
| except OSError: | |
| pass | |
| backend.refresh_cache() | |
| return {"ok": True, "applied": counts, "total_rows": sum(counts.values())} | |
| async def admin_snapshot_push(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Force an immediate user-state push to the Hub dataset (bypasses the periodic flusher).""" | |
| require_admin(x_admin_secret) | |
| from backend import user_snapshot, user_snapshot_hub | |
| if not user_snapshot_hub.snapshot_enabled(): | |
| raise HTTPException(status_code=409, detail="snapshot subsystem not enabled (HF_TOKEN or repo missing)") | |
| store_path = _default_store.resolve() | |
| if user_snapshot.user_state_is_empty(store_path): | |
| return {"ok": False, "skipped": "store has no user rows; refusing to overwrite Hub with empty snapshot"} | |
| import tempfile | |
| tmp = Path(tempfile.mkdtemp()) / "out.db" | |
| user_snapshot.dump_user_state(store_path, tmp) | |
| try: | |
| sent = user_snapshot_hub.push_user_snapshot(tmp, commit_message="admin: manual push") | |
| finally: | |
| try: | |
| tmp.unlink() | |
| tmp.parent.rmdir() | |
| except OSError: | |
| pass | |
| return {"ok": bool(sent), "fingerprint": user_snapshot.compute_user_state_fingerprint(store_path)} | |
| async def admin_snapshot_pull(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Force pull the Hub snapshot and merge into the live store (INSERT OR REPLACE). | |
| Unlike boot-restore, this runs even when the store already has rows — so use it to | |
| recover a user who's missing from /tmp but present on Hub. | |
| """ | |
| require_admin(x_admin_secret) | |
| from backend import user_snapshot, user_snapshot_hub | |
| if not user_snapshot_hub.snapshot_enabled(): | |
| raise HTTPException(status_code=409, detail="snapshot subsystem not enabled") | |
| store_path = _default_store.resolve() | |
| tmp = store_path.parent / ".admin_pull_snapshot.db" | |
| if not user_snapshot_hub.pull_user_snapshot(tmp): | |
| raise HTTPException(status_code=502, detail="Hub pull returned no file (snapshot not present yet?)") | |
| counts = user_snapshot.restore_user_state(store_path, tmp) | |
| try: | |
| tmp.unlink() | |
| except OSError: | |
| pass | |
| backend.refresh_cache() | |
| return {"ok": True, "applied": counts, "total_rows": sum(counts.values())} | |
| def admin_stats(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Aggregate counts for an at-a-glance ops dashboard.""" | |
| require_admin(x_admin_secret) | |
| import sqlite3 | |
| store_path = _default_store.resolve() | |
| conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True) | |
| try: | |
| result = {} | |
| for table in ("user", "userpreference", "playpreference", "rolepreference", | |
| "scenariopreference", "promptdismissal", "partnerlink", | |
| "partnerlinkrequest", "partnergroup", "partnergroupmember"): | |
| try: | |
| result[table] = int(conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]) | |
| except sqlite3.OperationalError: | |
| result[table] = None | |
| # Top-rated kinks across all users | |
| top = conn.execute( | |
| "SELECT kink_id, COUNT(*) AS n FROM playpreference GROUP BY kink_id ORDER BY n DESC LIMIT 20" | |
| ).fetchall() | |
| top_kinks = [{"kink_id": k, "save_count": int(n)} for k, n in top] | |
| finally: | |
| conn.close() | |
| return {"row_counts": result, "top_kinks": top_kinks, "store_path": str(store_path)} | |
| def admin_health(x_admin_secret: str | None = Header(default=None, alias="x-admin-secret")) -> dict: | |
| """Detailed health for ops — includes the boot-restore log and snapshot subsystem state.""" | |
| require_admin(x_admin_secret) | |
| from backend import user_snapshot_hub | |
| return { | |
| "ok": _backend_impl is not None, | |
| "store_path": str(_default_store.resolve()), | |
| "store_path_persistent": not _store_path_is_ephemeral(_default_store), | |
| "snapshot": { | |
| "enabled": user_snapshot_hub.snapshot_enabled(), | |
| "repo": user_snapshot_hub.snapshot_repo() or None, | |
| "filename": user_snapshot_hub.snapshot_filename(), | |
| "has_hf_token": bool((os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()), | |
| "last_restore": dict(_LAST_RESTORE), | |
| }, | |
| } | |
| def admin_force_partner_link( | |
| user_id: str, | |
| partner_id: str, | |
| x_admin_secret: str | None = Header(default=None, alias="x-admin-secret"), | |
| ) -> dict: | |
| """Force-create a bidirectional partner link without going through the request/accept flow. | |
| Useful when one side's UI is stuck on a pending invite and the other side has already | |
| confirmed verbally. Idempotent — duplicate calls are a no-op. | |
| """ | |
| require_admin(x_admin_secret) | |
| if user_id == partner_id: | |
| raise HTTPException(status_code=400, detail="cannot link a user to themselves") | |
| if not backend.get_user(user_id): | |
| raise HTTPException(status_code=404, detail=f"Unknown user {user_id!r}") | |
| if not backend.get_user(partner_id): | |
| raise HTTPException(status_code=404, detail=f"Unknown user {partner_id!r}") | |
| from sqlmodel import Session, select | |
| from models import PartnerLink, PartnerLinkRequest | |
| a, b = sorted([user_id, partner_id]) | |
| with Session(backend.engine) as session: | |
| existing = session.exec( | |
| select(PartnerLink).where(PartnerLink.left_user_id == a, PartnerLink.right_user_id == b) | |
| ).first() | |
| if existing: | |
| return {"ok": True, "user_id": user_id, "partner_id": partner_id, "created": False} | |
| session.add(PartnerLink(left_user_id=a, right_user_id=b)) | |
| # Drop any stale pending requests in either direction. | |
| for req in session.exec( | |
| select(PartnerLinkRequest).where( | |
| ((PartnerLinkRequest.from_user_id == user_id) & (PartnerLinkRequest.to_user_id == partner_id)) | |
| | ((PartnerLinkRequest.from_user_id == partner_id) & (PartnerLinkRequest.to_user_id == user_id)) | |
| ) | |
| ).all(): | |
| session.delete(req) | |
| session.commit() | |
| return {"ok": True, "user_id": user_id, "partner_id": partner_id, "created": True} | |
| def admin_unlink_partners( | |
| user_a: str, | |
| user_b: str, | |
| x_admin_secret: str | None = Header(default=None, alias="x-admin-secret"), | |
| ) -> dict: | |
| """Drop a partner link between two users.""" | |
| require_admin(x_admin_secret) | |
| a, b = sorted([user_a, user_b]) | |
| from sqlmodel import Session, select | |
| from models import PartnerLink | |
| with Session(backend.engine) as session: | |
| link = session.exec( | |
| select(PartnerLink).where(PartnerLink.left_user_id == a, PartnerLink.right_user_id == b) | |
| ).first() | |
| if not link: | |
| return {"ok": True, "removed": False} | |
| session.delete(link) | |
| session.commit() | |
| return {"ok": True, "removed": True} | |
| def list_kinks(limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0)) -> dict: | |
| items = backend.list_kink_summaries() | |
| sliced = items[offset : offset + limit] | |
| return {"items": sliced, "total": len(items), "limit": limit, "offset": offset} | |
| def get_kink(kink_id: str) -> dict: | |
| return require_kink(kink_id) | |
| def similar_kinks(kink_id: str, limit: int = Query(default=8, ge=1, le=50)) -> dict: | |
| require_kink(kink_id) | |
| return {"items": backend.similar_kinks(kink_id, limit=limit)} | |
| def kink_scenarios(kink_id: str, limit: int = Query(default=24, ge=1, le=100)) -> dict: | |
| require_kink(kink_id) | |
| return {"items": backend.list_scenarios_for_parent(kink_id, limit=limit)} | |
| def search(q: str = Query(min_length=1), limit: int = Query(default=10, ge=1, le=100)) -> dict: | |
| return {"items": backend.search_kinks(q, limit=limit)} | |
| def search_roles(q: str = Query(min_length=1), limit: int = Query(default=10, ge=1, le=100)) -> dict: | |
| return {"items": backend.search_roles(q, limit=limit)} | |
| async def kink_batch(request: Request) -> dict: | |
| payload = await parse_payload(request, KinkBatchRequest) | |
| return await run_blocking(lambda: {"items": backend.get_kinks_batch(payload.ids)}) | |
| async def create_user(request: Request) -> dict: | |
| await parse_payload(request, CreateUserRequest) | |
| user = await run_blocking(backend.create_user) | |
| return { | |
| "id": user.id, | |
| "private_token": user.private_token, | |
| } | |
| async def login(request: Request) -> dict: | |
| payload = await parse_payload(request, LoginRequest) | |
| user = await run_blocking(require_user_auth, payload.user_id, payload.private_token) | |
| return { | |
| "user": sanitize_user(user), | |
| "private_token": payload.private_token, | |
| } | |
| def get_user(user_id: str, x_private_token: str | None = Header(default=None, alias="x-private-token")) -> dict: | |
| return sanitize_user(require_user_auth(user_id, x_private_token)) | |
| def play_board(user_id: str, x_private_token: str | None = Header(default=None, alias="x-private-token")) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| return backend.play_board(user_id) | |
| def scenario_parents( | |
| user_id: str, | |
| limit: int = Query(default=100, ge=1, le=500), | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| return {"items": backend.scenario_parents_for_user(user_id, limit=limit)} | |
| async def save_preferences( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PreferenceRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| user = backend.save_preferences(user_id, show_images=payload.show_images) | |
| if not user: | |
| raise HTTPException(status_code=404, detail="Unknown user") | |
| return sanitize_user(user) | |
| return await run_blocking(work) | |
| def delete_play( | |
| user_id: str, | |
| kink_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| backend.remove_play_preference(user_id, kink_id) | |
| return {"ok": True} | |
| async def save_play( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PlaySaveRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_kink(payload.kink_id) | |
| if payload.interest_state not in VALID_RATINGS: | |
| raise HTTPException(status_code=400, detail="Invalid interest state") | |
| backend.save_play_preference(user_id, payload.kink_id, payload.interest_state, payload.directions) | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| async def save_scenario_preference( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, ScenarioPreferenceRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_kink(payload.parent_kink_id) | |
| require_kink(payload.scenario_kink_id) | |
| if payload.interest_state not in VALID_RATINGS: | |
| raise HTTPException(status_code=400, detail="Invalid interest state") | |
| ok = backend.save_scenario_preference( | |
| user_id, | |
| payload.parent_kink_id, | |
| payload.scenario_kink_id, | |
| payload.interest_state, | |
| payload.directions, | |
| ) | |
| if not ok: | |
| raise HTTPException(status_code=400, detail="Scenario is not linked to that parent kink") | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| async def delete_scenario_preference( | |
| user_id: str, | |
| scenario_kink_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| backend.remove_scenario_preference(user_id, scenario_kink_id) | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| def get_roles(user_id: str, x_private_token: str | None = Header(default=None, alias="x-private-token")) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| return {"items": backend.role_cards(user_id)} | |
| async def save_role( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, RoleSaveRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_kink(payload.kink_id) | |
| backend.save_role_preference(user_id, payload.kink_id, payload.selected) | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| def recommendations( | |
| user_id: str, | |
| limit: int = Query(default=10, ge=1, le=100), | |
| group_id: str | None = None, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if group_id: | |
| require_partner_group_access(user_id, group_id) | |
| return {"items": backend.recommend(user_id, limit=limit, group_id=group_id)} | |
| def prompts( | |
| user_id: str, | |
| limit: int = Query(default=8, ge=1, le=50), | |
| group_id: str | None = None, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if group_id: | |
| require_partner_group_access(user_id, group_id) | |
| return {"items": backend.prompt_candidates(user_id, limit=limit, group_id=group_id)} | |
| async def respond_prompt( | |
| user_id: str, | |
| kink_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PromptRespondRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_kink(kink_id) | |
| if payload.dismiss and payload.interest_state: | |
| raise HTTPException(status_code=400, detail="Dismiss or rate a prompt, not both") | |
| if payload.dismiss: | |
| backend.dismiss_prompt(user_id, kink_id) | |
| return {"ok": True} | |
| if not payload.interest_state: | |
| raise HTTPException(status_code=400, detail="Missing interest state") | |
| backend.save_play_preference(user_id, kink_id, payload.interest_state, payload.directions) | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| async def request_partner_link_endpoint( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| """Send a link request; the other user must accept before you appear as partners.""" | |
| payload = await parse_payload(request, LinkPartnerRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_user(payload.partner_id) | |
| ok, msg = backend.request_partner_link(user_id, payload.partner_id) | |
| if not ok: | |
| raise HTTPException(status_code=400, detail=msg) | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| async def accept_partner_request_endpoint( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PartnerAcceptBody) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if not backend.accept_partner_request(user_id, payload.from_user_id): | |
| raise HTTPException(status_code=400, detail="No pending request from that user") | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| async def decline_partner_request_endpoint( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PartnerDeclineBody) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if not backend.decline_partner_request(user_id, payload.from_user_id): | |
| raise HTTPException(status_code=400, detail="No pending request from that user") | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| async def cancel_partner_request_endpoint( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PartnerCancelBody) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if not backend.cancel_partner_request(user_id, payload.to_user_id): | |
| raise HTTPException(status_code=400, detail="No outgoing request to that user") | |
| return sanitize_user(require_user(user_id)) | |
| return await run_blocking(work) | |
| def list_partner_groups( | |
| user_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| return {"items": backend.list_partner_groups(user_id)} | |
| async def create_partner_group( | |
| user_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PartnerGroupCreateRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| group = backend.create_partner_group(user_id, payload.name, payload.member_ids) | |
| if not group: | |
| raise HTTPException(status_code=400, detail="Could not create partner group") | |
| return group | |
| return await run_blocking(work) | |
| async def add_partner_group_members( | |
| user_id: str, | |
| group_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, PartnerGroupMembersRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| group = backend.add_partner_group_members(user_id, group_id, payload.member_ids) | |
| if not group: | |
| raise HTTPException(status_code=400, detail="Could not update group members") | |
| return group | |
| return await run_blocking(work) | |
| def remove_partner_group_member( | |
| user_id: str, | |
| group_id: str, | |
| member_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if not backend.remove_partner_group_member(user_id, group_id, member_id): | |
| raise HTTPException(status_code=404, detail="Unknown group member") | |
| return {"ok": True} | |
| def overlap_group( | |
| user_id: str, | |
| group_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_partner_group_access(user_id, group_id) | |
| return backend.shared_play_list_for_group(user_id, group_id) | |
| def prompts_group( | |
| user_id: str, | |
| group_id: str, | |
| limit: int = Query(default=8, ge=1, le=50), | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| require_partner_group_access(user_id, group_id) | |
| return {"items": backend.group_prompt_candidates(user_id, group_id, limit=limit)} | |
| def overlap( | |
| user_id: str, | |
| partner_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| user = require_user_auth(user_id, x_private_token) | |
| require_user(partner_id) | |
| if partner_id not in user.get("partners", []): | |
| raise HTTPException(status_code=403, detail="Users are not linked partners") | |
| return backend.shared_play_list(user_id, partner_id) | |
| async def delete_user( | |
| user_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| backend.delete_user(user_id) | |
| return {"ok": True} | |
| return await run_blocking(work) | |
| def export_user( | |
| user_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| return backend.export_user_data(user_id) | |
| async def user_events( | |
| user_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> EventSourceResponse: | |
| await run_blocking(require_user_auth, user_id, x_private_token) | |
| async def event_stream(): | |
| last_hash = "" | |
| while True: | |
| await asyncio.sleep(5) | |
| current = await run_blocking(backend.partner_activity_hash, user_id) | |
| if current != last_hash: | |
| last_hash = current | |
| yield {"event": "partner-update", "data": json.dumps({"user_id": user_id, "hash": current})} | |
| return EventSourceResponse(event_stream()) | |
| async def toggle_share( | |
| user_id: str, | |
| group_id: str, | |
| request: Request, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| payload = await parse_payload(request, ShareToggleRequest) | |
| def work() -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| if not backend.toggle_share_full_list(user_id, group_id, payload.share): | |
| raise HTTPException(status_code=400, detail="Could not update sharing preference") | |
| return {"ok": True, "share": payload.share} | |
| return await run_blocking(work) | |
| def partner_board( | |
| user_id: str, | |
| group_id: str, | |
| partner_id: str, | |
| x_private_token: str | None = Header(default=None, alias="x-private-token"), | |
| ) -> dict: | |
| require_user_auth(user_id, x_private_token) | |
| board = backend.partner_full_board(user_id, group_id, partner_id) | |
| if board is None: | |
| raise HTTPException(status_code=403, detail="Partner has not enabled sharing in this group") | |
| return board | |
| TELEMETRY_FILE = Path(__file__).resolve().parent / "data" / "telemetry.jsonl" | |
| async def receive_telemetry(request: Request) -> dict: | |
| import time | |
| body = await request.body() | |
| try: | |
| payload = json.loads(body) if body else {} | |
| except json.JSONDecodeError: | |
| return {"ok": False} | |
| entry = {"ts": time.time(), "payload": payload} | |
| def write_entry() -> None: | |
| TELEMETRY_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| with open(TELEMETRY_FILE, "a") as f: | |
| f.write(json.dumps(entry) + "\n") | |
| await run_blocking(write_entry) | |
| return {"ok": True} | |