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:
@@ -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})
|
||||
Reference in New Issue
Block a user