From a33fea91cfa7ab8ce4b3fb0c48909dcce817ab74 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 15 Apr 2026 14:58:54 +0200 Subject: [PATCH] admin: mark ghost users (no DB account) and add Delete dir button - /api/admin/disk now includes in_db flag per user (true if account exists in DB) - Ghost users (directory exists, no DB account) show amber 'ghost' badge and only Diag + Delete dir buttons (no Re-extract, Rebuild, Reset pwd, Reset data) - DELETE /api/admin/users/{handle}/directory wipes the entire directory and updates the root manifest; refuses if the account still exists in the DB - Wires up rmdir-btn with a window.confirm before calling the new endpoint --- bincio/serve/server.py | 35 ++++++++++++ site/src/pages/admin/index.astro | 92 +++++++++++++++++++++++--------- 2 files changed, 103 insertions(+), 24 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 723f641..fba81ce 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -509,6 +509,8 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS return 0 return sum(1 for f in path.glob(pattern) if f.is_file()) + db = _get_db() + from bincio.serve.db import get_user as _get_user users = [] for user_dir in sorted(data_dir.iterdir()): if not user_dir.is_dir() or user_dir.name.startswith("_"): @@ -517,6 +519,7 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()] users.append({ "handle": user_dir.name, + "in_db": _get_user(db, user_dir.name) is not None, "total_mb": _mb(user_dir), "activities_mb": _mb(user_dir / "activities"), "activities_count": _count(user_dir / "activities", "*.json"), @@ -850,6 +853,38 @@ async def admin_delete_activities( return JSONResponse({"ok": True, "deleted": deleted}) +@app.delete("/api/admin/users/{handle}/directory") +async def admin_delete_user_directory( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Delete the entire user directory from disk (for ghost users not in the DB). + + Refuses if the handle exists as an account in the database — use + DELETE /api/admin/users/{handle}/activities for registered users. + """ + import shutil + _require_admin(bincio_session) + db = _get_db() + from bincio.serve.db import get_user as _get_user + if _get_user(db, handle) is not None: + raise HTTPException( + 400, + f"User '{handle}' is still in the database. Remove the account first, " + "or use 'Reset data' to wipe only activity files.", + ) + user_dir = _get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No directory for '{handle}'") + shutil.rmtree(user_dir) + # Rebuild root manifest so the ghost shard disappears from the site + from bincio.render.cli import _write_root_manifest + try: + _write_root_manifest(_get_data_dir()) + except Exception: + pass + return JSONResponse({"ok": True}) + # ── Write API (ported from bincio edit, auth-gated) ─────────────────────────── diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index bfa792d..25124ff 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -123,32 +123,14 @@ import Base from '../../layouts/Base.astro'; const leaked = u.leaked_zips_count > 0 ? `⚠ ${fmt(u.leaked_zips_mb)} leaked` : ''; + const ghostBadge = !u.in_db + ? `ghost` + : ''; const stravaNote = u.originals_strava_mb > 0 ? `(${fmt(u.originals_strava_mb)} Strava)` : ''; - return ` - - -
- @${u.handle} - ${leaked} -
- ${bar(rowPct)} - - ${fmt(u.total_mb)} - - ${fmt(u.activities_mb)} - ${u.activities_count} files - - - ${u.originals_mb > 0 ? fmt(u.originals_mb) : '—'} - ${stravaNote} - - ${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'} - ${u.images_mb > 0 ? fmt(u.images_mb) : '—'} - -
- + >Reset data` + : ` + `; + return ` + + +
+ @${u.handle} + ${ghostBadge}${leaked} +
+ ${bar(rowPct)} + + ${fmt(u.total_mb)} + + ${fmt(u.activities_mb)} + ${u.activities_count} files + + + ${u.originals_mb > 0 ? fmt(u.originals_mb) : '—'} + ${stravaNote} + + ${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'} + ${u.images_mb > 0 ? fmt(u.images_mb) : '—'} + +
+ ${actionButtons}
@@ -320,6 +335,35 @@ import Base from '../../layouts/Base.astro'; dialog.showModal(); }); }); + + tbodyEl.querySelectorAll('.rmdir-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const h = btn.dataset.handle!; + if (!confirm(`Delete entire directory for ghost user "${h}"? This cannot be undone.`)) return; + btn.disabled = true; + btn.textContent = 'Deleting…'; + try { + const r = await fetch(`/api/admin/users/${h}/directory`, { + method: 'DELETE', + credentials: 'include', + }); + const d = await r.json(); + if (r.ok) { + btn.textContent = 'Deleted'; + btn.classList.add('text-green-500'); + setTimeout(() => load(), 1500); + } else { + btn.disabled = false; + btn.textContent = 'Error: ' + (d.detail ?? 'failed'); + btn.classList.add('text-red-400'); + } + } catch { + btn.disabled = false; + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + } + }); + }); } confirmCancel.addEventListener('click', () => dialog.close());