"""Feed and wheel endpoints.""" from __future__ import annotations import json from fastapi import APIRouter, Cookie, Depends, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from bincio.serve import deps, tasks from bincio.serve.models import CurrentUserResponse from bincio.serve.db import ( User, get_member_tree, get_setting, ) router = APIRouter() @router.get("/api/me", response_model=CurrentUserResponse) async def me(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: user = deps._current_user(bincio_session) if not user: raise HTTPException(401, "Not authenticated") store_orig = get_setting(deps._get_db(), "store_originals") return JSONResponse({ "handle": user.handle, "display_name": user.display_name, "is_admin": user.is_admin, "wiki_access": user.wiki_access, "activity_access": user.activity_access, "store_originals_default": store_orig != "false", "dem_configured": bool(deps.dem_url), }) @router.get("/api/stats") async def stats() -> JSONResponse: """Public endpoint: member count, join dates, and invitation tree.""" import time as _time now = int(_time.time()) members = get_member_tree(deps._get_db()) return JSONResponse({ "user_count": len(members), "members": [ { "handle": m["handle"], "display_name": m["display_name"], "member_since": m["created_at"], "member_for_days": (now - m["created_at"]) // 86400, "invited_by": m["invited_by"], } for m in members ], }) @router.post("/api/internal/rebuild") async def internal_rebuild(request: Request) -> JSONResponse: """Trigger a site rebuild. Authenticated via X-Sync-Secret header. Called by the bincio sync-strava systemd timer after syncing new activities. Returns 503 if webroot is not configured (rebuild not possible). Returns 403 if the secret is missing or wrong. """ if not deps.sync_secret: raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)") if request.headers.get("X-Sync-Secret") != deps.sync_secret: raise HTTPException(403, "Forbidden") if deps.site_dir is None: raise HTTPException(503, "No site dir configured") tasks._site_rebuild_event.set() return JSONResponse({"status": "rebuild queued"}) @router.get("/api/wheel/version") async def wheel_version() -> JSONResponse: """Public endpoint: current bincio wheel version for mobile app update checks.""" import importlib.metadata try: version = importlib.metadata.version("bincio") except importlib.metadata.PackageNotFoundError: version = "0.1.0" return JSONResponse({ "version": version, "url": f"/bincio-{version}-py3-none-any.whl", "api_url": f"/api/wheel/download", }) @router.get("/api/wheel/download") async def wheel_download() -> FileResponse: """Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl).""" import importlib.metadata from pathlib import Path try: version = importlib.metadata.version("bincio") except importlib.metadata.PackageNotFoundError: version = "0.1.0" wheel_name = f"bincio-{version}-py3-none-any.whl" # Look in dist/ relative to repo root (two levels up from this file) dist_dir = Path(__file__).parent.parent.parent.parent / "dist" wheel_path = dist_dir / wheel_name if not wheel_path.exists(): raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/") return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name) @router.get("/api/feed") async def get_feed(user: User = Depends(deps._require_auth)) -> JSONResponse: """Return the authenticated user's activity summaries (mobile feed sync). _merged/index.json is a shard manifest (activities: []) when the user has more than FEED_PAGE_SIZE activities. Collect from all shard files. """ dd = deps._get_data_dir() user_dir = dd / user.handle for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"): if not index_path.exists(): continue index = json.loads(index_path.read_text()) activities: list[dict] = index.get("activities", []) for shard in index.get("shards", []): shard_path = index_path.parent / shard["url"] if shard_path.exists(): shard_doc = json.loads(shard_path.read_text()) activities.extend(shard_doc.get("activities", [])) return JSONResponse({"activities": activities}) return JSONResponse({"activities": []})