Compare commits
26 Commits
134ab54b34
...
a44511983d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44511983d | ||
|
|
f8e53809d5 | ||
|
|
0afd202164 | ||
|
|
ba3e5b481b | ||
|
|
3b783c1d9b | ||
|
|
a47b6895c2 | ||
|
|
3ca05dcb58 | ||
|
|
c308eb3eac | ||
|
|
69a9309c54 | ||
|
|
1ed1a134ea | ||
|
|
32d0a7e13b | ||
|
|
d779fa2b97 | ||
|
|
755417730f | ||
|
|
5b1ce581f3 | ||
|
|
c11df3182c | ||
|
|
d749b56d58 | ||
|
|
c24e296f07 | ||
|
|
9508fb8e86 | ||
|
|
ca55712622 | ||
|
|
4fe435cc76 | ||
|
|
f5cb460aec | ||
|
|
f83d49b83e | ||
|
|
cef41ce76a | ||
|
|
7570524e5b | ||
|
|
5b46eab73a | ||
|
|
91d6a50dba |
@@ -136,7 +136,7 @@ docker compose -f docker-compose.dev.yml up -d
|
||||
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
|
||||
|
||||
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
||||
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user page tags live in `page_tags`, which references the **shared** `tags` table by `tag_id` (migration 0024) — the same lookup table `manga_tags` uses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag *names*; `repo::page_tag` resolves name↔id via `repo::tag::upsert_by_name` and applies the stricter page-tag `normalize_tag` (lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Both `collection_pages` and `page_tags` cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design.
|
||||
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user page tags live in `page_tags`, which references the **shared** `tags` table by `tag_id` (migration 0024) — the same lookup table `manga_tags` uses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag *names*; `repo::page_tag` resolves name↔id via `repo::tag::upsert_by_name` and applies the stricter page-tag `normalize_tag` (lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Both `collection_pages` and `page_tags` cascade-delete with `pages` and `chapters`, so a saved-page reference is dropped only when its `pages` row is genuinely deleted — i.e. when the chapter is deleted (cascade), or when a user deletes and re-creates a chapter (the user upload path in `api::chapters::finalize_chapter` inserts a *new* chapter row with fresh page ids). A crawler **re-fetch** does **not** drop saves: `content::persist_pages` upserts pages by `(chapter_id, page_number)` (`ON CONFLICT (…) DO UPDATE … RETURNING id`), preserving each `pages.id`, so collections and page tags keyed on that id survive the re-fetch. (Migration 0023's header comment predates this and describes the cascade as an unconditional "re-upload drops saves"; the checked-in migration text is intentionally left as-is because sqlx checksums applied migrations.)
|
||||
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints performs **OCR full-text search** — the active OCR backend writes `page_ocr_text` rows and a weighted `search_doc` tsvector, and the aggregation queries JOIN on a `plainto_tsquery` filter ranked by `ts_rank` (see [backend/src/repo/page_analysis.rs](backend/src/repo/page_analysis.rs)). (`text=` was previously reserved and returned 501 `text_search_not_yet_supported`; that placeholder is gone now that the OCR backend is active. The generic `AppError::NotImplemented` 501 mechanism remains for future feature reservations.)
|
||||
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
|
||||
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
|
||||
|
||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.124.13"
|
||||
version = "0.128.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.124.13"
|
||||
version = "0.128.4"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
@@ -62,6 +62,7 @@ http-body-util = "0.1"
|
||||
mime = "0.3"
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["test-util"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
|
||||
# Trim debug builds: keep line numbers in panics / backtraces but drop the
|
||||
# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx +
|
||||
|
||||
103
backend/migrations/0036_manga_content_warnings.sql
Normal file
103
backend/migrations/0036_manga_content_warnings.sql
Normal file
@@ -0,0 +1,103 @@
|
||||
-- Denormalized manga -> content-warning set.
|
||||
--
|
||||
-- The list filter previously tested each candidate manga with a correlated
|
||||
-- `page_content_warnings -> pages -> chapters` join, i.e. O(mangas * pages) on
|
||||
-- every filtered list AND its count. This table holds the DISTINCT union of a
|
||||
-- manga's page warnings so the filter is a single indexed lookup.
|
||||
--
|
||||
-- Kept in sync by triggers that recompute an affected manga's set from current
|
||||
-- data (the set is tiny — at most the five moderation labels — so a
|
||||
-- delete-and-reinsert per change is cheap and always correct, sidestepping the
|
||||
-- cascade-ordering hazards of incremental maintenance).
|
||||
|
||||
CREATE TABLE manga_content_warnings (
|
||||
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
|
||||
warning text NOT NULL
|
||||
CHECK (warning IN ('sexual', 'nudity', 'gore', 'violence', 'disturbing')),
|
||||
PRIMARY KEY (manga_id, warning)
|
||||
);
|
||||
|
||||
-- warning -> mangas, for the include/exclude list filters.
|
||||
CREATE INDEX manga_content_warnings_warning_idx ON manga_content_warnings (warning);
|
||||
|
||||
-- Recompute a single manga's warning set from the live per-page rows.
|
||||
CREATE OR REPLACE FUNCTION mcw_refresh_for_manga(mid uuid) RETURNS void AS $$
|
||||
BEGIN
|
||||
-- Skip when the manga is gone (e.g. mid-cascade of a manga delete) so we
|
||||
-- never re-insert a row that would violate the FK / resurrect a deleted set.
|
||||
IF NOT EXISTS (SELECT 1 FROM mangas WHERE id = mid) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
DELETE FROM manga_content_warnings WHERE manga_id = mid;
|
||||
INSERT INTO manga_content_warnings (manga_id, warning)
|
||||
SELECT DISTINCT mid, pw.warning
|
||||
FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = mid;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- page_content_warnings changed for a page: refresh that page's manga.
|
||||
CREATE OR REPLACE FUNCTION mcw_on_pcw_change() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
mid uuid;
|
||||
pid uuid := COALESCE(NEW.page_id, OLD.page_id);
|
||||
BEGIN
|
||||
-- The page (and thus chapter) may already be gone when this fires as part
|
||||
-- of a pages/chapters cascade; in that case the pages/chapters triggers do
|
||||
-- the refresh instead, so a missing join here is harmless.
|
||||
SELECT c.manga_id INTO mid
|
||||
FROM pages p JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE p.id = pid;
|
||||
IF mid IS NOT NULL THEN
|
||||
PERFORM mcw_refresh_for_manga(mid);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER mcw_pcw_ins AFTER INSERT ON page_content_warnings
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change();
|
||||
CREATE TRIGGER mcw_pcw_del AFTER DELETE ON page_content_warnings
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change();
|
||||
|
||||
-- A page was deleted (directly, or via a chapter cascade): its
|
||||
-- page_content_warnings rows are already cascade-gone, so recompute from the
|
||||
-- chapter's manga. If the chapter is gone too, the chapters trigger covers it.
|
||||
CREATE OR REPLACE FUNCTION mcw_on_page_delete() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
mid uuid;
|
||||
BEGIN
|
||||
SELECT c.manga_id INTO mid FROM chapters c WHERE c.id = OLD.chapter_id;
|
||||
IF mid IS NOT NULL THEN
|
||||
PERFORM mcw_refresh_for_manga(mid);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER mcw_page_del AFTER DELETE ON pages
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_page_delete();
|
||||
|
||||
-- A chapter was deleted (directly, or via a manga cascade): recompute from the
|
||||
-- chapter's manga. `chapters.manga_id` is on the row, so it is always available
|
||||
-- even after the child pages have cascaded; the refresh no-ops when the manga
|
||||
-- itself is being deleted.
|
||||
CREATE OR REPLACE FUNCTION mcw_on_chapter_delete() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM mcw_refresh_for_manga(OLD.manga_id);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER mcw_chapter_del AFTER DELETE ON chapters
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_chapter_delete();
|
||||
|
||||
-- Backfill from existing per-page rows.
|
||||
INSERT INTO manga_content_warnings (manga_id, warning)
|
||||
SELECT DISTINCT c.manga_id, pw.warning
|
||||
FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
51
backend/migrations/0037_mangas_sort_author.sql
Normal file
51
backend/migrations/0037_mangas_sort_author.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Precomputed author sort key for `?sort=author`.
|
||||
--
|
||||
-- The author sort used a correlated `min(lower(a.name))` subquery as the ORDER
|
||||
-- BY key, evaluated per filter-matching row before LIMIT — it scaled worse than
|
||||
-- the indexed date/title sorts. Materialize the same value on `mangas` so the
|
||||
-- sort is a plain indexed column read.
|
||||
--
|
||||
-- `sort_author` = the alphabetically-first attached author's lowercased name,
|
||||
-- or NULL when the manga has no authors (kept last via NULLS LAST in the query).
|
||||
-- Author names are immutable (authors are upserted by unique lowercased name and
|
||||
-- never renamed), so the value only changes when the manga_authors join changes
|
||||
-- — maintained by the triggers below.
|
||||
|
||||
ALTER TABLE mangas ADD COLUMN sort_author text;
|
||||
|
||||
CREATE INDEX mangas_sort_author_idx ON mangas (sort_author, id);
|
||||
|
||||
-- Backfill from existing links.
|
||||
UPDATE mangas m
|
||||
SET sort_author = (
|
||||
SELECT min(lower(a.name))
|
||||
FROM manga_authors ma
|
||||
JOIN authors a ON a.id = ma.author_id
|
||||
WHERE ma.manga_id = m.id
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION refresh_manga_sort_author(mid uuid) RETURNS void AS $$
|
||||
BEGIN
|
||||
UPDATE mangas
|
||||
SET sort_author = (
|
||||
SELECT min(lower(a.name))
|
||||
FROM manga_authors ma
|
||||
JOIN authors a ON a.id = ma.author_id
|
||||
WHERE ma.manga_id = mid
|
||||
)
|
||||
WHERE id = mid;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mangas_sort_author_on_ma_change() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- On a manga cascade-delete the UPDATE simply no-ops (row already gone).
|
||||
PERFORM refresh_manga_sort_author(COALESCE(NEW.manga_id, OLD.manga_id));
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER manga_authors_sort_author_ins AFTER INSERT ON manga_authors
|
||||
FOR EACH ROW EXECUTE FUNCTION mangas_sort_author_on_ma_change();
|
||||
CREATE TRIGGER manga_authors_sort_author_del AFTER DELETE ON manga_authors
|
||||
FOR EACH ROW EXECUTE FUNCTION mangas_sort_author_on_ma_change();
|
||||
@@ -38,7 +38,7 @@ pub fn routes() -> Router<AppState> {
|
||||
"/auth/me/preferences",
|
||||
get(get_preferences).patch(update_preferences),
|
||||
)
|
||||
.route("/auth/tokens", post(create_token))
|
||||
.route("/auth/tokens", get(list_tokens).post(create_token))
|
||||
.route("/auth/tokens/:id", delete(delete_token))
|
||||
}
|
||||
|
||||
@@ -299,6 +299,22 @@ async fn update_preferences(
|
||||
Ok(Json(saved))
|
||||
}
|
||||
|
||||
/// `GET /auth/tokens` — the caller's bot tokens (newest first). The raw bearer
|
||||
/// is only ever shown once at creation, so this list carries just the metadata
|
||||
/// (name, created/last-used, expiry); `token_hash` is `#[serde(skip)]`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TokenListResponse {
|
||||
items: Vec<ApiToken>,
|
||||
}
|
||||
|
||||
async fn list_tokens(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> AppResult<Json<TokenListResponse>> {
|
||||
let items = repo::api_token::list_for_user(&state.db, user.id).await?;
|
||||
Ok(Json(TokenListResponse { items }))
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
|
||||
@@ -72,7 +72,7 @@ async fn create(
|
||||
}
|
||||
}
|
||||
|
||||
let bookmark = repo::bookmark::create(
|
||||
let (bookmark, created) = repo::bookmark::create(
|
||||
&state.db,
|
||||
user.id,
|
||||
input.manga_id,
|
||||
@@ -103,7 +103,13 @@ async fn create(
|
||||
}
|
||||
});
|
||||
|
||||
Ok((StatusCode::CREATED, Json(bookmark)))
|
||||
// 201 for a fresh bookmark, 200 when it already existed (idempotent add).
|
||||
let status = if created {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
Ok((status, Json(bookmark)))
|
||||
}
|
||||
|
||||
async fn delete_one(
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
//! The handler uses `Storage::get_stream` so a multi-MB page is piped to
|
||||
//! the client a chunk at a time instead of buffered server-side.
|
||||
//!
|
||||
//! **Thumbnails.** `?w=<px>` serves a width-bounded variant (grids ask for a
|
||||
//! small width so they download ~KB instead of the 1–5 MB original). The width
|
||||
//! snaps to a small allow-list so the number of cached derivatives stays
|
||||
//! bounded; the resized image is cached in storage under a `thumbs/w{W}/` prefix
|
||||
//! and regenerated on demand. Only JPEG/PNG sources are thumbnailed (encoders we
|
||||
//! ship); other formats fall back to the original.
|
||||
//!
|
||||
//! **Auth model — capability URLs by design.** This endpoint is
|
||||
//! deliberately unauthenticated: reads stay public per the project
|
||||
//! brief, and per-page authorisation would require either a per-request
|
||||
@@ -16,35 +23,130 @@
|
||||
//! would gate this endpoint behind a `Storage::owner_of(key)` check;
|
||||
//! the seam is intentional.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderName};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use image::imageops::FilterType;
|
||||
use image::{ImageFormat, ImageReader};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::error::AppResult;
|
||||
use crate::storage::StorageError;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::storage::{Storage, StorageError};
|
||||
|
||||
/// Widths a thumbnail may be rendered at. A requested width snaps up to the
|
||||
/// smallest of these so the set of cached derivatives stays tiny.
|
||||
const ALLOWED_THUMB_WIDTHS: &[u32] = &[160, 320, 480, 640, 960];
|
||||
|
||||
/// Storage key prefix for cached thumbnails.
|
||||
const THUMB_PREFIX: &str = "thumbs";
|
||||
|
||||
/// Decode allocation cap (mirrors `analysis::ocr`): a tiny file declaring huge
|
||||
/// dimensions is rejected before the decoder allocates, not after (OOM guard).
|
||||
const MAX_THUMB_DECODE_PIXELS: u64 = 40_000_000;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/files/*key", get(serve))
|
||||
}
|
||||
|
||||
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
|
||||
let file = match state.storage.get_stream(&key).await {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServeQuery {
|
||||
/// Requested thumbnail width in pixels; absent = serve the original.
|
||||
w: Option<String>,
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
Query(q): Query<ServeQuery>,
|
||||
) -> AppResult<Response> {
|
||||
// Thumbnail request: only for source formats we can re-encode; anything
|
||||
// else falls through to serving the original.
|
||||
if let Some(width) = resolve_thumb_width(q.w.as_deref()) {
|
||||
if let Some(fmt) = thumb_format_for(&key) {
|
||||
return serve_thumbnail(&state, &key, width, fmt).await;
|
||||
}
|
||||
}
|
||||
serve_original(&state, &key).await
|
||||
}
|
||||
|
||||
async fn serve_original(state: &AppState, key: &str) -> AppResult<Response> {
|
||||
let file = match state.storage.get_stream(key).await {
|
||||
Ok(f) => f,
|
||||
Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound),
|
||||
Err(StorageError::NotFound) => return Err(AppError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let ct = content_type_for(&key);
|
||||
// `nosniff` makes the contract explicit: the browser must trust the
|
||||
// Content-Type we declared (and that the magic-byte sniff at upload
|
||||
// time produced) instead of trying to detect HTML/JS in the body.
|
||||
// Belt-and-braces vs. polyglot files that survive the upload sniff.
|
||||
Ok(image_response(
|
||||
state,
|
||||
content_type_for(key),
|
||||
file.size_bytes.to_string(),
|
||||
Body::from_stream(file.stream),
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_thumbnail(
|
||||
state: &AppState,
|
||||
key: &str,
|
||||
width: u32,
|
||||
fmt: ImageFormat,
|
||||
) -> AppResult<Response> {
|
||||
let derived = thumb_key(key, width);
|
||||
|
||||
// Serve the cached variant if it exists.
|
||||
match state.storage.get_stream(&derived).await {
|
||||
Ok(f) => {
|
||||
return Ok(image_response(
|
||||
state,
|
||||
content_type_for(key),
|
||||
f.size_bytes.to_string(),
|
||||
Body::from_stream(f.stream),
|
||||
));
|
||||
}
|
||||
Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
// Generate from the original.
|
||||
let original = match state.storage.get(key).await {
|
||||
Ok(b) => b,
|
||||
Err(StorageError::NotFound) => return Err(AppError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// Resizing is CPU-bound; keep it off the async worker threads.
|
||||
let thumb = tokio::task::spawn_blocking(move || make_thumbnail(&original, width, fmt))
|
||||
.await
|
||||
.map_err(|e| AppError::Other(anyhow::anyhow!("thumbnail task join: {e}")))??;
|
||||
|
||||
// Best-effort cache; a write failure just means we regenerate next time.
|
||||
let _ = state.storage.put(&derived, &thumb).await;
|
||||
|
||||
let len = thumb.len().to_string();
|
||||
Ok(image_response(
|
||||
state,
|
||||
content_type_for(key),
|
||||
len,
|
||||
Body::from(thumb),
|
||||
))
|
||||
}
|
||||
|
||||
/// Shared response builder for both the original and thumbnail paths.
|
||||
fn image_response(
|
||||
state: &AppState,
|
||||
content_type: &str,
|
||||
content_length: String,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, ct.to_string()),
|
||||
(header::CONTENT_LENGTH, file.size_bytes.to_string()),
|
||||
(header::CONTENT_TYPE, content_type.to_string()),
|
||||
(header::CONTENT_LENGTH, content_length),
|
||||
// `nosniff` makes the contract explicit: the browser must trust the
|
||||
// Content-Type we declared (and that the magic-byte sniff at upload
|
||||
// time produced) instead of trying to detect HTML/JS in the body.
|
||||
(
|
||||
HeaderName::from_static("x-content-type-options"),
|
||||
"nosniff".to_string(),
|
||||
@@ -52,15 +154,12 @@ async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppRes
|
||||
// Blobs are content-addressed by unguessable, immutable keys (a
|
||||
// re-upload mints new UUIDs), so a fetched page/cover never changes.
|
||||
// Cache it for a year and mark it `immutable` so browsers skip
|
||||
// revalidation entirely — this is what lets the reader's page and
|
||||
// next-chapter preloading hit cache instead of re-downloading (and
|
||||
// re-proxying every byte through the SvelteKit node server in prod).
|
||||
// revalidation entirely.
|
||||
//
|
||||
// BUT under PRIVATE_MODE these blobs are auth-gated (see
|
||||
// `private_mode_guard`), so they must NOT be marked `public`: a shared
|
||||
// cache / CDN in front of the app would store the object and then serve
|
||||
// it to unauthenticated clients, defeating the gate. Use `private` so
|
||||
// only the requesting user's browser caches it.
|
||||
// BUT under PRIVATE_MODE these blobs are auth-gated, so they must NOT be
|
||||
// marked `public`: a shared cache / CDN would store the object and serve
|
||||
// it to unauthenticated clients. Use `private` so only the requesting
|
||||
// user's browser caches it.
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
if state.auth.private_mode {
|
||||
@@ -70,7 +169,83 @@ async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppRes
|
||||
},
|
||||
),
|
||||
];
|
||||
Ok((headers, Body::from_stream(file.stream)).into_response())
|
||||
(headers, body).into_response()
|
||||
}
|
||||
|
||||
/// Parse and clamp a requested thumbnail width. Returns `None` for absent /
|
||||
/// unparseable / zero widths (serve the original); otherwise snaps the request
|
||||
/// up to the smallest allowed width (capped at the largest) so cached variants
|
||||
/// stay bounded.
|
||||
fn resolve_thumb_width(raw: Option<&str>) -> Option<u32> {
|
||||
let requested: u32 = raw?.trim().parse().ok()?;
|
||||
if requested == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
ALLOWED_THUMB_WIDTHS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&w| w >= requested)
|
||||
.unwrap_or_else(|| *ALLOWED_THUMB_WIDTHS.last().expect("non-empty")),
|
||||
)
|
||||
}
|
||||
|
||||
/// The re-encode format for a source key, or `None` when it isn't one we ship an
|
||||
/// encoder for (gif/avif → serve the original instead of a broken thumbnail).
|
||||
fn thumb_format_for(key: &str) -> Option<ImageFormat> {
|
||||
match content_type_for(key) {
|
||||
"image/jpeg" => Some(ImageFormat::Jpeg),
|
||||
"image/png" => Some(ImageFormat::Png),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The storage key a width-`w` thumbnail of `key` is cached under.
|
||||
fn thumb_key(key: &str, width: u32) -> String {
|
||||
format!("{THUMB_PREFIX}/w{width}/{key}")
|
||||
}
|
||||
|
||||
/// Every cached thumbnail key for an original `key`, across all allowed widths.
|
||||
/// Used by the cover handlers to purge stale variants when a cover (whose key is
|
||||
/// reused, unlike content-addressed pages) is replaced or deleted.
|
||||
pub(crate) fn thumbnail_keys(key: &str) -> Vec<String> {
|
||||
ALLOWED_THUMB_WIDTHS
|
||||
.iter()
|
||||
.map(|&w| thumb_key(key, w))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Best-effort deletion of every cached thumbnail for `key`. Call after the
|
||||
/// underlying blob at `key` changes or is removed.
|
||||
pub(crate) async fn purge_thumbnails(storage: &dyn Storage, key: &str) {
|
||||
for derived in thumbnail_keys(key) {
|
||||
let _ = storage.delete(&derived).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode, downscale to `width` (aspect-preserving, never upscaling), and
|
||||
/// re-encode in `fmt`. Pure + synchronous so it runs under `spawn_blocking` and
|
||||
/// is unit-testable without a server.
|
||||
fn make_thumbnail(bytes: &[u8], width: u32, fmt: ImageFormat) -> anyhow::Result<Vec<u8>> {
|
||||
use anyhow::Context;
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.context("guess image format for thumbnail")?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_THUMB_DECODE_PIXELS.saturating_mul(4));
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().context("decode image for thumbnail")?;
|
||||
|
||||
// Only downscale; a source narrower than the target is served as-is.
|
||||
let out = if img.width() > width {
|
||||
img.resize(width, u32::MAX, FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
out.write_to(&mut buf, fmt).context("encode thumbnail")?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
fn content_type_for(key: &str) -> &'static str {
|
||||
@@ -84,3 +259,68 @@ fn content_type_for(key: &str) -> &'static str {
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_thumb_width_snaps_and_validates() {
|
||||
// Absent / unparseable / zero → serve the original.
|
||||
assert_eq!(resolve_thumb_width(None), None);
|
||||
assert_eq!(resolve_thumb_width(Some("")), None);
|
||||
assert_eq!(resolve_thumb_width(Some("abc")), None);
|
||||
assert_eq!(resolve_thumb_width(Some("0")), None);
|
||||
// Snap up to the smallest allowed width.
|
||||
assert_eq!(resolve_thumb_width(Some("1")), Some(160));
|
||||
assert_eq!(resolve_thumb_width(Some("160")), Some(160));
|
||||
assert_eq!(resolve_thumb_width(Some("161")), Some(320));
|
||||
assert_eq!(resolve_thumb_width(Some("640")), Some(640));
|
||||
// Above the max → capped at the largest allowed width.
|
||||
assert_eq!(resolve_thumb_width(Some("5000")), Some(960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumb_format_only_for_encodable_sources() {
|
||||
assert_eq!(thumb_format_for("a/b/cover.jpg"), Some(ImageFormat::Jpeg));
|
||||
assert_eq!(thumb_format_for("a/b/cover.png"), Some(ImageFormat::Png));
|
||||
// Formats we can't re-encode fall back to the original.
|
||||
assert_eq!(thumb_format_for("a/b/cover.webp"), None);
|
||||
assert_eq!(thumb_format_for("a/b/cover.gif"), None);
|
||||
assert_eq!(thumb_format_for("a/b/cover.avif"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumb_key_is_prefixed_by_width() {
|
||||
assert_eq!(
|
||||
thumb_key("mangas/x/cover.png", 320),
|
||||
"thumbs/w320/mangas/x/cover.png"
|
||||
);
|
||||
assert_eq!(thumbnail_keys("k.png").len(), ALLOWED_THUMB_WIDTHS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumbnail_downscales_and_preserves_aspect() {
|
||||
// 100x50 red PNG → thumbnail width 40 → 40x20, still decodable PNG.
|
||||
let mut src = Cursor::new(Vec::new());
|
||||
image::RgbImage::from_pixel(100, 50, image::Rgb([255, 0, 0]))
|
||||
.write_to(&mut src, ImageFormat::Png)
|
||||
.unwrap();
|
||||
let out = make_thumbnail(src.get_ref(), 40, ImageFormat::Png).unwrap();
|
||||
let decoded = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!(decoded.width(), 40);
|
||||
assert_eq!(decoded.height(), 20, "aspect ratio preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumbnail_does_not_upscale() {
|
||||
// A 30px-wide source requested at 40 stays 30 wide (no upscaling).
|
||||
let mut src = Cursor::new(Vec::new());
|
||||
image::RgbImage::from_pixel(30, 30, image::Rgb([0, 255, 0]))
|
||||
.write_to(&mut src, ImageFormat::Png)
|
||||
.unwrap();
|
||||
let out = make_thumbnail(src.get_ref(), 40, ImageFormat::Png).unwrap();
|
||||
let decoded = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!(decoded.width(), 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ async fn create(
|
||||
metadata = Some(parse_metadata_json(&bytes)?);
|
||||
}
|
||||
Some("cover") => {
|
||||
let bytes = read_field_bytes(field).await?.to_vec();
|
||||
let bytes =
|
||||
crate::upload::read_capped(field, state.upload.max_file_bytes, "cover").await?;
|
||||
cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?);
|
||||
}
|
||||
_ => continue,
|
||||
@@ -387,7 +388,8 @@ async fn put_cover(
|
||||
let mut cover: Option<UploadedImage> = None;
|
||||
while let Some(field) = next_field(&mut multipart).await? {
|
||||
if field.name() == Some("cover") {
|
||||
let bytes = read_field_bytes(field).await?.to_vec();
|
||||
let bytes =
|
||||
crate::upload::read_capped(field, state.upload.max_file_bytes, "cover").await?;
|
||||
cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?);
|
||||
}
|
||||
}
|
||||
@@ -412,8 +414,14 @@ async fn put_cover(
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
// Old key's cached thumbnails are now orphaned.
|
||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), prev).await;
|
||||
}
|
||||
}
|
||||
// Cover keys are reused (mangas/{id}/cover.{ext}), so a same-extension
|
||||
// replacement overwrites the blob at an existing key — drop any thumbnails
|
||||
// cached for it so we don't serve a stale variant of the old cover.
|
||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), &new_key).await;
|
||||
|
||||
repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?;
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
@@ -437,6 +445,7 @@ async fn delete_cover(
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), &key).await;
|
||||
repo::manga::clear_cover_image_path(&state.db, id).await?;
|
||||
}
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
|
||||
@@ -605,16 +605,16 @@ async fn spawn_crawler_daemon(
|
||||
if let Some(proxy) = &cfg.proxy {
|
||||
http_builder = http_builder
|
||||
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy: {proxy}"))?);
|
||||
} else {
|
||||
// DNS-rebinding guard: reject hosts that resolve to a private/internal
|
||||
// IP, complementing the string-level allowlist check which can't see
|
||||
// post-resolution addresses. ONLY on the direct (unproxied) path. With
|
||||
// a `socks5h://` proxy the target hostname is resolved by the proxy —
|
||||
// reqwest never resolves it — so the only name this resolver would ever
|
||||
// see is the proxy's OWN host, which legitimately lives on a private
|
||||
// Docker IP (e.g. `tor` → 172.x). Attaching it there rejected every
|
||||
// fetch ("SOCKS error: failed to create underlying connection") for
|
||||
// zero security gain, since Tor can't route to internal ranges anyway.
|
||||
}
|
||||
// DNS-rebinding guard: reject hosts that resolve to a private/internal IP,
|
||||
// complementing the string-level allowlist check which can't see
|
||||
// post-resolution addresses. Attached on the direct path AND on http(s)
|
||||
// proxies (reqwest resolves the target itself there). Skipped only for SOCKS
|
||||
// proxies, where the proxy — not reqwest — resolves the target, so the only
|
||||
// name this resolver would see is the proxy's OWN host (legitimately on a
|
||||
// private Docker IP, e.g. `tor` → 172.x); attaching it there rejected every
|
||||
// fetch for zero gain. See `should_attach_safe_resolver`.
|
||||
if crate::crawler::safety::should_attach_safe_resolver(cfg.proxy.as_deref()) {
|
||||
http_builder = http_builder.dns_resolver(crate::crawler::safety::safe_dns_resolver());
|
||||
}
|
||||
let http = http_builder.build().context("build crawler reqwest")?;
|
||||
@@ -990,7 +990,17 @@ impl ChapterDispatcher for RealChapterDispatcher {
|
||||
pages_done: 0,
|
||||
pages_total: None,
|
||||
});
|
||||
let lease = self.browser_manager.acquire().await?;
|
||||
let lease = match self.browser_manager.acquire().await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
// Browser down / mid-restart: defer the job WITHOUT
|
||||
// burning an attempt (the daemon releases it back to
|
||||
// pending) rather than counting an infrastructure
|
||||
// outage as a per-job failure.
|
||||
tracing::warn!(error = ?e, "dispatch: browser unavailable — deferring job");
|
||||
return Ok(SyncOutcome::BrowserUnavailable);
|
||||
}
|
||||
};
|
||||
let result = content::sync_chapter_content(
|
||||
&lease,
|
||||
&self.db,
|
||||
@@ -1061,7 +1071,15 @@ impl ChapterDispatcher for RealChapterDispatcher {
|
||||
// Scope the lease so it (and the borrowing FetchContext) drop
|
||||
// before any browser-restart handling in the match below.
|
||||
let result = {
|
||||
let lease = self.browser_manager.acquire().await?;
|
||||
let lease = match self.browser_manager.acquire().await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
// See the SyncChapterContent arm: defer without
|
||||
// burning an attempt when the browser is unavailable.
|
||||
tracing::warn!(error = ?e, "dispatch: browser unavailable — deferring job");
|
||||
return Ok(SyncOutcome::BrowserUnavailable);
|
||||
}
|
||||
};
|
||||
let ctx = crate::crawler::source::FetchContext {
|
||||
browser: &lease,
|
||||
rate: &self.rate,
|
||||
|
||||
@@ -141,10 +141,10 @@ impl AuthRateLimiter {
|
||||
return self
|
||||
.global
|
||||
.lock()
|
||||
.expect("rate limiter mutex poisoned")
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.try_take(&self.cfg, now);
|
||||
};
|
||||
let mut map = self.per_ip.lock().expect("rate limiter mutex poisoned");
|
||||
let mut map = self.per_ip.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
|
||||
map.retain(|_, b| !b.is_idle(&self.cfg, now));
|
||||
if map.len() >= MAX_TRACKED_IPS {
|
||||
@@ -155,7 +155,7 @@ impl AuthRateLimiter {
|
||||
return self
|
||||
.global
|
||||
.lock()
|
||||
.expect("rate limiter mutex poisoned")
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.try_take(&self.cfg, now);
|
||||
}
|
||||
}
|
||||
@@ -287,4 +287,35 @@ mod tests {
|
||||
}
|
||||
assert!(rl.per_ip.lock().unwrap().len() <= MAX_TRACKED_IPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survives_a_poisoned_mutex() {
|
||||
// If any thread ever panics while holding a limiter mutex, the lock
|
||||
// becomes poisoned. With the old `.expect(...)` every later auth request
|
||||
// would re-panic — one blip turned into a permanent auth outage. Recover
|
||||
// the guard via `into_inner()` instead so the limiter keeps serving.
|
||||
let rl = AuthRateLimiter::new(RateLimitConfig {
|
||||
per_sec: 5,
|
||||
burst: 5,
|
||||
});
|
||||
|
||||
// Poison per_ip by panicking while holding its guard.
|
||||
let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _g = rl.per_ip.lock().unwrap();
|
||||
panic!("poison the per-IP mutex");
|
||||
}));
|
||||
assert!(poisoned.is_err(), "the panic must unwind");
|
||||
assert!(rl.per_ip.is_poisoned(), "the mutex must now be poisoned");
|
||||
|
||||
// The per-IP path (Some(ip)) must still work despite the poison.
|
||||
assert_eq!(rl.try_acquire(ip("198.51.100.9")), AcquireResult::Allowed);
|
||||
|
||||
// And poison the global bucket too — the None-key path must recover.
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _g = rl.global.lock().unwrap();
|
||||
panic!("poison the global mutex");
|
||||
}));
|
||||
assert!(rl.global.is_poisoned());
|
||||
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,13 @@ async fn sync_bookmarked_chapter_content(
|
||||
s.fetched += 1;
|
||||
}
|
||||
Ok(SyncOutcome::Skipped) => s.skipped += 1,
|
||||
// Unreachable in the one-shot CLI (it holds its own lease
|
||||
// and never dispatches through the queue), but count it as a
|
||||
// failure for exhaustiveness.
|
||||
Ok(SyncOutcome::BrowserUnavailable) => {
|
||||
tracing::warn!(%chapter_id, "crawler browser unavailable");
|
||||
s.failed += 1;
|
||||
}
|
||||
Ok(SyncOutcome::SessionExpired) => {
|
||||
tracing::error!(
|
||||
%chapter_id,
|
||||
|
||||
@@ -71,6 +71,13 @@ pub enum SyncOutcome {
|
||||
/// Session probe failed mid-sync (avatar selector missing on the
|
||||
/// chapter page). Caller should abort the whole crawler run.
|
||||
SessionExpired,
|
||||
/// The headless browser could not be acquired (down or mid-restart).
|
||||
/// Produced only by the queue dispatcher (which calls `acquire()`); it is
|
||||
/// an *infrastructure* outage, not a job failure, so the daemon returns the
|
||||
/// job to `pending` WITHOUT burning a retry attempt. `sync_chapter_content`
|
||||
/// itself never returns this — callers that already hold a lease can treat
|
||||
/// it as unreachable.
|
||||
BrowserUnavailable,
|
||||
}
|
||||
|
||||
/// Per-chapter max fetch attempts when TOR is configured. `N = 3` means
|
||||
|
||||
@@ -66,6 +66,43 @@ const LEASE_DURATION: Duration = Duration::from_secs(60);
|
||||
/// the lease window leaves two missed-beat's slack before expiry.
|
||||
const LEASE_HEARTBEAT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Consecutive failed lease renews the heartbeat tolerates before it gives up
|
||||
/// and signals the worker to abandon the in-flight dispatch. At
|
||||
/// [`LEASE_HEARTBEAT`] spacing this is ~1 lease window of DB flakiness — past
|
||||
/// that the lease has very likely lapsed and another worker may re-lease the
|
||||
/// job, so continuing to crawl it is wasted (and duplicated) work.
|
||||
const MAX_HEARTBEAT_RENEW_FAILURES: u32 = 3;
|
||||
|
||||
/// Whether the heartbeat should abandon the job after `consecutive_failures`
|
||||
/// failed renews. Split out so the escalation threshold is unit-testable.
|
||||
fn should_abort_after_renew_failures(consecutive_failures: u32) -> bool {
|
||||
consecutive_failures >= MAX_HEARTBEAT_RENEW_FAILURES
|
||||
}
|
||||
|
||||
/// How long a worker waits after a `BrowserUnavailable` outcome before looping
|
||||
/// back to lease again. The job was released (not failed) so it stays pending;
|
||||
/// this backoff keeps the worker from hot-looping lease→acquire→release while
|
||||
/// the browser is down or mid-restart.
|
||||
const BROWSER_UNAVAILABLE_BACKOFF: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Longest an idle worker waits between lease polls. Bounds the exponential
|
||||
/// [`idle_backoff`] so a worker still notices freshly-enqueued work reasonably
|
||||
/// soon after a quiet spell.
|
||||
const IDLE_BACKOFF_CAP: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Backoff for a worker that keeps finding no work: 1s, 2s, 4s, … capped at
|
||||
/// [`IDLE_BACKOFF_CAP`]. `consecutive_empty` is the number of empty polls seen
|
||||
/// so far (0 on the first miss); reset to 0 the moment a job is leased. Replaces
|
||||
/// the old flat 1s sleep so an idle daemon isn't firing a row-locking `SELECT …
|
||||
/// FOR UPDATE SKIP LOCKED` lease query every second per worker.
|
||||
fn idle_backoff(consecutive_empty: u32) -> Duration {
|
||||
let cap = IDLE_BACKOFF_CAP.as_secs();
|
||||
// 1 << n grows the interval; saturate to the cap once the shift overflows
|
||||
// or the value exceeds the cap.
|
||||
let secs = 1u64.checked_shl(consecutive_empty).unwrap_or(cap).min(cap);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MetadataPass: Send + Sync {
|
||||
async fn run(&self) -> anyhow::Result<pipeline::MetadataStats>;
|
||||
@@ -356,6 +393,9 @@ struct WorkerContext {
|
||||
|
||||
impl WorkerContext {
|
||||
async fn run(self) {
|
||||
// Consecutive empty lease polls, driving the idle backoff. Reset to 0
|
||||
// the moment any job is leased.
|
||||
let mut idle_streak: u32 = 0;
|
||||
loop {
|
||||
if self.cancel.is_cancelled() {
|
||||
tracing::info!(worker = self.id, "worker: shutdown");
|
||||
@@ -385,11 +425,14 @@ impl WorkerContext {
|
||||
}
|
||||
};
|
||||
let Some(lease) = leases.into_iter().next() else {
|
||||
let backoff = idle_backoff(idle_streak);
|
||||
idle_streak = idle_streak.saturating_add(1);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => continue,
|
||||
_ = tokio::time::sleep(backoff) => continue,
|
||||
_ = self.cancel.cancelled() => return,
|
||||
}
|
||||
};
|
||||
idle_streak = 0;
|
||||
self.process_lease(lease).await;
|
||||
}
|
||||
}
|
||||
@@ -413,18 +456,34 @@ impl WorkerContext {
|
||||
// dispatch runs, so a slow-but-healthy job is never re-leased and
|
||||
// never inflates `attempts` toward `max_attempts`. Stops itself
|
||||
// once the job is no longer ours (renew returns false).
|
||||
// Signalled by the heartbeat if it gives up after too many consecutive
|
||||
// renew failures, so the worker can abandon a dispatch whose lease has
|
||||
// very likely lapsed (rather than crawl a job another worker may now own).
|
||||
let hb_lost = CancellationToken::new();
|
||||
let heartbeat = {
|
||||
let hb_pool = self.pool.clone();
|
||||
let hb_id = lease.id;
|
||||
let hb_gen = lease.lease_generation;
|
||||
let hb_lost = hb_lost.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut failures: u32 = 0;
|
||||
loop {
|
||||
tokio::time::sleep(LEASE_HEARTBEAT).await;
|
||||
match jobs::renew(&hb_pool, hb_id, hb_gen, LEASE_DURATION).await {
|
||||
Ok(true) => {}
|
||||
Ok(true) => failures = 0,
|
||||
Ok(false) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(lease_id = %hb_id, ?e, "heartbeat renew failed");
|
||||
failures += 1;
|
||||
tracing::warn!(lease_id = %hb_id, failures, ?e, "heartbeat renew failed");
|
||||
if should_abort_after_renew_failures(failures) {
|
||||
tracing::error!(
|
||||
lease_id = %hb_id,
|
||||
failures,
|
||||
"heartbeat lost the lease after repeated renew failures — signalling abandon"
|
||||
);
|
||||
hb_lost.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,6 +521,20 @@ impl WorkerContext {
|
||||
);
|
||||
return;
|
||||
}
|
||||
_ = hb_lost.cancelled() => {
|
||||
// The heartbeat gave up renewing: the lease has very likely
|
||||
// expired and may already be re-leased elsewhere. Abandon the
|
||||
// dispatch rather than keep crawling a job we no longer own. Do
|
||||
// NOT ack/release — a generation-guarded write would no-op, and
|
||||
// another worker may now hold this lease.
|
||||
heartbeat.abort();
|
||||
tracing::error!(
|
||||
worker = self.id,
|
||||
lease_id = %lease.id,
|
||||
"worker: abandoning dispatch — lease lost (heartbeat renew failures)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
o = tokio::time::timeout(self.job_timeout, dispatch) => o,
|
||||
};
|
||||
heartbeat.abort();
|
||||
@@ -502,6 +575,24 @@ impl WorkerContext {
|
||||
self.status.poke();
|
||||
let _ = jobs::release(&self.pool, lease.id, lease.lease_generation).await;
|
||||
}
|
||||
Ok(Ok(SyncOutcome::BrowserUnavailable)) => {
|
||||
// Infrastructure outage, not a job failure: the browser was
|
||||
// down or mid-restart when the dispatcher tried to acquire it.
|
||||
// Return the job to `pending` WITHOUT burning an attempt (like
|
||||
// the cancel/session paths) so an outage doesn't chew the whole
|
||||
// backlog to `dead`, then back off to avoid hot-looping while
|
||||
// the browser recovers.
|
||||
tracing::warn!(
|
||||
worker = self.id,
|
||||
lease_id = %lease.id,
|
||||
"worker: browser unavailable — released lease without burning an attempt"
|
||||
);
|
||||
let _ = jobs::release(&self.pool, lease.id, lease.lease_generation).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(BROWSER_UNAVAILABLE_BACKOFF) => {}
|
||||
_ = self.cancel.cancelled() => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
worker = self.id,
|
||||
@@ -750,6 +841,37 @@ mod tests {
|
||||
Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_aborts_only_after_threshold_consecutive_failures() {
|
||||
// A blip or two is tolerated; sustained failures escalate to abandon.
|
||||
assert!(!should_abort_after_renew_failures(0));
|
||||
assert!(!should_abort_after_renew_failures(1));
|
||||
assert!(!should_abort_after_renew_failures(MAX_HEARTBEAT_RENEW_FAILURES - 1));
|
||||
assert!(should_abort_after_renew_failures(MAX_HEARTBEAT_RENEW_FAILURES));
|
||||
assert!(should_abort_after_renew_failures(MAX_HEARTBEAT_RENEW_FAILURES + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_backoff_grows_then_caps() {
|
||||
// First miss is a short 1s poll; the interval doubles each empty poll…
|
||||
assert_eq!(idle_backoff(0), Duration::from_secs(1));
|
||||
assert_eq!(idle_backoff(1), Duration::from_secs(2));
|
||||
assert_eq!(idle_backoff(2), Duration::from_secs(4));
|
||||
assert_eq!(idle_backoff(3), Duration::from_secs(8));
|
||||
assert_eq!(idle_backoff(4), Duration::from_secs(16));
|
||||
// …and saturates at the cap rather than growing unbounded.
|
||||
assert_eq!(idle_backoff(5), IDLE_BACKOFF_CAP);
|
||||
assert_eq!(idle_backoff(100), IDLE_BACKOFF_CAP);
|
||||
// Never exceeds the cap and is monotonic non-decreasing.
|
||||
let mut prev = Duration::ZERO;
|
||||
for n in 0..40 {
|
||||
let b = idle_backoff(n);
|
||||
assert!(b >= prev, "backoff must be non-decreasing at n={n}");
|
||||
assert!(b <= IDLE_BACKOFF_CAP, "backoff must never exceed the cap");
|
||||
prev = b;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_fire_in_utc_at_midnight_advances_one_day() {
|
||||
let now = dt_utc(2026, 5, 25, 12, 0); // noon UTC
|
||||
|
||||
@@ -9,10 +9,16 @@
|
||||
//! attacker-owned hostname that resolves to `169.254.169.254`) would be
|
||||
//! loaded and parsed as if it were catalog content.
|
||||
//!
|
||||
//! This module installs CDP `Fetch` interception on a page so every
|
||||
//! **Document** (main-frame) request and redirect is re-validated through the
|
||||
//! same [`ensure_public_target`] + resolved-IP check the reqwest paths use,
|
||||
//! failing any that target an internal address.
|
||||
//! This module installs CDP `Fetch` interception on a page so **every** request
|
||||
//! it issues — the main-frame Document, its redirects, *and every subresource*
|
||||
//! (`<img>`, `fetch()`/XHR, media, …) — is re-validated through the same
|
||||
//! [`ensure_public_target`] + resolved-IP check the reqwest paths use, failing
|
||||
//! any that target an internal address. Intercepting only the Document would
|
||||
//! leave a scraped page free to pull `<img src="http://169.254.169.254/…">` or
|
||||
//! `fetch('http://postgres:5432')` straight past the guard, so no resource type
|
||||
//! is exempt. (WebSocket handshakes are not surfaced by CDP `Fetch`, so `ws://`
|
||||
//! internal targets remain out of this hook's reach — the reqwest-layer
|
||||
//! resolver does not see them either; documented as a known gap.)
|
||||
//!
|
||||
//! **Opt-in / default-off.** Enabling `Fetch` means every intercepted request
|
||||
//! *must* be resolved by a live handler or the navigation hangs, so this is a
|
||||
@@ -20,9 +26,9 @@
|
||||
//! `CRAWLER_SSRF_INTERCEPT` (default `false`) and the wiring has **not** been
|
||||
//! exercised against a real Chromium in CI — validate with a manual crawl
|
||||
//! before enabling in production. When disabled, [`open_page`] is byte-for-byte
|
||||
//! the previous `browser.new_page(url)` behavior. Even when enabled, a failure
|
||||
//! to install the guard falls back to an unguarded navigation rather than
|
||||
//! failing the crawl.
|
||||
//! the previous `browser.new_page(url)` behavior. When enabled, the guard is
|
||||
//! **fail-closed**: if interception can't be installed, [`open_page`] closes the
|
||||
//! blank page and returns the error rather than navigating unguarded.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -32,7 +38,7 @@ use chromiumoxide::cdp::browser_protocol::fetch::{
|
||||
ContinueRequestParams, EnableParams, EventRequestPaused, FailRequestParams, RequestPattern,
|
||||
RequestStage,
|
||||
};
|
||||
use chromiumoxide::cdp::browser_protocol::network::{ErrorReason, ResourceType};
|
||||
use chromiumoxide::cdp::browser_protocol::network::ErrorReason;
|
||||
use chromiumoxide::error::Result as CdpResult;
|
||||
use chromiumoxide::Page;
|
||||
use futures_util::StreamExt;
|
||||
@@ -129,26 +135,35 @@ pub async fn open_page(browser: &Browser, url: &str) -> CdpResult<Page> {
|
||||
}
|
||||
let page = browser.new_page("about:blank").await?;
|
||||
if let Err(e) = install_navigation_guard(&page).await {
|
||||
// Fail open on install error: navigate unguarded rather than wedge the
|
||||
// crawl. The reqwest-layer resolver still covers image downloads.
|
||||
tracing::warn!(url = %url, error = %e, "SSRF navigation guard failed to install; navigating unguarded");
|
||||
// Fail closed: an unguarded page could be redirected (or pull a
|
||||
// subresource) to an internal target, so refuse to navigate. Close the
|
||||
// blank page and surface the error so the caller aborts this fetch.
|
||||
tracing::warn!(url = %url, error = %e, "SSRF navigation guard failed to install; aborting navigation (fail-closed)");
|
||||
let _ = page.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
page.goto(url).await?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
/// Enable `Fetch` for Document requests on `page` and spawn a task that
|
||||
/// The `Fetch` interception patterns to register. A single pattern with **no**
|
||||
/// `resource_type` constraint matches every request the page makes — Document,
|
||||
/// Image, Fetch/XHR, media, everything — so subresources to internal targets are
|
||||
/// re-validated too, not only the main-frame navigation. Restricting this to
|
||||
/// `ResourceType::Document` (the previous behavior) left every `<img>`/`fetch()`
|
||||
/// subresource unguarded, which is the SSRF hole this closes. Pinned to the
|
||||
/// request stage so each request is paused once, before it leaves the browser.
|
||||
fn interception_patterns() -> Vec<RequestPattern> {
|
||||
vec![RequestPattern::builder()
|
||||
.request_stage(RequestStage::Request)
|
||||
.build()]
|
||||
}
|
||||
|
||||
/// Enable `Fetch` for all requests on `page` and spawn a task that
|
||||
/// continues/fails each paused request per [`is_blocked`].
|
||||
async fn install_navigation_guard(page: &Page) -> CdpResult<()> {
|
||||
// Intercept only main-frame Document requests at the request stage — a
|
||||
// navigation plus its redirects, nothing else. Keeps the paused-request
|
||||
// volume tiny so the handler can't become a page-load bottleneck.
|
||||
let pattern = RequestPattern::builder()
|
||||
.resource_type(ResourceType::Document)
|
||||
.request_stage(RequestStage::Request)
|
||||
.build();
|
||||
page.execute(EnableParams {
|
||||
patterns: Some(vec![pattern]),
|
||||
patterns: Some(interception_patterns()),
|
||||
handle_auth_requests: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -244,6 +259,21 @@ mod tests {
|
||||
assert!(!is_blocked("about:blank").await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interception_covers_all_resource_types() {
|
||||
// Regression guard for the SSRF subresource hole: the CDP Fetch pattern
|
||||
// must NOT be constrained to Document, or `<img>`/`fetch()`/XHR
|
||||
// subresources to internal targets slip past `is_blocked`. A pattern
|
||||
// with no `resource_type` set intercepts every request type.
|
||||
let patterns = interception_patterns();
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert!(
|
||||
patterns[0].resource_type.is_none(),
|
||||
"interception must cover all resource types (subresources included), not just Document"
|
||||
);
|
||||
assert_eq!(patterns[0].request_stage, Some(RequestStage::Request));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_toggle_roundtrips() {
|
||||
// Global; restore afterwards so other tests see the default.
|
||||
|
||||
@@ -135,28 +135,7 @@ impl ResyncService for RealResyncService {
|
||||
.await
|
||||
.with_context(|| format!("fetch_manga during resync of {manga_id}"))?;
|
||||
|
||||
// Partial-render guard: same logic as run_metadata_pass.
|
||||
let source_id = source.id();
|
||||
if !manga.chapters.is_empty() || {
|
||||
let prior = repo::crawler::live_chapter_count_for_source_manga(
|
||||
&self.db,
|
||||
source_id,
|
||||
&source_manga_key,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
prior == 0
|
||||
} {
|
||||
// Either the new fetch surfaced chapters, or there were
|
||||
// none before either — chapter sync is safe to run.
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%manga_id,
|
||||
source_url = %source_url,
|
||||
"resync_manga: fetch returned empty chapters but prior count > 0; skipping chapter sync to avoid soft-drop"
|
||||
);
|
||||
}
|
||||
|
||||
let upsert = repo::crawler::upsert_manga_from_source(
|
||||
&self.db,
|
||||
source_id,
|
||||
@@ -201,6 +180,11 @@ impl ResyncService for RealResyncService {
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
// Partial-render guard (same logic as run_metadata_pass): only sync
|
||||
// chapters when the fetch surfaced some, or the manga never had any.
|
||||
// A fetch that returned empty while a prior count exists is almost
|
||||
// certainly a partial render — skip the sync so we don't soft-drop the
|
||||
// real chapters.
|
||||
if !manga.chapters.is_empty() || prior_chapter_count == 0 {
|
||||
match repo::crawler::sync_manga_chapters(
|
||||
&self.db,
|
||||
@@ -223,6 +207,12 @@ impl ResyncService for RealResyncService {
|
||||
"resync_manga: chapter sync failed"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%manga_id,
|
||||
source_url = %source_url,
|
||||
"resync_manga: fetch returned empty chapters but prior count > 0; skipping chapter sync to avoid soft-drop"
|
||||
);
|
||||
}
|
||||
|
||||
drop(lease);
|
||||
@@ -280,6 +270,12 @@ impl ResyncService for RealResyncService {
|
||||
SyncOutcome::SessionExpired => {
|
||||
anyhow::bail!("source session expired — operator must refresh PHPSESSID")
|
||||
}
|
||||
// Unreachable here: resync already holds its own browser lease and
|
||||
// `sync_chapter_content` never acquires one, so it can't report the
|
||||
// browser unavailable. Handled defensively for exhaustiveness.
|
||||
SyncOutcome::BrowserUnavailable => {
|
||||
anyhow::bail!("crawler browser unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,40 @@ pub fn safe_dns_resolver() -> Arc<SafeResolver> {
|
||||
Arc::new(SafeResolver)
|
||||
}
|
||||
|
||||
/// Whether the DNS-rebinding [`SafeResolver`] should be attached to a crawler
|
||||
/// reqwest client given the configured proxy (if any).
|
||||
///
|
||||
/// The resolver only guards targets that *reqwest itself* resolves:
|
||||
/// - **No proxy** (direct): reqwest resolves the target — attach.
|
||||
/// - **`http(s)://` proxy**: reqwest still resolves the target host locally and
|
||||
/// issues a `CONNECT`, so a hostname resolving to a private IP must be
|
||||
/// refused — attach.
|
||||
/// - **`socks5://` / `socks5h://` / `socks4://` (Tor)**: the *proxy* resolves
|
||||
/// the target; reqwest only ever resolves the proxy's own host, which
|
||||
/// legitimately lives on a private Docker IP (e.g. `tor` → 172.x). Attaching
|
||||
/// the resolver there rejects every fetch for zero security gain (a SOCKS
|
||||
/// proxy can't route into internal ranges anyway) — do **not** attach.
|
||||
///
|
||||
/// This narrows commit 134ab54, which dropped the resolver for *any* proxy, back
|
||||
/// to SOCKS-only so the http(s)-proxy path keeps its DNS-rebinding guard.
|
||||
pub fn should_attach_safe_resolver(proxy: Option<&str>) -> bool {
|
||||
match proxy {
|
||||
None => true,
|
||||
Some(p) => !is_socks_proxy(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `proxy`'s scheme is a SOCKS variant (`socks4`, `socks5`,
|
||||
/// `socks5h`). A scheme-less value (reqwest treats it as HTTP) is not SOCKS.
|
||||
fn is_socks_proxy(proxy: &str) -> bool {
|
||||
proxy
|
||||
.split_once("://")
|
||||
.map(|(scheme, _)| scheme)
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("socks")
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum UrlSafetyError {
|
||||
#[error("URL is not parseable")]
|
||||
@@ -743,6 +777,24 @@ mod tests {
|
||||
assert!(err.to_string().contains("rebind.attacker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_resolver_attaches_except_for_socks_proxies() {
|
||||
// Direct + http(s) proxies: reqwest resolves the target, so the
|
||||
// DNS-rebinding guard must be attached.
|
||||
assert!(should_attach_safe_resolver(None));
|
||||
assert!(should_attach_safe_resolver(Some("http://proxy.internal:8080")));
|
||||
assert!(should_attach_safe_resolver(Some("https://proxy.internal:8080")));
|
||||
assert!(should_attach_safe_resolver(Some("HTTP://Proxy:8080")));
|
||||
// A scheme-less proxy is treated as HTTP by reqwest — keep the guard.
|
||||
assert!(should_attach_safe_resolver(Some("proxy.internal:8080")));
|
||||
// SOCKS variants (incl. Tor): the proxy resolves, so attaching the
|
||||
// resolver would reject every fetch for zero gain.
|
||||
assert!(!should_attach_safe_resolver(Some("socks5://tor:9050")));
|
||||
assert!(!should_attach_safe_resolver(Some("socks5h://tor:9050")));
|
||||
assert!(!should_attach_safe_resolver(Some("socks4://x:1080")));
|
||||
assert!(!should_attach_safe_resolver(Some("SOCKS5://Tor:9050")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_url_blocks_non_http_schemes() {
|
||||
let allow = allow_just("anywhere");
|
||||
|
||||
@@ -32,6 +32,23 @@ pub async fn create(
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// The caller's tokens, newest first. `token_hash` is `#[serde(skip)]` on the
|
||||
/// domain type, so returning the full row never leaks the secret.
|
||||
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<ApiToken>> {
|
||||
let rows = sqlx::query_as::<_, ApiToken>(
|
||||
r#"
|
||||
SELECT id, user_id, name, token_hash, created_at, last_used_at, expires_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<ApiToken>> {
|
||||
let row = sqlx::query_as::<_, ApiToken>(
|
||||
r#"
|
||||
|
||||
@@ -6,13 +6,23 @@ use uuid::Uuid;
|
||||
use crate::domain::{Bookmark, BookmarkSummary};
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Add a bookmark, idempotently. Returns the bookmark plus whether it was
|
||||
/// newly created (`true`) or already existed (`false`), so the handler can
|
||||
/// answer 201 vs 200. Re-adding an existing bookmark is a no-op success rather
|
||||
/// than a 409 — matching the idempotent collection semantics, so the UI doesn't
|
||||
/// show a false "Could not add bookmark" toast when the manga is already saved.
|
||||
///
|
||||
/// Uniqueness is per `(user_id, manga_id, chapter_id)` — enforced by the 0001
|
||||
/// constraint for chapter-level rows and the 0004 partial index for manga-level
|
||||
/// (NULL chapter) rows. `page` is not part of the key, so the existing row is
|
||||
/// returned unchanged (its page is not overwritten).
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
manga_id: Uuid,
|
||||
chapter_id: Option<Uuid>,
|
||||
page: Option<i32>,
|
||||
) -> AppResult<Bookmark> {
|
||||
) -> AppResult<(Bookmark, bool)> {
|
||||
let result = sqlx::query_as::<_, Bookmark>(
|
||||
r#"
|
||||
INSERT INTO bookmarks (user_id, manga_id, chapter_id, page)
|
||||
@@ -28,10 +38,27 @@ pub async fn create(
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(b) => Ok(b),
|
||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => Err(
|
||||
AppError::Conflict("bookmark already exists for this manga/chapter".into()),
|
||||
),
|
||||
Ok(b) => Ok((b, true)),
|
||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
|
||||
// A bookmark for this (user, manga, chapter) already exists — fetch
|
||||
// and return it. `IS NOT DISTINCT FROM` matches a NULL chapter_id
|
||||
// (manga-level bookmark) as well as a concrete one, covering both
|
||||
// uniqueness paths with a single lookup.
|
||||
let existing = sqlx::query_as::<_, Bookmark>(
|
||||
r#"
|
||||
SELECT id, user_id, manga_id, chapter_id, page, created_at
|
||||
FROM bookmarks
|
||||
WHERE user_id = $1 AND manga_id = $2
|
||||
AND chapter_id IS NOT DISTINCT FROM $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(manga_id)
|
||||
.bind(chapter_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok((existing, false))
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,19 @@ pub async fn list_for_manga(
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> AppResult<Vec<Chapter>> {
|
||||
// Display order = source-site order reversed. The crawler stamps
|
||||
// `source_index` = position in the source DOM (0 = first = newest
|
||||
// on this site, see migration 0021), so DESC puts the oldest
|
||||
// chapter first and keeps the site's variant grouping and the
|
||||
// placement of non-numeric entries (e.g. "notice. : Officials")
|
||||
// intact. NULLS LAST keeps user-uploaded chapters (no source row)
|
||||
// and rows that pre-date the migration below crawled rows; the
|
||||
// (number, created_at) tail then orders them deterministically.
|
||||
// Display order. Crawled chapters carry `source_index` = position in the
|
||||
// source DOM (0 = newest on this site, migration 0021); we display them
|
||||
// reversed (oldest first) via the `-source_index` key, which keeps the
|
||||
// site's variant grouping and non-numeric entries (e.g. "notice.") in the
|
||||
// spot the site placed them — NOT clustered at number 0.
|
||||
//
|
||||
// User-uploaded chapters have no `source_index`. Instead of dumping them
|
||||
// after every crawled chapter (the old `NULLS LAST` bug, which put an
|
||||
// uploaded chapter 5 after crawled chapter 100), each is slotted by NUMBER:
|
||||
// just before the crawled chapter with the smallest number greater than it,
|
||||
// so it interleaves. An upload newer than every crawled chapter goes last;
|
||||
// when there are no crawled chapters at all, uploads fall back to the
|
||||
// `number, created_at` tail.
|
||||
let rows = sqlx::query_as::<_, Chapter>(
|
||||
r#"
|
||||
SELECT id, manga_id, number, title, page_count, created_at,
|
||||
@@ -31,7 +36,22 @@ pub async fn list_for_manga(
|
||||
FROM pages p WHERE p.chapter_id = chapters.id)::bigint AS size_bytes
|
||||
FROM chapters
|
||||
WHERE manga_id = $1
|
||||
ORDER BY source_index DESC NULLS LAST, number ASC, created_at ASC
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN source_index IS NOT NULL THEN (-source_index)::float8
|
||||
ELSE COALESCE(
|
||||
(SELECT MIN(-c2.source_index)::float8 - 0.5
|
||||
FROM chapters c2
|
||||
WHERE c2.manga_id = chapters.manga_id
|
||||
AND c2.source_index IS NOT NULL
|
||||
AND c2.number > chapters.number),
|
||||
(SELECT COALESCE(MAX(-c3.source_index), 0)::float8 + 0.5
|
||||
FROM chapters c3
|
||||
WHERE c3.manga_id = chapters.manga_id
|
||||
AND c3.source_index IS NOT NULL)
|
||||
)
|
||||
END,
|
||||
number ASC, created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -144,17 +144,13 @@ const FILTER_WHERE: &str = r#"
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unnest($6::text[]) AS req(w)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = mangas.id AND pw.warning = req.w
|
||||
SELECT 1 FROM manga_content_warnings mcw
|
||||
WHERE mcw.manga_id = mangas.id AND mcw.warning = req.w
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = mangas.id AND pw.warning = ANY($7::text[])
|
||||
SELECT 1 FROM manga_content_warnings mcw
|
||||
WHERE mcw.manga_id = mangas.id AND mcw.warning = ANY($7::text[])
|
||||
)
|
||||
"#;
|
||||
|
||||
@@ -172,16 +168,11 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
|
||||
SortField::Created => "created_at",
|
||||
SortField::Updated => "updated_at",
|
||||
SortField::Title => "lower(title)",
|
||||
// Sorts on the alphabetically-first attached author. As an ORDER BY
|
||||
// key this correlated subquery is evaluated per filter-matching row
|
||||
// before LIMIT applies, so it scales worse than the indexed date/title
|
||||
// sorts — revisit with a LATERAL join or precomputed sort-name column
|
||||
// if the library grows large.
|
||||
SortField::Author => {
|
||||
"(SELECT min(lower(a.name)) \
|
||||
FROM manga_authors ma JOIN authors a ON a.id = ma.author_id \
|
||||
WHERE ma.manga_id = mangas.id)"
|
||||
}
|
||||
// Sorts on the alphabetically-first attached author. Precomputed into
|
||||
// `mangas.sort_author` (migration 0037, maintained by triggers on
|
||||
// manga_authors) and index-backed by `mangas_sort_author_idx`, so this
|
||||
// is a plain column read rather than a per-row correlated subquery.
|
||||
SortField::Author => "sort_author",
|
||||
};
|
||||
let dir = match query.order {
|
||||
SortOrder::Asc => "ASC",
|
||||
|
||||
@@ -45,21 +45,13 @@ pub const STAGING_PREFIX: &str = "staging";
|
||||
/// read-all-then-persist path.
|
||||
pub async fn stage_image_part(
|
||||
storage: &dyn Storage,
|
||||
mut field: Field<'_>,
|
||||
field: Field<'_>,
|
||||
upload_id: Uuid,
|
||||
seq: usize,
|
||||
max_size: usize,
|
||||
field_name: &str,
|
||||
) -> AppResult<StagedImage> {
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = field.chunk().await.map_err(map_multipart_error)? {
|
||||
if bytes.len().saturating_add(chunk.len()) > max_size {
|
||||
return Err(AppError::PayloadTooLarge(format!(
|
||||
"{field_name} exceeds {max_size}-byte cap"
|
||||
)));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let bytes = read_capped(field, max_size, field_name).await?;
|
||||
// Reuse the shared sniff + whitelist check; it re-verifies the size cap
|
||||
// (already enforced above) and derives mime/ext from magic bytes.
|
||||
let img = parse_image(bytes, max_size, field_name)?;
|
||||
@@ -73,6 +65,46 @@ pub async fn stage_image_part(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read one multipart part fully into memory, enforcing the per-file byte cap
|
||||
/// as chunks arrive so an oversized part is rejected (413) **without being fully
|
||||
/// buffered**. Use for parts whose bytes the caller needs in hand — e.g. the
|
||||
/// cover image, which is written to a manga-scoped key. Page parts should prefer
|
||||
/// [`stage_image_part`], which streams straight to storage.
|
||||
///
|
||||
/// Replaces the `Field::bytes()` path, which buffered the entire field before
|
||||
/// any size check ran, letting an attacker allocate an arbitrarily large body
|
||||
/// before the cap kicked in.
|
||||
pub async fn read_capped(
|
||||
mut field: Field<'_>,
|
||||
max_size: usize,
|
||||
field_name: &str,
|
||||
) -> AppResult<Vec<u8>> {
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = field.chunk().await.map_err(map_multipart_error)? {
|
||||
push_capped(&mut bytes, &chunk, max_size, field_name)?;
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Append `chunk` to `buf`, rejecting with 413 as soon as the running total
|
||||
/// would exceed `max_size` — so the oversized chunk is never copied in. Split
|
||||
/// out from the read loop so the cap logic is unit-testable without a live
|
||||
/// multipart field.
|
||||
fn push_capped(
|
||||
buf: &mut Vec<u8>,
|
||||
chunk: &[u8],
|
||||
max_size: usize,
|
||||
field_name: &str,
|
||||
) -> AppResult<()> {
|
||||
if buf.len().saturating_add(chunk.len()) > max_size {
|
||||
return Err(AppError::PayloadTooLarge(format!(
|
||||
"{field_name} exceeds {max_size}-byte cap"
|
||||
)));
|
||||
}
|
||||
buf.extend_from_slice(chunk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_image(bytes: Vec<u8>, max_size: usize, field_name: &str) -> AppResult<UploadedImage> {
|
||||
if bytes.len() > max_size {
|
||||
return Err(AppError::PayloadTooLarge(format!(
|
||||
@@ -172,4 +204,30 @@ mod tests {
|
||||
assert!(matches!(err, AppError::PayloadTooLarge(_)));
|
||||
assert_eq!(err.code(), "payload_too_large");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_capped_accumulates_under_cap() {
|
||||
let mut buf = Vec::new();
|
||||
push_capped(&mut buf, b"hello ", 100, "cover").unwrap();
|
||||
push_capped(&mut buf, b"world", 100, "cover").unwrap();
|
||||
assert_eq!(buf, b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_capped_rejects_before_copying_oversized_chunk() {
|
||||
// The whole point: the offending chunk must NOT be appended — an
|
||||
// oversized part is rejected without buffering it.
|
||||
let mut buf = vec![0u8; 90];
|
||||
let err = push_capped(&mut buf, &[0u8; 20], 100, "cover").unwrap_err();
|
||||
assert!(matches!(err, AppError::PayloadTooLarge(_)));
|
||||
assert_eq!(err.code(), "payload_too_large");
|
||||
assert_eq!(buf.len(), 90, "buffer must not grow past the cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_capped_allows_exactly_at_cap() {
|
||||
let mut buf = vec![0u8; 90];
|
||||
push_capped(&mut buf, &[0u8; 10], 100, "cover").unwrap();
|
||||
assert_eq!(buf.len(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +541,62 @@ async fn create_and_use_bot_token(pool: PgPool) {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_tokens_returns_callers_tokens_scoped_and_without_hash(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
// Mint two tokens for this user, one with an expiry.
|
||||
for body in [
|
||||
json!({ "name": "no-expiry" }),
|
||||
json!({ "name": "expiring", "expires_in_days": 30 }),
|
||||
] {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
body,
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// A second user's token must NOT appear in the first user's list.
|
||||
let (_, other) = common::register_user(&h.app).await;
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
json!({ "name": "someone-elses" }),
|
||||
&other,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/auth/tokens", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2, "only the caller's two tokens");
|
||||
|
||||
let names: Vec<&str> = items.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"no-expiry") && names.contains(&"expiring"));
|
||||
// Raw secret / hash must never appear, but expiry metadata must.
|
||||
for t in items {
|
||||
assert!(t.get("token_hash").is_none(), "token_hash must be absent");
|
||||
assert!(t.get("bearer").is_none(), "raw bearer only shown at creation");
|
||||
assert!(t.get("expires_at").is_some(), "expiry metadata present");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn bot_token_with_future_expiry_authenticates(pool: PgPool) {
|
||||
// A token minted with expires_in_days is still active before its
|
||||
|
||||
@@ -52,7 +52,7 @@ async fn create_then_list_returns_only_own(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) {
|
||||
async fn create_is_idempotent_on_duplicate_manga_level(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
@@ -64,12 +64,26 @@ async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) {
|
||||
&cookie,
|
||||
)
|
||||
};
|
||||
// First add creates (201); re-adding is an idempotent no-op (200) that
|
||||
// returns the SAME bookmark rather than a 409 — collections behave this way
|
||||
// too, so the UI shouldn't surface a false "Could not add bookmark" error.
|
||||
let first = h.app.clone().oneshot(make()).await.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
let second = h.app.oneshot(make()).await.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
||||
let body = common::body_json(second).await;
|
||||
assert_eq!(body["error"]["code"], "conflict");
|
||||
let first_body = common::body_json(first).await;
|
||||
|
||||
let second = h.app.clone().oneshot(make()).await.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
let second_body = common::body_json(second).await;
|
||||
assert_eq!(second_body["id"], first_body["id"], "same bookmark returned");
|
||||
|
||||
// Exactly one row exists.
|
||||
let list = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(list).await;
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -225,13 +239,16 @@ async fn concurrent_manga_bookmarks_serialised_by_unique_index(pool: PgPool) {
|
||||
|
||||
let (s1, s2) = tokio::join!(f1, f2);
|
||||
let statuses = [s1.unwrap(), s2.unwrap()];
|
||||
// The unique index serialises the two inserts: one wins with 201, the other
|
||||
// hits the violation and — now idempotent — returns the existing bookmark
|
||||
// with 200 rather than a 409.
|
||||
assert!(
|
||||
statuses.contains(&StatusCode::CREATED),
|
||||
"expected one winner with 201, got {statuses:?}"
|
||||
);
|
||||
assert!(
|
||||
statuses.contains(&StatusCode::CONFLICT),
|
||||
"expected one loser with 409 (the partial unique index), got {statuses:?}"
|
||||
statuses.contains(&StatusCode::OK),
|
||||
"expected the loser to return 200 (idempotent), got {statuses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,49 @@ async fn list_chapters_returned_in_number_order(pool: PgPool) {
|
||||
assert_eq!(body["items"][1]["title"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_interleaves_uploaded_and_crawled_chapters_by_number(pool: PgPool) {
|
||||
// Mixed source: crawled chapters carry a `source_index`; an uploaded
|
||||
// chapter has none. The uploaded chapter must interleave by NUMBER, not sort
|
||||
// after every crawled chapter (the old `source_index DESC NULLS LAST` bug
|
||||
// dumped uploaded chapter 2 to the end, giving [1, 3, 2]).
|
||||
let h = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
||||
|
||||
// Two crawled chapters (1 and 3) with DOM positions — newest-first, so
|
||||
// chapter 3 is at source_index 0 and chapter 1 at source_index 1.
|
||||
let c1 = seed_chapter(&pool, manga_id, 1, Some("Crawled One")).await;
|
||||
let c3 = seed_chapter(&pool, manga_id, 3, Some("Crawled Three")).await;
|
||||
sqlx::query("UPDATE chapters SET source_index = 1 WHERE id = $1")
|
||||
.bind(c1)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE chapters SET source_index = 0 WHERE id = $1")
|
||||
.bind(c3)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// Uploaded chapter 2 — no source_index.
|
||||
seed_chapter(&pool, manga_id, 2, Some("Uploaded Two")).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}/chapters")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let numbers: Vec<i64> = body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["number"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(numbers, vec![1, 2, 3], "uploaded chapter 2 must slot between 1 and 3");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_chapters_returns_404_for_unknown_manga(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
@@ -127,6 +127,46 @@ async fn list_filters_by_content_warning(pool: PgPool) {
|
||||
assert_eq!(ids, want);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn denormalized_warnings_track_chapter_deletion(pool: PgPool) {
|
||||
// The denormalized manga_content_warnings table must stay in sync when the
|
||||
// underlying pages disappear. Deleting the only chapter (which cascade-
|
||||
// deletes its pages and their page_content_warnings) must drop the manga
|
||||
// from the include filter.
|
||||
let h = common::harness(pool.clone());
|
||||
let gory = seed_manga(&pool, "Gory", &[&["gore"]]).await;
|
||||
|
||||
// Present in the denorm table and matched by the filter.
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM manga_content_warnings WHERE manga_id = $1")
|
||||
.bind(gory)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1, "warning denormalized on insert");
|
||||
assert_eq!(list_ids(&h.app, "cw_include=gore").await, vec![gory.to_string()]);
|
||||
|
||||
// Delete the chapter → cascade removes pages + page_content_warnings →
|
||||
// trigger recomputes the (now empty) set for this manga.
|
||||
sqlx::query("DELETE FROM chapters WHERE manga_id = $1")
|
||||
.bind(gory)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM manga_content_warnings WHERE manga_id = $1")
|
||||
.bind(gory)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0, "warning removed after chapter (and pages) deleted");
|
||||
assert!(
|
||||
list_ids(&h.app, "cw_include=gore").await.is_empty(),
|
||||
"manga no longer matches the include filter"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_rejects_unknown_warning(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
@@ -45,6 +46,65 @@ fn cover_form(bytes: &[u8]) -> MultipartBuilder {
|
||||
MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes)
|
||||
}
|
||||
|
||||
/// A real, decodable PNG (unlike `fake_png_bytes`, which is only magic bytes)
|
||||
/// so the thumbnail endpoint has something to resize.
|
||||
fn real_png(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::from_pixel(width, height, image::Rgb([10, 120, 200]))
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn files_serves_downscaled_thumbnail_variant(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
let (_, cookie) = register_user(&h.app).await;
|
||||
let manga = create_manga_with_cover(&h.app, &cookie, "Thumb", None).await;
|
||||
let id = id_of(&manga);
|
||||
|
||||
// Upload a real 800x400 PNG cover.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(put_multipart_with_cookie(
|
||||
&format!("/api/v1/mangas/{id}/cover"),
|
||||
cover_form(&real_png(800, 400)),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let key = format!("mangas/{id}/cover.png");
|
||||
|
||||
// ?w=320 serves a 320px-wide variant with the source content-type.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{key}?w=320")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get("content-type").unwrap().to_str().unwrap(),
|
||||
"image/png"
|
||||
);
|
||||
let body = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let thumb = image::load_from_memory(&body).unwrap();
|
||||
assert_eq!(thumb.width(), 320, "served a 320px-wide variant");
|
||||
assert_eq!(thumb.height(), 160, "aspect ratio preserved");
|
||||
|
||||
// The un-parametrised request still serves the full-resolution original.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/v1/files/{key}")))
|
||||
.await
|
||||
.unwrap();
|
||||
let full = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(image::load_from_memory(&full).unwrap().width(), 800);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_cover_sets_path_when_none_existed(pool: PgPool) {
|
||||
let h = harness(pool);
|
||||
|
||||
@@ -326,6 +326,54 @@ async fn patch_updates_status_authors_and_genres(pool: PgPool) {
|
||||
assert_eq!(body["genres"][0]["name"], "Drama");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn patch_authors_updates_precomputed_sort_author(pool: PgPool) {
|
||||
// The precomputed mangas.sort_author (used by ?sort=author) must track
|
||||
// author edits: changing the alphabetically-first author reorders the
|
||||
// author sort. Two mangas whose relative author order flips after a PATCH.
|
||||
let h = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
let a = id_of(&create_manga(&h.app, &cookie, json!({ "title": "A", "authors": ["Zeta"] })).await);
|
||||
let _b = id_of(&create_manga(&h.app, &cookie, json!({ "title": "B", "authors": ["Mid"] })).await);
|
||||
|
||||
// Initially: Mid < Zeta, so B before A.
|
||||
let ids = list_titles(&h.app, "sort=author&order=asc").await;
|
||||
assert_eq!(ids, vec!["B", "A"]);
|
||||
|
||||
// Re-author A to "Alpha", which now sorts first.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::patch_json_with_cookie(
|
||||
&format!("/api/v1/mangas/{a}"),
|
||||
json!({ "authors": ["Alpha"] }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Now A (Alpha) sorts before B (Mid) — proving sort_author was recomputed.
|
||||
let ids = list_titles(&h.app, "sort=author&order=asc").await;
|
||||
assert_eq!(ids, vec!["A", "B"]);
|
||||
}
|
||||
|
||||
async fn list_titles(app: &axum::Router, query: &str) -> Vec<String> {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/mangas?{query}")))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["title"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn patch_404_on_unknown_id(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
@@ -104,6 +104,20 @@ impl ChapterDispatcher for FailingDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Always reports the browser unavailable — models a Chromium outage where
|
||||
/// `acquire()` fails. The job must be returned to `pending` WITHOUT burning an
|
||||
/// attempt, so an outage doesn't chew the backlog to `dead`.
|
||||
struct BrowserUnavailableDispatcher {
|
||||
seen: AtomicUsize,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl ChapterDispatcher for BrowserUnavailableDispatcher {
|
||||
async fn dispatch(&self, _payload: JobPayload) -> anyhow::Result<SyncOutcome> {
|
||||
self.seen.fetch_add(1, Ordering::AcqRel);
|
||||
Ok(SyncOutcome::BrowserUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Never completes — used to verify the worker's outer dispatch timeout.
|
||||
struct HangingDispatcher {
|
||||
seen: AtomicUsize,
|
||||
@@ -204,6 +218,58 @@ async fn shutdown_mid_dispatch_releases_lease_without_burning_attempt(pool: PgPo
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn browser_unavailable_releases_lease_without_burning_attempt(pool: PgPool) {
|
||||
// During a browser outage the dispatcher reports BrowserUnavailable. The
|
||||
// worker must return the job to `pending` with the attempt refunded
|
||||
// (attempts stays 0) instead of ack-failing it toward `dead`, so the whole
|
||||
// pending backlog survives the outage.
|
||||
enqueue_chapter_job(&pool).await;
|
||||
let dispatcher = Arc::new(BrowserUnavailableDispatcher {
|
||||
seen: AtomicUsize::new(0),
|
||||
});
|
||||
let session_expired = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let cancel = CancellationToken::new();
|
||||
let handle = daemon::spawn(
|
||||
pool.clone(),
|
||||
cancel.clone(),
|
||||
make_cfg(None, dispatcher.clone(), session_expired, 1),
|
||||
);
|
||||
|
||||
// Wait until the dispatcher has been invoked at least once (the job was
|
||||
// leased and deferred).
|
||||
let mut dispatched = false;
|
||||
for _ in 0..40 {
|
||||
if dispatcher.seen.load(Ordering::Acquire) >= 1 {
|
||||
dispatched = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(dispatched, "dispatcher must have been invoked");
|
||||
|
||||
handle.shutdown().await;
|
||||
|
||||
assert_eq!(
|
||||
count_state(&pool, "dead").await,
|
||||
0,
|
||||
"browser outage must not dead-letter the job"
|
||||
);
|
||||
assert_eq!(
|
||||
count_state(&pool, "pending").await,
|
||||
1,
|
||||
"job returns to pending after a browser-unavailable outcome"
|
||||
);
|
||||
let attempts: i32 = sqlx::query_scalar("SELECT attempts FROM crawler_jobs")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attempts, 0,
|
||||
"browser-unavailable must refund the lease attempt (no burn)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn workers_drain_jobs_through_dispatcher(pool: PgPool) {
|
||||
enqueue_chapter_job(&pool).await;
|
||||
|
||||
@@ -1175,7 +1175,7 @@ async fn list_for_manga_returns_source_order_reversed(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_for_manga_places_null_source_index_last(pool: PgPool) {
|
||||
async fn list_for_manga_interleaves_null_source_index_by_number(pool: PgPool) {
|
||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1184,23 +1184,24 @@ async fn list_for_manga_places_null_source_index_last(pool: PgPool) {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Crawled chapters get source_index 0 and 1; the upload path leaves
|
||||
// it NULL. NULLS LAST plus the (number, created_at) tail means the
|
||||
// upload sits after both crawled rows even though its number is in
|
||||
// the middle.
|
||||
// Crawled chapters, newest-first in the source DOM (so chapter 3 is at
|
||||
// source_index 0 and chapter 1 at source_index 1). Reversed for display that
|
||||
// is [Ch.1, Ch.3]. The uploaded chapter 2 must slot BETWEEN them by number,
|
||||
// not fall to the end — that was the bug (NULLS LAST dumped it last
|
||||
// regardless of its number).
|
||||
let crawled = vec![
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "a".into(),
|
||||
number: 1,
|
||||
title: Some("Ch.1".into()),
|
||||
url: "https://x.example/foo/a".into(),
|
||||
},
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "b".into(),
|
||||
number: 3,
|
||||
title: Some("Ch.3".into()),
|
||||
url: "https://x.example/foo/b".into(),
|
||||
},
|
||||
SourceChapterRef {
|
||||
source_chapter_key: "a".into(),
|
||||
number: 1,
|
||||
title: Some("Ch.1".into()),
|
||||
url: "https://x.example/foo/a".into(),
|
||||
},
|
||||
];
|
||||
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &crawled)
|
||||
.await
|
||||
@@ -1220,11 +1221,11 @@ async fn list_for_manga_places_null_source_index_last(pool: PgPool) {
|
||||
assert_eq!(
|
||||
titles,
|
||||
vec![
|
||||
"Ch.3".to_string(),
|
||||
"Ch.1".to_string(),
|
||||
"User upload Ch.2".to_string(),
|
||||
"Ch.3".to_string(),
|
||||
],
|
||||
"crawled rows ordered by reversed source_index; user upload \
|
||||
(NULL source_index) falls through to the end",
|
||||
"uploaded chapter (NULL source_index) interleaves by number between the \
|
||||
crawled rows instead of falling to the end",
|
||||
);
|
||||
}
|
||||
|
||||
132
frontend/e2e/admin-users.spec.ts
Normal file
132
frontend/e2e/admin-users.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// E2E for the admin Users page role toggle: flipping the Admin checkbox is a
|
||||
// one-click privilege change, so it must confirm first, and the checkbox must
|
||||
// never show a state the server didn't actually apply (cancel or failure keeps
|
||||
// it on the true value).
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const adminUser = {
|
||||
id: 'u11111111-1111-1111-1111-111111111111',
|
||||
username: 'admin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: true
|
||||
};
|
||||
|
||||
const bob = {
|
||||
id: 'b22222222-2222-2222-2222-222222222222',
|
||||
username: 'bob',
|
||||
created_at: '2026-02-01T00:00:00Z',
|
||||
is_admin: false
|
||||
};
|
||||
|
||||
type Captured = { patched: boolean };
|
||||
|
||||
async function mockAdmin(page: Page, cap: Captured, opts: { patchStatus: number }) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: adminUser })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/system', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
disk: null,
|
||||
memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 },
|
||||
cpu: { percent_used: 0 },
|
||||
alerts: []
|
||||
})
|
||||
})
|
||||
);
|
||||
// The PATCH toggle — record whether it was ever sent.
|
||||
await page.route('**/api/v1/admin/users/*', async (r) => {
|
||||
cap.patched = true;
|
||||
await r.fulfill({
|
||||
status: opts.patchStatus,
|
||||
contentType: 'application/json',
|
||||
body:
|
||||
opts.patchStatus < 400
|
||||
? JSON.stringify({ ...bob, is_admin: true })
|
||||
: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } })
|
||||
});
|
||||
});
|
||||
// The user list (registered after the specific PATCH glob; list is GET).
|
||||
await page.route('**/api/v1/admin/users**', async (r) => {
|
||||
if (r.request().method() !== 'GET') return r.fallback();
|
||||
await r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [adminUser, bob],
|
||||
page: { limit: 100, offset: 0, total: 2 }
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('/admin/users role toggle', () => {
|
||||
test('cancelling the confirm leaves the checkbox unchanged and sends no request', async ({
|
||||
page
|
||||
}) => {
|
||||
const cap: Captured = { patched: false };
|
||||
await mockAdmin(page, cap, { patchStatus: 200 });
|
||||
page.on('dialog', (d) => d.dismiss());
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/users');
|
||||
|
||||
const bobRow = page.locator('tr', { hasText: 'bob' });
|
||||
const checkbox = bobRow.getByLabel('admin');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
await checkbox.click();
|
||||
|
||||
// Cancelled → no PATCH, and the box still reflects the server state.
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
expect(cap.patched).toBe(false);
|
||||
});
|
||||
|
||||
test('a failed toggle reverts the checkbox to the server state', async ({ page }) => {
|
||||
const cap: Captured = { patched: false };
|
||||
await mockAdmin(page, cap, { patchStatus: 500 });
|
||||
page.on('dialog', (d) => d.accept());
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/users');
|
||||
|
||||
const bobRow = page.locator('tr', { hasText: 'bob' });
|
||||
const checkbox = bobRow.getByLabel('admin');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
await checkbox.click();
|
||||
|
||||
// The PATCH was attempted and failed → the checkbox must snap back to
|
||||
// the actual (still non-admin) server state rather than stay flipped.
|
||||
await expect.poll(() => cap.patched).toBe(true);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
73
frontend/e2e/bookmarks-load-more.spec.ts
Normal file
73
frontend/e2e/bookmarks-load-more.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The bookmarks list pages the results: the first 50 load with the page, and a
|
||||
// "Load more" button fetches the next page (offset-based) and appends it, so
|
||||
// bookmarks past the first page are reachable rather than silently truncated.
|
||||
|
||||
function bookmark(i: number) {
|
||||
return {
|
||||
id: `bm-${i}`,
|
||||
manga_id: `m-${i}`,
|
||||
manga_title: `Manga ${i}`,
|
||||
manga_cover_image_path: null,
|
||||
chapter_id: null,
|
||||
chapter_number: null,
|
||||
page: null,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
}
|
||||
|
||||
async function authed(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
|
||||
}
|
||||
|
||||
test('bookmarks list loads more pages on demand', async ({ page }) => {
|
||||
await authed(page);
|
||||
// total = 60: first page 50 (offset 0), second page 10 (offset 50).
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) => {
|
||||
const url = new URL(r.request().url());
|
||||
const offset = Number(url.searchParams.get('offset') ?? '0');
|
||||
const items =
|
||||
offset === 0
|
||||
? Array.from({ length: 50 }, (_, i) => bookmark(i))
|
||||
: Array.from({ length: 10 }, (_, i) => bookmark(50 + i));
|
||||
return r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items, page: { limit: 50, offset, total: 60 } })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/bookmarks');
|
||||
|
||||
// First page rendered; more remain so the button shows.
|
||||
await expect(page.getByText('Manga 0')).toBeVisible();
|
||||
await expect(page.getByText('Manga 49')).toBeVisible();
|
||||
await expect(page.getByText('Manga 50')).toHaveCount(0);
|
||||
const loadMore = page.getByTestId('load-more');
|
||||
await expect(loadMore).toBeVisible();
|
||||
|
||||
// Load the rest; the button disappears once everything is shown.
|
||||
await loadMore.click();
|
||||
await expect(page.getByText('Manga 59')).toBeVisible();
|
||||
await expect(loadMore).toHaveCount(0);
|
||||
});
|
||||
74
frontend/e2e/collections-load-more.spec.ts
Normal file
74
frontend/e2e/collections-load-more.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The collections grid pages results: a "Load more" button fetches the next
|
||||
// page (offset-based) and appends it, so collections past the first page are
|
||||
// reachable rather than silently truncated at the old 200 cap.
|
||||
|
||||
function collection(i: number) {
|
||||
return {
|
||||
id: `col-${i}`,
|
||||
user_id: 'u1',
|
||||
name: `Collection ${i}`,
|
||||
description: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
manga_count: 0,
|
||||
sample_covers: []
|
||||
};
|
||||
}
|
||||
|
||||
async function authed(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('collections grid loads more pages on demand', async ({ page }) => {
|
||||
await authed(page);
|
||||
await page.route('**/api/v1/me/collections*', (r) => {
|
||||
const offset = Number(new URL(r.request().url()).searchParams.get('offset') ?? '0');
|
||||
const items =
|
||||
offset === 0
|
||||
? Array.from({ length: 60 }, (_, i) => collection(i))
|
||||
: Array.from({ length: 5 }, (_, i) => collection(60 + i));
|
||||
return r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items, page: { limit: 60, offset, total: 65 } })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/collections');
|
||||
|
||||
await expect(page.getByText('Collection 0')).toBeVisible();
|
||||
await expect(page.getByText('Collection 64')).toHaveCount(0);
|
||||
const loadMore = page.getByTestId('load-more');
|
||||
await expect(loadMore).toBeVisible();
|
||||
|
||||
await loadMore.click();
|
||||
await expect(page.getByText('Collection 64')).toBeVisible();
|
||||
await expect(loadMore).toHaveCount(0);
|
||||
});
|
||||
42
frontend/e2e/error-boundary.spec.ts
Normal file
42
frontend/e2e/error-boundary.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The root +error.svelte catches load() errors that a page rethrows rather than
|
||||
// capturing in-band (e.g. the profile overview). Without it these fell through
|
||||
// to SvelteKit's bare default error page — or blanked entirely on a raw
|
||||
// TypeError. Drive the profile loader into a failure and assert the boundary.
|
||||
|
||||
async function authed(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
}
|
||||
|
||||
test('profile: a network failure renders the error boundary, not a blank page', async ({
|
||||
page
|
||||
}) => {
|
||||
await authed(page);
|
||||
// Profile's load rethrows non-401 errors; a raw abort is the TypeError case
|
||||
// the client now normalises to an ApiError so it reaches the boundary.
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) => r.abort());
|
||||
await page.route('**/api/v1/me/collections*', (r) => r.abort());
|
||||
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByTestId('error-boundary')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
|
||||
});
|
||||
94
frontend/e2e/profile-tokens.spec.ts
Normal file
94
frontend/e2e/profile-tokens.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// E2E for the bot API token management page: list existing tokens, create one
|
||||
// (the raw bearer is shown once), and revoke one.
|
||||
|
||||
const existing = {
|
||||
id: 't1111111-1111-1111-1111-111111111111',
|
||||
user_id: 'u1',
|
||||
name: 'existing-bot',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: null
|
||||
};
|
||||
|
||||
type Captured = { created: Record<string, unknown> | null; deleted: string | null };
|
||||
|
||||
async function mockTokens(page: Page, cap: Captured) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
|
||||
// Specific id-scoped DELETE first (last-match-wins ordering).
|
||||
await page.route('**/api/v1/auth/tokens/*', async (r) => {
|
||||
cap.deleted = r.request().url().split('/').pop() ?? null;
|
||||
await r.fulfill({ status: 204, body: '' });
|
||||
});
|
||||
await page.route('**/api/v1/auth/tokens', async (r) => {
|
||||
if (r.request().method() === 'POST') {
|
||||
cap.created = r.request().postDataJSON();
|
||||
await r.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 't2',
|
||||
user_id: 'u1',
|
||||
name: cap.created?.name,
|
||||
created_at: '2026-03-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
bearer: 'secret-raw-bearer-xyz'
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
await r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [existing] })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('lists, creates (shows the bearer once), and revokes tokens', async ({ page }) => {
|
||||
const cap: Captured = { created: null, deleted: null };
|
||||
await mockTokens(page, cap);
|
||||
await page.goto('/profile/tokens');
|
||||
|
||||
// Existing token is listed.
|
||||
await expect(page.getByTestId(`token-row-${existing.id}`)).toContainText('existing-bot');
|
||||
|
||||
// Create a token — the raw bearer is revealed exactly once.
|
||||
await page.getByTestId('token-name').fill('new-bot');
|
||||
await page.getByRole('button', { name: 'Create token' }).click();
|
||||
await expect(page.getByTestId('token-fresh')).toContainText('secret-raw-bearer-xyz');
|
||||
await expect.poll(() => cap.created).toEqual({ name: 'new-bot' });
|
||||
|
||||
// Revoke the existing token (confirm dialog accepted).
|
||||
page.on('dialog', (d) => d.accept());
|
||||
await page.getByTestId(`token-revoke-${existing.id}`).click();
|
||||
await expect.poll(() => cap.deleted).toBe(existing.id);
|
||||
});
|
||||
56
frontend/e2e/scroll-lock.spec.ts
Normal file
56
frontend/e2e/scroll-lock.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Opening an overlay (Sheet/Modal) locks background scroll so the page behind
|
||||
// it can't scroll under the overlay; closing restores it.
|
||||
|
||||
const MOBILE = { width: 390, height: 780 } as const;
|
||||
|
||||
async function authed(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const bodyOverflow = (page: Page) => page.evaluate(() => document.body.style.overflow);
|
||||
|
||||
test('opening the account password sheet locks body scroll, closing restores it', async ({
|
||||
page
|
||||
}) => {
|
||||
await authed(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/profile/account');
|
||||
|
||||
expect(await bodyOverflow(page)).not.toBe('hidden');
|
||||
|
||||
await page.getByTestId('account-row-change-password').click();
|
||||
await expect(page.getByTestId('password-sheet')).toBeVisible();
|
||||
expect(await bodyOverflow(page)).toBe('hidden');
|
||||
|
||||
// Close via Escape; scroll is released.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('password-sheet')).toBeHidden();
|
||||
expect(await bodyOverflow(page)).not.toBe('hidden');
|
||||
});
|
||||
59
frontend/e2e/unsaved-guard.spec.ts
Normal file
59
frontend/e2e/unsaved-guard.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Leaving a form with unsaved work (e.g. a half-filled upload) must prompt so a
|
||||
// misclicked nav link doesn't silently discard everything.
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
async function authed(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'uploader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/genres', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('upload form prompts before navigating away with unsaved changes', async ({ page }) => {
|
||||
await authed(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/upload');
|
||||
|
||||
// Make the form dirty.
|
||||
await page.getByLabel(/Title/).fill('WIP Manga');
|
||||
|
||||
// Cancel the confirm → navigation is blocked, we stay put with our input.
|
||||
let dialogSeen = false;
|
||||
page.once('dialog', (d) => {
|
||||
dialogSeen = true;
|
||||
d.dismiss();
|
||||
});
|
||||
await page.getByRole('link', { name: 'Bookmarks' }).click();
|
||||
|
||||
await expect.poll(() => dialogSeen).toBe(true);
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
await expect(page.getByLabel(/Title/)).toHaveValue('WIP Manga');
|
||||
});
|
||||
104
frontend/e2e/upload-retry.spec.ts
Normal file
104
frontend/e2e/upload-retry.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// A partial upload failure (manga created, a chapter fails) must NOT create a
|
||||
// duplicate manga when the user retries — the second submit reuses the created
|
||||
// manga and only re-sends the failed chapter.
|
||||
|
||||
const userFixture = {
|
||||
id: 'u1',
|
||||
username: 'uploader',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: false
|
||||
};
|
||||
|
||||
const manga = {
|
||||
id: 'm9',
|
||||
title: 'Retry Saga',
|
||||
status: 'ongoing',
|
||||
description: null,
|
||||
authors: [],
|
||||
alt_titles: [],
|
||||
genres: [],
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
async function stub(page: Page, counters: { mangaPosts: number; chapterPosts: number }) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: userFixture }) })
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/genres', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
|
||||
|
||||
await page.route('**/api/v1/mangas', (r) => {
|
||||
if (r.request().method() === 'POST') {
|
||||
counters.mangaPosts += 1;
|
||||
return r.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(manga) });
|
||||
}
|
||||
return r.fallback();
|
||||
});
|
||||
// Chapter POST: fail the first attempt, succeed on the retry.
|
||||
await page.route('**/api/v1/mangas/m9/chapters', (r) => {
|
||||
if (r.request().method() === 'POST') {
|
||||
counters.chapterPosts += 1;
|
||||
if (counters.chapterPosts === 1) {
|
||||
return r.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } })
|
||||
});
|
||||
}
|
||||
return r.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'c1', manga_id: 'm9', number: 1, title: null, page_count: 1, created_at: '2026-01-01T00:00:00Z' })
|
||||
});
|
||||
}
|
||||
return r.fallback();
|
||||
});
|
||||
// Post-success destination + its data.
|
||||
await page.route('**/api/v1/mangas/m9', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(manga) })
|
||||
);
|
||||
await page.route('**/api/v1/mangas/m9/chapters?*', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||
);
|
||||
await page.route('**/api/v1/me/read-progress/m9', (r) =>
|
||||
r.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'x' } }) })
|
||||
);
|
||||
}
|
||||
|
||||
test('retrying a partially-failed upload does not create a duplicate manga', async ({ page }) => {
|
||||
const counters = { mangaPosts: 0, chapterPosts: 0 };
|
||||
await stub(page, counters);
|
||||
|
||||
await page.goto('/upload');
|
||||
await page.getByTestId('manga-title').fill('Retry Saga');
|
||||
await page.getByTestId('add-chapter').click();
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
await page
|
||||
.getByTestId('staged-chapter-pages-input')
|
||||
.setInputFiles([{ name: 'p.png', mimeType: 'image/png', buffer: png }]);
|
||||
|
||||
// First submit: manga created, chapter fails → the retry note + relabeled button.
|
||||
await page.getByTestId('manga-submit').click();
|
||||
await expect(page.getByTestId('manga-success')).toBeVisible();
|
||||
await expect(page.getByTestId('manga-created-note')).toBeVisible();
|
||||
await expect(page.getByTestId('manga-submit')).toHaveText('Retry failed chapters');
|
||||
expect(counters.mangaPosts).toBe(1);
|
||||
|
||||
// Retry: no second manga POST, chapter re-sent, navigate to the manga.
|
||||
await page.getByTestId('manga-submit').click();
|
||||
await expect(page).toHaveURL(/\/manga\/m9$/);
|
||||
expect(counters.mangaPosts).toBe(1);
|
||||
expect(counters.chapterPosts).toBe(2);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.124.13",
|
||||
"version": "0.128.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
changePassword,
|
||||
createToken,
|
||||
deleteToken,
|
||||
listTokens,
|
||||
getAuthConfig
|
||||
} from './auth';
|
||||
|
||||
@@ -170,6 +171,50 @@ describe('auth api client', () => {
|
||||
expect(url).toMatch(/\/v1\/auth\/tokens$/);
|
||||
});
|
||||
|
||||
it('createToken forwards expires_in_days when provided', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok(
|
||||
{
|
||||
id: 't2',
|
||||
user_id: 'user-1',
|
||||
name: 'expiring',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: '2026-02-01T00:00:00Z',
|
||||
bearer: 'raw'
|
||||
},
|
||||
201
|
||||
)
|
||||
);
|
||||
await createToken('expiring', 30);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: 'expiring', expires_in_days: 30 });
|
||||
});
|
||||
|
||||
it('listTokens GETs /v1/auth/tokens and unwraps items', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
id: 't1',
|
||||
user_id: 'user-1',
|
||||
name: 'ci-bot',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: null
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
const tokens = await listTokens();
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0].name).toBe('ci-bot');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/auth\/tokens$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init?.method ?? 'GET').toBe('GET');
|
||||
});
|
||||
|
||||
it('getAuthConfig GETs /v1/auth/config and parses the flag', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false }));
|
||||
const cfg = await getAuthConfig();
|
||||
|
||||
@@ -92,15 +92,29 @@ export type ApiToken = {
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
/** When the token stops authenticating; `null` = never expires. */
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
export type CreatedToken = ApiToken & { bearer: string };
|
||||
|
||||
export async function createToken(name: string): Promise<CreatedToken> {
|
||||
/** The caller's bot tokens, newest first (metadata only — the raw bearer is
|
||||
* shown once at creation and never returned again). */
|
||||
export async function listTokens(): Promise<ApiToken[]> {
|
||||
const res = await request<{ items: ApiToken[] }>('/v1/auth/tokens');
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function createToken(
|
||||
name: string,
|
||||
expiresInDays?: number
|
||||
): Promise<CreatedToken> {
|
||||
const payload: { name: string; expires_in_days?: number } = { name };
|
||||
if (expiresInDays !== undefined) payload.expires_in_days = expiresInDays;
|
||||
return request<CreatedToken>('/v1/auth/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'vitest';
|
||||
import {
|
||||
listChapters,
|
||||
listAllChapters,
|
||||
getChapter,
|
||||
getChapterPages,
|
||||
createChapter,
|
||||
@@ -142,6 +143,47 @@ describe('chapters api client', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('listAllChapters pages through the 200-row cap and returns every chapter', async () => {
|
||||
const mk = (n: number) => ({ ...chapterFixture, id: `c${n}`, number: n });
|
||||
const first = Array.from({ length: 200 }, (_, i) => mk(i + 1));
|
||||
const second = Array.from({ length: 50 }, (_, i) => mk(i + 201));
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(ok({ items: first, page: { limit: 200, offset: 0, total: 250 } }))
|
||||
.mockResolvedValueOnce(ok({ items: second, page: { limit: 200, offset: 200, total: 250 } }));
|
||||
|
||||
const all = await listAllChapters('m1');
|
||||
expect(all).toHaveLength(250);
|
||||
expect(all[249].number).toBe(250);
|
||||
expect(fetchSpy.mock.calls[0][0]).toContain('offset=0');
|
||||
expect(fetchSpy.mock.calls[1][0]).toContain('offset=200');
|
||||
});
|
||||
|
||||
it('listAllChapters keeps paging when the API omits total (total: null)', async () => {
|
||||
// The real /chapters endpoint returns `total: null` on every page
|
||||
// (PagedResponse::new). A full 200-row first page must NOT be mistaken
|
||||
// for the end — otherwise the reader dead-ends past chapter 200.
|
||||
const mk = (n: number) => ({ ...chapterFixture, id: `c${n}`, number: n });
|
||||
const first = Array.from({ length: 200 }, (_, i) => mk(i + 1));
|
||||
const second = Array.from({ length: 50 }, (_, i) => mk(i + 201));
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(ok({ items: first, page: { limit: 200, offset: 0, total: null } }))
|
||||
.mockResolvedValueOnce(ok({ items: second, page: { limit: 200, offset: 200, total: null } }));
|
||||
|
||||
const all = await listAllChapters('m1');
|
||||
expect(all).toHaveLength(250);
|
||||
expect(all[249].number).toBe(250);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('listAllChapters stops after a short final page (single request)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [chapterFixture], page: { limit: 200, offset: 0, total: 1 } })
|
||||
);
|
||||
const all = await listAllChapters('m1');
|
||||
expect(all).toHaveLength(1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('getChapterPages unwraps the {pages} envelope into the array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
|
||||
@@ -40,6 +40,31 @@ export async function listChapters(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every chapter of a manga, in display order, paging through the API (which
|
||||
* clamps `limit` to 200 per request). The reader needs the complete list so
|
||||
* prev/next navigation and the chapter dropdown work for a deep chapter — a
|
||||
* single 200-row window dead-ended chapter #250 with null neighbours.
|
||||
*/
|
||||
export async function listAllChapters(mangaId: string): Promise<Chapter[]> {
|
||||
const PAGE = 200;
|
||||
const all: Chapter[] = [];
|
||||
let offset = 0;
|
||||
// Hard iteration cap (200 * 100 = 20k chapters) so a bad `total` can never
|
||||
// spin forever.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const { items, page } = await listChapters(mangaId, { limit: PAGE, offset });
|
||||
all.push(...items);
|
||||
offset += items.length;
|
||||
// A short page is the only reliable end-of-list signal: the /chapters
|
||||
// endpoint returns `total: null`, so we can't infer completion from it.
|
||||
// Only stop early on `total` when the API actually reports one.
|
||||
if (items.length < PAGE) break;
|
||||
if (page.total != null && all.length >= page.total) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function getChapter(mangaId: string, chapterId: string): Promise<Chapter> {
|
||||
return request<Chapter>(
|
||||
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { ApiError, request, setOn401Hook, fileUrl } from './client';
|
||||
import { ApiError, request, setOn401Hook, fileUrl, thumbUrl, thumbSrcset } from './client';
|
||||
import { getManga } from './mangas';
|
||||
|
||||
describe('fileUrl', () => {
|
||||
@@ -19,6 +19,21 @@ describe('fileUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbUrl / thumbSrcset', () => {
|
||||
it('appends the width query to the file URL', () => {
|
||||
expect(thumbUrl('mangas/abc/cover.png', 320)).toBe(
|
||||
'/api/v1/files/mangas/abc/cover.png?w=320'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a srcset over the given candidate widths', () => {
|
||||
expect(thumbSrcset('mangas/abc/cover.png', [160, 320])).toBe(
|
||||
'/api/v1/files/mangas/abc/cover.png?w=160 160w, ' +
|
||||
'/api/v1/files/mangas/abc/cover.png?w=320 320w'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request error envelope parsing', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
@@ -65,6 +80,17 @@ describe('request error envelope parsing', () => {
|
||||
expect(err.code).toBe('http_error');
|
||||
});
|
||||
|
||||
it('wraps a network-level fetch rejection in ApiError (status 0)', async () => {
|
||||
// Connection refused / offline / DNS: fetch rejects with a TypeError.
|
||||
// It must surface as an ApiError, not a bare TypeError that crashes a
|
||||
// load function into SvelteKit's default error page.
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
const err = (await getManga('x').catch((e) => e)) as ApiError;
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.status).toBe(0);
|
||||
expect(err.code).toBe('network_error');
|
||||
});
|
||||
|
||||
it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => {
|
||||
// Regression: addMangaToCollection is typed `void` and the
|
||||
// backend returns 201 (created) / 200 (already there) with
|
||||
|
||||
@@ -19,6 +19,25 @@ export function fileUrl(key: string): string {
|
||||
return `${BASE}/v1/files/${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL to a width-bounded thumbnail variant of a stored image (the backend
|
||||
* `/files/{key}?w=` endpoint). Grids use this so a cover downloads ~KB instead
|
||||
* of the 1–5 MB original; the backend snaps `width` to a small allow-list and
|
||||
* caches the result.
|
||||
*/
|
||||
export function thumbUrl(key: string, width: number): string {
|
||||
return `${fileUrl(key)}?w=${width}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `srcset` string over the candidate `widths` for `key`, e.g.
|
||||
* `.../files/k?w=320 320w, .../files/k?w=480 480w`. Pair with a `sizes`
|
||||
* attribute so the browser picks the right variant for the rendered size.
|
||||
*/
|
||||
export function thumbSrcset(key: string, widths: number[]): string {
|
||||
return widths.map((w) => `${thumbUrl(key, w)} ${w}w`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an API URL for non-`fetch` consumers (e.g. `EventSource` for SSE),
|
||||
* applying the same `VITE_API_BASE` prefix as `request()`. `path` is the
|
||||
@@ -84,7 +103,25 @@ export async function request<T>(
|
||||
// working. For same-origin requests this is a no-op compared to the
|
||||
// default 'same-origin', so the same-origin happy path is
|
||||
// unchanged.
|
||||
const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
|
||||
} catch (e) {
|
||||
// A deliberate cancellation (AbortController) must propagate unchanged so
|
||||
// callers can tell "cancelled" from "network failure" (e.g. the
|
||||
// cancellable admin fetches treat AbortError as a no-op).
|
||||
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
||||
// Any other rejection is a network-level failure (connection refused,
|
||||
// DNS, offline, blocked by CORS): `fetch` rejects with a bare TypeError
|
||||
// instead of returning a response. Normalise it to an ApiError (status 0
|
||||
// = "no response reached us") so callers and SvelteKit load functions
|
||||
// handle it uniformly instead of crashing on an unexpected TypeError.
|
||||
throw new ApiError(
|
||||
0,
|
||||
'network_error',
|
||||
'Could not reach the server. Check your connection and try again.'
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let code = 'http_error';
|
||||
let message = `${res.status} ${res.statusText}`;
|
||||
|
||||
@@ -165,6 +165,16 @@ describe('page_tags api client', () => {
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
|
||||
});
|
||||
|
||||
it('aggregation calls forward the OCR text filter when provided', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await listTaggedChapters({ tag: 'funny', text: 'hello world' });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('tag=funny');
|
||||
expect(url).toContain('text=hello+world');
|
||||
});
|
||||
|
||||
it('searchPages serializes tags (CSV), text and content-warning filters', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
|
||||
@@ -179,6 +179,9 @@ export type AggregateOptions = {
|
||||
order?: 'desc' | 'asc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** OCR full-text filter: when set, only pages whose analysis search_doc
|
||||
* matches are aggregated (and `match_count` reflects that). */
|
||||
text?: string;
|
||||
};
|
||||
|
||||
function aggregateQs(opts: AggregateOptions): string {
|
||||
@@ -187,6 +190,7 @@ function aggregateQs(opts: AggregateOptions): string {
|
||||
if (opts.order != null) params.set('order', opts.order);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
if (opts.text != null && opts.text !== '') params.set('text', opts.text);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl } from '$lib/api/client';
|
||||
import type { Bookmark } from '$lib/api/bookmarks';
|
||||
import { overflowTooltip } from '$lib/actions/overflowTooltip';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="/manga/{b.manga_id}" class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if b.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(b.manga_cover_image_path)}
|
||||
src={thumbUrl(b.manga_cover_image_path, 320)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl } from '$lib/api/client';
|
||||
import type { CollectionSummary } from '$lib/api/collections';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{:else}
|
||||
{#each c.sample_covers as cover (cover)}
|
||||
<img
|
||||
src={fileUrl(cover)}
|
||||
src={thumbUrl(cover, 320)}
|
||||
alt=""
|
||||
class="collage-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { overflowTooltip } from '$lib/actions/overflowTooltip';
|
||||
import type { ReadProgressSummary } from '$lib/api/read_progress';
|
||||
@@ -47,7 +47,7 @@
|
||||
<span class="cover-wrap">
|
||||
{#if p.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(p.manga_cover_image_path)}
|
||||
src={thumbUrl(p.manga_cover_image_path, 320)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
|
||||
83
frontend/src/lib/components/LoadMore.svelte
Normal file
83
frontend/src/lib/components/LoadMore.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { ApiError } from '$lib/api/client';
|
||||
|
||||
// Renders an accumulating list with a "Load more" button. The parent seeds
|
||||
// the first page (`initial` + `total`, usually from a streamed loader that
|
||||
// also gates auth); further pages are fetched on demand via `fetchMore`,
|
||||
// which is handed the current item count as its offset. The `children`
|
||||
// snippet receives the accumulated array so the parent controls layout.
|
||||
let {
|
||||
initial,
|
||||
total,
|
||||
fetchMore,
|
||||
children
|
||||
}: {
|
||||
initial: T[];
|
||||
total: number | null;
|
||||
fetchMore: (offset: number) => Promise<{ items: T[]; total: number | null }>;
|
||||
children: import('svelte').Snippet<[T[]]>;
|
||||
} = $props();
|
||||
|
||||
let extra = $state<T[]>([]);
|
||||
let curTotal = $state<number | null>(total);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const all = $derived([...initial, ...extra]);
|
||||
const hasMore = $derived(curTotal != null && all.length < curTotal);
|
||||
|
||||
async function more() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const r = await fetchMore(all.length);
|
||||
extra = [...extra, ...r.items];
|
||||
curTotal = r.total;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not load more.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{@render children(all)}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="load-more">
|
||||
<button type="button" onclick={more} disabled={loading} data-testid="load-more">
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{#if error}
|
||||
<p class="lm-error" role="alert">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.load-more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.lm-error {
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: var(--font-sm);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { thumbUrl, thumbSrcset } from '$lib/api/client';
|
||||
import type { Manga } from '$lib/api/client';
|
||||
import type { AuthorRef, GenreRef } from '$lib/api/mangas';
|
||||
import { overflowTooltip } from '$lib/actions/overflowTooltip';
|
||||
@@ -48,7 +48,9 @@
|
||||
<a href="/manga/{manga.id}" class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
src={thumbUrl(manga.cover_image_path, 320)}
|
||||
srcset={thumbSrcset(manga.cover_image_path, [160, 320, 480, 640])}
|
||||
sizes="(max-width: 600px) 45vw, 200px"
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { lockBodyScroll, unlockBodyScroll } from '$lib/scroll-lock';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
let {
|
||||
@@ -46,6 +47,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Lock background scroll while open so the page behind the modal can't
|
||||
// scroll under it; released on close or unmount.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
lockBodyScroll();
|
||||
return () => unlockBodyScroll();
|
||||
});
|
||||
|
||||
function focusable(): HTMLElement[] {
|
||||
if (!dialog) return [];
|
||||
// Standard set of "tab can land here" elements, minus those
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { lockBodyScroll, unlockBodyScroll } from '$lib/scroll-lock';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
let {
|
||||
@@ -32,6 +33,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Lock background scroll while the sheet is open (released on close/unmount).
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
lockBodyScroll();
|
||||
return () => unlockBodyScroll();
|
||||
});
|
||||
|
||||
function focusable(): HTMLElement[] {
|
||||
if (!panel) return [];
|
||||
const selector = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { fileUrl, thumbUrl } from '$lib/api/client';
|
||||
import type { TaggedMangaAggregate } from '$lib/api/page_tags';
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@
|
||||
<li class="row" data-testid={testid}>
|
||||
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if primaryCover}
|
||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||
<img src={thumbUrl(primaryCover, 320)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||
{:else}
|
||||
<div class="cover cover-placeholder"></div>
|
||||
{/if}
|
||||
|
||||
36
frontend/src/lib/scroll-lock.test.ts
Normal file
36
frontend/src/lib/scroll-lock.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { lockBodyScroll, unlockBodyScroll, _lockCount } from './scroll-lock';
|
||||
|
||||
describe('scroll-lock', () => {
|
||||
afterEach(() => {
|
||||
// Drain any leftover locks so tests are independent.
|
||||
while (_lockCount() > 0) unlockBodyScroll();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it('locks body scroll on first lock and restores on last unlock', () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
lockBodyScroll();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
unlockBodyScroll();
|
||||
// Restores the pre-lock value.
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
expect(_lockCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('is ref-counted: stacked overlays keep the lock until all release', () => {
|
||||
lockBodyScroll();
|
||||
lockBodyScroll();
|
||||
expect(_lockCount()).toBe(2);
|
||||
unlockBodyScroll();
|
||||
// Still locked — one overlay remains open.
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
unlockBodyScroll();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('ignores an unbalanced unlock', () => {
|
||||
unlockBodyScroll();
|
||||
expect(_lockCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
31
frontend/src/lib/scroll-lock.ts
Normal file
31
frontend/src/lib/scroll-lock.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Ref-counted body scroll lock shared by overlay components (Modal, Sheet).
|
||||
// A counter keeps the lock held while any overlay is open, so closing one of
|
||||
// two stacked overlays doesn't prematurely restore background scrolling.
|
||||
|
||||
let count = 0;
|
||||
let previousOverflow = '';
|
||||
|
||||
/** Lock body scroll (idempotent per caller — pair every call with `unlock`). */
|
||||
export function lockBodyScroll(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (count === 0) {
|
||||
previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
|
||||
/** Release one lock; body scroll is restored once the last one is released. */
|
||||
export function unlockBodyScroll(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (count === 0) return;
|
||||
count -= 1;
|
||||
if (count === 0) {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: current lock depth. */
|
||||
export function _lockCount(): number {
|
||||
return count;
|
||||
}
|
||||
36
frontend/src/lib/unsaved-guard.ts
Normal file
36
frontend/src/lib/unsaved-guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/**
|
||||
* Warn before leaving a page with unsaved form state. Call once from a
|
||||
* component's script (during init). `isDirty` is read live on each navigation,
|
||||
* so pass a closure over the form's reactive state — and have it return `false`
|
||||
* once a save has succeeded (or is in flight) so the post-save redirect doesn't
|
||||
* prompt.
|
||||
*
|
||||
* Covers both in-app navigation (SvelteKit `beforeNavigate`, with a confirm) and
|
||||
* full-page unload / tab close / reload (the native `beforeunload` prompt).
|
||||
*/
|
||||
export function guardUnsavedChanges(isDirty: () => boolean): void {
|
||||
beforeNavigate((nav) => {
|
||||
// `leave` (tab close / reload) can't show a custom confirm here — the
|
||||
// beforeunload handler below covers it. Only guard in-app navigations.
|
||||
if (nav.type === 'leave') return;
|
||||
if (!isDirty()) return;
|
||||
if (!confirm('You have unsaved changes. Leave this page and discard them?')) {
|
||||
nav.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty()) {
|
||||
e.preventDefault();
|
||||
// Legacy browsers require returnValue to be set.
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
});
|
||||
}
|
||||
73
frontend/src/routes/+error.svelte
Normal file
73
frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
|
||||
// Shown whenever a load function throws (including a wrapped network_error
|
||||
// ApiError from the client). Without this boundary an uncaught load error
|
||||
// fell through to SvelteKit's bare default error page — or, for a raw
|
||||
// TypeError, blanked the view entirely.
|
||||
let status = $derived($page.status);
|
||||
let message = $derived($page.error?.message || 'Something went wrong.');
|
||||
let isNetwork = $derived(status === 0 || status >= 500);
|
||||
|
||||
async function retry() {
|
||||
// Re-run the failed load(s) in place rather than a hard reload.
|
||||
await invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="error-page" data-testid="error-boundary">
|
||||
<p class="status">{status || 'Error'}</p>
|
||||
<h1>{isNetwork ? 'We hit a snag' : 'This page is unavailable'}</h1>
|
||||
<p class="message">{message}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" onclick={retry}>Try again</button>
|
||||
<button type="button" class="ghost" onclick={() => goto('/')}>Go home</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.error-page {
|
||||
max-width: 32rem;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.status {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
h1 {
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.message {
|
||||
color: var(--color-text-muted, #888);
|
||||
margin: 0 0 1.5rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
button {
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
}
|
||||
.primary {
|
||||
background: var(--color-accent, #3b82f6);
|
||||
color: var(--color-accent-contrast, #fff);
|
||||
border-color: transparent;
|
||||
}
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -51,12 +51,19 @@
|
||||
}
|
||||
|
||||
async function onToggleAdmin(id: string, next: boolean) {
|
||||
// Privilege changes are one-click and consequential — confirm first,
|
||||
// like Delete. The checkbox is kept controlled by `u.is_admin` (see the
|
||||
// onchange handler), so a cancel here leaves it showing the true state.
|
||||
const verb = next ? 'grant admin rights to' : 'revoke admin rights from';
|
||||
if (!confirm(`Are you sure you want to ${verb} this user?`)) return;
|
||||
busyId = id;
|
||||
try {
|
||||
await setUserAdmin(id, next);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'update failed';
|
||||
// The checkbox was reverted in onchange and `u.is_admin` is
|
||||
// unchanged, so it correctly still reflects the server state.
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
@@ -187,7 +194,15 @@
|
||||
type="checkbox"
|
||||
checked={u.is_admin}
|
||||
disabled={busyId === u.id || isSelf}
|
||||
onchange={(e) => onToggleAdmin(u.id, e.currentTarget.checked)}
|
||||
onchange={(e) => {
|
||||
const desired = e.currentTarget.checked;
|
||||
// Keep the box controlled by `u.is_admin`: undo the
|
||||
// optimistic flip so it only changes once the server
|
||||
// confirms — and never lies if the change is
|
||||
// cancelled or the request fails.
|
||||
e.currentTarget.checked = u.is_admin;
|
||||
onToggleAdmin(u.id, desired);
|
||||
}}
|
||||
aria-label="admin"
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script lang="ts">
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
|
||||
import LoadMore from '$lib/components/LoadMore.svelte';
|
||||
import { listMyBookmarks, type Bookmark } from '$lib/api/bookmarks';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
async function fetchMore(offset: number) {
|
||||
const p = await listMyBookmarks({ limit: data.pageSize, offset });
|
||||
return { items: p.items, total: p.page.total };
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Bookmarks</h1>
|
||||
@@ -21,7 +28,11 @@
|
||||
{:else if r.bookmarks.length === 0}
|
||||
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
||||
{:else}
|
||||
<BookmarkList bookmarks={r.bookmarks} />
|
||||
<LoadMore initial={r.bookmarks as Bookmark[]} total={r.total} {fetchMore}>
|
||||
{#snippet children(items: Bookmark[])}
|
||||
<BookmarkList bookmarks={items} />
|
||||
{/snippet}
|
||||
</LoadMore>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
|
||||
@@ -4,17 +4,32 @@ import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
/** First-page size; "Load more" pulls further pages of the same size.
|
||||
* Not exported — SvelteKit only allows specific `+page.ts` exports. */
|
||||
const BOOKMARKS_PAGE_SIZE = 50;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
// Streamed (the load itself doesn't await) so the list shows a skeleton
|
||||
// while the single fetch — which is also the auth gate — is in flight.
|
||||
return {
|
||||
pageSize: BOOKMARKS_PAGE_SIZE,
|
||||
result: (async () => {
|
||||
try {
|
||||
const page = await listMyBookmarks();
|
||||
return { bookmarks: page.items, authenticated: true, error: null as string | null };
|
||||
const page = await listMyBookmarks({ limit: BOOKMARKS_PAGE_SIZE, offset: 0 });
|
||||
return {
|
||||
bookmarks: page.items,
|
||||
total: page.page.total,
|
||||
authenticated: true,
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { bookmarks: [], authenticated: false, error: null as string | null };
|
||||
return {
|
||||
bookmarks: [],
|
||||
total: 0,
|
||||
authenticated: false,
|
||||
error: null as string | null
|
||||
};
|
||||
}
|
||||
// Anything else — an HTTP error (502 upstream_unavailable from
|
||||
// a backend restart, 500 internal_error) or a raw network
|
||||
@@ -23,7 +38,7 @@ export const load: PageLoad = async () => {
|
||||
// right UX for a transient API blip and the user is already
|
||||
// authenticated as far as we know.
|
||||
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||
return { bookmarks: [], authenticated: true, error: message };
|
||||
return { bookmarks: [], total: 0, authenticated: true, error: message };
|
||||
}
|
||||
})()
|
||||
};
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script lang="ts">
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||
import LoadMore from '$lib/components/LoadMore.svelte';
|
||||
import { listMyCollections, type CollectionSummary } from '$lib/api/collections';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
async function fetchMore(offset: number) {
|
||||
const p = await listMyCollections({ limit: data.pageSize, offset });
|
||||
return { items: p.items, total: p.page.total };
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Collections</h1>
|
||||
@@ -22,7 +29,11 @@
|
||||
<strong>Add to collection</strong> to start one.
|
||||
</p>
|
||||
{:else}
|
||||
<CollectionsGrid collections={r.collections} />
|
||||
<LoadMore initial={r.collections as CollectionSummary[]} total={r.total} {fetchMore}>
|
||||
{#snippet children(items: CollectionSummary[])}
|
||||
<CollectionsGrid collections={items} />
|
||||
{/snippet}
|
||||
</LoadMore>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
|
||||
@@ -4,23 +4,38 @@ import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
/** First-page size; "Load more" pulls further pages of the same size.
|
||||
* Not exported — SvelteKit only allows specific `+page.ts` exports. */
|
||||
const COLLECTIONS_PAGE_SIZE = 60;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
// Streamed so the grid shows a skeleton while the single fetch (also the
|
||||
// auth gate) is in flight.
|
||||
return {
|
||||
pageSize: COLLECTIONS_PAGE_SIZE,
|
||||
result: (async () => {
|
||||
try {
|
||||
const page = await listMyCollections({ limit: 200 });
|
||||
return { collections: page.items, authenticated: true, error: null as string | null };
|
||||
const page = await listMyCollections({ limit: COLLECTIONS_PAGE_SIZE, offset: 0 });
|
||||
return {
|
||||
collections: page.items,
|
||||
total: page.page.total,
|
||||
authenticated: true,
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { collections: [], authenticated: false, error: null as string | null };
|
||||
return {
|
||||
collections: [],
|
||||
total: 0,
|
||||
authenticated: false,
|
||||
error: null as string | null
|
||||
};
|
||||
}
|
||||
// An HTTP error or a raw network failure (a non-ApiError
|
||||
// TypeError) renders inline rather than escaping to the
|
||||
// framework error page for a transient API blip.
|
||||
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||
return { collections: [], authenticated: true, error: message };
|
||||
return { collections: [], total: 0, authenticated: true, error: message };
|
||||
}
|
||||
})()
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getManga } from '$lib/api/mangas';
|
||||
import { getChapter, getChapterPages, listChapters } from '$lib/api/chapters';
|
||||
import { getChapter, getChapterPages, listAllChapters } from '$lib/api/chapters';
|
||||
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
@@ -13,19 +13,18 @@ export const load: PageLoad = async ({ params, url }) => {
|
||||
// `null` for guests or first-time openers — the reader uses
|
||||
// this to seed its session-local high-water mark.
|
||||
getMyReadProgressForManga(params.id),
|
||||
// Loaded so the reader can compute prev/next chapter for the
|
||||
// chevron-driven chapter navigation. limit=200 covers every
|
||||
// realistic series; mangas with more chapters will lose some
|
||||
// chapter-jump precision at the tail edge but the in-page
|
||||
// navigation still works fine.
|
||||
listChapters(params.id, { limit: 200 })
|
||||
// The FULL chapter list (paged through the API's 200-row cap) so the
|
||||
// prev/next chevrons and the chapter dropdown work even for a deep
|
||||
// chapter — a single 200-row window dead-ended chapter #250 with null
|
||||
// neighbours.
|
||||
listAllChapters(params.id)
|
||||
]);
|
||||
return {
|
||||
manga,
|
||||
chapter,
|
||||
pages,
|
||||
readProgress,
|
||||
chapters: chapterList.items,
|
||||
chapters: chapterList,
|
||||
// `?page=N` lets the prev-chapter chevron land directly on the
|
||||
// last page of the chapter it just navigated to. `last` is a
|
||||
// convenience sentinel for "however many pages this chapter
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError, fileUrl } from '$lib/api/client';
|
||||
import { guardUnsavedChanges } from '$lib/unsaved-guard';
|
||||
import {
|
||||
deleteMangaCover,
|
||||
updateManga,
|
||||
@@ -49,6 +50,25 @@
|
||||
mangaTitle.trim().length > 0 && !coverError && !submitting
|
||||
);
|
||||
|
||||
// Warn before leaving with unsaved edits. `submitting` stays true through
|
||||
// the post-save redirect, so that navigation isn't prompted.
|
||||
guardUnsavedChanges(
|
||||
() =>
|
||||
!submitting &&
|
||||
(mangaTitle !== data.manga.title ||
|
||||
mangaStatus !== data.manga.status ||
|
||||
mangaDescription !== (data.manga.description ?? '') ||
|
||||
JSON.stringify(mangaAuthors) !==
|
||||
JSON.stringify(data.manga.authors.map((a) => a.name)) ||
|
||||
JSON.stringify(mangaAltTitles) !== JSON.stringify([...data.manga.alt_titles]) ||
|
||||
JSON.stringify(mangaGenreIds) !==
|
||||
JSON.stringify(data.manga.genres.map((g) => g.id)) ||
|
||||
coverFile != null ||
|
||||
pendingCoverRemoval ||
|
||||
authorDraft.trim() !== '' ||
|
||||
altTitleDraft.trim() !== '')
|
||||
);
|
||||
|
||||
function addAuthor() {
|
||||
const name = authorDraft.trim();
|
||||
if (!name) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError, fileUrl } from '$lib/api/client';
|
||||
import { guardUnsavedChanges } from '$lib/unsaved-guard';
|
||||
import { createChapter } from '$lib/api/chapters';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import ChapterPagesEditor, {
|
||||
@@ -19,6 +20,16 @@
|
||||
let submitting = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Warn before leaving with staged pages / edits. `submitting` stays true
|
||||
// through the post-save redirect, so that navigation isn't prompted.
|
||||
guardUnsavedChanges(
|
||||
() =>
|
||||
!submitting &&
|
||||
(pages.length > 0 ||
|
||||
title.trim() !== '' ||
|
||||
(number != null && number !== data.defaultNumber))
|
||||
);
|
||||
|
||||
const allPagesValid = $derived(pages.every((p) => !p.error));
|
||||
const canSubmit = $derived(
|
||||
Boolean(session.user) &&
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
import Terminal from '@lucide/svelte/icons/terminal';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
@@ -26,6 +27,7 @@
|
||||
{ href: '/profile', label: 'Overview', icon: User, testid: 'tab-overview', guestVisible: true },
|
||||
{ href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true },
|
||||
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
|
||||
{ href: '/profile/tokens', label: 'API tokens', icon: Terminal, testid: 'tab-tokens', guestVisible: false },
|
||||
{ href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
|
||||
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false },
|
||||
{ href: '/profile/page-tags', label: 'Page tags', icon: Tag, testid: 'tab-page-tags', guestVisible: false },
|
||||
|
||||
@@ -224,8 +224,8 @@
|
||||
<h2>Change password</h2>
|
||||
<p class="hint">
|
||||
Changing your password signs out every other device using this account.
|
||||
Bot API tokens keep working — revoke them individually from the bot-token
|
||||
list if you want to invalidate them too.
|
||||
Bot API tokens keep working — revoke them individually from the
|
||||
<a href="/profile/tokens">API tokens</a> page if you want to invalidate them too.
|
||||
</p>
|
||||
{@render passwordForm()}
|
||||
</section>
|
||||
|
||||
306
frontend/src/routes/profile/tokens/+page.svelte
Normal file
306
frontend/src/routes/profile/tokens/+page.svelte
Normal file
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { listTokens, createToken, deleteToken, type ApiToken } from '$lib/api/auth';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let tokens = $state<ApiToken[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Create form.
|
||||
let newName = $state('');
|
||||
let newExpiry = $state<string>(''); // '' = never; otherwise days as a string
|
||||
let creating = $state(false);
|
||||
// The raw bearer, shown once right after creation.
|
||||
let freshBearer = $state<string | null>(null);
|
||||
|
||||
let busyId = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tokens = await listTokens();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not load tokens.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load once the session resolves. Guests get the sign-in prompt; a
|
||||
// signed-in user's tokens are fetched exactly once.
|
||||
let didLoad = $state(false);
|
||||
$effect(() => {
|
||||
if (!session.loaded) return;
|
||||
if (session.user && !didLoad) {
|
||||
didLoad = true;
|
||||
load();
|
||||
} else if (!session.user) {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function onCreate(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
freshBearer = null;
|
||||
try {
|
||||
const days = newExpiry ? Number(newExpiry) : undefined;
|
||||
const created = await createToken(name, days);
|
||||
freshBearer = created.bearer;
|
||||
newName = '';
|
||||
newExpiry = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not create token.';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(t: ApiToken) {
|
||||
if (!confirm(`Revoke the token "${t.name}"? Any bot using it will stop working.`)) return;
|
||||
busyId = t.id;
|
||||
try {
|
||||
await deleteToken(t.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not revoke token.';
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBearer() {
|
||||
if (!freshBearer) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(freshBearer);
|
||||
toast.success('Token copied to clipboard.');
|
||||
} catch {
|
||||
toast.error('Copy failed — select and copy it manually.');
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(d: string | null): string {
|
||||
return d ? new Date(d).toLocaleDateString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card" data-testid="tokens-page">
|
||||
<h2>Bot API tokens</h2>
|
||||
<p class="hint">
|
||||
Bot tokens authenticate scripts against the same HTTP API the site uses.
|
||||
Send one as <code>Authorization: Bearer <token></code>. The token is
|
||||
shown only once, right after you create it — store it somewhere safe.
|
||||
</p>
|
||||
|
||||
{#if !session.user}
|
||||
<p class="empty" data-testid="tokens-guest">Sign in to manage your API tokens.</p>
|
||||
{:else}
|
||||
<form class="create" onsubmit={onCreate} data-testid="token-create-form">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
maxlength="64"
|
||||
placeholder="e.g. ci-bot"
|
||||
data-testid="token-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Expires</span>
|
||||
<select bind:value={newExpiry} data-testid="token-expiry">
|
||||
<option value="">Never</option>
|
||||
<option value="30">In 30 days</option>
|
||||
<option value="90">In 90 days</option>
|
||||
<option value="365">In 1 year</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="primary" disabled={!newName.trim() || creating}>
|
||||
{creating ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if freshBearer}
|
||||
<div class="fresh" data-testid="token-fresh">
|
||||
<p>Copy your new token now — you won't see it again:</p>
|
||||
<div class="fresh-row">
|
||||
<code class="bearer">{freshBearer}</code>
|
||||
<button type="button" onclick={copyBearer} aria-label="Copy token">
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error" data-testid="tokens-error">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">Loading…</p>
|
||||
{:else if tokens.length === 0}
|
||||
<p class="empty" data-testid="tokens-empty">You have no API tokens yet.</p>
|
||||
{:else}
|
||||
<ul class="token-list" data-testid="token-list">
|
||||
{#each tokens as t (t.id)}
|
||||
<li class="token" data-testid={`token-row-${t.id}`}>
|
||||
<div class="meta">
|
||||
<span class="name">{t.name}</span>
|
||||
<span class="sub">
|
||||
Created {fmt(t.created_at)} · Last used {fmt(t.last_used_at)} ·
|
||||
{t.expires_at ? `Expires ${fmt(t.expires_at)}` : 'No expiry'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
disabled={busyId === t.id}
|
||||
onclick={() => onRevoke(t)}
|
||||
aria-label={`Revoke ${t.name}`}
|
||||
data-testid={`token-revoke-${t.id}`}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
<span>Revoke</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
code {
|
||||
background: var(--surface-2, rgba(127, 127, 127, 0.12));
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.create {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.create label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.create input,
|
||||
.create select {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast, #fff);
|
||||
border-color: transparent;
|
||||
}
|
||||
.danger {
|
||||
color: var(--danger, #dc2626);
|
||||
border-color: var(--danger, #dc2626);
|
||||
}
|
||||
.fresh {
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.fresh p {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.fresh-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.bearer {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.token-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.sub {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
}
|
||||
</style>
|
||||
3
frontend/src/routes/profile/tokens/+page.ts
Normal file
3
frontend/src/routes/profile/tokens/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Client-only: tokens are per-user and fetched on mount, matching the other
|
||||
// authenticated profile subroutes.
|
||||
export const ssr = false;
|
||||
@@ -103,26 +103,44 @@ export const load: PageLoad = async ({ url }) => {
|
||||
// every tag / view / sort / text change instead of freezing the old list.
|
||||
const results = (async () => {
|
||||
try {
|
||||
// Content search takes precedence over tag browsing.
|
||||
// Chapters / Mangas are tag-scoped aggregation views. When a tag is
|
||||
// selected, run the aggregation — optionally filtered by the OCR
|
||||
// `text` — so the selected view AND tag are honoured. Previously any
|
||||
// `text` collapsed every view into a flat page search, discarding
|
||||
// both the tag and the chapters/mangas grouping.
|
||||
if (tag && view === 'chapters') {
|
||||
const r = await listTaggedChapters({
|
||||
tag,
|
||||
order,
|
||||
limit: 100,
|
||||
text: text || undefined
|
||||
});
|
||||
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
if (tag && view === 'mangas') {
|
||||
const r = await listTaggedMangas({
|
||||
tag,
|
||||
order,
|
||||
limit: 100,
|
||||
text: text || undefined
|
||||
});
|
||||
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
// Pages view (or a content search with no tag): a positive filter
|
||||
// runs the page search — intersecting the selected tag with the OCR
|
||||
// text / content-warning filters instead of ignoring the tag.
|
||||
if (contentSearch) {
|
||||
const r = await searchPages({
|
||||
text: text || undefined,
|
||||
tags: tag ? [tag] : undefined,
|
||||
cwInclude,
|
||||
cwExclude,
|
||||
limit: 100
|
||||
});
|
||||
return { ...emptyResults, results: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
// No tag selected → just the chip cloud, no results.
|
||||
// No tag selected and no content filter → just the chip cloud.
|
||||
if (!tag) return emptyResults;
|
||||
if (view === 'chapters') {
|
||||
const r = await listTaggedChapters({ tag, order, limit: 100 });
|
||||
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
if (view === 'mangas') {
|
||||
const r = await listTaggedMangas({ tag, order, limit: 100 });
|
||||
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
const r = await listMyPageTags({ tag, limit: 100 });
|
||||
return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 };
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { guardUnsavedChanges } from '$lib/unsaved-guard';
|
||||
import { createManga, type MangaStatus } from '$lib/api/mangas';
|
||||
import { createChapter } from '$lib/api/chapters';
|
||||
import { session } from '$lib/session.svelte';
|
||||
@@ -43,6 +44,30 @@
|
||||
let mangaError = $state<string | null>(null);
|
||||
let success = $state<string | null>(null);
|
||||
|
||||
// Set once the manga row is created. A retry after a partial chapter
|
||||
// failure reuses this id instead of creating a duplicate manga, and the
|
||||
// form switches into "finish uploading chapters" mode.
|
||||
let createdMangaId = $state<string | null>(null);
|
||||
let createdMangaTitle = $state<string | null>(null);
|
||||
const mangaCreated = $derived(createdMangaId != null);
|
||||
|
||||
// Warn before navigating away with an in-progress upload. Suppressed while
|
||||
// submitting (the post-save redirect). After a partial failure the created
|
||||
// manga's failed chapters are still unsaved staged work, so we keep warning.
|
||||
guardUnsavedChanges(
|
||||
() =>
|
||||
!submitting &&
|
||||
(mangaTitle.trim() !== '' ||
|
||||
mangaDescription.trim() !== '' ||
|
||||
mangaAuthors.length > 0 ||
|
||||
mangaAltTitles.length > 0 ||
|
||||
mangaGenreIds.length > 0 ||
|
||||
coverFile != null ||
|
||||
stagedChapters.length > 0 ||
|
||||
authorDraft.trim() !== '' ||
|
||||
altTitleDraft.trim() !== '')
|
||||
);
|
||||
|
||||
const allChapterPagesValid = $derived(
|
||||
stagedChapters.every((c) => c.pages.every((p) => !p.error))
|
||||
);
|
||||
@@ -130,39 +155,46 @@
|
||||
submitting = true;
|
||||
mangaError = null;
|
||||
success = null;
|
||||
let manga;
|
||||
try {
|
||||
manga = await createManga(
|
||||
{
|
||||
title: mangaTitle.trim(),
|
||||
status: mangaStatus,
|
||||
authors: mangaAuthors,
|
||||
alt_titles: mangaAltTitles,
|
||||
genre_ids: mangaGenreIds,
|
||||
description: mangaDescription.trim() || null
|
||||
},
|
||||
coverFile ?? undefined
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
await goto('/login?next=/upload');
|
||||
|
||||
// Create the manga only once. On a retry after a partial failure the
|
||||
// row already exists (createdMangaId set), so we skip creation and go
|
||||
// straight to re-uploading the not-yet-done chapters — creating it again
|
||||
// would spawn a duplicate manga.
|
||||
if (createdMangaId == null) {
|
||||
try {
|
||||
const manga = await createManga(
|
||||
{
|
||||
title: mangaTitle.trim(),
|
||||
status: mangaStatus,
|
||||
authors: mangaAuthors,
|
||||
alt_titles: mangaAltTitles,
|
||||
genre_ids: mangaGenreIds,
|
||||
description: mangaDescription.trim() || null
|
||||
},
|
||||
coverFile ?? undefined
|
||||
);
|
||||
createdMangaId = manga.id;
|
||||
createdMangaTitle = manga.title;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
await goto('/login?next=/upload');
|
||||
return;
|
||||
}
|
||||
mangaError = e instanceof Error ? e.message : String(e);
|
||||
submitting = false;
|
||||
return;
|
||||
}
|
||||
mangaError = e instanceof Error ? e.message : String(e);
|
||||
submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Manga is created; ship chapters one at a time and surface
|
||||
// per-row status. Failures don't roll back the manga — the
|
||||
// user can retry just the failed chapters from the manga
|
||||
// page's Upload-chapter button.
|
||||
// Ship the outstanding chapters one at a time, surfacing per-row status.
|
||||
// Already-succeeded rows are skipped so a retry only re-sends failures.
|
||||
for (const c of stagedChapters) {
|
||||
if (c.status === 'done') continue;
|
||||
c.status = 'uploading';
|
||||
c.error = null;
|
||||
try {
|
||||
await createChapter(
|
||||
manga.id,
|
||||
createdMangaId,
|
||||
{ number: c.number, title: c.title.trim() || null },
|
||||
c.pages.map((p) => p.file)
|
||||
);
|
||||
@@ -175,12 +207,11 @@
|
||||
|
||||
const failed = stagedChapters.filter((c) => c.status === 'failed');
|
||||
if (failed.length === 0) {
|
||||
// All-good — land the user on the manga page where they
|
||||
// can confirm and continue uploading.
|
||||
await goto(`/manga/${manga.id}`);
|
||||
// All-good — land the user on the manga page.
|
||||
await goto(`/manga/${createdMangaId}`);
|
||||
return;
|
||||
}
|
||||
success = `"${manga.title}" was created, but ${failed.length} of ${stagedChapters.length} chapters failed. Fix them and retry from the manga page.`;
|
||||
success = `"${createdMangaTitle}" was created, but ${failed.length} of ${stagedChapters.length} chapters still failed. Fix them and retry — the manga won't be duplicated.`;
|
||||
submitting = false;
|
||||
}
|
||||
</script>
|
||||
@@ -197,6 +228,12 @@
|
||||
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
|
||||
<section class="card">
|
||||
<h2>Manga details</h2>
|
||||
{#if mangaCreated}
|
||||
<p class="status" data-testid="manga-created-note">
|
||||
Manga created. Its details are saved — fix any failed chapters
|
||||
below and submit again to finish; the manga won't be duplicated.
|
||||
</p>
|
||||
{/if}
|
||||
<label class="form-field">
|
||||
<span>Title <span aria-hidden="true">*</span></span>
|
||||
<input
|
||||
@@ -204,6 +241,7 @@
|
||||
bind:value={mangaTitle}
|
||||
required
|
||||
maxlength="200"
|
||||
disabled={mangaCreated}
|
||||
data-testid="manga-title"
|
||||
/>
|
||||
</label>
|
||||
@@ -417,7 +455,13 @@
|
||||
disabled={!canSubmit}
|
||||
data-testid="manga-submit"
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Create manga'}
|
||||
{#if submitting}
|
||||
Submitting…
|
||||
{:else if mangaCreated}
|
||||
Retry failed chapters
|
||||
{:else}
|
||||
Create manga
|
||||
{/if}
|
||||
</button>
|
||||
{#if success}
|
||||
<p class="success" data-testid="manga-success">{success}</p>
|
||||
|
||||
Reference in New Issue
Block a user