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.
281 lines
8.7 KiB
Svelte
281 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import {
|
|
listAdminMangas,
|
|
listAdminChapters,
|
|
type AdminMangasPage,
|
|
type AdminChapterRow,
|
|
type MangaSyncState
|
|
} from '$lib/api/admin';
|
|
import { ApiError } from '$lib/api/client';
|
|
|
|
let mangasPage: AdminMangasPage | null = $state(null);
|
|
let search = $state('');
|
|
let syncFilter: MangaSyncState | '' = $state('');
|
|
let error: string | null = $state(null);
|
|
let expandedId: string | null = $state(null);
|
|
type ChaptersView = {
|
|
items: AdminChapterRow[];
|
|
total: number;
|
|
};
|
|
let chaptersByManga: Record<string, ChaptersView | 'loading'> = $state({});
|
|
|
|
async function load() {
|
|
error = null;
|
|
try {
|
|
mangasPage = await listAdminMangas({
|
|
search: search.trim() || undefined,
|
|
syncState: syncFilter || undefined,
|
|
limit: 100
|
|
});
|
|
} catch (e) {
|
|
error = e instanceof ApiError ? e.message : 'load failed';
|
|
}
|
|
}
|
|
|
|
onMount(load);
|
|
|
|
async function toggleChapters(id: string) {
|
|
if (expandedId === id) {
|
|
expandedId = null;
|
|
return;
|
|
}
|
|
expandedId = id;
|
|
if (!chaptersByManga[id]) {
|
|
chaptersByManga[id] = 'loading';
|
|
try {
|
|
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';
|
|
}
|
|
}
|
|
}
|
|
|
|
function badgeClass(state: string): string {
|
|
return `badge badge-${state}`;
|
|
}
|
|
</script>
|
|
|
|
<h1>Mangas</h1>
|
|
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
load();
|
|
}}
|
|
>
|
|
<input
|
|
type="search"
|
|
placeholder="search by title"
|
|
bind:value={search}
|
|
data-testid="admin-mangas-search"
|
|
/>
|
|
<select bind:value={syncFilter} aria-label="sync state">
|
|
<option value="">all states</option>
|
|
<option value="in_progress">in progress</option>
|
|
<option value="dropped">dropped</option>
|
|
<option value="synced">synced</option>
|
|
</select>
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
|
|
{#if error}
|
|
<p class="error" role="alert">{error}</p>
|
|
{/if}
|
|
|
|
{#if mangasPage}
|
|
<p class="total">{mangasPage.page.total ?? mangasPage.items.length} mangas</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Sync</th>
|
|
<th>Chapters</th>
|
|
<th>Last seen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each mangasPage.items as m (m.id)}
|
|
<tr>
|
|
<td>
|
|
<button class="link" onclick={() => toggleChapters(m.id)}>
|
|
{expandedId === m.id ? '▼' : '▶'} {m.title}
|
|
</button>
|
|
</td>
|
|
<td><span class={badgeClass(m.sync_state)}>{m.sync_state}</span></td>
|
|
<td>{m.chapter_count}</td>
|
|
<td>
|
|
{m.latest_seen_at
|
|
? new Date(m.latest_seen_at).toLocaleDateString()
|
|
: '—'}
|
|
</td>
|
|
</tr>
|
|
{#if expandedId === m.id}
|
|
<tr class="chapter-row">
|
|
<td colspan="4">
|
|
{#if chaptersByManga[m.id] === 'loading'}
|
|
<p>Loading chapters…</p>
|
|
{:else if chaptersByManga[m.id]}
|
|
{@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>
|
|
<th>#</th>
|
|
<th>Title</th>
|
|
<th>Pages</th>
|
|
<th>Sync</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each view.items as c (c.id)}
|
|
<tr>
|
|
<td>{c.number}</td>
|
|
<td>{c.title ?? '—'}</td>
|
|
<td>{c.page_count}</td>
|
|
<td>
|
|
<span class={badgeClass(c.sync_state)}>
|
|
{c.sync_state}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{:else}
|
|
<p>Loading…</p>
|
|
{/if}
|
|
|
|
<style>
|
|
h1 {
|
|
margin: 0 0 var(--space-4) 0;
|
|
}
|
|
form {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
input[type='search'] {
|
|
flex: 1;
|
|
padding: var(--space-2) var(--space-3);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
}
|
|
select {
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
}
|
|
button {
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
button.link {
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
font-weight: inherit;
|
|
}
|
|
button.link:hover {
|
|
color: var(--primary);
|
|
}
|
|
.total {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
margin: 0 0 var(--space-2) 0;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
th,
|
|
td {
|
|
padding: var(--space-2);
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.chapter-row td {
|
|
background: var(--surface-elevated);
|
|
}
|
|
table.inner {
|
|
margin: var(--space-2) 0;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 0 var(--space-2);
|
|
border-radius: var(--radius-sm, 4px);
|
|
font-size: var(--font-xs, 0.75rem);
|
|
font-weight: var(--weight-semibold);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
}
|
|
.badge-in_progress,
|
|
.badge-downloading {
|
|
background: #fef3c7;
|
|
color: #92400e;
|
|
border-color: #fcd34d;
|
|
}
|
|
.badge-dropped {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
border-color: #fca5a5;
|
|
}
|
|
.badge-failed {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
border-color: #fca5a5;
|
|
}
|
|
.badge-not_downloaded {
|
|
background: var(--surface-elevated);
|
|
color: var(--text-muted);
|
|
}
|
|
.badge-synced {
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
border-color: #86efac;
|
|
}
|
|
.muted {
|
|
color: var(--text-muted);
|
|
}
|
|
.error {
|
|
color: var(--danger, #dc2626);
|
|
padding: var(--space-2) var(--space-3);
|
|
border: 1px solid var(--danger, #dc2626);
|
|
border-radius: var(--radius-md);
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
</style>
|