diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 260cf85..acac2dc 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -887,6 +887,151 @@ async def admin_delete_user_directory( +# ── Self-service user settings ──────────────────────────────────────────────── + +@app.get("/api/me/storage") +async def me_storage(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Return per-category disk usage for the logged-in user.""" + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + + def _mb(path: Path) -> float: + if not path.exists(): + return 0.0 + total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + 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, + }) + + +@app.delete("/api/me/originals") +async def me_delete_originals(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Delete the user's originals/ directory (frees space after re-extraction).""" + user = _require_user(bincio_session) + originals = _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}) + + +@app.delete("/api/me/activities") +async def me_delete_activities( + request: Request, + bincio_session: Optional[str] = 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 = _require_user(bincio_session) + body = await request.json() + password = body.get("password", "") + if not authenticate(_get_db(), user.handle, password): + raise HTTPException(401, "Wrong password") + + user_dir = _get_data_dir() / user.handle + deleted = _wipe_user_activities(user_dir) + _trigger_rebuild(user.handle) + return JSONResponse({"ok": True, "deleted": deleted}) + + +@app.delete("/api/me") +async def me_delete_account( + request: Request, + bincio_session: Optional[str] = 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 = _require_user(bincio_session) + body = await request.json() + password = body.get("password", "") + if not authenticate(_get_db(), user.handle, password): + raise HTTPException(401, "Wrong password") + + # Wipe data directory + user_dir = _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(_get_db(), user.handle) + + # Update root manifest so the shard disappears + from bincio.render.cli import _write_root_manifest + try: + _write_root_manifest(_get_data_dir()) + except Exception: + pass + + resp = JSONResponse({"ok": True}) + resp.delete_cookie(_SESSION_COOKIE) + return resp + + +@app.put("/api/me/display-name") +async def me_update_display_name( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Update the logged-in user's display name.""" + user = _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 = _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}) + + +@app.put("/api/me/password") +async def me_change_password( + request: Request, + bincio_session: Optional[str] = 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 = _require_user(bincio_session) + body = await request.json() + current = body.get("current_password", "") + new_pw = body.get("new_password", "") + if not authenticate(_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(_get_db(), user.handle, new_pw) + return JSONResponse({"ok": True}) + + # ── Write API (ported from bincio edit, auth-gated) ─────────────────────────── def _user_data_dir(handle: str) -> Path: diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 8b92e23..85ab878 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -200,6 +200,13 @@ try { title="" class="text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300 border border-amber-700/50 animate-pulse cursor-default" > + + Settings + + Settings + + + + Storage + Loading… + + + Activities + + + + Original files + + + + ↳ Strava originals + + + + Photos + + + + Total + + + + + + + Original files are kept for reprocessing. Once your activities look correct you can free this space — the extracted data is not affected. + + + Delete original files + + + + + + + + Profile + + + Display name + + + + Save + + + + + + + + Password + + + Current password + + + + New password + + At least 8 characters + + + + Change password + + + + + + + Danger zone + + + + Reset activity data + Wipes all extracted activities, edits, and photos. Your account is kept. Cannot be undone. + + Delete all activities + + + + + Delete account + Permanently deletes your account and all data. Cannot be undone. + + Delete account + + + + + + + + + + + Confirm with your password + + + + + Cancel + Confirm + + + + +
+ Original files are kept for reprocessing. Once your activities look correct you can free this space — the extracted data is not affected. +
At least 8 characters
Reset activity data
Wipes all extracted activities, edits, and photos. Your account is kept. Cannot be undone.
Delete account
Permanently deletes your account and all data. Cannot be undone.