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.
This commit is contained in:
Davide Scaini
2026-05-13 23:47:19 +02:00
parent 2ec4d9157c
commit 8380b1d2cc
28 changed files with 3982 additions and 3193 deletions
+293
View File
@@ -0,0 +1,293 @@
"""Self-service user settings endpoints (/api/me/*)."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse
from bincio.serve import deps, tasks
from bincio.serve.db import (
authenticate,
get_user_prefs,
set_user_prefs,
)
router = APIRouter()
def _wipe_user_activities(user_dir: Path) -> int:
"""Delete all extracted activity files and caches for a user.
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
_merged/, index.json, athlete.json, and the dedup cache.
Leaves the user directory itself intact (account remains in the DB).
Returns the number of files deleted.
"""
import shutil
deleted = 0
for subdir in ("activities", "edits", "originals"):
d = user_dir / subdir
if d.exists():
for f in d.rglob("*"):
if f.is_file():
deleted += 1
shutil.rmtree(d)
for name in ("_merged", ):
d = user_dir / name
if d.exists():
shutil.rmtree(d)
for name in ("index.json", "athlete.json", ".bincio_cache.json"):
f = user_dir / name
if f.exists():
f.unlink()
deleted += 1
return deleted
@router.get("/api/me/storage")
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return per-category disk usage for the logged-in user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
def _mb(path: Path) -> float:
if not path.exists():
return 0.0
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
return round(total / 1_048_576, 2)
def _count(path: Path, pattern: str = "*") -> int:
if not path.exists():
return 0
return sum(1 for f in path.glob(pattern) if f.is_file())
activities_mb = _mb(dd / "activities")
originals_mb = _mb(dd / "originals")
strava_mb = _mb(dd / "originals" / "strava")
images_mb = _mb(dd / "edits" / "images")
total_mb = _mb(dd)
return JSONResponse({
"total_mb": total_mb,
"activities_mb": activities_mb,
"activities_count": _count(dd / "activities", "*.json"),
"originals_mb": originals_mb,
"strava_originals_mb": strava_mb,
"strava_originals_count": _count(dd / "originals" / "strava", "*.json"),
"images_mb": images_mb,
})
@router.delete("/api/me/originals")
async def me_delete_originals(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Delete the user's originals/ directory (frees space after re-extraction)."""
user = deps._require_user(bincio_session)
originals = deps._get_data_dir() / user.handle / "originals"
if not originals.exists():
return JSONResponse({"ok": True, "freed_mb": 0.0})
freed = round(
sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2
)
shutil.rmtree(originals)
return JSONResponse({"ok": True, "freed_mb": freed})
@router.delete("/api/me/activities")
async def me_delete_activities(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON).
Requires the user's current password in the request body for confirmation.
"""
user = deps._require_user(bincio_session)
body = await request.json()
password = body.get("password", "")
if not authenticate(deps._get_db(), user.handle, password):
raise HTTPException(401, "Wrong password")
user_dir = deps._get_data_dir() / user.handle
deleted = _wipe_user_activities(user_dir)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "deleted": deleted})
@router.delete("/api/me")
async def me_delete_account(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Delete the account and all data permanently.
Requires the user's current password. Deletes the DB row, all sessions,
and the entire user data directory. The root shard manifest is updated.
"""
user = deps._require_user(bincio_session)
body = await request.json()
password = body.get("password", "")
if not authenticate(deps._get_db(), user.handle, password):
raise HTTPException(401, "Wrong password")
# Wipe data directory
user_dir = deps._get_data_dir() / user.handle
if user_dir.is_dir():
shutil.rmtree(user_dir)
# Remove from DB (cascades to sessions, invites, reset_codes)
from bincio.serve.db import delete_user as _delete_user
_delete_user(deps._get_db(), user.handle)
# Update root manifest so the shard disappears
from bincio.render.cli import _write_root_manifest
try:
_write_root_manifest(deps._get_data_dir())
except Exception:
pass
resp = JSONResponse({"ok": True})
resp.delete_cookie(deps._SESSION_COOKIE)
return resp
@router.put("/api/me/display-name")
async def me_update_display_name(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Update the logged-in user's display name."""
user = deps._require_user(bincio_session)
body = await request.json()
display_name = str(body.get("display_name", "")).strip()
if len(display_name) > 60:
raise HTTPException(400, "Display name too long (max 60 characters)")
db = deps._get_db()
db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle))
db.commit()
return JSONResponse({"ok": True, "display_name": display_name})
@router.get("/api/me/prefs")
async def me_get_prefs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return all user preferences as a key→value dict."""
user = deps._require_user(bincio_session)
return JSONResponse(get_user_prefs(deps._get_db(), user.handle))
@router.put("/api/me/prefs")
async def me_set_prefs(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Upsert one or more user preferences. Body: {key: value, ...} (all strings)."""
user = deps._require_user(bincio_session)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Body must be a JSON object")
# Coerce all values to strings; ignore unknown keys silently
prefs = {str(k): str(v) for k, v in body.items()}
set_user_prefs(deps._get_db(), user.handle, prefs)
return JSONResponse({"ok": True})
@router.get("/api/me/strava-credentials")
async def me_get_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return whether per-user Strava credentials are configured (never returns the secret)."""
user = deps._require_user(bincio_session)
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
has_user_creds = False
client_id_hint = ""
if creds_path.exists():
try:
d = json.loads(creds_path.read_text(encoding="utf-8"))
cid = str(d.get("client_id", "")).strip()
csec = str(d.get("client_secret", "")).strip()
if cid and csec:
has_user_creds = True
client_id_hint = cid
except Exception:
pass
return JSONResponse({
"has_user_creds": has_user_creds,
"client_id": client_id_hint,
"instance_configured": bool(deps.strava_client_id),
})
@router.put("/api/me/strava-credentials")
async def me_set_strava_credentials(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Save per-user Strava credentials. Body: {client_id, client_secret}."""
user = deps._require_user(bincio_session)
body = await request.json()
cid = str(body.get("client_id", "")).strip()
csec = str(body.get("client_secret", "")).strip()
if not cid:
raise HTTPException(400, "client_id is required")
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
# If client_secret is omitted, preserve existing secret (if any)
if not csec:
if creds_path.exists():
try:
existing = json.loads(creds_path.read_text(encoding="utf-8"))
csec = str(existing.get("client_secret", "")).strip()
except Exception:
pass
if not csec:
raise HTTPException(400, "client_secret is required (no existing secret to preserve)")
# If the client_id changed, the existing token belongs to a different OAuth
# app and will fail on refresh — delete it so the user must re-authenticate.
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
if creds_path.exists() and token_path.exists():
try:
old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip()
if old_cid and old_cid != cid:
token_path.unlink(missing_ok=True)
except Exception:
pass
creds_path.write_text(
json.dumps({"client_id": cid, "client_secret": csec}, indent=2),
encoding="utf-8",
)
return JSONResponse({"ok": True})
@router.delete("/api/me/strava-credentials")
async def me_delete_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Remove per-user Strava credentials (falls back to instance credentials)."""
user = deps._require_user(bincio_session)
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
creds_path.unlink(missing_ok=True)
return JSONResponse({"ok": True})
@router.put("/api/me/password")
async def me_change_password(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Change the logged-in user's password. Requires current password."""
from bincio.serve.db import change_password as _change_password
user = deps._require_user(bincio_session)
body = await request.json()
current = body.get("current_password", "")
new_pw = body.get("new_password", "")
if not authenticate(deps._get_db(), user.handle, current):
raise HTTPException(401, "Current password is wrong")
if len(new_pw) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
_change_password(deps._get_db(), user.handle, new_pw)
return JSONResponse({"ok": True})