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.
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""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})
|