Compare commits

...

26 Commits

Author SHA1 Message Date
MechaCat02
a44511983d fix: page through all chapters when the API omits total
All checks were successful
deploy / test-backend (push) Successful in 44m20s
deploy / test-frontend (push) Successful in 11m3s
deploy / build-and-push (push) Successful in 13m53s
deploy / deploy (push) Successful in 20s
listAllChapters used `page.total ?? all.length` as the loop bound. The
/chapters endpoint always returns `total: null`, so a full 200-row first
page broke the loop immediately — the reader dead-ended past chapter 200.
Stop on a short page instead; only trust `total` when actually reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:24:49 +02:00
MechaCat02
f8e53809d5 chore: sync backend version to 0.128.3 (lockstep with frontend)
Several frontend-only commits bumped both manifests but staged only the
frontend, leaving backend/Cargo.toml behind. Bring it back in lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:26:12 +02:00
MechaCat02
0afd202164 fix: let AbortError propagate through the API client
The network-error wrapping added in the error-boundary work also caught
deliberate AbortController cancellations and turned them into a network_error
ApiError, breaking callers that distinguish "cancelled" from "failed" (the
cancellable admin fetches). Rethrow DOMException AbortError unchanged; only
genuine network failures become ApiError(status 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:25:01 +02:00
MechaCat02
ba3e5b481b fix: lock background scroll while a Modal or Sheet is open
Overlays didn't lock body scroll, so the page behind a modal/sheet scrolled
under it. Add a ref-counted scroll lock (so stacked overlays don't prematurely
release it) and hold it via an effect in Modal and Sheet while open, restoring
on close or unmount. Focus-trapping was already handled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:23:07 +02:00
MechaCat02
3b783c1d9b fix: upload retry reuses the created manga instead of duplicating it
After a partial upload failure (manga created, a chapter failed), re-submitting
re-ran createManga and spawned a duplicate manga. Track the created manga id:
a retry skips creation, re-sends only the not-yet-done chapters, disables the
now-saved title, relabels the button "Retry failed chapters", and shows a note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:20:03 +02:00
MechaCat02
a47b6895c2 feat: warn before leaving a form with unsaved changes
Drag-dropping page images then clicking a nav link silently discarded the
whole form. Add a reusable guardUnsavedChanges helper (SvelteKit beforeNavigate
confirm + native beforeunload) and wire it into the manga upload, manga edit,
and chapter upload forms. The dirty check returns false while submitting so the
post-save redirect isn't prompted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:16:40 +02:00
MechaCat02
3ca05dcb58 fix: paginate the bookmarks and collections lists with Load more
Both lists fetched a single capped page (100 / 200) with no pager, so entries
past the cap were silently unreachable. Add a reusable LoadMore component that
seeds from the streamed first page (preserving the auth gate + inline error
handling) and fetches further offset-based pages on demand, and wire it into
the bookmarks and collections pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:12:03 +02:00
MechaCat02
c308eb3eac fix: reader loads the full chapter list for prev/next and the dropdown
The reader fetched only the first 200 chapters, so a deep chapter (#250) fell
outside the window and its prev/next chevrons resolved to null — a dead end —
and the chapter dropdown was truncated. Add listAllChapters, which pages
through the API's 200-row cap, and use it in the reader loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:02:44 +02:00
MechaCat02
69a9309c54 feat: honor OCR text and tag scope in search Chapters/Mangas views
The Chapters/Mangas aggregation views accept an OCR ?text= filter and the
page search intersects tags, but the client dropped both: any text query
collapsed every view into a flat page search that discarded the selected tag
and grouping. Add text to AggregateOptions, thread it into
listTaggedChapters/listTaggedMangas, and rework the search loader so a
tag-scoped view runs the aggregation (optionally text-filtered) and the page
search passes the selected tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:58:48 +02:00
MechaCat02
1ed1a134ea feat: bot API token management page
The account page pointed users at a "bot-token list" that didn't exist: there
was no GET endpoint, no UI route, and the client type lacked expiry. Add
GET /v1/auth/tokens (caller-scoped, token_hash never serialised), extend the
auth client with listTokens/expires_at and createToken expiry, and add a
/profile/tokens page to list, create (revealing the raw bearer once), and
revoke tokens. Link the account-page copy to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:55:24 +02:00
MechaCat02
32d0a7e13b feat: root error boundary and network-error normalisation
A load() that rethrew (e.g. the profile overview) fell through to SvelteKit's
bare default error page, and a connection-refused fetch rejected as a raw
TypeError that blanked the view. Wrap fetch network failures in the client as
ApiError(status 0, network_error) so callers handle them uniformly, and add a
root +error.svelte with a friendly message plus Try again / Go home actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:47:41 +02:00
MechaCat02
d779fa2b97 fix: confirm admin role toggle and keep the checkbox controlled
The admin checkbox fired an immediate, irreversible privilege change with no
confirmation, and on cancel/failure the optimistic DOM flip stuck while the
server state was unchanged. Add a confirm() gate (like Delete) and make the
checkbox controlled by u.is_admin: onchange reverts the DOM immediately and it
only flips once the server confirms — so a cancelled or failed toggle never
misrepresents the real role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:44:47 +02:00
MechaCat02
755417730f feat: serve width-bounded thumbnail variants for image grids
Add /files/{key}?w=<px>: the endpoint decodes JPEG/PNG sources, downscales to
a width snapped to a small allow-list (aspect-preserving, never upscaling),
caches the result under a thumbs/ prefix, and serves it — so cover grids
download ~KB instead of the 1–5 MB original. Cover handlers purge cached
variants when a cover (whose key is reused) is replaced or deleted. Frontend
grids use new thumbUrl/thumbSrcset helpers (MangaCard with responsive srcset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:41:19 +02:00
MechaCat02
5b1ce581f3 perf: precompute mangas.sort_author for the author sort
?sort=author used a correlated min(lower(a.name)) subquery as the ORDER BY
key, evaluated per filter-matching row before LIMIT. Materialize it into
mangas.sort_author (indexed by mangas_sort_author_idx), maintained by triggers
on manga_authors and backfilled in the migration, so the sort is a plain
indexed column read. Author names are immutable, so only join changes matter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:29:26 +02:00
MechaCat02
c11df3182c perf: denormalize content-warning filter to manga_content_warnings
The content-warning include/exclude filter (and its count) ran a correlated
page_content_warnings->pages->chapters join per candidate manga — O(mangas *
pages) on every filtered list. Add a manga_content_warnings(manga_id, warning)
table holding each manga's deduped warning union, maintained by triggers that
recompute an affected manga's set (delete+reinsert of the tiny five-label set,
robust across page/chapter/manga cascade deletes), backfilled in the migration.
The filter is now a single indexed lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:24:31 +02:00
MechaCat02
d749b56d58 docs: correct re-upload page-preservation semantics
CLAUDE.md claimed re-uploading a chapter unconditionally drops saved-page
references. In fact the crawler re-fetch (content::persist_pages) upserts by
(chapter_id, page_number) with ON CONFLICT DO UPDATE, preserving pages.id, so
collections/page-tags survive a re-fetch. Saves drop only when a pages row is
actually deleted (chapter deletion, or a user delete-then-recreate). Migration
0023's text is left untouched (sqlx checksums applied migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:19:03 +02:00
MechaCat02
c24e296f07 fix: interleave uploaded chapters by number in the chapter list
Uploaded chapters have no source_index, and ORDER BY source_index DESC NULLS
LAST sorted every one of them after all crawled chapters regardless of number
— an uploaded chapter 5 landed after crawled chapter 100. Slot each uploaded
chapter by number, just before the crawled chapter with the smallest greater
number, so it interleaves. Crawled rows keep their reversed source-DOM order,
preserving the deliberate placement of non-numeric entries (migration 0021).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:17:18 +02:00
MechaCat02
9508fb8e86 fix: make bookmark add idempotent instead of 409
Re-adding an already-bookmarked manga returned 409, which the UI surfaced as
a false "Could not add bookmark" toast — collections are idempotent, bookmarks
weren't. On a unique violation, fetch and return the existing bookmark, and
answer 200 (vs 201 for a fresh insert). The frontend toggle already treats a
successful POST as done, so the false toast disappears with no client change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:08:27 +02:00
MechaCat02
ca55712622 refactor: remove dead partial-render guard in resync_manga
The first guard block computed the partial-render condition but had an empty
if-body, so it never skipped anything — the real skip is the chapter-sync
guard further down. Delete the dead block and move its observability warning
into the live guard's else branch, eliminating the data-loss trap without
changing behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:04:44 +02:00
MechaCat02
4fe435cc76 fix: recover the auth rate limiter from a poisoned mutex
try_acquire used .expect on lock(), so a single panic while holding either
limiter mutex would poison it and make every subsequent auth request re-panic
— one blip became a persistent auth outage. Recover the guard with
unwrap_or_else(|e| e.into_inner()) so the limiter keeps serving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:02:54 +02:00
MechaCat02
f5cb460aec fix: abandon dispatch when heartbeat loses the lease
Heartbeat renew failures were only logged, so under DB flakiness a job could
be leased twice concurrently — the original worker kept crawling past a lapsed
lease while another re-leased it. Track consecutive renew failures and, after
MAX_HEARTBEAT_RENEW_FAILURES, signal the worker (via a CancellationToken) to
abandon the in-flight dispatch instead of working a job it no longer owns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:01:04 +02:00
MechaCat02
f83d49b83e fix: exponential backoff for idle crawler workers
An idle worker re-issued a row-locking lease query every 1s per worker with
no backoff. Replace the flat sleep with idle_backoff: 1s, 2s, 4s, … capped at
30s, reset to 0 the moment a job is leased, so a quiet daemon stops hammering
the crawler_jobs table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:58:39 +02:00
MechaCat02
cef41ce76a fix: don't burn a job attempt when the browser is unavailable
A job's attempt is incremented at lease time, then the dispatcher acquires
the headless browser. When the browser was down or mid-restart, acquire()
failed and the error was ack_failed — so during an outage the whole pending
backlog was chewed to `dead` within minutes while workers hot-looped.

Add SyncOutcome::BrowserUnavailable: the dispatcher returns it instead of
propagating the acquire error, and the daemon releases the lease (refunding
the attempt, like the cancel/session paths) and backs off, so an outage is
treated as an infrastructure blip rather than per-job failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:55:50 +02:00
MechaCat02
7570524e5b fix: stream cover uploads with a per-chunk size cap
The cover multipart path called Field::bytes(), buffering the entire field
into memory before parse_image checked max_file_bytes — an oversized cover
was fully allocated before rejection. Add read_capped/push_capped, which
reject with 413 as soon as the running total exceeds the cap (the offending
chunk is never copied in), and route both cover handlers and stage_image_part
through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:51:10 +02:00
MechaCat02
5b46eab73a fix: keep SSRF DNS resolver on http(s)-proxy crawler paths
Commit 134ab54 dropped the safe DNS resolver for any configured proxy, but
that guard is only pointless for SOCKS proxies (where the proxy resolves the
target). On an http(s):// proxy reqwest still resolves the target locally, so
a hostname resolving to a private IP would be reachable. Narrow the exemption
to SOCKS-only via should_attach_safe_resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:46:39 +02:00
MechaCat02
91d6a50dba fix: guard all browser subresources against SSRF and fail closed
The CDP Fetch interceptor only paused main-frame Document requests, so a
scraped page's <img>/fetch()/XHR subresources to internal targets
(169.254.169.254, postgres:5432, RFC1918) reached those hosts unguarded.
Drop the ResourceType::Document constraint so every request runs through
is_blocked. Also make open_page fail-closed: if the guard can't install,
close the blank page and return the error instead of navigating unguarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:44:37 +02:00
73 changed files with 2945 additions and 227 deletions

View File

@@ -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
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.124.13"
version = "0.128.4"
dependencies = [
"anyhow",
"argon2",

View File

@@ -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 +

View 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;

View 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();

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 15 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);
}
}

View File

@@ -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?))

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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")
}
}
}
}

View File

@@ -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");

View File

@@ -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#"

View File

@@ -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)),
}
}

View File

@@ -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
"#,
)

View File

@@ -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",

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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:?}"
);
}

View File

@@ -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);

View File

@@ -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());

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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",
);
}

View 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();
});
});

View 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);
});

View 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);
});

View 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();
});

View 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);
});

View 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');
});

View 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');
});

View 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);
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.124.13",
"version": "0.128.4",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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();

View File

@@ -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)
});
}

View File

@@ -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({

View File

@@ -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)}`

View File

@@ -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

View File

@@ -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 15 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}`;

View File

@@ -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 } })

View File

@@ -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();
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View 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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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}

View 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);
});
});

View 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;
}

View 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);
});
}

View 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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 };
}
})()
};

View File

@@ -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}

View File

@@ -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 };
}
})()
};

View File

@@ -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

View File

@@ -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;

View File

@@ -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) &&

View File

@@ -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 },

View File

@@ -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>

View 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 &lt;token&gt;</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>

View 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;

View File

@@ -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) {

View File

@@ -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>