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:
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.41.1"
|
version = "0.41.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/admin/mangas/:id/chapters", get(list_chapters))
|
.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)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
pub struct ListMangasParams {
|
pub struct ListMangasParams {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -76,12 +88,23 @@ async fn list_chapters(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_admin: RequireAdmin,
|
_admin: RequireAdmin,
|
||||||
Path(manga_id): Path<Uuid>,
|
Path(manga_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<Vec<AdminChapterRow>>> {
|
Query(params): Query<ListChaptersParams>,
|
||||||
|
) -> AppResult<Json<PagedResponse<AdminChapterRow>>> {
|
||||||
// Explicit existence check so a typo / deleted manga returns 404
|
// Explicit existence check so a typo / deleted manga returns 404
|
||||||
// rather than a misleading "no chapters" 200.
|
// rather than a misleading "no chapters" 200.
|
||||||
if !repo::manga::exists(&state.db, manga_id).await? {
|
if !repo::manga::exists(&state.db, manga_id).await? {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&state.db, manga_id).await?;
|
// Cap at 500 to bound the per-row scalar-subquery cost on
|
||||||
Ok(Json(rows))
|
// 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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,18 @@ async fn start_session(
|
|||||||
Ok(jar.add(build_session_cookie(raw, &state.auth)))
|
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> {
|
fn build_session_cookie(raw: String, cfg: &AuthConfig) -> Cookie<'static> {
|
||||||
let mut builder = Cookie::build((SESSION_COOKIE_NAME, raw))
|
let mut builder = Cookie::build((SESSION_COOKIE_NAME, raw))
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
|
|||||||
@@ -166,11 +166,23 @@ pub struct AdminChapterRow {
|
|||||||
pub latest_seen_at: Option<DateTime<Utc>>,
|
pub latest_seen_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(
|
pub async fn list_chapters_with_sync_state(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
manga_id: Uuid,
|
q: &ListAdminChaptersQuery,
|
||||||
) -> AppResult<Vec<AdminChapterRow>> {
|
) -> AppResult<(Vec<AdminChapterRow>, i64)> {
|
||||||
let rows: Vec<AdminChapterRow> = sqlx::query_as(
|
let items: Vec<AdminChapterRow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.manga_id, c.number, c.title, c.page_count, c.created_at,
|
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
|
FROM chapters c
|
||||||
WHERE c.manga_id = $1
|
WHERE c.manga_id = $1
|
||||||
ORDER BY c.number ASC
|
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)
|
.fetch_all(pool)
|
||||||
.await?;
|
.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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ pub async fn list_with_total(
|
|||||||
Ok((items, 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")
|
sqlx::query("UPDATE users SET is_admin = $1 WHERE id = $2")
|
||||||
.bind(value)
|
.bind(value)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String) {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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)
|
(username, cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +107,25 @@ async fn insert_job(pool: &PgPool, payload: serde_json::Value, state: &str) {
|
|||||||
.unwrap();
|
.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<mangalord::repo::admin_view::AdminChapterRow> {
|
||||||
|
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 ------------------------------------------------------
|
// ---- manga sync state ------------------------------------------------------
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[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;
|
let c = insert_chapter(&pool, m, 1, 12).await;
|
||||||
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
||||||
|
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
let rows = fetch_chapter_rows(&pool, m).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(rows[0].id, c);
|
assert_eq!(rows[0].id, c);
|
||||||
assert_eq!(rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced);
|
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;
|
let c = insert_chapter(&pool, m, 1, 0).await;
|
||||||
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
||||||
|
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
let rows = fetch_chapter_rows(&pool, m).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows[0].sync_state,
|
rows[0].sync_state,
|
||||||
mangalord::domain::ChapterSyncState::NotDownloaded
|
mangalord::domain::ChapterSyncState::NotDownloaded
|
||||||
@@ -299,9 +314,7 @@ async fn chapter_state_downloading_when_job_in_flight(pool: PgPool) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
let rows = fetch_chapter_rows(&pool, m).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows[0].sync_state,
|
rows[0].sync_state,
|
||||||
mangalord::domain::ChapterSyncState::Downloading
|
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;
|
let c = insert_chapter(&pool, m, 1, 0).await;
|
||||||
insert_chapter_source(&pool, c, "ckey-1", true).await;
|
insert_chapter_source(&pool, c, "ckey-1", true).await;
|
||||||
|
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
let rows = fetch_chapter_rows(&pool, m).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows[0].sync_state,
|
rows[0].sync_state,
|
||||||
mangalord::domain::ChapterSyncState::Dropped
|
mangalord::domain::ChapterSyncState::Dropped
|
||||||
@@ -342,9 +353,7 @@ async fn chapter_state_failed_when_most_recent_job_dead(pool: PgPool) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
let rows = fetch_chapter_rows(&pool, m).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows[0].sync_state,
|
rows[0].sync_state,
|
||||||
mangalord::domain::ChapterSyncState::Failed
|
mangalord::domain::ChapterSyncState::Failed
|
||||||
@@ -415,12 +424,67 @@ async fn http_list_chapters_returns_per_chapter_state(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
let body = common::body_json(resp).await;
|
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.len(), 2);
|
||||||
assert_eq!(items[0]["id"], c1.to_string());
|
assert_eq!(items[0]["id"], c1.to_string());
|
||||||
assert_eq!(items[0]["sync_state"], "synced");
|
assert_eq!(items[0]["sync_state"], "synced");
|
||||||
assert_eq!(items[1]["id"], c2.to_string());
|
assert_eq!(items[1]["id"], c2.to_string());
|
||||||
assert_eq!(items[1]["sync_state"], "not_downloaded");
|
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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
@@ -463,9 +527,7 @@ async fn chapter_state_synced_when_pages_present_even_with_dead_job(pool: PgPool
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
let rows = fetch_chapter_rows(&pool, m).await;
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows[0].sync_state,
|
rows[0].sync_state,
|
||||||
mangalord::domain::ChapterSyncState::Synced,
|
mangalord::domain::ChapterSyncState::Synced,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ async fn require_admin_accepts_admin_cookie(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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
|
let resp = app
|
||||||
.oneshot(common::get_with_cookie("/_test/admin_only", &cookie))
|
.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
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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
|
let resp = app
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> String {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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
|
cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String, Uuid) {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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)
|
(username, cookie, u.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.41.1",
|
"version": "0.41.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ describe('admin api client', () => {
|
|||||||
expect(url).toContain('limit=100');
|
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 = {
|
const chapter = {
|
||||||
id: 'c-1',
|
id: 'c-1',
|
||||||
manga_id: 'm-1',
|
manga_id: 'm-1',
|
||||||
@@ -160,13 +160,26 @@ describe('admin api client', () => {
|
|||||||
sync_state: 'synced' as const,
|
sync_state: 'synced' as const,
|
||||||
latest_seen_at: null
|
latest_seen_at: null
|
||||||
};
|
};
|
||||||
fetchSpy.mockResolvedValueOnce(ok([chapter]));
|
fetchSpy.mockResolvedValueOnce(
|
||||||
const rows = await listAdminChapters('m-1');
|
ok({ items: [chapter], page: { limit: 200, offset: 0, total: 1 } })
|
||||||
expect(rows).toEqual([chapter]);
|
);
|
||||||
|
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;
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
|
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 ----
|
// ---- system ----
|
||||||
|
|
||||||
it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {
|
it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {
|
||||||
|
|||||||
@@ -102,9 +102,26 @@ export type AdminChapterRow = {
|
|||||||
latest_seen_at: string | null;
|
latest_seen_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listAdminChapters(mangaId: string): Promise<AdminChapterRow[]> {
|
export type AdminChaptersPage = {
|
||||||
return request<AdminChapterRow[]>(
|
items: AdminChapterRow[];
|
||||||
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters`
|
page: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListAdminChaptersOptions = {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAdminChapters(
|
||||||
|
mangaId: string,
|
||||||
|
opts: ListAdminChaptersOptions = {}
|
||||||
|
): Promise<AdminChaptersPage> {
|
||||||
|
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<AdminChaptersPage>(
|
||||||
|
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@
|
|||||||
let syncFilter: MangaSyncState | '' = $state('');
|
let syncFilter: MangaSyncState | '' = $state('');
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let expandedId: 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() {
|
async function load() {
|
||||||
error = null;
|
error = null;
|
||||||
@@ -40,7 +44,11 @@
|
|||||||
if (!chaptersByManga[id]) {
|
if (!chaptersByManga[id]) {
|
||||||
chaptersByManga[id] = 'loading';
|
chaptersByManga[id] = 'loading';
|
||||||
try {
|
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 {
|
} catch {
|
||||||
delete chaptersByManga[id];
|
delete chaptersByManga[id];
|
||||||
error = 'failed to load chapters';
|
error = 'failed to load chapters';
|
||||||
@@ -113,10 +121,16 @@
|
|||||||
{#if chaptersByManga[m.id] === 'loading'}
|
{#if chaptersByManga[m.id] === 'loading'}
|
||||||
<p>Loading chapters…</p>
|
<p>Loading chapters…</p>
|
||||||
{:else if chaptersByManga[m.id]}
|
{:else if chaptersByManga[m.id]}
|
||||||
{@const list = chaptersByManga[m.id] as AdminChapterRow[]}
|
{@const view = chaptersByManga[m.id] as ChaptersView}
|
||||||
{#if list.length === 0}
|
{#if view.items.length === 0}
|
||||||
<p class="muted">No chapters.</p>
|
<p class="muted">No chapters.</p>
|
||||||
{:else}
|
{: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">
|
<table class="inner">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -127,7 +141,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each list as c (c.id)}
|
{#each view.items as c (c.id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{c.number}</td>
|
<td>{c.number}</td>
|
||||||
<td>{c.title ?? '—'}</td>
|
<td>{c.title ?? '—'}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user