8380b1d2cc
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.
130 lines
4.7 KiB
Python
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": []})
|