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
This commit is contained in:
Davide Scaini
2026-04-15 14:58:54 +02:00
parent dfd56e4448
commit a33fea91cf
2 changed files with 103 additions and 24 deletions
+35
View File
@@ -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) ───────────────────────────
+68 -24
View File
@@ -123,32 +123,14 @@ import Base from '../../layouts/Base.astro';
const leaked = u.leaked_zips_count > 0
? `<span class="text-red-400 font-medium ml-2" title="${u.leaked_zips_count} orphaned temp ZIP(s)">⚠ ${fmt(u.leaked_zips_mb)} leaked</span>`
: '';
const ghostBadge = !u.in_db
? `<span class="text-amber-500 text-xs ml-1" title="No account in database">ghost</span>`
: '';
const stravaNote = u.originals_strava_mb > 0
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
: '';
return `
<tr class="border-b border-zinc-800/50 hover:bg-zinc-900/40" data-handle="${u.handle}">
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
${leaked}
</div>
${bar(rowPct)}
</td>
<td class="px-4 py-3 text-right text-zinc-300 font-medium tabular-nums">${fmt(u.total_mb)}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${fmt(u.activities_mb)}
<span class="text-zinc-600 text-xs block">${u.activities_count} files</span>
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${u.originals_mb > 0 ? fmt(u.originals_mb) : '—'}
${stravaNote}
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.images_mb > 0 ? fmt(u.images_mb) : '—'}</td>
<td class="px-4 py-3 text-right">
<div class="flex gap-2 justify-end">
<button
const actionButtons = u.in_db
? `<button
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
title="Show diagnostic snapshot of this user's data directory"
@@ -172,7 +154,40 @@ import Base from '../../layouts/Base.astro';
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Wipe all activities, originals, edits and images — account is kept"
>Reset data</button>
>Reset data</button>`
: `<button
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
title="Show diagnostic snapshot of this user's data directory"
>Diag</button>
<button
class="rmdir-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Delete the entire directory for this ghost user (no DB account)"
>Delete dir</button>`;
return `
<tr class="border-b border-zinc-800/50 hover:bg-zinc-900/40" data-handle="${u.handle}">
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
${ghostBadge}${leaked}
</div>
${bar(rowPct)}
</td>
<td class="px-4 py-3 text-right text-zinc-300 font-medium tabular-nums">${fmt(u.total_mb)}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${fmt(u.activities_mb)}
<span class="text-zinc-600 text-xs block">${u.activities_count} files</span>
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${u.originals_mb > 0 ? fmt(u.originals_mb) : '—'}
${stravaNote}
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.images_mb > 0 ? fmt(u.images_mb) : '—'}</td>
<td class="px-4 py-3 text-right">
<div class="flex gap-2 justify-end">
${actionButtons}
</div>
</td>
</tr>
@@ -320,6 +335,35 @@ import Base from '../../layouts/Base.astro';
dialog.showModal();
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.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());