fix(admin): security-audit findings — paginate chapters, lock down unchecked helper (0.41.2)
Addresses the security-audit findings on top of the admin feature stack: M1: /admin/mangas/:id/chapters now paginates (default limit 200, max 500). A long-runner with thousands of chapters would otherwise produce a multi-MB response with that many scalar subqueries per row — admin-only but a real stall risk on one expand-click. Adds explicit pagination tests for the cap and offset; frontend renders a "Showing first N of M" hint when the cap clips the result. L1: repo::user::set_is_admin renamed to set_is_admin_unchecked with a doc-comment pointing at admin_safe_set_is_admin for production use. The short name was a footgun — a future contributor reaching for it would silently bypass self-protection, the last-admin invariant, and the audit log. Used only by integration-test setup; production code goes through the admin_safe_* paths. CSRF posture: build_session_cookie carries a comment that the SameSite=Lax default is the project's CSRF defense for state-changing mutations and breaks the instant anyone adds a side-effecting GET under /admin/*. Spells out what to do then (Strict + explicit token check). Test counts: 43 backend admin tests + 12 vitest admin tests all green; svelte-check 0/0 across 446 files.
This commit is contained in:
@@ -14,7 +14,11 @@
|
||||
let syncFilter: MangaSyncState | '' = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let expandedId: string | null = $state(null);
|
||||
let chaptersByManga: Record<string, AdminChapterRow[] | 'loading'> = $state({});
|
||||
type ChaptersView = {
|
||||
items: AdminChapterRow[];
|
||||
total: number;
|
||||
};
|
||||
let chaptersByManga: Record<string, ChaptersView | 'loading'> = $state({});
|
||||
|
||||
async function load() {
|
||||
error = null;
|
||||
@@ -40,7 +44,11 @@
|
||||
if (!chaptersByManga[id]) {
|
||||
chaptersByManga[id] = 'loading';
|
||||
try {
|
||||
chaptersByManga[id] = await listAdminChapters(id);
|
||||
const resp = await listAdminChapters(id, { limit: 500 });
|
||||
chaptersByManga[id] = {
|
||||
items: resp.items,
|
||||
total: resp.page.total ?? resp.items.length
|
||||
};
|
||||
} catch {
|
||||
delete chaptersByManga[id];
|
||||
error = 'failed to load chapters';
|
||||
@@ -113,10 +121,16 @@
|
||||
{#if chaptersByManga[m.id] === 'loading'}
|
||||
<p>Loading chapters…</p>
|
||||
{:else if chaptersByManga[m.id]}
|
||||
{@const list = chaptersByManga[m.id] as AdminChapterRow[]}
|
||||
{#if list.length === 0}
|
||||
{@const view = chaptersByManga[m.id] as ChaptersView}
|
||||
{#if view.items.length === 0}
|
||||
<p class="muted">No chapters.</p>
|
||||
{:else}
|
||||
{#if view.total > view.items.length}
|
||||
<p class="muted">
|
||||
Showing first {view.items.length} of {view.total}
|
||||
chapters (cap reached).
|
||||
</p>
|
||||
{/if}
|
||||
<table class="inner">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -127,7 +141,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each list as c (c.id)}
|
||||
{#each view.items as c (c.id)}
|
||||
<tr>
|
||||
<td>{c.number}</td>
|
||||
<td>{c.title ?? '—'}</td>
|
||||
|
||||
Reference in New Issue
Block a user