Spaces:
Runtime error
Runtime error
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import os | |
| import threading | |
| import time | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from fastapi import FastAPI, Header, HTTPException, Query, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from pydantic import BaseModel, Field, ValidationError | |
| from sse_starlette.sse import EventSourceResponse | |
| from backend import Backend, VALID_RATINGS | |
| from backend.hf_bootstrap import ensure_store_db | |
| _data_dir = Path(__file__).resolve().parent / "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 sets KINK_HF_DATASET_REPO for full-catalog bootstrap. | |
| _default_store = Path(os.environ["KINK_STORE_PATH"]) if os.environ.get("KINK_STORE_PATH") else _data_dir / "store_slim.db" | |
| FRONTEND_PATH = Path(__file__).resolve().parent / "frontend" / "index.html" | |
| FRONTEND_DIR = Path(__file__).resolve().parent / "frontend" | |
| MEDIA_ROOT = Path(__file__).resolve().parent / "data" / "cached_assets" | |
| ADMIN_SECRET = os.environ.get("KINK_ADMIN_SECRET", "") | |
| _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 _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 _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 | |
| path = ensure_store_db(_default_store) | |
| _warn_or_fail_ephemeral_store(path) | |
| b = Backend(path) | |
| # Optional: set KINK_SKIP_HEAVY_WARM=1 only if you have measured a real memory/startup problem. | |
| if os.environ.get("KINK_SKIP_HEAVY_WARM", "").strip().lower() not in ("1", "true", "yes", "on"): | |
| b.warm_up_catalog() | |
| b._catalog() | |
| from backend.recsys_graph import warm_ppr_caches | |
| warm_ppr_caches(b) | |
| _backend_impl = b | |
| return b | |
| class _BackendProxy: | |
| def __getattr__(self, name: str): | |
| return getattr(_get_backend(), name) | |
| backend = _BackendProxy() | |
| 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) | |
| yield | |
| 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: ES module graph (esm.sh), dynamic import (e.g. web-vitals from unpkg), fetch, beacon, WS. | |
| connect_src = " ".join( | |
| sorted( | |
| { | |
| "'self'", | |
| "https://esm.sh", | |
| "https://unpkg.com", | |
| *(origin.strip() for origin in cors_origins if origin.strip()), | |
| } | |
| ) | |
| ) | |
| csp = ( | |
| "default-src 'self' https://esm.sh https://unpkg.com data:; " | |
| "base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'none'; object-src 'none'; " | |
| "script-src 'self' https://esm.sh https://unpkg.com; 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") | |
| return response | |
| class CreateUserRequest(BaseModel): | |
| pass | |
| class LoginRequest(BaseModel): | |
| 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: | |
| if not private_token: | |
| raise HTTPException(status_code=401, detail="Missing private token") | |
| user = backend.authenticate_user(user_id, private_token) | |
| if not user: | |
| 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: | |
| return FileResponse(FRONTEND_PATH, headers={"Cache-Control": "no-store"}) | |
| def frontend_static(filepath: str) -> FileResponse: | |
| target = (FRONTEND_DIR / filepath).resolve() | |
| if FRONTEND_DIR.resolve() not in target.parents and target != FRONTEND_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 = "public, max-age=300, stale-while-revalidate=60" if target.suffix in {".js", ".css"} else "no-store" | |
| return FileResponse(target, headers={"Cache-Control": cache_control}) | |
| def media(bucket: str, filename: str) -> FileResponse: | |
| target = (MEDIA_ROOT / bucket / filename).resolve() | |
| if MEDIA_ROOT.resolve() not in target.parents: | |
| raise HTTPException(status_code=404, detail="Unknown asset") | |
| if not target.exists() or not target.is_file(): | |
| raise HTTPException(status_code=404, detail="Unknown asset") | |
| return FileResponse(target, headers={"Cache-Control": "public, max-age=86400, immutable"}) | |
| 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, | |
| } | |
| 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 | |
| ) | |
| 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, | |
| "stats": b.catalog_stats(), | |
| "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()} | |
| def list_kinks(limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0)) -> dict: | |
| items = backend.list_kinks() | |
| 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} | |