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:
@@ -509,6 +509,8 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS
|
|||||||
return 0
|
return 0
|
||||||
return sum(1 for f in path.glob(pattern) if f.is_file())
|
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 = []
|
users = []
|
||||||
for user_dir in sorted(data_dir.iterdir()):
|
for user_dir in sorted(data_dir.iterdir()):
|
||||||
if not user_dir.is_dir() or user_dir.name.startswith("_"):
|
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()]
|
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
|
||||||
users.append({
|
users.append({
|
||||||
"handle": user_dir.name,
|
"handle": user_dir.name,
|
||||||
|
"in_db": _get_user(db, user_dir.name) is not None,
|
||||||
"total_mb": _mb(user_dir),
|
"total_mb": _mb(user_dir),
|
||||||
"activities_mb": _mb(user_dir / "activities"),
|
"activities_mb": _mb(user_dir / "activities"),
|
||||||
"activities_count": _count(user_dir / "activities", "*.json"),
|
"activities_count": _count(user_dir / "activities", "*.json"),
|
||||||
@@ -850,6 +853,38 @@ async def admin_delete_activities(
|
|||||||
return JSONResponse({"ok": True, "deleted": deleted})
|
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) ───────────────────────────
|
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
|
||||||
|
|||||||
@@ -123,32 +123,14 @@ import Base from '../../layouts/Base.astro';
|
|||||||
const leaked = u.leaked_zips_count > 0
|
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>`
|
? `<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
|
const stravaNote = u.originals_strava_mb > 0
|
||||||
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
|
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
|
||||||
: '';
|
: '';
|
||||||
return `
|
const actionButtons = u.in_db
|
||||||
<tr class="border-b border-zinc-800/50 hover:bg-zinc-900/40" data-handle="${u.handle}">
|
? `<button
|
||||||
<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
|
|
||||||
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"
|
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}"
|
data-handle="${u.handle}"
|
||||||
title="Show diagnostic snapshot of this user's data directory"
|
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"
|
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}"
|
data-handle="${u.handle}"
|
||||||
title="Wipe all activities, originals, edits and images — account is kept"
|
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -320,6 +335,35 @@ import Base from '../../layouts/Base.astro';
|
|||||||
dialog.showModal();
|
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());
|
confirmCancel.addEventListener('click', () => dialog.close());
|
||||||
|
|||||||
Reference in New Issue
Block a user