Files
bincio-activity/bincio/serve/routers/me.py
T
Davide Scaini 2c69e75842 Show orange upload button when Strava/Garmin auth fails
GET /api/me/sync-status reads _strava_sync_status.json and
_garmin_sync_status.json for the logged-in user. On page load the nav
script checks this endpoint and, if either service has status=auth_error,
turns the upload arrow orange with a tooltip naming the disconnected
service(s).
2026-05-16 20:27:43 +02:00

343 lines
13 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, Response
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", "tracks.json", "tracks_index.json"):
f = user_dir / name
if f.exists():
f.unlink()
deleted += 1
for shard in user_dir.glob("tracks_*.json"):
shard.unlink(missing_ok=True)
deleted += 1
return deleted
@router.get("/api/me/tracks")
async def me_tracks(bincio_session: str | None = Cookie(default=None)) -> Response:
"""Return the tracks manifest (years list + total) for the logged-in user."""
user = deps._require_user(bincio_session)
index_path = deps._get_data_dir() / user.handle / "tracks_index.json"
if not index_path.exists():
raise HTTPException(404, "Tracks not yet baked — upload an activity first")
return Response(content=index_path.read_bytes(), media_type="application/json")
@router.get("/api/me/tracks/{year}")
async def me_tracks_year(year: str, bincio_session: str | None = Cookie(default=None)) -> Response:
"""Return the pre-baked tracks shard for a specific year."""
user = deps._require_user(bincio_session)
if not year.isdigit() or len(year) != 4:
raise HTTPException(400, "year must be a 4-digit string")
shard_path = deps._get_data_dir() / user.handle / f"tracks_{year}.json"
if not shard_path.exists():
raise HTTPException(404, f"No tracks shard for year {year}")
return Response(content=shard_path.read_bytes(), media_type="application/json")
@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 (OSError, json.JSONDecodeError):
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 (OSError, json.JSONDecodeError):
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 (OSError, json.JSONDecodeError):
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 (OSError, json.JSONDecodeError):
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})
@router.get("/api/me/sync-status")
async def get_sync_status(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return the last sync status for Strava and Garmin for the logged-in user."""
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
def _read_status(filename: str) -> dict | None:
p = user_dir / filename
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
strava = _read_status("_strava_sync_status.json")
garmin = _read_status("_garmin_sync_status.json")
return JSONResponse({
"strava": strava,
"garmin": garmin,
})