diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 369d11c..87942a5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.41.1" +version = "0.41.2" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/admin/mangas.rs b/backend/src/api/admin/mangas.rs index 0095705..425c1f2 100644 --- a/backend/src/api/admin/mangas.rs +++ b/backend/src/api/admin/mangas.rs @@ -25,6 +25,18 @@ pub fn routes() -> Router { .route("/admin/mangas/:id/chapters", get(list_chapters)) } +#[derive(Debug, Deserialize, Default)] +pub struct ListChaptersParams { + #[serde(default = "default_chapter_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_chapter_limit() -> i64 { + 200 +} + #[derive(Debug, Deserialize, Default)] pub struct ListMangasParams { #[serde(default)] @@ -76,12 +88,23 @@ async fn list_chapters( State(state): State, _admin: RequireAdmin, Path(manga_id): Path, -) -> AppResult>> { + Query(params): Query, +) -> AppResult>> { // Explicit existence check so a typo / deleted manga returns 404 // rather than a misleading "no chapters" 200. if !repo::manga::exists(&state.db, manga_id).await? { return Err(AppError::NotFound); } - let rows = repo::admin_view::list_chapters_with_sync_state(&state.db, manga_id).await?; - Ok(Json(rows)) + // Cap at 500 to bound the per-row scalar-subquery cost on + // long-runners with thousands of chapters; default 200 covers + // typical browsing without paging round-trips. + let limit = params.limit.clamp(1, 500); + let offset = params.offset.max(0); + let q = repo::admin_view::ListAdminChaptersQuery { + manga_id, + limit, + offset, + }; + let (items, total) = repo::admin_view::list_chapters_with_sync_state(&state.db, &q).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index ed6f600..2309923 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -309,6 +309,18 @@ async fn start_session( Ok(jar.add(build_session_cookie(raw, &state.auth))) } +// CSRF posture: `SameSite=Lax` is the project's primary CSRF defense. +// Browsers refuse to attach this cookie to cross-site POST / PATCH / +// DELETE requests, which covers every state-changing endpoint (auth +// mutations, uploads, bookmarks, collections, admin user management, +// etc. — all JSON over POST/PATCH/DELETE). Lax DOES still attach the +// cookie on top-level cross-site GETs, so this defense breaks the +// instant anyone adds a state-changing GET. If you reach for one, +// switch to `SameSite=Strict` here AND add an explicit CSRF-token +// check on the new endpoint. The Bearer-token branch in the +// extractor is unaffected (bots authenticate with the token header, +// not the cookie) and admin routes reject Bearer entirely — see +// `auth::extractor::RequireAdmin`. fn build_session_cookie(raw: String, cfg: &AuthConfig) -> Cookie<'static> { let mut builder = Cookie::build((SESSION_COOKIE_NAME, raw)) .http_only(true) diff --git a/backend/src/repo/admin_view.rs b/backend/src/repo/admin_view.rs index 80da958..63564f3 100644 --- a/backend/src/repo/admin_view.rs +++ b/backend/src/repo/admin_view.rs @@ -166,11 +166,23 @@ pub struct AdminChapterRow { pub latest_seen_at: Option>, } +#[derive(Debug, Default)] +pub struct ListAdminChaptersQuery { + pub manga_id: Uuid, + pub limit: i64, + pub offset: i64, +} + +/// Paginated chapter list with derived sync state. Pagination is non- +/// optional — long-runners can have thousands of chapters and the +/// per-row scalar subqueries make the unbounded variant a real +/// stall risk even behind an admin guard. Returns the page slice plus +/// the unfiltered total so the UI can render "showing N of M". pub async fn list_chapters_with_sync_state( pool: &PgPool, - manga_id: Uuid, -) -> AppResult> { - let rows: Vec = sqlx::query_as( + q: &ListAdminChaptersQuery, +) -> AppResult<(Vec, i64)> { + let items: Vec = sqlx::query_as( r#" SELECT c.id, c.manga_id, c.number, c.title, c.page_count, c.created_at, @@ -202,10 +214,19 @@ pub async fn list_chapters_with_sync_state( FROM chapters c WHERE c.manga_id = $1 ORDER BY c.number ASC + LIMIT $2 OFFSET $3 "#, ) - .bind(manga_id) + .bind(q.manga_id) + .bind(q.limit) + .bind(q.offset) .fetch_all(pool) .await?; - Ok(rows) + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM chapters WHERE manga_id = $1") + .bind(q.manga_id) + .fetch_one(pool) + .await?; + + Ok((items, total)) } diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index e80ec1a..e376325 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -114,7 +114,12 @@ pub async fn list_with_total( Ok((items, total)) } -pub async fn set_is_admin(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> { +/// Raw `is_admin` update with no safety checks, no audit log, and no +/// advisory lock. Exists only as a test setup helper for the admin- +/// feature integration suite — production code MUST go through +/// [`admin_safe_set_is_admin`], which enforces self-protection, the +/// last-admin invariant, and the audit log atomically. +pub async fn set_is_admin_unchecked(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> { sqlx::query("UPDATE users SET is_admin = $1 WHERE id = $2") .bind(value) .bind(id) diff --git a/backend/tests/api_admin_mangas.rs b/backend/tests/api_admin_mangas.rs index b2d06d2..30a30e3 100644 --- a/backend/tests/api_admin_mangas.rs +++ b/backend/tests/api_admin_mangas.rs @@ -24,7 +24,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String) { .await .unwrap() .unwrap(); - repo::user::set_is_admin(pool, u.id, true).await.unwrap(); + repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap(); (username, cookie) } @@ -107,6 +107,25 @@ async fn insert_job(pool: &PgPool, payload: serde_json::Value, state: &str) { .unwrap(); } +/// Per-variant tests don't care about pagination — fetch the whole +/// chapter set (up to the hard cap) and discard the total. +async fn fetch_chapter_rows( + pool: &PgPool, + manga_id: Uuid, +) -> Vec { + let (rows, _) = repo::admin_view::list_chapters_with_sync_state( + pool, + &repo::admin_view::ListAdminChaptersQuery { + manga_id, + limit: 500, + offset: 0, + }, + ) + .await + .unwrap(); + rows +} + // ---- manga sync state ------------------------------------------------------ #[sqlx::test(migrations = "./migrations")] @@ -257,9 +276,7 @@ async fn chapter_state_synced_when_pages_present(pool: PgPool) { let c = insert_chapter(&pool, m, 1, 12).await; insert_chapter_source(&pool, c, "ckey-1", false).await; - let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m) - .await - .unwrap(); + let rows = fetch_chapter_rows(&pool, m).await; assert_eq!(rows.len(), 1); assert_eq!(rows[0].id, c); assert_eq!(rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced); @@ -272,9 +289,7 @@ async fn chapter_state_not_downloaded_when_page_count_zero(pool: PgPool) { let c = insert_chapter(&pool, m, 1, 0).await; insert_chapter_source(&pool, c, "ckey-1", false).await; - let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m) - .await - .unwrap(); + let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::NotDownloaded @@ -299,9 +314,7 @@ async fn chapter_state_downloading_when_job_in_flight(pool: PgPool) { ) .await; - let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m) - .await - .unwrap(); + let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Downloading @@ -315,9 +328,7 @@ async fn chapter_state_dropped_when_all_sources_dropped(pool: PgPool) { let c = insert_chapter(&pool, m, 1, 0).await; insert_chapter_source(&pool, c, "ckey-1", true).await; - let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m) - .await - .unwrap(); + let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Dropped @@ -342,9 +353,7 @@ async fn chapter_state_failed_when_most_recent_job_dead(pool: PgPool) { ) .await; - let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m) - .await - .unwrap(); + let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Failed @@ -415,12 +424,67 @@ async fn http_list_chapters_returns_per_chapter_state(pool: PgPool) { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; - let items = body.as_array().unwrap(); + let items = body["items"].as_array().unwrap(); assert_eq!(items.len(), 2); assert_eq!(items[0]["id"], c1.to_string()); assert_eq!(items[0]["sync_state"], "synced"); assert_eq!(items[1]["id"], c2.to_string()); assert_eq!(items[1]["sync_state"], "not_downloaded"); + assert_eq!(body["page"]["total"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn http_list_chapters_caps_limit_at_500(pool: PgPool) { + // The handler clamps limit to [1, 500] so a long-runner with + // thousands of chapters can't be turned into a request-stall by an + // admin (or by a curious admin tab) just clicking expand. + let h = common::harness(pool.clone()); + let (_admin, cookie) = seed_admin(&pool, &h.app).await; + seed_source(&pool).await; + let m = insert_manga(&pool, "M").await; + for n in 1..=3 { + let _c = insert_chapter(&pool, m, n, 0).await; + } + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/admin/mangas/{m}/chapters?limit=999"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["page"]["limit"], 500, "limit must clamp to 500"); + assert_eq!(body["items"].as_array().unwrap().len(), 3); +} + +#[sqlx::test(migrations = "./migrations")] +async fn http_list_chapters_paginates(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_admin, cookie) = seed_admin(&pool, &h.app).await; + seed_source(&pool).await; + let m = insert_manga(&pool, "M").await; + for n in 1..=5 { + let _c = insert_chapter(&pool, m, n, 0).await; + } + + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie( + &format!("/api/v1/admin/mangas/{m}/chapters?limit=2&offset=2"), + &cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + // Ordered by chapter number ascending; offset=2 skips chapters 1 & 2. + assert_eq!(items[0]["number"], 3); + assert_eq!(items[1]["number"], 4); + assert_eq!(body["page"]["total"], 5); } #[sqlx::test(migrations = "./migrations")] @@ -463,9 +527,7 @@ async fn chapter_state_synced_when_pages_present_even_with_dead_job(pool: PgPool ) .await; - let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m) - .await - .unwrap(); + let rows = fetch_chapter_rows(&pool, m).await; assert_eq!( rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced, diff --git a/backend/tests/api_admin_role.rs b/backend/tests/api_admin_role.rs index 9a2855f..5b6b4e5 100644 --- a/backend/tests/api_admin_role.rs +++ b/backend/tests/api_admin_role.rs @@ -189,7 +189,7 @@ async fn require_admin_accepts_admin_cookie(pool: PgPool) { .await .unwrap() .unwrap(); - repo::user::set_is_admin(&pool, u.id, true).await.unwrap(); + repo::user::set_is_admin_unchecked(&pool, u.id, true).await.unwrap(); let resp = app .oneshot(common::get_with_cookie("/_test/admin_only", &cookie)) @@ -216,7 +216,7 @@ async fn require_admin_rejects_bearer_token_even_for_admin_user(pool: PgPool) { .await .unwrap() .unwrap(); - repo::user::set_is_admin(&pool, u.id, true).await.unwrap(); + repo::user::set_is_admin_unchecked(&pool, u.id, true).await.unwrap(); let resp = app .clone() diff --git a/backend/tests/api_admin_system.rs b/backend/tests/api_admin_system.rs index 7b6f802..b055687 100644 --- a/backend/tests/api_admin_system.rs +++ b/backend/tests/api_admin_system.rs @@ -20,7 +20,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> String { .await .unwrap() .unwrap(); - repo::user::set_is_admin(pool, u.id, true).await.unwrap(); + repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap(); cookie } diff --git a/backend/tests/api_admin_users.rs b/backend/tests/api_admin_users.rs index c350ee9..baf5627 100644 --- a/backend/tests/api_admin_users.rs +++ b/backend/tests/api_admin_users.rs @@ -35,7 +35,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String, Uuid) { .await .unwrap() .unwrap(); - repo::user::set_is_admin(pool, u.id, true).await.unwrap(); + repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap(); (username, cookie, u.id) } diff --git a/frontend/package.json b/frontend/package.json index 2e4aeb5..58b8575 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.41.1", + "version": "0.41.2", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index 5fc8619..104331f 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -149,7 +149,7 @@ describe('admin api client', () => { expect(url).toContain('limit=100'); }); - it('listAdminChapters GETs the nested chapter route', async () => { + it('listAdminChapters GETs the nested chapter route and parses the paged envelope', async () => { const chapter = { id: 'c-1', manga_id: 'm-1', @@ -160,13 +160,26 @@ describe('admin api client', () => { sync_state: 'synced' as const, latest_seen_at: null }; - fetchSpy.mockResolvedValueOnce(ok([chapter])); - const rows = await listAdminChapters('m-1'); - expect(rows).toEqual([chapter]); + fetchSpy.mockResolvedValueOnce( + ok({ items: [chapter], page: { limit: 200, offset: 0, total: 1 } }) + ); + const resp = await listAdminChapters('m-1'); + expect(resp.items).toEqual([chapter]); + expect(resp.page.total).toBe(1); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/); }); + it('listAdminChapters forwards limit + offset query params', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 50, offset: 100, total: 0 } }) + ); + await listAdminChapters('m-1', { limit: 50, offset: 100 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('limit=50'); + expect(url).toContain('offset=100'); + }); + // ---- system ---- it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => { diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 556a29b..0dfe398 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -102,9 +102,26 @@ export type AdminChapterRow = { latest_seen_at: string | null; }; -export async function listAdminChapters(mangaId: string): Promise { - return request( - `/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters` +export type AdminChaptersPage = { + items: AdminChapterRow[]; + page: Page; +}; + +export type ListAdminChaptersOptions = { + limit?: number; + offset?: number; +}; + +export async function listAdminChapters( + mangaId: string, + opts: ListAdminChaptersOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request( + `/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}` ); } diff --git a/frontend/src/routes/admin/mangas/+page.svelte b/frontend/src/routes/admin/mangas/+page.svelte index 9b849c5..f619912 100644 --- a/frontend/src/routes/admin/mangas/+page.svelte +++ b/frontend/src/routes/admin/mangas/+page.svelte @@ -14,7 +14,11 @@ let syncFilter: MangaSyncState | '' = $state(''); let error: string | null = $state(null); let expandedId: string | null = $state(null); - let chaptersByManga: Record = $state({}); + type ChaptersView = { + items: AdminChapterRow[]; + total: number; + }; + let chaptersByManga: Record = $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'}

Loading chapters…

{: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}

No chapters.

{:else} + {#if view.total > view.items.length} +

+ Showing first {view.items.length} of {view.total} + chapters (cap reached). +

+ {/if} @@ -127,7 +141,7 @@ - {#each list as c (c.id)} + {#each view.items as c (c.id)}
{c.number} {c.title ?? '—'}