Files
bincio-activity/bincio/serve/routers/feed.py
T
Davide Scaini 8380b1d2cc Refactor: split serve/server.py (3220 lines) into focused modules
serve/server.py is now 69 lines — app factory, middleware, and router
registration only.

New modules:
  deps.py    (168 lines) — module-level globals + auth dependency functions
  models.py   (85 lines) — all Pydantic request/response models
  tasks.py   (136 lines) — background workers and job tracker
  routers/               — one file per domain (10 routers, ~2750 lines total)
    auth.py, me.py, admin.py, activities.py, uploads.py,
    segments.py, strava.py, garmin.py, ideas.py, feed.py

cli.py updated to set globals on deps instead of server.

88 new regression tests in tests/serve/ cover auth guards and key
behaviours for every router; 294 total passing after the split.
2026-05-13 23:47:19 +02:00

130 lines
4.7 KiB
Python

"""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": []})