5167f2a988
bake_tracks now writes tracks_YYYY.json shards + tracks_index.json manifest
instead of a single monolithic tracks.json. API /api/me/tracks returns the
manifest; /api/me/tracks/{year} serves individual shards. Explore.svelte
fetches the two most recent years eagerly then streams the rest in the
background so the map renders immediately with recent data.
320 lines
12 KiB
Python
320 lines
12 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})
|