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.
111 lines
3.5 KiB
Rust
111 lines
3.5 KiB
Rust
//! Admin manga/chapter overview with derived sync state.
|
|
//!
|
|
//! Sync state comes from `repo::admin_view`, which joins the manga /
|
|
//! chapter tables with the crawler signals at query time — there is no
|
|
//! persisted sync_state column. See [`repo::admin_view`] for the
|
|
//! derivation priority order.
|
|
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use crate::api::pagination::PagedResponse;
|
|
use crate::app::AppState;
|
|
use crate::auth::extractor::RequireAdmin;
|
|
use crate::domain::MangaSyncState;
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::repo;
|
|
use crate::repo::admin_view::{AdminChapterRow, AdminMangaRow};
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/admin/mangas", get(list_mangas))
|
|
.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)]
|
|
pub search: Option<String>,
|
|
/// `in_progress` | `dropped` | `synced`. Unrecognised values are a 400.
|
|
#[serde(default)]
|
|
pub sync_state: Option<String>,
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
#[serde(default)]
|
|
pub offset: i64,
|
|
}
|
|
|
|
fn default_limit() -> i64 {
|
|
50
|
|
}
|
|
|
|
async fn list_mangas(
|
|
State(state): State<AppState>,
|
|
_admin: RequireAdmin,
|
|
Query(params): Query<ListMangasParams>,
|
|
) -> AppResult<Json<PagedResponse<AdminMangaRow>>> {
|
|
let limit = params.limit.clamp(1, 200);
|
|
let offset = params.offset.max(0);
|
|
|
|
let sync_state = match params.sync_state.as_deref() {
|
|
None | Some("") => None,
|
|
Some("in_progress") => Some(MangaSyncState::InProgress),
|
|
Some("dropped") => Some(MangaSyncState::Dropped),
|
|
Some("synced") => Some(MangaSyncState::Synced),
|
|
Some(other) => {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"sync_state must be one of in_progress|dropped|synced (got {other:?})"
|
|
)));
|
|
}
|
|
};
|
|
|
|
let q = repo::admin_view::ListAdminMangasQuery {
|
|
search: params.search.filter(|s| !s.trim().is_empty()),
|
|
sync_state,
|
|
limit,
|
|
offset,
|
|
};
|
|
let (items, total) = repo::admin_view::list_mangas_with_sync_state(&state.db, &q).await?;
|
|
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
|
}
|
|
|
|
async fn list_chapters(
|
|
State(state): State<AppState>,
|
|
_admin: RequireAdmin,
|
|
Path(manga_id): Path<Uuid>,
|
|
Query(params): Query<ListChaptersParams>,
|
|
) -> AppResult<Json<PagedResponse<AdminChapterRow>>> {
|
|
// 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);
|
|
}
|
|
// 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)))
|
|
}
|