Compare commits

..

247 Commits

Author SHA1 Message Date
MechaCat02
33cc41bacd fix: make the genre dedup migration collision-proof (0038)
All checks were successful
deploy / test-backend (push) Successful in 38m34s
deploy / test-frontend (push) Successful in 10m58s
deploy / build-and-push (push) Successful in 11m46s
deploy / deploy (push) Successful in 24s
Review caught a boot-crash risk: 0038's repoint-UPDATE could set two
non-canonical variant links of one manga to the same canonical id in a single
statement (NOT EXISTS sees the pre-statement snapshot) -> manga_genres PK
violation -> migration rollback -> startup failure. Replace with INSERT ... SELECT
DISTINCT ... ON CONFLICT DO NOTHING (collision-proof) then delete variants. Amended
in place (0038 is unpushed). Tested with the exact collision scenario.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:12:52 +02:00
MechaCat02
08a9819d76 test: await async render in AddToCollectionModal collection-name assertion
FE-2's CancellableLoader hop renders the list one microtask after the test's
fixed tick budget; switch getByText -> findByText (async retry). Fixes a
regression merged in ac5641b (FE-2 landed without running this component test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:21:47 +02:00
MechaCat02
70a6598924 chore: harden docker-compose deployment (bind, memory limits, no-new-privileges)
- frontend publishes on ${FRONTEND_PUBLISH_ADDR:-127.0.0.1} not 0.0.0.0 (M4).
- mem_limit on backend(4g)/postgres(1g)/frontend(512m), .env-overridable (M3).
- security_opt: no-new-privileges on app + postgres services.
New knobs documented + allowlisted in the config meta-test; compose validates.
Deferred (need host verification): image pinning, backend healthcheck, cap_drop
on postgres/tor, Postgres backup sidecar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:10:29 +02:00
MechaCat02
e6aefaa804 test: pin AppError -> HTTP status mapping (incl 501 and 429 Retry-After)
Table-driven test over every AppError variant's into_response().status(), closing
the unexercised 501 NotImplemented path and asserting a deterministic 429
Retry-After header (audit GAP B). Test-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:03:28 +02:00
MechaCat02
6354a7a8a3 fix: tighten SSRF interceptor residuals
- verdict() now blocks file:/ftp:/gopher: subresources instead of allowing all
  non-http(s) schemes (keeps data:/blob:/internal for page loads).
- bin/crawler.rs uses should_attach_safe_resolver() so an http(s) proxy keeps the
  DNS-rebinding guard, matching the daemon.
- Regression test pins the url crate's decimal/hex/octal/short IPv4 normalization
  to loopback (ensure_public_target rejects them). Tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:00:55 +02:00
MechaCat02
60d5a2efea docs: clarify that MAX_PAGES_PER_CHAPTER=0 relies on the body-size backstop
Make explicit (config.rs + .env.example) that disabling the per-chapter page cap
leaves MAX_REQUEST_BYTES as the sole bound (audit footgun). No behavior change;
the thumbnail-source read is already bounded by MAX_FILE_BYTES + decode limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:56:19 +02:00
MechaCat02
90f2398e56 fix: send HSTS on HTTPS document responses
Add Strict-Transport-Security (max-age=63072000; includeSubDomains) in
applySecurityHeaders, gated on x-forwarded-proto === 'https' so a plain-HTTP
dev/misconfig deploy never pins the browser to HTTPS (audit LOW). Tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:52:54 +02:00
MechaCat02
e960aae163 fix: enable browser SSRF interception by default (H1)
Off-by-default left headless-browser page JS/subresources/redirects able to reach
internal IPs (Chromium bypasses the reqwest SafeResolver). The crawler runs in the
backend container so it can't be network-isolated without breaking Postgres — the
CDP Fetch interceptor is the control. Flip CRAWLER_SSRF_INTERCEPT default to true
(config + .env.example + compose); off-path unchanged as break-glass. The
#[ignore]'d smoke test validates against real Chromium (command documented).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:51:05 +02:00
MechaCat02
adce950049 fix: exponential idle backoff for the analysis worker
The analysis worker's flat 1s idle sleep meant an empty queue fired a lease query
per worker per second forever (audit). Add idle_backoff (1s..30s, reset on lease),
mirroring the crawler. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:49:01 +02:00
MechaCat02
bde72e47f4 fix: batch and index the crawler_jobs retention reaper
reap_terminal was one unbatched DELETE with no updated_at index — a full scan and
long-held lock over a large backlog (audit M5). Migration 0040 adds a partial
index on (updated_at) WHERE state IN ('done','dead'); reap_terminal now deletes in
bounded 5k batches. Existing tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:46:01 +02:00
MechaCat02
cb757e7b69 fix: index the running/leased_until arm of the job-lease query
The lease predicate's `state='running' AND leased_until < now()` arm had no
index, so every lease poll seq-scanned the whole crawler_jobs table (audit H2).
migration 0039 adds a partial index on (leased_until) WHERE state='running'.
Behavior-neutral; 29 lease tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:43:33 +02:00
MechaCat02
042e7e9047 fix: fall back to the original when a thumbnail fails to decode
A blob with valid magic but a corrupt body passed upload then 500'd on a ?w=
thumbnail decode (audit). Fall back to serve_original (streams without decoding)
instead. Tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:41:28 +02:00
MechaCat02
cc8ae2566f fix: reject a malformed reenqueue body instead of running the full scope
Option<Json<T>> collapses a malformed body to None, so a bad reenqueue body
silently ran the default full "All" scope (audit). Parse raw bytes: empty =
default All, malformed = 422. Tested both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:37:16 +02:00
MechaCat02
4f085f228a fix: commit cover DB pointer before mutating the old blob
put_cover/delete_cover mutated storage before the DB pointer, so a transient DB
failure could leave the row pointing at a deleted blob. Reorder to
put->set-DB->delete-old and clear-DB->delete-blob, so a failure only orphans a
blob, never dangles the pointer. Happy path covered by the 16 existing cover tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:33:57 +02:00
MechaCat02
ac5641bcd3 fix: guard AddToCollectionModal against out-of-order load results
Retargeting the modal mid-load could resolve responses out of order, overwriting
the newer target's membership with the older one's (audit FE-2). Route load()
through the tested CancellableLoader so a superseded result is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:31:26 +02:00
MechaCat02
cd5358dc9b fix: stop reader key navigation leaking behind open overlays
Arrows/j/k/Home/End bubbled to the reader's window handler while a
jump/settings/action/collections/tags/context overlay was open, paging the
chapter behind it (FE-1); native select/contenteditable weren't exempt (FE-4).
New unit-tested isTypingTarget() + an overlay-open early return fix both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:28:57 +02:00
MechaCat02
c31830468c fix: enforce case-insensitive genre uniqueness
genres had only case-sensitive name UNIQUE while authors/tags use
UNIQUE (lower(name)); sync_genres could race two case variants into duplicate
rows (audit DB-3). Migration 0038 pre-dedups then adds
UNIQUE (lower(name)); sync_genres upserts ON CONFLICT (lower(name)). Tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:25:46 +02:00
MechaCat02
75f4481bbd fix: invalidate stale page analysis when a re-crawl overwrites a page
Deterministic per-page storage keys mean a re-crawl overwrites the image but the
pages row survives the upsert, so analysis keyed to page_id (page_analysis/
search_doc, OCR, auto-tags, content-warnings) stayed stale forever and
force=false re-enqueue never re-analyzed it — poisoning search + the derived
manga_content_warnings (audit DB-1). persist_pages now detects conflict-updated
pages via xmax and clears their four derived tables in the same tx (the
content-warnings DELETE fires the mcw_* triggers), returning them to unanalyzed
so they re-derive. Tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:21:29 +02:00
MechaCat02
958d5bd713 fix: scheme-validate crawler source_url before rendering as a link
Harvested source_url (external content) rendered as an admin <a href> without
scheme validation — a javascript:/data: link would execute on click (audit M2).
safeExternalHref() gates to http(s); CrawlerHistoryTable/DeadJobsTable fall back
to plain text otherwise. Tested in safeHref.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:16:08 +02:00
MechaCat02
f2c9cfe162 test: cover the trusted_proxy X-Forwarded-For rate-limit gate
Two integration tests pinning both sides of the gate: trusted_proxy=true gives
each forwarded IP its own bucket; trusted_proxy=false ignores spoofed XFF and
shares the global bucket. Closes audit gap M7. Test-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:13:37 +02:00
MechaCat02
b5f7467c47 refactor: remove unused constant_time_eq and correct the token doc
All checks were successful
deploy / test-backend (push) Successful in 35m33s
deploy / test-frontend (push) Successful in 10m46s
deploy / build-and-push (push) Successful in 11m31s
deploy / deploy (push) Successful in 13s
constant_time_eq (and the `subtle` dependency) was never wired into any
comparison — the module doc wrongly claimed token comparison used it, but lookup
is an indexed DB equality on the 256-bit SHA-256 hash, so there's no in-process
secret compare to time-attack. Delete the dead fn, its test, and the unused
`subtle` dep; rewrite the doc. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:39 +02:00
MechaCat02
9148e23da8 fix: reap expired sessions with a periodic background sweep
The sessions table only grew — find_active ignored expired rows but nothing
deleted them, so lapsed sessions accumulated indefinitely.

- repo::session::delete_expired: indexed DELETE (sessions_expires_idx) returning
  the reaped count.
- app::build spawns a detached hourly reaper (SESSION_GC_INTERVAL) that calls it,
  independent of crawler/analysis config.

Test: delete_expired_removes_only_lapsed_sessions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:19:41 +02:00
MechaCat02
f39307232c fix: force download for untyped (octet-stream) served blobs
The /files handler served the application/octet-stream fallback with no
Content-Disposition, letting a browser render it inline — a stored-XSS vector if
the blob is crafted HTML/JS. Add Content-Disposition: attachment for that type
(belt to the existing nosniff); known image types stay inline.

Test (new tests/api_files.rs): octet-stream → attachment; png → inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:15:23 +02:00
MechaCat02
1b7b8a3038 fix: cap the multipart metadata part instead of buffering it unbounded
The `metadata` JSON part in the manga-create and chapter-upload handlers was
read with `Field::bytes()`, buffering the whole part before any check — a client
could send a multi-hundred-MB metadata blob (up to the 200 MiB request limit) as
one allocation. Image parts were already streamed with a cap; metadata was the
last unbounded read.

Cap it at a shared upload::MAX_METADATA_BYTES (64 KiB) via the read_capped
streaming loop (413 on overflow), and remove the now-unused read_field_bytes
helper so the unbounded path can't return.

Tests: manga + chapter oversized-metadata parts are rejected with 413.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:10:05 +02:00
MechaCat02
253d46c7e5 fix: return NotFound when a storage key resolves to a directory
`get` (fs::read → EISDIR) and `get_stream` (File::open succeeds on a Unix
directory, then streams garbage that fails mid-read) both mishandled a key
resolving to a directory. Mirror `size`'s existing guard: `get` maps
IsADirectory to NotFound; `get_stream` checks metadata.is_file() after open.

Test: get_on_directory_is_not_found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:59:51 +02:00
MechaCat02
c570e0cc37 fix: escape LIKE wildcards in user-search ILIKE queries
A `%` or `_` in a search term silently acted as a LIKE wildcard rather than
matching literally (`50%` matched everything, `a_b` matched `axb`). Not
injection — terms are bound — but a search-correctness bug across every
user-facing ILIKE site.

- repo::escape_like: shared pub(crate) escaper (promoted from page_tag), unit-tested.
- Trigram-entangled sites (manga, tag, author) append a separate escaped param
  used only by the ILIKE branch; the trigram/similarity branches keep the raw term.
- Pattern-built sites (admin manga list, admin users, analysis coverage + history,
  crawler search incl. JSONB payload title) escape inside format!("%{}%", ..) and
  pair each ILIKE with ESCAPE '\'.

Tests: escape_like unit tests + integration tests on public manga search, author
autocomplete, and admin user search proving `_` matches literally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:57:06 +02:00
MechaCat02
5784483a57 fix: add CSP and defense-in-depth security response headers
Document responses carried no CSP, X-Frame-Options, Referrer-Policy, or
Permissions-Policy — leaving clickjacking and zero script-injection defense
in depth (security audit T4).

- svelte.config.js: enable kit.csp hash mode. SvelteKit hashes its own inline
  hydration scripts; the inline theme <script> in app.html isn't part of
  %sveltekit.head%, so its sha256 is pinned by hand (csp-config.js) and added
  to script-src alongside object-src 'none', base-uri 'self', frame-ancestors
  'none'. Styles stay unconstrained (Svelte emits dynamic inline style= attrs).
- hooks.server.ts: applySecurityHeaders() sets X-Frame-Options: DENY,
  Referrer-Policy: strict-origin-when-cross-origin, X-Content-Type-Options:
  nosniff, and a Permissions-Policy locking down unused features, only when the
  response hasn't already set them.
- src/csp-theme-hash.test.ts: drift guard against editing the theme script
  without updating THEME_SCRIPT_HASH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:27:27 +02:00
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
134ab54b34 fix: don't apply the SSRF DNS resolver to proxied/internal clients
All checks were successful
deploy / test-backend (push) Successful in 39m39s
deploy / test-frontend (push) Successful in 10m52s
deploy / build-and-push (push) Successful in 12m5s
deploy / deploy (push) Successful in 13s
ff4ca96 wired SafeResolver (drops any host resolving to a private IP) into
all four crawler/analysis reqwest clients. That broke every proxied fetch:
with a socks5h:// proxy the target is resolved by Tor, so reqwest only ever
resolves the proxy's OWN host — `tor`, on a private Docker IP (172.x) — which
the resolver then rejects ("SOCKS error: failed to create underlying
connection"). 100% of crawler image downloads failed. The vision clients
broke the same way against `mangalord-vision` (latent; analysis was off).

Fix: attach the resolver only on the crawler's direct (unproxied) path, where
it genuinely guards DNS rebinding. Behind a proxy it adds no protection (Tor
can't reach internal ranges and the target is invisible to reqwest) so it's
omitted. Drop it from the vision clients entirely — that endpoint is an
operator-configured internal service that must resolve to a private IP.

Security posture is >= the pre-ff4ca96 baseline (which had no resolver at all).
is_private_ip / retain_public_addrs and their tests are unchanged.

Bump 0.124.13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:20:25 +02:00
MechaCat02
f879ce1866 fix: per-IP auth rate limiting instead of one global bucket
All checks were successful
deploy / test-frontend (push) Successful in 10m44s
deploy / build-and-push (push) Successful in 11m35s
deploy / deploy (push) Successful in 12s
deploy / test-backend (push) Successful in 31m21s
A single global token bucket let one attacker at the sustained rate 429
every user's login/register/change-password. Key buckets by client IP: the
SvelteKit proxy now stamps the real peer address onto X-Forwarded-For
(overriding any client-supplied value, anti-spoof), and axum reads it via a
ClientIp extractor — but only when AUTH_TRUSTED_PROXY is set, else it falls
back to the shared bucket (today's behavior). The per-IP map is bounded
(10k IPs, idle buckets pruned) so a spoofed-IP spray can't grow it.

AUTH_TRUSTED_PROXY defaults false (safe for a directly-exposed backend);
compose sets it true since the proxy is the single trusted hop.

Bump to 0.124.12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:07:06 +02:00
MechaCat02
bf425cf8e6 fix: opt-in CDP re-validation of headless-browser navigations (SSRF)
The reqwest DNS resolver can't see Chromium navigations, so a scraped page
that redirects the browser to an internal target (302 -> 127.0.0.1:5432, or
a rebinding hostname) was still loaded. Add crawler::intercept: an opt-in CDP
Fetch guard that re-validates every Document navigation/redirect through the
same ensure_public_target + resolved-IP check, failing internal targets.

Gated behind CRAWLER_SSRF_INTERCEPT (default OFF): enabling Fetch is a fragile
critical-path hook and the CDP wiring is not CI-verifiable (no Chromium in
CI). When off, open_page is byte-identical to browser.new_page; when on, a
guard-install failure falls back to an unguarded navigation rather than
wedging the crawl. The pure decision logic (verdict/is_blocked) is unit-tested;
an #[ignore] smoke test covers the CDP path. Validate with a manual crawl
before enabling in production.

Bump to 0.124.11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 06:29:17 +02:00
MechaCat02
ff4ca964f5 fix: reject private IPs after DNS resolution (SSRF/DNS-rebinding)
The SSRF guard only checked the host string, so an attacker-owned domain
resolving to 169.254.169.254 / 10.x (DNS rebinding, TOCTOU) bypassed it.
Add a reqwest dns::Resolve (SafeResolver) that drops resolved addresses in
private ranges, wired into all four crawler/analysis clients — it fires per
connection so it also covers redirect hops. Also close the is_private_ip
IPv6-embedding gaps (IPv4-compatible ::/96, NAT64 64:ff9b::/96, 6to4
2002::/16 all now unwrap to the embedded IPv4).

Bump to 0.124.10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:52:30 +02:00
MechaCat02
61669aac3f fix: don't mark private-mode blobs as publicly cacheable
The immutable Cache-Control added for the reader preload work was
`public` unconditionally. Under PRIVATE_MODE, /files is auth-gated, so a
shared cache/CDN would store the blob and serve it to unauthenticated
clients, defeating the gate. Emit `private, ...` when private_mode is on,
`public, ...` otherwise.

Found in the security audit; regression from 0.124.3. Bump to 0.124.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:39:20 +02:00
MechaCat02
795eb76f9a fix: document and wire the DB pool env vars into compose
The new DB_MAX_CONNECTIONS / DB_ACQUIRE_TIMEOUT_SECS knobs were read by the
backend but never added to .env.example or docker-compose.yml, so an operator
setting them in .env would see no effect (caught by the config meta-tests).
Document both in .env.example and wire them into backend.environment.

Bump to 0.124.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:18:49 +02:00
MechaCat02
ebf0b8289b fix: compute the /mangas total only on the first page
GET /mangas recomputed count(*) over FILTER_WHERE (up to five correlated
subqueries) on every page, so pagination cost scaled with catalog size. The
total doesn't change as a caller walks pages, so compute it once at offset 0
and return null thereafter (the envelope already serialises total as
Option<i64>; the frontend types it number|null and the library route ignores
it).

Bump to 0.124.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:14:15 +02:00
MechaCat02
ce9a727c73 fix: enforce the analysis image cap while reading, not after
The OCR and vision dispatchers read the whole page image into a Vec via
storage.get() and only then checked max_image_bytes, so the cap couldn't
bound the read. Stream via get_stream() + safety::accumulate_capped so the
read bails as soon as the running total exceeds the cap.

Bump to 0.124.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:10:29 +02:00
MechaCat02
46134c8760 fix: close Chromium tabs on crawler fetch error paths
new_page() opened a tab that was only closed on the happy path; a `?` on
wait_for_nav / content() leaked it (chromiumoxide doesn't close on drop),
so errored fetches accumulated tabs in the shared browser. Add a generic,
unit-tested `close_after(close, body)` helper and wrap the three fetch
sites (chapter content, session probe, source navigate) so the tab is
closed on every exit path — including navigate's content()-read branch,
which previously leaked.

Bump to 0.124.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:04:50 +02:00
MechaCat02
b7d8faadf7 fix: size the DB pool and fail fast on saturation
The single Postgres pool (shared by HTTP handlers and the crawler/analysis
daemons) hardcoded max_connections=10 with no acquire_timeout, so under
saturation callers hung on the driver's silent 30s default. Add a DbConfig
(DB_MAX_CONNECTIONS default 20, DB_ACQUIRE_TIMEOUT_SECS default 10, clamped
to >=1) and apply both in PgPoolOptions.

Bump to 0.124.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:56:14 +02:00
MechaCat02
b0500e8e48 fix: cache immutable blobs served from /files
Page and cover blobs are content-addressed by unguessable, immutable keys,
but /files sent no Cache-Control, so the browser refetched on every reader
open — defeating the whole-chapter and next-chapter preloading (and re-
proxying every byte through the node server in prod). Send
`Cache-Control: public, max-age=31536000, immutable`.

Bump to 0.124.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:30:03 +02:00
MechaCat02
379224bee4 fix: lazy-load continuous-reader pages beyond an eager window
Continuous mode rendered every page (up to 2000) with loading="eager",
fetching and decoding the whole chapter up front — enough to OOM a mobile
tab on a long chapter. Eager-load only a leading window ([0..=eagerThrough],
covering the deep-link target + a lead, min 6 pages) and lazy-load the rest;
the browser fetches them as the reader scrolls near. Unloaded lazy pages
reserve height (page width x ~1.4) so they stay distinct scroll targets for
the progress IntersectionObserver, cleared on load.

Keeps the DOM dense so the scroll-to-?page=N effect, progress observer, and
next-chapter preload trigger are all unchanged. Rewrote reader-preload-window
spec to assert the eager/lazy attribute partition + deep-link scroll landing.

Bump to 0.124.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:28:41 +02:00
MechaCat02
1c955458d6 fix: run Argon2 password hashing on the blocking pool
Argon2id hashing (~15-50ms, 19 MiB) ran synchronously inside async
handlers, stalling every task sharing those runtime worker threads under
concurrent auth load. Add spawn_blocking wrappers hash_password_async /
verify_password_async and switch all async call sites; sync primitives
stay for the login timing-equaliser and unit tests. Bump to 0.124.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:19:14 +02:00
MechaCat02
987d1ba235 feat: preload the whole current chapter in continuous mode
Eager-load every page of the current chapter rather than a bounded
rolling window, so pages are fetched up front (the browser paces them
over its per-origin connection limit, top-to-bottom = reading order)
instead of filling in as they're scrolled onto. This drops the
scrollLeadIdx/eagerThrough machinery in favour of a plain
`loading="eager"`.

The first few pages of the *next* chapter are already warmed when the
reader nears the end of the current one (the next-chapter preloader), so
flipping over stays instant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:47:48 +02:00
MechaCat02
c69ff502f7 feat: preload the current chapter ahead of the reader
Continuous mode eager-loaded only pages 0..=initialIndex; everything
below stayed `loading="lazy"`, so pages fetched and reflowed in as they
scrolled into view. Now a rolling window eager-loads a few pages ahead of
the reading position (PRELOAD_AHEAD) so they arrive — fetched and sized —
while still below the fold, then swap in without pop-in.

The window rolls forward off `scrollLeadIdx`, the top-of-viewport page
measured from live rects and guarded by `scrollY`. It deliberately does
not use the read-progress high-water mark: before images load they are
0-height and pile into the viewport, which would latch that mark to the
last page and eager-load the whole chapter on open. Pages past the window
stay lazy so opening a long chapter doesn't fetch every page at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:39:50 +02:00
MechaCat02
51ea254dde fix: surface streamed load failures inline
All checks were successful
deploy / test-backend (push) Successful in 30m44s
deploy / test-frontend (push) Successful in 10m46s
deploy / build-and-push (push) Successful in 10m59s
deploy / deploy (push) Successful in 13s
The streamed loaders on bookmarks, collections, library, search, and the
collection detail page swallowed only ApiError into their in-band `error`
field and re-threw anything else. A raw network failure (a non-ApiError
TypeError from fetch) therefore escaped the `{#await}` to SvelteKit's
generic error page. On the collection detail page a content-load failure
showed a misleading "this collection is empty", and on search a failed
results query showed a false "no matches" because the streamed `error`
was never rendered.

Fold non-ApiError failures into the same in-band `error` so every
transient blip renders as an inline message, add a `contentError` branch
to the collection detail grid, and render the search results `error` in
both result views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:11:37 +02:00
MechaCat02
fee68dd9ac feat: loading skeleton on the manga detail page
The detail loader now streams its six-call bundle instead of blocking
navigation on it. The page component is a thin {#await} wrapper: it shows a
MangaDetailSkeleton (cover + meta + chapter rows) while the bundle loads,
renders the extracted DetailView once resolved, and shows an inline
not-found on a 404 (previously the framework error page). Extracting
DetailView also makes it remount per navigation, so its tag/bookmark/reaction
state re-seeds cleanly on manga-to-manga moves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:30:49 +02:00
MechaCat02
3c8e264f48 feat: loading skeleton on the library page
The library loader streams its one-shot bundle (also the auth gate), so the
active sub-tab shows a skeleton while it loads — a grid skeleton for the
Collections tab, a row skeleton for Bookmarks/History/Page-tags — instead of
only the global nav bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:21:20 +02:00
MechaCat02
16cdec051a feat: loading skeletons on the bookmarks and collections pages
Both loaders now stream the whole result (the single fetch is also the auth
gate) so the page shows a skeleton while it loads instead of the global nav
bar only: a ListRowSkeleton on bookmarks and a MangaGridSkeleton on the
collections grid, resolving to the sign-in / error / empty / list branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:18:43 +02:00
MechaCat02
5b51bcf056 feat: results skeleton on the search page
The search loader now awaits only the chip cloud (its auth gate) and streams
the view-specific results, so switching tag / view / sort / text shows a
SearchResultsSkeleton in place of the frozen previous list until the query
resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:14:43 +02:00
MechaCat02
f1e66141f4 fix: add tags optimistically on the detail page
submitTag now shows the chip immediately with the typed name and reconciles
it with the server's (normalized) ref on success, rolling back on error —
matching the already-optimistic tag removal instead of blocking on the
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:05:23 +02:00
MechaCat02
cee1e73f98 feat: grid skeletons on the author and collection detail pages
Both loaders now stream their manga grid instead of blocking on it: the cheap
header renders immediately (and still handles 404/401 in the loader) while a
MangaGridSkeleton stands in until the grid resolves, then swaps in without a
layout shift. Collection detail seeds its optimistic-removal state from the
stream via a guarded effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:01:53 +02:00
MechaCat02
ec73c6e001 feat: reserve home shelf space while it loads (fixes CLS)
The Continue-reading / Recommended shelves were fetched after the catalogue
and rendered only once populated, so they popped in late and shoved the grid
down on every authenticated home visit. Now the shelf fetch fires
concurrently with the catalogue and a reserved ShelfSkeleton holds the space
for logged-in users until it resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:53:03 +02:00
MechaCat02
bd6ae86a85 feat: preload the reader from the detail CTA
The Continue / Read-first-chapter CTA is the highest-intent link to the
heaviest route in the app. An effect now programmatically preloadData()s the
target chapter as soon as the detail resolves, so the reader's load and first
page images are warm before the tap. Complements the reader's own
next-chapter warming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:47:53 +02:00
MechaCat02
3622dcc02f fix: reserve detail cover box and async-decode list/shelf covers
- The manga detail desktop overview cover had no reserved aspect-ratio, so
  the meta column reflowed when it decoded. Give .cover aspect-ratio: 2/3 +
  object-fit: cover (mirrors MangaCard).
- Add decoding="async" to the detail hero/overview covers and the list/shelf
  covers (bookmarks, history, continue-reading, collections, tagged rows) so
  decode stays off the main thread — previously only MangaCard had it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:43:21 +02:00
MechaCat02
9cb2f152d3 fix: bookmark toggle is optimistic and no longer fails silently
The detail-page bookmark toggle awaited the round-trip before updating and
had no catch — a failed create/delete threw unhandled and the user saw
nothing. It now flips optimistically (placeholder reconciled with the server
row on success), rolls back on error, and surfaces a toast, matching the
sibling like/dislike buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:40:33 +02:00
MechaCat02
7c3c9cf699 feat: app-wide toast notifications
Adds a client-side toast store (info/success/error, auto-dismiss with a
sticky option) and a Toaster component mounted in the root layout, wired to
the reserved --z-toast token. Gives mutations a consistent way to surface
success/failure instead of inline-only text or silent catches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:36:34 +02:00
MechaCat02
0d9505ce9f fix: address skeleton/nav-progress review findings
Correctness:
- MangaGridSkeleton: covers rendered 0px tall because height="0" is a
  definite height that defeats aspect-ratio. Skeleton gains an `aspectRatio`
  prop (height stays auto) so the 2/3 cover box is reserved and the grid no
  longer shifts when real covers load.
- Move `.manga-card` layout into global tokens.css (it was scoped to
  MangaCard, so the skeleton's cards inherited none of it). Both consumers
  now share one definition.
- NavProgress: rebuilt as an idle/loading/done state machine. It now
  completes to full and fades on settle (was snapping away instantly),
  restarts the trickle when one navigation supersedes another (was parking
  frozen near 90%), animates transform/opacity only with will-change gated to
  the active phase, and hides itself in reader fullscreen.
- Skeleton: text-variant height resolved in one place (no inline style
  shadowing a stylesheet rule); shimmer gradient/animation gated behind
  prefers-reduced-motion: no-preference so reduced motion shows a flat block.

A11y:
- Home loading region announces via visually-hidden text instead of an
  aria-label a live region won't reliably read.

Cleanup:
- Drop the fragile :first-child selector and index-keying ceremony in
  MangaGridSkeleton; simplify Skeleton's style construction.

Tests: adds regression guards for the 0px cover collapse, the aspect-ratio
box, text-height defaulting, and the nav-progress complete/restart phases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:02:27 +02:00
MechaCat02
7bdbe3ce5b fix: define missing --font-md and --success-soft-bg tokens
Both were referenced by components (RecommendationShelf, the manga detail
page) but never declared, so var() fell through to inherited/fallback
values. Adds --font-md (1rem, on the xs/sm/md/lg scale) and
--success-soft-bg in both the light and dark theme blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:44:43 +02:00
MechaCat02
3364ea52c9 feat: async decode for manga cover images
Adds decoding="async" to the MangaCard cover so image decode happens off the
main thread, keeping grid scroll/paint smooth as covers stream in. The 2/3
aspect-ratio already reserves space, so no layout shift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:41:28 +02:00
MechaCat02
def97d3087 feat: skeleton loading state on the home catalog
Replaces the plain "Loading…" text with a content-shaped MangaGridSkeleton
so the catalog grid's layout is reserved while it loads and swaps in without
a shift. Keeps the data-testid="loading" wrapper (now role="status") for
accessibility and selector stability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:40:28 +02:00
MechaCat02
09f12c8959 feat: global navigation progress bar
Most data routes run their load with ssr = false, so SvelteKit holds the
previous page on screen while the next one's data resolves — with no
feedback. NavProgress, mounted once in the root layout and driven by the
$navigating store, shows a thin top bar that trickles while a navigation is
pending and fades out on completion. Degrades to a static visible bar under
prefers-reduced-motion. New --z-nav-progress token sits above the fixed
header but below modals/toasts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:38:14 +02:00
MechaCat02
34c1122e4e feat: add MangaGridSkeleton loading placeholder
Composes Skeleton into a cover-grid placeholder that reuses the shared
.manga-grid / .manga-card layout, so it matches the real catalog grid at
every breakpoint and content swaps in without a layout shift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:31:58 +02:00
MechaCat02
200ab1c0a0 feat: add Skeleton loading-placeholder primitive
A reusable, token-driven shimmer block with configurable width/height,
radius, and variant. Decorative (aria-hidden) — loading state is announced
by the container. Freezes to a solid muted block under the global
prefers-reduced-motion override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:28:00 +02:00
MechaCat02
ef8d226ba6 fix(detail): resync reaction toggle across manga navigation
All checks were successful
deploy / test-backend (push) Successful in 36m1s
deploy / test-frontend (push) Successful in 10m41s
deploy / build-and-push (push) Successful in 11m41s
deploy / deploy (push) Successful in 13s
The detail page component is reused across /manga/A -> /manga/B
navigations (clicking a similar or recommendation card), so
ReactionButtons' locally-held `current` state kept the previous manga's
reaction until interacted with — a visibly wrong thumb on the new page.
Re-seed `current` from the loader-supplied prop via an effect keyed on
mangaId. Also fetch the homepage's two independent /me/* shelves
concurrently (Promise.allSettled) instead of in series.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:17:18 +02:00
MechaCat02
19db66a845 feat(home): add a "Recommended for you" shelf
Surface the content-based /me/recommendations feed as a horizontal shelf on
the homepage, below the Continue-reading shelf. Fetched after the catalogue
(same non-blocking pattern), shown only when signed in and the feed is
non-empty; reuses MangaCard in a fixed-width scroller. Component unit test +
e2e (signed-in shows cards, anonymous hides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:46:33 +02:00
MechaCat02
d6a109df2d feat(recommendations): content-based "Recommended for you" endpoint
Add GET /me/recommendations: rank mangas by weighted tag overlap with the
user's taste — explicit like +1.0, bookmark +0.5, dislike −1.0 (reaction
overrides bookmark). Per-tag affinities are summed, candidates scored by
their tags' affinity normalized by tag count (anti-tag-stuffing, à la
list_similar), with already-reacted/bookmarked/read mangas excluded and
net-negative candidates dropped (dislike down-ranks, not browse-hides).
Reuses manga_cols/cards_from_rows. Integration tests cover like-driven recs,
dislike down-rank, bookmark half-weight ordering, seen-exclusion, empty
cold-start, and auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:42:05 +02:00
MechaCat02
bb833c7e71 feat(detail): like/dislike buttons on the manga page
Add a tri-state like/dislike toggle beside the bookmark action (signed-in
only): click a reaction to set it, the other to switch, or the active one to
clear — optimistic with rollback, mirroring the bookmark toggle. Seeds from
the new GET /me/reactions/:id in the detail loader (non-critical: any failure
degrades to an unset toggle). Unit tests cover the state machine; e2e drives
set → switch → clear against the real endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:20:24 +02:00
MechaCat02
258a536254 feat(reactions): per-user like/dislike on mangas
Add a private per-user reaction resource: a manga_reactions table (one row
per user+manga, like/dislike, cascades) and endpoints PUT/DELETE
/mangas/:id/reaction plus GET /me/reactions/:manga_id, wired on the existing
per-user seam (mirrors bookmarks/read_progress). Unknown manga → 404 via the
FK-violation mapping; an invalid reaction value → 422. Reactions are never
exposed publicly — this is a taste signal for the upcoming content-based
recommendations. Integration tests cover set/toggle/clear/read, per-user
isolation, and the 404/422/401 paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:54:11 +02:00
MechaCat02
9c4a93c058 fix(reader): harden swipe/long-press coexistence and next-chapter prefetch
All checks were successful
deploy / test-backend (push) Successful in 32m21s
deploy / test-frontend (push) Successful in 10m33s
deploy / build-and-push (push) Successful in 11m40s
deploy / deploy (push) Successful in 18s
- A long-press now cancels any pending swipe, so a hold-then-drag gesture
  opens the action sheet without also turning the page (regression-tested).
- The next-chapter prefetch no longer retries on every page flip when the
  endpoint fails (guard clears only when the next chapter changes), and it
  clears a previous chapter's warmed images on navigation so stale hidden
  <img>s don't linger.
- Tap zones set touch-action: pan-y so vertical panning stays native while
  horizontal swipes reach our handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:57:36 +02:00
MechaCat02
bfaa166e0a fix(home): keep finished series off the Continue-reading shelf
A "Continue reading" shelf listing series the reader already finished (read
to the last page, nothing new) is odd. Expose the last-read chapter's
page_count on the read-progress list, and filter the shelf to drop entries
that are caught up (on the last page of the latest chapter with no new
chapters). Mid-chapter and has-new-chapters entries stay; the full history
view is unaffected. Finished detection is a pure, unit-tested helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:52:58 +02:00
MechaCat02
3824eaafb2 fix(history): count distinct new chapter numbers, authoritative on detail
new_chapters_count over-counted when scanlations share a chapter number
((manga_id, number) is non-unique, migration 0013): COUNT(*) tallied rows,
so duplicate-numbered chapters inflated the badge. Switch both the list and
per-manga read-progress queries to COUNT(DISTINCT number), and drop the dead
COALESCE (an empty set counts as 0, and the NULL-number case is handled by
the predicate). Fix the misleading comment.

Expose new_chapters_count on GET /me/read-progress/:manga_id and drive the
detail page's "N new" badge from it, instead of recomputing over the
paginated chapter list — which under-counted series past 50 chapters and
disagreed with the homepage shelf. Read markers stay client-side over the
loaded chapters and are documented as by-number (all we persist).

Adds duplicate-scanlation tests at both endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:48:36 +02:00
MechaCat02
f1349100d7 feat(reader): prefetch the next chapter's first pages near the end
When the reader reaches within two pages of the end of a chapter (tracked in
both modes off the furthest page reached), warm the next chapter's first few
page images via hidden imgs so flipping over renders instantly. Fetched once
per next-chapter id, guarded against mid-flight chapter changes, and retried
on failure. e2e asserts the prefetch is absent at the chapter start and
present (with the next chapter's URLs) near the end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:28:06 +02:00
MechaCat02
3f1a5e9c41 feat(reader): swipe left/right to turn pages in single mode
Wire horizontal swipe into the mobile tap-zone overlay (single mode only,
so continuous mode keeps native vertical scroll). A leftward swipe past the
threshold turns to the next page, rightward to the previous; a mostly-
vertical drag is ignored so panning a tall page never turns it. The gesture
suppresses the synthesized zone tap so it doesn't double-fire. Swipe
classification is a pure, unit-tested helper; e2e drives real touch pointer
events for horizontal (both directions) and vertical cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:23:34 +02:00
MechaCat02
c212deb7b0 feat(reader): add a keyboard-shortcut help overlay
Press `?` in the reader to open a Sheet documenting the existing bindings
(arrows/J/K paging, Home/End, chapter nav, Esc); Esc or the close button
dismisses it. While open, the reader swallows navigation keys so paging
can't happen behind the overlay. e2e covers open-on-? and close-on-Esc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:18:47 +02:00
MechaCat02
f5692ea109 feat(ui): show a tooltip with the full text on truncated titles
Add an `overflowTooltip` Svelte action that sets a native `title` only when
an element is actually clipped (ellipsis or line-clamp), re-checking on
resize. Native title is keyboard/touch reachable and zero-dep, unlike a
hover-only popover. Applied to the clipped titles on MangaCard, BookmarkList,
HistoryList, and the Continue-reading shelf.

Unit tests cover the overflow decision and the set/clear/update behaviour;
an e2e verifies a real-browser truncated card title gets the tooltip while a
short one does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:08:28 +02:00
MechaCat02
ad1689b818 feat(detail): link genre and tag chips to the filtered catalogue
Genre chips now deep-link to /?genres=<id> and user-tag chips to
/?tags=<id>, reusing the homepage catalogue's existing filter params —
turning the detail page's metadata into one-tap discovery. Tag chips keep
their remove affordance (the Chip's href branch preventDefaults the remove
button). e2e asserts both chip hrefs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:04:38 +02:00
MechaCat02
01d18e7ba2 feat(detail): mark read chapters and count new ones on the manga page
On the manga detail page, dim chapters at or before the reader's last-read
chapter (with an accent check + screen-reader "Read." label) and show a
"N new since last read" badge counting chapters that have landed since.
All client-side from the already-loaded chapter list and per-manga read
progress — no extra requests. The count reflects the loaded (paginated)
chapter list. Logic lives in a pure, unit-tested chapterProgress helper;
e2e covers the mid-series and never-opened cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:01:58 +02:00
MechaCat02
2267a83f6c feat(home): add a Continue-reading shelf with new-chapter badges
Surface a horizontal "Continue reading" shelf at the top of the homepage,
fed by the user's read-progress history. Each card jumps back to the exact
page they left off (deep-linking `?page=N` past the first) and flags how
many chapters have landed since — the personal new_chapters_count from the
read-progress list. The shelf is fetched after the catalogue load so the
public browse path stays unauthenticated; guests get an empty list (401
swallowed) and the shelf stays hidden.

Add new_chapters_count to the frontend ReadProgressSummary type to match
the backend. Component unit tests cover href/deep-link, badge visibility,
and the no-chapter fallback; e2e covers signed-in (shelf + badge) and
anonymous (hidden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:57:17 +02:00
MechaCat02
f318c3bf51 feat(history): report personal new-chapter count on read-progress list
Add `new_chapters_count` to each `GET /me/read-progress` item: how many
chapters sit past the reader's last-read chapter, computed by a correlated
subquery on chapter number. It is 0 when the reader is caught up, and 0
when the last-read chapter is unknown (manga-level progress or a deleted
chapter) so we never over-claim. (manga_id, number) is non-unique, so this
counts positions past the reader rather than distinct files.

Backs the personal "new since last read" indicator on the upcoming
Continue-reading shelf. Tests cover the mid-series, caught-up, and
no-read-chapter cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:50:49 +02:00
MechaCat02
cf8971faae fix(reader): render pages at a consistent width instead of thin stripes
Reader page sizing was height-driven — `max-height: 90vh` in single mode
and unbounded natural width in continuous mode. A long vertical page hit
the height cap first and, preserving aspect ratio, collapsed into a thin
vertical stripe; narrow and wide scans rendered at different widths.

Switch both modes to width-driven sizing via a capped `--reader-page-width`
reading column (min(100%, 700px)): every page renders at the same width
regardless of intrinsic dimensions, and tall pages extend downward and
scroll instead of shrinking. Top-align the single-mode grid and make the
prev/next chevrons sticky so they stay reachable on a long page.

Add a Playwright spec asserting equal rendered width across differently-
proportioned pages and that a tall page exceeds the viewport height. The
page-context-menu fixture served a 1x1 image that now renders as a 700x700
square taller than the viewport, making Playwright scroll it into view
before right-clicking — a synthetic scroll that trips the menu's by-design
close-on-scroll. Give that fixture a realistic landscape aspect so the page
fits the viewport, matching real user behaviour (no scroll on right-click).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:45:59 +02:00
MechaCat02
38146b4d03 chore(frontend): sync package-lock version to 0.94.0
All checks were successful
deploy / build-and-push (push) Successful in 11m29s
deploy / test-backend (push) Successful in 30m25s
deploy / test-frontend (push) Successful in 10m29s
deploy / deploy (push) Successful in 13s
The lockfile's root version field trailed the manifests (0.90.0 vs
0.94.0) — harmless to `npm ci` (it checks the dependency tree, not the
project version) but tidy to keep aligned. Also folds in the peer-dep
metadata npm regenerated on the last install. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:45:05 +02:00
MechaCat02
8acc0e6cc2 feat(frontend): use the Mangalord monogram as the browser tab icon
Some checks failed
deploy / test-frontend (push) Has been cancelled
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-backend (push) Has been cancelled
The favicon link pointed at a non-existent favicon.ico. Ship the
monogram SVGs from static/ and wire two theme-aware <link rel="icon">
tags so the tab icon follows the browser color scheme: the dark-M
"light" monogram on light chrome, the white-M "dark" monogram on dark.

Bump 0.93.10 -> 0.94.0 (feat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:34:45 +02:00
MechaCat02
4e154434a1 fix(upload): stream chapter pages to storage instead of buffering the whole chapter
The chapter upload handler read every `page` part fully into a Vec before
writing any, so peak memory was the whole chapter (bounded only by the
200 MiB body limit and amplified by concurrent uploads). It also accepted
an unbounded number of pages.

Stream each page part to a `staging/{upload_id}/…` key as it arrives — at
most one page's bytes are held at a time — then, once the chapter row (and
its id) exists, promote each staged blob to its final key via a new
`Storage::rename` (LocalStorage: fs rename; default impl: stream+delete for
future backends). Finalization is all-or-nothing: on any failure the DB
rolls back and both staged and already-finalized blobs are cleaned up.

Add MAX_PAGES_PER_CHAPTER (UploadConfig, default 2000, 0 = disabled),
rejecting an over-cap upload with 413 before any DB write. Also document
the crawler-side CRAWLER_MAX_IMAGES_PER_CHAPTER (added earlier) in
.env.example + docker-compose so the env-coverage test passes.

Tests: LocalStorage rename unit tests; a 413 over-cap upload test; existing
rollback + happy-path upload tests still green (the fault-injecting storage
counts put/put_stream, so mid-upload failure still rolls back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:33:47 +02:00
MechaCat02
141cd52f7e fix(frontend): percent-encode fileUrl key segments
fileUrl interpolated the raw storage key into the path, so a key segment
containing a reserved character (`?`, `#`, `%`, space) could be
reinterpreted as a query/fragment delimiter. Encode each `/`-separated
segment with encodeURIComponent, keeping slashes as literal path
separators. Keys are backend-generated today, so this is defence-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:18:34 +02:00
MechaCat02
83a9ab40cd fix(crawler): reap dead jobs alongside done ones
The retention reaper deleted only `state = 'done'` rows, so terminal
`dead` jobs (retries exhausted) accumulated in crawler_jobs forever —
slow unbounded table growth. Both states are end-of-life and never
leased again.

Rename `reap_done` → `reap_terminal` and broaden the filter to
`state IN ('done','dead')`; `pending`/`running` stay untouched. Test
extended to assert old done + old dead both reap while fresh terminal and
old-but-active rows survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:16:56 +02:00
MechaCat02
a141d65db1 fix(analysis): cap decoded pixels on the vision backend too
The vision prep pass decoded pages with bare `image::load_from_memory`,
which applies no allocation bound. `max_pixels` only downscales after a
full decode, so a tiny WebP/JPEG header declaring huge dimensions could
OOM the blocking worker — the same decompression-bomb the OCR backend
already guards against. Manga pages are commonly WebP/JPEG, where the
format's own self-limits are weaker than PNG's.

Route the decode through `decode_within`, applying an `image::Limits`
alloc cap sized from the shared `ocr_max_decode_pixels`
(ANALYSIS_OCR_MAX_DECODE_PIXELS). Add real-WebP bomb coverage asserting
both the helper and the end-to-end Undecodable fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:13:27 +02:00
MechaCat02
3a9e7ca2da fix(analysis): hold the OCR permit for the full blocking inference
The OCR dispatcher acquired a borrowed semaphore permit, then ran the
CPU-bound inference in `spawn_blocking`. On cancellation (graceful
shutdown) the future dropped the permit while the detached blocking task
kept running, briefly oversubscribing the blocking pool past
ANALYSIS_WORKERS.

Extract `run_ocr_blocking`, which acquires an *owned* permit and moves it
into the blocking task so the concurrency slot's lifetime matches the
actual work. Covered by a cancellation-invariant test asserting the
permit stays held after the caller future is aborted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:08:50 +02:00
MechaCat02
5dc93bfb84 fix(crawler): cap the number of page images per chapter
The per-image byte cap bounds each download but not the count, so a
hostile or compromised reader page listing thousands of <img> tags could
drive an unbounded disk fill. Add `CRAWLER_MAX_IMAGES_PER_CHAPTER`
(CrawlerConfig::max_images_per_chapter, default 2000, 0 = disabled) and
reject an over-cap chapter with a failed ack (exponential backoff) rather
than downloading it.

Threaded through sync_chapter_content and its three call sites (daemon
dispatcher, admin resync, CLI); enforced via `image_count_over_cap` right
after parse. The cap is an env-only safety knob, preserved across settings
reloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:05:28 +02:00
MechaCat02
592747f1e0 fix(crawler): budget image tail against actual sniff-prefix length
The per-image download cap subtracted the constant SNIFF_PREFIX_BYTES (64)
from the budget instead of the bytes actually drained into the prefix.
Because the prefix loop appends whole chunks, a single ~16 KiB first chunk
fills the 64-byte sniff window in one drain, so the tail could then add
another near-full cap — storing up to ~2× max_image_bytes per page.

Capture prefix.len() before the prefix is moved into the stream and budget
the tail as `max_image_bytes - prefix_len` via a small `remaining_after_prefix`
helper (unit-tested for the overshoot and saturation cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:57:29 +02:00
MechaCat02
a0d63ac9fd fix(crawler): guard headless nav against internal SSRF targets on list/detail path
`navigate()` (list/detail/pagination) and the session probe called
`Browser::new_page()` without the SSRF check that already guards the
chapter-content path, so a hostile or compromised scraped source could
serve `<a href="http://169.254.169.254/…">` / `http://postgres:5432/` in a
listing and use the in-container Chromium as a read oracle.

Add `guard_navigate_url` (reusing `ensure_public_target`) at the top of
`navigate`, before rate-limiting or opening a page, and apply the same
check to `fetch_probe_html`. Covers base URL, pagination, and detail links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:54:45 +02:00
MechaCat02
35c02066fe refactor(clippy): fix the never-loop error and clear all lint warnings
All checks were successful
deploy / test-backend (push) Successful in 30m17s
deploy / test-frontend (push) Successful in 10m33s
deploy / build-and-push (push) Successful in 11m11s
deploy / deploy (push) Successful in 13s
`cargo clippy --all-targets` was failing on a deny-by-default
`never_loop` in the analysis SSE handler (the `loop` always returned on
the first iteration — the stream `unfold` already re-enters per event),
plus ~28 warnings. All resolved with no behaviour change:

- admin/analysis SSE: drop the dead `loop` wrapper.
- app: match port literals directly instead of `if p == 80` guards.
- repo/user: separate doc list from the following paragraphs.
- repo/upload_history: `sort_by_key(Reverse(..))` over `sort_by`.
- crawler/nav test: construct the error directly (no `unwrap_err` on a
  literal `Err`).
- test helpers: build configs via struct-update syntax instead of
  `Default::default()` + field reassignment; add a type alias for a
  complex audit-row tuple.
- plus the mechanical `deref`/etc. fixes from `cargo clippy --fix`.

Note: not running `cargo fmt` — the backend uses a consistent
hand-formatted style that differs from rustfmt-default across ~110
files, so a blanket reformat would be pure churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:45:37 +02:00
MechaCat02
ded77fe4ba test(e2e): kill cold-start flakiness with an /api/v1 fallback + one retry
A full parallel run intermittently failed the first few tests: unmocked
/api/v1 calls fell through to the dev proxy, whose backend isn't running
under E2E, so each incurred a slow ECONNREFUSED round-trip + Vite error
logging that piled up across 8 workers at cold start.

- Add e2e/fixtures.ts: a shared `test` whose auto-use fixture installs an
  `**/api/v1/**` fallback (fast 503, logged) for any endpoint a spec
  didn't mock. Registered before the test body, so per-test routes still
  win; only genuine gaps land here. Turns silent mock-gap hangs into
  instant, attributable failures — and cuts the full run ~96s → ~25s.
- Point all 22 specs at ./fixtures instead of @playwright/test.
- retries: 1 in the config as a safety net for the residual Vite
  cold-compile timing (re-runs on the now-warm server).

Two consecutive full runs: 110 passed, 0 flaky, no retries consumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:06:20 +02:00
MechaCat02
4d02b56b77 test(e2e): realign mocks + assertions with the current API and OCR-era UI
The route-mocked E2E specs had drifted from the app they exercise, so
each rendered an empty/500 page and timed out on missing elements:

- Manga detail + reader loads now also fetch read-progress/{id} and
  /similar; several specs never mocked them, so the load's Promise.all
  rejected. Add the mocks and fill the manga fixtures out to a real
  MangaDetail (status/alt_titles/authors/genres/tags/... ) so the
  components stop throwing mid-render.
- back-nav + library: widen `read-progress*`/`page-tags` globs — `*`
  doesn't cross `/`, so `/me/read-progress/{id}` fell through to the
  proxy and 500'd; mock the library page-tags pair too.
- manga-list search: wait for the initial load to settle before
  searching so the search fetch can't race the mount-time load.
- reader-chapter-select: assert the shared `chapterLabel` output.
- admin-analysis: the active OCR backend extracts text only, so the
  detail modal shows OCR (no tags/NSFW) and metrics shows tiles +
  trend charts (no by-model) — assert what the OCR-era UI renders.
- profile: assert the wrong-password 401 stays inline (guards the
  fix in the preceding commit) and mock the guest bookmark/collection
  fetches the /profile load maps to the sign-in prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:11:23 +02:00
MechaCat02
0865659ab3 fix(auth): keep session on a wrong-current-password 401
The change-password endpoint returns 401 for a wrong *current* password,
the same status the global on401 hook uses to detect an expired session.
The hook fired first and cleared session.user, so the account form's
"still signed in?" guard always saw null and bounced the user to /login
instead of surfacing the error inline.

Add a per-call `suppressOn401` option to `request`; changePassword opts
in, so a 401 there stays inline and the session is left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:11:07 +02:00
MechaCat02
5596e1920d fix(compose): wire ANALYSIS_OCR_MAX_DECODE_PIXELS into the backend service
The OCR decompression-bomb guard added the env var to .env.example and
config.rs but not docker-compose.yml, so the container never received it
and the compose-env-coverage test failed once the branches were integrated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:25:59 +02:00
MechaCat02
3cba9ecf95 feat(auth): support optional expiry for bot API tokens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:22:57 +02:00
MechaCat02
ed18d95bb0 feat(crawler): guard Chromium chapter navigation against internal targets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:22:10 +02:00
MechaCat02
2ed42f7b9e feat(crawler): re-validate redirect hops to close SSRF via 3xx
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:21:15 +02:00
MechaCat02
cbbc626768 docs: correct ?text= OCR-search drift (no longer 501)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:19:38 +02:00
MechaCat02
a3e53f303b test(chapters): lock open-contribution upload contract + document intent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:19:04 +02:00
MechaCat02
2af421893f fix(crawler): don't hold browser mutex across Chromium close()
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:18:49 +02:00
MechaCat02
48f0439273 fix(crawler): evict idle hosts from the per-host rate-limiter map
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:18:35 +02:00
MechaCat02
de7aefef69 fix(auth): guard the login + change-password verify paths against oversized passwords
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:18:19 +02:00
MechaCat02
3ba30f4ba9 fix(login): close ?next= control-char open-redirect + cover register
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:18:01 +02:00
MechaCat02
4306d1c96a fix(frontend): strip hop-by-hop headers from proxied responses too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:17:32 +02:00
MechaCat02
886caaecfa fix(settings): bound manga_limit + the timeout knobs the finding named
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:17:14 +02:00
MechaCat02
de510cc19a fix(admin): make force-reanalyze audit transactional; document storage exception
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:16:08 +02:00
MechaCat02
5b76e0cc37 fix(analysis): bound concurrent OCR inferences with a shared semaphore
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:14:19 +02:00
MechaCat02
66ae4b221b fix(analysis): cap OCR decoded pixels to stop decompression-bomb OOM
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:12:40 +02:00
MechaCat02
83c2899373 feat(analysis): add ocrs OCR backend and OCR-driven page text search
All checks were successful
deploy / test-backend (push) Successful in 28m40s
deploy / test-frontend (push) Successful in 10m36s
deploy / build-and-push (push) Successful in 11m8s
deploy / deploy (push) Successful in 12s
Adds an in-process ocrs OCR backend as the active analysis engine
(vision left dormant), enables OCR text search on the page-tag
aggregation endpoints, and reshapes the admin analysis/settings UI to
present the OCR-only surface. Bumps version 0.89.0 -> 0.90.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:52:43 +02:00
MechaCat02
4fb98e4a1e refactor(history): reuse IconButton; drop redundant emptyText overrides
All checks were successful
deploy / test-backend (push) Successful in 30m16s
deploy / test-frontend (push) Successful in 10m36s
deploy / build-and-push (push) Successful in 10m24s
deploy / deploy (push) Successful in 13s
The HistoryList clear button hand-rolled the same `.icon-btn danger`
styles that were extracted into the shared IconButton component one
commit earlier — the exact duplication that refactor targeted. Swap it
for <IconButton variant="danger">; data-testid/aria pass through the
component's {...rest} spread so behaviour is unchanged.

Both callers also passed an emptyText equal to the prop's default; drop
the overrides so the default lives in one place.

No behaviour change, so no version bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:51:05 +02:00
MechaCat02
5130c9933e feat(profile): add a desktop Page-tags section
Page tags were a first-class section of the mobile Library tab but had no
home in the desktop /profile tabs, so desktop users couldn't browse their
tagged pages without going through /search. Add a /profile/page-tags route
that reuses PageTagsList (the same component the mobile Library renders) and
a "Page tags" tab between Collections and History.

The loader mirrors the library wrapper's page-tags fetch; 401 → sign-in
prompt, matching the other profile sub-routes.

e2e: the desktop Page-tags tab navigates to /profile/page-tags and renders
the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
e9331747d0 feat(ui): present page-action editors as sheets on mobile
The Add-tags and Add-to-collection editors opened as centered Modals on
every form factor, so a mobile user went action-Sheet → centered Modal — an
inconsistent hop, and AddTagsSheet's docstring promised a sheet it never
got. Introduce AdaptiveDialog, which renders a bottom Sheet under 640px and
a centered Modal above it behind one shared contract (open / title / onClose
/ children / testid). Route both editors through it:

- AddToCollectionModal now wraps AdaptiveDialog (both its call sites — manga
  detail and the reader — adapt automatically).
- The reader hosts AddTagsSheet in an AdaptiveDialog instead of a Modal, so
  its "slots into a Sheet (mobile) or Modal (desktop)" contract is now true.

Modal/Sheet already share an API, so the switch is transparent: the dialog
keeps the same role, title, testid, and close affordance. Desktop is
unchanged.

AdaptiveDialog has its own unit test (Sheet on mobile, Modal on desktop);
an e2e proves the mobile long-press → Add-tag flow now opens a Sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
af6a07bd1f feat(nav): give admins a mobile entry point to the admin area
The admin area was reachable only from the desktop header's is_admin link;
on mobile there was no tab or row, so admins had to type the URL and no tab
highlighted. Add a conditional Admin row to the mobile Account hub and
`/admin` to the Account tab's match prefixes, so the tab stays highlighted
while an admin is in the admin area.

e2e: an admin session shows an Admin row linking to /admin; a non-admin
never sees it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
5e8323d7fa feat(reader): surface brightness on desktop, not just the mobile sheet
The brightness slider lived only in the mobile reader settings sheet, so
desktop users couldn't dim — and could be left stuck dimmed by a value a
phone had persisted (brightness is shared via localStorage and the
--reader-dim effect is form-factor agnostic). Add an inline brightness
slider to the desktop reader nav, marked `desktop-control` alongside the
existing mode toggle and gap select. It binds the same `brightness` state,
so a value set on either form factor stays in sync.

Also mocks `/me/read-progress` in the reader-mode e2e setup — without it the
GET/PUT fell through to the (absent) backend and stalled the reader load,
which is why those specs couldn't run headless. The spec is now hermetic and
covers the new desktop control (it dims/undims, and a phone-persisted value
is recoverable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
2715275065 feat(history): share one HistoryList across desktop and mobile
The desktop /profile/history page and the mobile Library "History" tab
each hand-rendered reading history with separate markup, which had drifted:
the Library tab was a read-only 2-column list with no date and no
removed-chapter / whole-manga fallbacks, while /profile/history was a
3-column list with a per-row clear button. Extract a shared HistoryList
component so the two can't diverge again.

- New HistoryList.svelte owns row rendering, the optimistic-removal UX
  (instant remove, rollback + inline error on failure), and the empty
  state. Clear is opt-in via an `onClear` prop so a caller can render
  read-only, but both surfaces now pass it.
- The mobile Library History tab gains the clear action, the "Read {date}"
  line, and the (chapter removed) / whole-manga fallbacks it was missing.
- Continue label is built in script (was inline) so the " — page N" suffix
  keeps its spaces — Svelte trimmed them at the {#if} edge, rendering
  "Chapter 3— page 7". Both surfaces now read "Continue Chapter N".

Net -204 lines across the two pages. Uploads parity on the mobile tab is
left as a follow-up (it needs the library loader to fetch uploads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
198a1d46b0 refactor(ui): fold the 36px header/search buttons into IconButton
Completes the IconButton extraction by adding `size` and `radius` props and
converting the two remaining outliers that were left out of the first pass:
the desktop header logout button (+layout) and the home search submit button
(+page), both 36px with the medium radius.

IconButton now drives every icon button in the app (except profile/history's,
which the shared-HistoryList change removes separately). Defaults stay 32px /
small radius, so the four already-converted sites are untouched. Rendered
size, radius, and variant match the old markup — no behaviour change, no
version bump.

This also sets up applying the --tap-min touch floor in one place once the
touch-target change lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
3a36796768 refactor(ui): extract shared IconButton from duplicated .icon-btn copies
Five files hand-rolled a near-identical 32px `.icon-btn` (same size, hover,
and primary/danger variants). Extract a single IconButton.svelte component
so the treatment lives in one place. Converts the four sites with the
standard 32px form: collections detail, manga edit, upload, and the
chapter-pages editor.

The component takes a `variant` (plain/primary/danger) and spreads any
button attributes (onclick, disabled, aria-label, title, data-testid)
straight through; `type="button"` defaults but a caller can override. The
rendered button keeps the same class, styles, and DOM position, so layout
and behaviour are unchanged — no version bump.

Three icon-button sites are intentionally left out:
- The header (+layout) and home search button are 36px / different radius —
  size outliers that need a size/radius prop before folding in.
- profile/history's copy is being removed in the shared-HistoryList change;
  touching it here would just conflict.

A component (not a global class) avoids colliding with those remaining
local `.icon-btn` definitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:27:58 +02:00
MechaCat02
bd7c3fc28c refactor(catalog): derive desktop sort options from SORT_FIELD_LABELS
The desktop sort <select> hard-coded its four <option>s while the mobile
sort sheet rendered them from SORT_FIELD_LABELS. The two happened to match,
but a label rename in mangaSort.ts would have silently diverged the desktop
control. Render the desktop options from the same map so they can't.

No behaviour change (the rendered options are identical), so no version
bump. Adds an e2e guard asserting the desktop select's option values and
labels equal SORT_FIELD_LABELS in order — verified red against the old
hard-coded markup (with a label renamed) and green after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
4456deb146 fix(collections): make per-card remove buttons usable on touch
The remove buttons on a collection's cards were revealed only on
hover / focus-within (opacity 0 → 1), so on touch they were invisible and
undiscoverable. Show them persistently at the 640px breakpoint and enlarge
the hit area from 24px to 32px (kept below the full 44px floor because the
button floats over a 4-up cover thumbnail).

Test pins the CSS contract (jsdom can't evaluate @media): the mobile block
makes `.remove` opaque.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
94591dfda4 fix(manga): show force-resync result on mobile, not just desktop
`.resync-msg` (the admin Force-resync result) was display:none under 640px,
so mobile admins — who trigger resync from the overflow sheet — got no
success or error feedback. The element is already a sibling of `.action-row`
(not a child), so it survives the row being hidden; the only thing hiding it
was an explicit mobile rule. Drop that rule.

e2e: a mobile admin triggers Force resync from the overflow sheet and now
sees the "Metadata updated" result (written test-first against the hidden
element, then unhidden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
194adf1339 fix(ui): meet 44px touch-target floor on shared mobile primitives
The desktop/mobile consistency review found primary mobile controls
rendering below the ~44px comfortable touch floor. Address the genuinely
shared primitives in one pass:

- Add a --tap-min: 44px token (documented anchor for the floor).
- tokens.css: text inputs, selects, and submit buttons grow to --tap-min
  under the 640px breakpoint (fixes the 36px auth/login form controls).
  Scoped to form controls + type=submit so dense icon buttons aren't
  inflated.
- SegmentedControl: .seg options reach the floor on mobile (it doubles as
  the catalog sort toggle and the Library tab switcher).
- Chip: expand .chip-remove's tappable area to --tap-min via a centered
  pseudo-element so the 16px glyph stays compact and tag rows don't grow.

Pure-CSS responsive change — jsdom can't evaluate @media or layout, so the
test pins the stylesheet contract (each shared primitive bumps to --tap-min
inside the mobile breakpoint), guarding against the bump being dropped.

The per-route .icon-btn (copy-pasted across seven files) is left for a
separate refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00
MechaCat02
dee53fa212 feat(mangas): per-field sort options + direction toggle on the catalog (0.88.0)
Sort the manga catalog by created / updated / title / author with an
independent asc/desc direction control. An omitted `order` defaults per field
(dates newest-first, text A→Z), matching the UI, so a bare `?sort=<field>`
means the same thing in the browser and over the API; `sort=recent` remains a
back-compat alias for `created`.

- Backend: SortField/SortOrder parsed with validation (structured 422 on bad
  input), per-field default_order, NULLS LAST only on the nullable author key,
  and migration 0033 indexing mangas(updated_at DESC, id) to back the default
  sort and its id tie-break.
- Frontend: catalog sort field + direction (SegmentedControl) on desktop and a
  mobile bottom sheet; pure helpers in $lib/mangaSort; keyboard-accessible
  direction control; visible Direction labels and a sheet "Done" button.
- Tests: backend integration coverage (defaults, alias, invalid input,
  ascending-id tie-break), frontend unit + e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:15:51 +02:00
MechaCat02
93b7e451bf fix(jobs): per-lease generation token closes the ack-from-dead-lease race (0.87.26)
0.87.20 narrowed but did not close the force-analyze race:
ack_done / ack_failed / renew matched only on (id, state='running'),
so a worker A whose lease was released mid-flight (force-analyze,
reclaim_orphaned) could still come back later — once a successor B
re-leased the same id and put it back to state='running' — and
clobber B's lease. A's stale result became the row's `done` payload;
B's in-flight work was silently lost. The pre-existing test even
documented this hole: "We can't make ack_done's lease_id distinguish
A from B today".

Add `lease_generation BIGINT NOT NULL DEFAULT 0` to crawler_jobs
(migration 0032). It is bumped by every lease, release / release_unowned,
and reclaim_orphaned, so lease identity is `(id, generation)` rather
than just `id`. Each Lease struct carries its generation. ack_done /
ack_failed / renew / release match on `(id, generation, state='running')`
so a stale ack from a prior generation finds zero rows and falls
back to the existing warn-and-skip branch.

Split the release surface in two:
  * release(lease_id, lease_generation) — owner-aware path for
    workers (graceful shutdown, session-expired), matches the
    caller's specific lease.
  * release_unowned(lease_id) — id-only path for force-analyze and
    ops tools that drop a running lease without holding a Lease
    struct; unconditionally bumps generation so any pending ack
    from the in-flight original becomes a no-op.

Force-analyze in repo::page_analysis now uses release_unowned; the
test in api_admin_analysis still passes unchanged (it only asserts
the steady-state outcome). The pre-existing `ack_done_no_ops_when_
lease_was_stolen` test is strengthened: it now mints both A and B
leases, asserts their generations differ, and verifies A's stale
ack does NOT clobber B's running row — the assertion the old test
explicitly couldn't make.

Five new tests in tests/crawler_jobs.rs pin every facet:
  * lease_assigns_strictly_increasing_generation_per_release
  * ack_done_from_dead_lease_after_release_unowned_is_a_no_op
  * ack_failed_from_dead_lease_after_release_unowned_is_a_no_op
  * renew_from_dead_lease_is_a_no_op
  * release_unowned_bumps_generation_even_when_lease_is_held

Mutation-confirmed: relaxing the ack_done guard to
`lease_generation >= $2` (bind 0) makes the race test fail with
state=done — the exact prior-bug shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 20:05:55 +02:00
MechaCat02
ae708a72d0 fix(config): derive BACKEND_CONSUMED from .env.example; scan src/ for undocumented env reads (0.87.25)
0.87.12 added a regression test for backend env wiring, but its
BACKEND_CONSUMED list was hand-maintained and drifted: ~22 vars read
by backend/src/ never made it into compose's backend.environment.
An operator setting ANALYSIS_TEMPERATURE=0.3 in .env got a silent
no-op — exactly the bug class the test was supposed to catch.

Make .env.example the single source of truth:

  * docker_compose_wires_every_documented_backend_env_var now reads
    .env.example at test time, filters NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE
    (compose-side composed vars, frontend-only vars, sidecar vars),
    and requires every remaining key to be wired into compose with a
    matching ${KEY...} interpolation OR allowlisted via COMPOSE_RHS_EXEMPT.
  * every_backend_src_env_read_is_documented_or_allowlisted scans
    backend/src/ for env::var / env_bool / env_i64 / ... reads and
    requires each key to be in .env.example OR in INTERNAL_NOT_CONFIGURABLE
    with a per-entry rationale. Catches the silent-no-op bug from the
    other side.

Wire the previously-missing env vars into docker-compose.yml's
backend.environment (CRAWLER_DAEMON, CRAWLER_DAILY_AT, CRAWLER_TZ,
CRAWLER_IDLE_TIMEOUT_S, CRAWLER_CHAPTER_WORKERS, CRAWLER_JOB_RETENTION_DAYS,
CRAWL_METRICS_RETENTION_DAYS, CRAWLER_RATE_MS, CRAWLER_CDN_RATE_MS,
CRAWLER_USER_AGENT, CRAWLER_PHPSESSID, CRAWLER_COOKIE_DOMAIN,
CRAWLER_TOR_CONTROL_COOKIE_PATH, all ANALYSIS_* tuning knobs).
Document each new var in .env.example with the same default value
shown in compose's `${KEY:-default}` form. Allowlist internal/system
vars (HOME = chromium cache dir fallback, CRAWLER_BROWSER_*,
CRAWLER_SKIP_*, the multi-paragraph ANALYSIS_*_PROMPT seeds) with
per-entry rationale.

Mutation-tested both sides: adding `env::var("MANGALORD_FAKE_NEW_KNOB")`
to main.rs fails the scanner; removing the new compose wiring fails
the documented-var test with all 23 missing keys named.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 19:28:00 +02:00
MechaCat02
579e6ade0f fix(cancellable): suppress non-Abort errors from superseded predecessors (0.87.24)
CancellableLoader.run() previously swallowed AbortError but propagated
every other rejection. A predecessor whose fetch survived its abort
(timing race, or fn that ignores signal) and then 5xx'd would bubble
the error into the caller's catch, clearing the successor's spinner
and writing its `error` field — exactly the flicker the loader was
supposed to prevent.

Strengthen the contract: a call that no longer owns this.current
returns null regardless of how it died. Symmetric with the existing
late-resolve suppression at the success path. The loadCoverage catch
in admin/analysis/+page.svelte now only fires when this call genuinely
failed AND still owns the loader — so clearing the spinner there is
sound.

Three new unit tests pin all three contract corners: a superseded
predecessor's non-Abort rejection resolves to null; a standalone call
with no successor still throws normally; the AbortError swallow on a
still-owning call (synthetic AbortError from fn with no successor or
cancel()) remains protected, since the new supersede check would
otherwise have eaten every existing AbortError-path test and left
that branch as silently-removable dead code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 07:11:01 +02:00
MechaCat02
4e14ed09ef test(frontend): extract CancellableLoader; pin debounce-race invariants in unit tests (0.87.23)
0.87.16 followup. Extract the four debounce-race invariants
(predecessor abort, late-result suppression, AbortError swallow,
ownership-aware finally) from `loadCoverage` into a reusable
`CancellableLoader` helper at $lib/util/cancellable, with 9 unit
tests covering each. Page rewrite uses the helper.

Also corrects: hooks.server.test.ts (added Node-fetch rejection
shape for already-aborted entry; removed redundant tick); admin.test.ts
(AbortError-propagation now uses pre-aborted signal + mockRejectedValueOnce
mirroring real Node fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 21:03:12 +02:00
MechaCat02
2ee77bc867 test(vision): tighten spawn_blocking interval test against Burst-replay (0.87.22)
0.87.14 followup. The 0.87.14 test used MissedTickBehavior::Burst —
any post-prep yield replayed missed ticks and could fake the >= 2
threshold. Switch to Skip and assert on `ticks_post - ticks_pre >= 50`,
which sits well above the inlined-prep ceiling (~5) and far below
the spawn_blocking floor (~700+). Mutation-confirmed.

Also corrects: prepare_analysis doc count ("five fallback paths" → 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 20:52:42 +02:00
MechaCat02
a62a5f155b test(analysis): assert Cancelled event is published; correct cron-test rationale (0.87.21)
0.87.13 followup. The Cancelled publish had no asserting test —
mechanical revert of the publish block shipped green. New sqlx test
subscribes before spawn, drives SlowDispatcher into flight, cancels,
asserts both Started and Cancelled frames with breadcrumb fields.
Mutation-confirmed.

Also corrects: cron-test rationale comment (inverted unlock-axis),
Cancelled docstring (self-contradicting clear-triggers sentence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 20:46:08 +02:00
MechaCat02
e3e86843d6 fix(force-analyze): release running lease so worker drops stale-payload local (0.87.20)
0.87.11 followup. The UPDATE matched pending+running but the worker
holds the pre-update `force=false` in an in-memory local — so on the
running branch the row flipped but the worker's skip-if-done still
acked done without re-analyzing. Now `jobs::release` any running row
the UPDATE matched, so the worker's ack_done no-ops (state guard) and
a fresh lease picks up the updated payload.

New test mints the running-state shape, asserts state=pending +
force=true + attempts=0 post-click. Mutation-confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 20:42:11 +02:00
MechaCat02
0f9254b054 test(authz): bearer-authed admin cannot edit/cover NULL-uploader mangas (0.87.19)
0.87.10 followup from the rereview. The fix lacked a test for the
specific axis it changed (admin authority via cookie only). New test
mints a bearer for a promoted admin and asserts PATCH/PUT-cover/DELETE-
cover all 403 on NULL-uploader rows. Mutation-confirmed.

Also: `MultipartBuilder::finalize` now `pub` for bearer-multipart use;
`admin_csrf_guard` rustdoc updated to describe the post-0.87.10
cookie-precedence rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 20:36:50 +02:00
MechaCat02
d6a4fd668c test(vision-manager): extract refresh_running_after_mem_check; assert log distinguishes fix from bug (0.87.18)
Two 0.87.15 ship-blockers:

1. mem_check_and_stop test now ALSO greps for the "deferring to next
   tick" warning — distinguishes fix from bug. Mutation-confirmed.

2. Extract `refresh_running_after_mem_check` (sets global instead of
   echoing so loop-scope flags persist across calls) and drive it
   directly from the harness, closing the structurally-untestable
   post-mem refresh ERR guard.

Adjacent: log-on-flip gating on both ERR warnings; `log` to stderr;
dangling-TempDir in app::tests reclaim test fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 20:18:56 +02:00
MechaCat02
e3d16e49b7 fix(config): ANALYSIS_VISION_MODEL reader + compose RHS-interpolation test (0.87.17)
Two ship-blockers from the rereview:

1. Env var name mismatch: 0.87.12 renamed every operator surface to
   ANALYSIS_VISION_MODEL but the reader still called env::var on
   ANALYSIS_MODEL — every documented deploy ran analysis with an
   empty model. Rename the reader.

2. Compose regression test was substring-only and missed exactly the
   ANALYSIS_VISION_MODEL vs ANALYSIS_MODEL RHS mismatch from 0.87.12.
   Tightened to require each backend-consumed var either be in a
   small hardcoded allowlist (BIND_ADDRESS, STORAGE_DIR) or have its
   RHS interpolate `${KEY...}` with the same name. Mutation-confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 20:05:17 +02:00
MechaCat02
54cbbfc440 test(frontend): pin abort-chain + signal pass-through contracts (0.87.16)
Three 0.87.9 follow-ups:

1. hooks.server.ts: two new tests covering the
   already-aborted-at-entry and mid-flight client-disconnect branches
   of the abort chain. Mutation-confirmed.

2. lib/api/admin.ts: two new tests pinning the
   getAnalysisMangaCoverage(init?.signal) pass-through and the
   AbortError-rejection propagation the dashboard's debounce-race
   handler relies on. Mutation-confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 19:22:48 +02:00
MechaCat02
27fc1a52f6 fix(vision-manager): symmetric ERR handling + smoke tests + analysis reload reclaim test (0.87.15)
0.87.7 + 0.87.8 follow-ups:

1. mem_check_and_stop now defers on docker ERR instead of silently
   no-op'ing the SIGTERM under pressure.
2. Post-mem_check_and_stop loop refresh now also handles the ERR
   sentinel (continues the loop) rather than letting it reset up_for
   and defeat MAX_UPTIME.
3. New `vision-manager/test_manager.sh` — 7 smoke tests covering
   vision_running's three exit shapes and mem_check_and_stop's ERR
   symmetry, via stubbed docker/psql/curl on PATH. Production loop
   guarded by MANAGER_TEST_NO_MAIN.
4. New `app::tests::spawn_analysis_daemon_reclaims_orphaned_analyze_leases`
   pins the 0.87.7 wire-through: seeds an expired analyze_page lease,
   spawns + shuts down, asserts pending + attempts refunded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 19:18:54 +02:00
MechaCat02
95de02f583 fix(analysis-vision): pin spawn_blocking dispatch + warn on undecodable fallback (0.87.14)
Two 0.87.5 follow-ups:

1. Pin spawn_blocking dispatch: new current_thread runtime test runs
   analyze() on a 6 MP image while a counter task ticks every 5 ms.
   Without spawn_blocking the counter is starved (0 ticks); with it
   the runtime stays responsive (≥2 ticks). Mutation-confirmed.

2. Warn on Undecodable fallback: five fallback paths in prepare_analysis
   now emit a tracing::warn! with dimensions so a JPEG encoder
   regression isn't indistinguishable from "the page was just garbage."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 19:07:02 +02:00
MechaCat02
651fb27522 fix(analysis): emit Cancelled event + assert lock-release after cancel (0.87.13)
Two 0.87.4 follow-ups:

1. Cron tick: existing shutdown-on-cancel test now also asserts the
   advisory lock is re-acquirable after cancel-during-tick. Without
   this, a refactor moving `pg_advisory_unlock` under the cancel arm
   would ship green and break multi-replica safety.

2. Analysis worker: cancel-mid-dispatch left the dashboard banner stuck
   on the cancelled page until a later page overwrote it. Add a
   `Cancelled` AnalysisEvent variant, publish on the cancel-release
   branch, wire the analysis and admin overview dashboards to clear
   on it. Per-page chip rolls back from `analyzing` to `queued`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 19:00:38 +02:00
MechaCat02
95b98eebf1 fix(compose): wire all documented backend env vars + regression test (0.87.12)
0.87.1 added 12+ env vars to `.env.example` but the compose backend
`environment:` block forwarded none of them — `.env` interpolation
doesn't pass vars to the container. Operators got
anonymous-serving sites, silent ADMIN_* no-ops, and missing
ANALYSIS_* configuration.

Wire 36 backend-consumed vars; also substitute the hardcoded
`BACKEND_URL` on the frontend; emit a startup warning on half-set
`ADMIN_USERNAME`/`ADMIN_PASSWORD`. Regression-test parses
docker-compose.yml and asserts the wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 07:25:57 +02:00
MechaCat02
34d6d570eb fix(analyze-dedup): migration pre-dedup, force-collision upgrade, gate test (0.87.11)
Four 0.87.6 follow-ups from the adversarial review:

1. **Migration 0031 pre-dedup.** Demote all-but-lowest-id duplicate
   `analyze_page` rows in `(pending|running)` to `dead` before
   creating the unique index, with a curator-recoverable last_error
   marker. Without this, `sqlx::migrate!` would refuse to boot on
   any dirty production DB.

2. **`enqueue_for_page(force=true)` collision.** The partial unique
   index used to silently swallow force requests when a
   `force=false` job was already pending. Repo function now upgrades
   the pending row's `force` flag in place (or falls through to
   re-INSERT if the sibling drained mid-call), and reports an
   `EnqueueForPageOutcome` for accurate auditing.

3. **`record_duration` gate test.** New test seeds a `done` row with
   known duration, force-re-analyzes with failing dispatcher +
   max_attempts=3 (non-terminal), asserts duration_ms wasn't
   overwritten.

4. **`bookmark.rs` PK comment correction.** Use `b.id DESC` instead
   of the wrong `manga_id`; PK is actually `id`, and migration 0004
   allows both chapter-level and manga-level bookmarks on the same
   manga.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 07:22:20 +02:00
MechaCat02
93bb156fba fix(admin-security): close the bearer-cookie ride; require session for admin gate (0.87.10)
Two follow-ups to 0.87.2 + 0.87.3 from the adversarial review:

1. **CSRF cookie-ride.** Bearer-bypass branch checked Authorization
   header presence only — an attacker page could mint `Authorization:
   Bearer junk` and ride the still-attached session cookie. Bypass now
   requires bearer AND no cookie. Drive the cookie-name comparison off
   `SESSION_COOKIE_NAME` and split on `=` (not prefix).

2. **`require_can_edit` admin path open to bearer.** Now reads admin
   authority off an `Option<CurrentSessionUser>` extractor — None on
   bearer-only requests. Owner-match path still accepts bearer; admin
   path is cookie-gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 22:28:16 +02:00
MechaCat02
747bdeda46 fix(frontend-admin): SSE/AbortController hygiene + signal-chain through proxy (0.87.9)
Three frontend findings (medium):

1. **`hooks.server.ts` didn't chain client-disconnect into the upstream
   fetch.** Closing an admin tab left the backend's `broadcast::Receiver`
   + spawned tokio task running until next backend restart. Listen for
   `event.request.signal.abort` and forward to the timeout controller.

2. **`admin/analysis/+page.svelte` missing visibility/pagehide/pageshow
   handlers.** Mobile Safari throttles SSE in background tabs; bfcache
   leaves a dead connection on back-nav. Apply the close/open-stream
   pattern from `admin/crawler/+page.svelte`.

3. **`loadCoverage` had no AbortController.** Debounced search
   keystrokes raced. Per-loader controller plumbed through
   `getAnalysisMangaCoverage`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:52:33 +02:00
MechaCat02
c0281f7e9b fix(vision-manager): disambiguate docker errors, fail-symmetrically on psql, stop wedged container (0.87.8)
Three medium vision-manager findings:

1. **`vision_running()` conflated "container absent" with "inspect
   failed".** A transient docker-socket-proxy hiccup made the manager
   think vision had stopped, which reset `up_for`/`idle_for` and
   defeated MAX_UPTIME. Distinguishes: exit 0 → echo true/false;
   "No such container" → false; else → ERR (loop skips tick).

2. **`crawl_running()` fail-closed where `analysis_enabled()` fail-opens.**
   A psql `ERR` logged a misleading "RAM mutex" line every tick.
   Numeric-guard at the call site with a distinct log.

3. **`start_vision` left wedged containers running** on
   `START_HEALTH_TIMEOUT` expiry — pinned forever in not-ready state.
   Now `stop_vision` so next tick retries from a clean state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:47:33 +02:00
MechaCat02
6444ddee29 fix(analysis): reclaim orphaned leases from analysis daemon startup too (0.87.7)
`reclaim_orphaned` previously ran only inside `spawn_crawler_daemon` at
startup. A deploy with the crawler disabled and analysis enabled
(analysis-only mode) never refunded orphaned `analyze_page` leases —
recovery deferred to the lazy lease-expiry path. Wire it into
`spawn_analysis_daemon` too. Safe under multi-replica + idempotent
against the crawler-side call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:44:14 +02:00
MechaCat02
b9dd75684e fix(correctness): pagination tiebreakers, dedup race, duration gating (0.87.6)
Three medium correctness fixes:

1. **Pagination ORDER BY missing tiebreakers** — four admin list queries
   sorted by a timestamp or `chapters.number` with no stable secondary
   sort. Add `id` tiebreakers across `repo::admin_view`,
   `repo::admin_audit`, `repo::bookmark`.

2. **`enqueue_pages` NOT EXISTS read-then-insert race** — concurrent
   admin clicks could land duplicate analyze_page jobs. Migration 0031
   adds a partial unique index mirroring 0014; query relies on
   `ON CONFLICT DO NOTHING`.

3. **`record_duration` overwrote a prior done row's duration on a
   non-terminal failed retry of a force-re-analyze.** Gate the call
   on "did this attempt actually write a row?".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:34:39 +02:00
MechaCat02
80f4819fad perf(analysis): run image decode/resize/encode on the blocking pool (0.87.5)
`VisionClient::analyze` ran `image::load_from_memory`, `DynamicImage::resize_exact(Triangle)`,
and per-band JPEG encodes directly on the tokio task. Each call is
hundreds of ms of pure CPU per page. With multiple workers, the runtime
threads got starved — axum handlers, SSE streams, the crawler daemon's
async timers, and other tasks sharing the runtime all stalled while
analysis was active.

Extract a `prepare_analysis` helper that does ALL the CPU work
(decode + plan + width-reduce + slice + JPEG encode) and returns a
`PreparedAnalysis { Single | Sliced | Undecodable }` of already-encoded
byte payloads. `analyze` calls it inside `tokio::task::spawn_blocking`,
then the async HTTP loop only iterates over the prepared bytes — no
image-crate operations happen on the runtime any more.

Single page → one spawn_blocking → one HTTP call.
Tall page → one spawn_blocking → N OCR calls + 1 grounding call.

Memory peak rises slightly for the Sliced path (all bands held
encoded at the same time instead of one-at-a-time) — for a typical
manga page split into 4 slices at ~200 KB each, that's ~600 KB of
extra peak. Negligible vs. the runtime-starvation cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:34 +02:00
MechaCat02
660184a048 fix(daemons): race in-flight work against cancellation on shutdown (0.87.4)
Two high findings sharing one root cause: long-running daemon work that
ignored the shutdown signal until it finished naturally.

**Cron tick (crawler).** `CronContext::run_tick` ran the metadata-pass
body inside `AssertUnwindSafe(...).catch_unwind().await` with no
cancellation. A fresh-catalog walk (many minutes) wedged
`DaemonHandle::shutdown`, `Supervisors::reload_crawler` (under the
supervisor lock), and SIGTERM responsiveness for the whole pass.

Wrap the body in `tokio::select! { biased; _ = self.cancel.cancelled() => ...; r = body => ... }`.
On cancel the body future drops, which cascades to dropping
`metadata.run()` — exactly what gives Chromium/lease teardown a chance
via the BrowserManager idle reaper. The advisory unlock + conn drop
still run unconditionally.

**Analysis dispatch.** `process_lease` wrapped the vision call only in
`tokio::time::timeout(self.job_timeout, …)` — default 600s. Toggling
analysis off in the dashboard (under the supervisor lock) or stopping
the container blocked on whichever call was in flight. Same fix:
`tokio::select!` against `self.cancel.cancelled()`. On cancel,
`jobs::release` returns the lease to the queue without burning an
attempt — next tick picks it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:18:18 +02:00
MechaCat02
ee9f5c1a4d fix(authz): require admin to edit/cover crawler-imported mangas (0.87.3)
`require_can_edit` matched `Some(owner) != caller → Forbidden` but let
`None` through. Every crawler row (`repo::crawler::upsert_manga`) has
NULL `uploaded_by`, so any signed-in user could PATCH /api/v1/mangas/<id>
and rewrite the catalog — `update`, `put_cover`, and `delete_cover`
were all in scope. With self-registration on by default, the path was:
register → mass-edit catalog + delete cover blobs from storage.

The original carve-out at the comment said "Once an admin role lands the
NULL case can flip to admin-only." The admin role landed in migration
0018; flipping it now. New rule:

  * `Some(owner)` and owner == caller → ok (unchanged)
  * `Some(_)` and caller is admin → ok (admin moderation)
  * `Some(_)` otherwise → 403 (unchanged)
  * `None` and caller is admin → ok (NEW — operator can curate)
  * `None` otherwise → 403 (was: ok — the bug)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:09:34 +02:00
MechaCat02
dd25f073cd fix(admin-security): SSRF defence + CSRF fail-closed + analysis .no_proxy() (0.87.2)
Four high/medium findings from the audit, plus their tests:

1. **SSRF on admin-editable URLs.** `CrawlerSettings::start_url` and
   `AnalysisSettings::endpoint` validated only with `Url::parse`. A
   hostile or CSRF-able admin could repoint at `169.254.169.254`
   (cloud metadata), `127.0.0.1:5432` (postgres), or any RFC1918 host
   — and the vision worker bearer-attaches an env-managed API key to
   every call. Extract `ensure_public_target` from `safety::is_safe_url`
   (allowlist-free public-host check: scheme + private-IP literal +
   localhost) and wire it into both fields. Docker DNS names
   (`mangalord-vision`) keep passing because they're hostnames, not
   IP literals. The analysis check is gated on `enabled=true` so the
   dev-default `localhost:8000` stays usable until the worker is
   actually turned on (toggling enabled=true later re-runs the gate).

2. **Admin CSRF fail-open default.** `ADMIN_ALLOWED_ORIGINS=` empty
   silently skipped the entire CSRF check, so an operator forgetting
   the env var shipped an unguarded admin surface. Cookie-auth POSTs
   now fail-closed when the allowlist is empty AND when neither
   `Origin` nor `Referer` accompanies the request. Two carve-outs:
   `Authorization: Bearer …` callers bypass (bots can't be CSRF'd),
   and requests with NO session cookie at all bypass to let the auth
   extractor return a clean 401 instead of a confusing 403.

3. **Analysis HTTP clients didn't `.no_proxy()`.** The crawler client
   already did. Ambient `HTTP_PROXY`/`HTTPS_PROXY` in container env
   would exfiltrate page-image bytes + the bearer key through an
   upstream proxy. Same for the readiness probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 21:04:50 +02:00
MechaCat02
b14ed02670 fix(infra): plug operational defaults — pg restart, proxy timeout, env template (0.87.1)
Three operational gaps tripped a recent review:

1. **Postgres had no `restart:` policy.** tor / docker-socket-proxy /
   vision-manager all set `unless-stopped`; pg was the lone exception.
   A transient OOM or panic left backend's `depends_on: service_healthy`
   blocking every future restart and took the stack offline. Add
   `unless-stopped` on postgres and on backend/frontend in the base
   compose so the prod overlay is the same shape.

2. **`BACKEND_PROXY_TIMEOUT_MS` was documented but silently a no-op.**
   `.env.example` carried it but `docker-compose.yml` never forwarded it
   into the frontend service env, so the value never reached
   `hooks.server.ts`. Wire it through.

3. **`.env.example` omitted required env vars.** Operators copying the
   template got no admin bootstrap, no boot-seed crawler URL, no
   analysis configuration. Add: `ADMIN_USERNAME`, `ADMIN_PASSWORD`,
   `PRIVATE_MODE`, `ALLOW_SELF_REGISTER`, `CRAWLER_START_URL`,
   `CRAWLER_CDN_HOST`, `ANALYSIS_ENABLED`, `ANALYSIS_VISION_URL`,
   `ANALYSIS_VISION_MODEL`, `ANALYSIS_WORKERS`,
   `ANALYSIS_JOB_TIMEOUT_SECS`, `ANALYSIS_API_KEY` — each with the
   comment block that explains seed-vs-runtime semantics.

Also adds `frontend/vite.config.ts.timestamp-*.mjs` to `.gitignore` — a
crashed dev server leaves these transient files behind otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 20:23:56 +02:00
MechaCat02
9f7dfe4d4e fix(admin): keyboard-reachable audit payload toggle; drop dead step ternary
All checks were successful
deploy / test-backend (push) Successful in 27m23s
deploy / test-frontend (push) Successful in 10m20s
deploy / build-and-push (push) Successful in 10m42s
deploy / deploy (push) Successful in 13s
Review follow-ups: the audit row's expand was mouse-only (onclick on a bare
<tr>). Move the toggle to a real <button> in the actions cell with
aria-expanded + aria-label so keyboard/AT users can open a row's payload; the
row onclick stays as a mouse convenience (stopPropagation avoids a double
toggle). Also simplify a no-op `step={'%' ? '1' : '1'}` to step="1" on the
threshold inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:55:48 +02:00
MechaCat02
c6a6d1690d feat(admin): time-series trend charts on the metrics tabs (0.87.0)
Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.

Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.

Closes the Phase-1 observability set (audit log · health checks · trends).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:48:56 +02:00
MechaCat02
314fc8738b feat(admin): operational health checks page with editable thresholds
New /admin/health rolls up signals already collected — disk/memory sensors,
dead-job + missing-cover backlogs, 24h crawl/analysis failure rates, metadata
cron freshness, and the live crawler session/browser flags — into a flat
checks list (ok/warn/critical) with an overall status and remediation links.
The overview gains a compact Health summary banner linking in.

GET /v1/admin/health evaluates the checks (DB sub-queries run concurrently via
try_join; all hit existing indexes). PUT /v1/admin/health/thresholds persists
the thresholds to app_settings (merged over compiled defaults, forward-compat
via serde(default)), validates ranges (400 on bad input), and audit-logs the
change in one transaction. New repo::crawler::last_metadata_tick_at getter and
a lightweight system::memory_percent_used (no CPU settle delay).

Pure status helpers (over_limit for gauges, exceeds for backlog counts so a
0-limit doesn't false-alarm at 0) are unit-tested; endpoint tests cover shape,
gating, persistence+audit, and validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:38:18 +02:00
MechaCat02
31013cc893 feat(admin): audit-log viewer
Surface the admin_audit table (written by every mutating admin action but
previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list
(LEFT JOIN users for the actor's username, filter by action/target_kind/actor/
since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the
crawler history endpoint's paged/clamped idiom.

Frontend: a self-contained AuditTable (target-kind + window + action filters,
expandable JSON payload, AbortController-cancelled fetches, loading/error/empty
states) and shared fmtAgo() in format.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:26:09 +02:00
MechaCat02
dd300a150c feat(crawler): reconcile pass to enqueue mangas missing from the DB (0.85.0)
The interleaved metadata pass misses mangas to list drift (a title slips a
pagination slot during the slow detail walk). Add a reconcile pass: a cheap,
full, list-only walk (refs only, no detail visit, no early stop) that
set-diffs the walked keys against manga_sources and enqueues the strictly-
missing ones as SyncManga jobs. A set-diff is immune to drift — a manga only
has to appear somewhere in the list.

- Build the previously-dead SyncManga worker by extending RealChapterDispatcher
  to run the shared pipeline::process_manga_ref (fetch → upsert → cover →
  chapters), refactored out of run_metadata_pass so both paths stay in lockstep.
  The crawl worker now leases both sync_chapter_content and sync_manga
  (jobs::lease_kinds); both serialize on the single exclusive browser.
- SyncManga payload carries url + title so the worker can rebuild the ref and
  the dead-jobs/history UI can label a missing manga that has no manga row yet.
- "Missing" = strict NOT EXISTS (dropped rows count as present). Re-enqueue is
  skipped when a pending/running/dead SyncManga job already exists, so a gone
  manga (detail 404 → retries → dead) is left dead and not retried.
- New POST /v1/admin/crawler/reconcile (fire-and-forget, shares
  manual_pass_lock) + "Reconcile missing" admin button + Reconciling status
  phase streamed over SSE.
- dead-jobs/history queries surface payload title/url/key; tables fall back to
  them for sync_manga rows; both searches match the payload title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:15:11 +02:00
35664bccc7 perf(admin): fix O(mangas x jobs) overview query that pinned Postgres (0.85.1)
All checks were successful
deploy / test-backend (push) Successful in 26m28s
deploy / test-frontend (push) Successful in 10m18s
deploy / build-and-push (push) Successful in 11m21s
deploy / deploy (push) Successful in 13s
The admin overview dashboard polls /admin/overview every 30s. Its
`manga_stats` aggregate evaluated MANGA_SYNC_STATE_CASE over the WHOLE
library (14.5k mangas) with no LIMIT, and that CASE runs a correlated
EXISTS over crawler_jobs. With no index on the `sync_chapter_list`
manga_id path, Postgres seqscanned all in-flight jobs *per manga* —
O(mangas x jobs). The disabled-analysis backlog (9.5k pending
`analyze_page` rows that can never match the sync-kind filter) inflated
every scan, so each call took 6-7 min. The 30s poll stacked ~10 of them
concurrently → load 11, Postgres at ~100%.

EXPLAIN ANALYZE on the live DB: the rewrite drops the query from 6-7 min
to ~1.5s (per-manga crawler_jobs seqscan → single index-backed pass).

Fixes:
- Rewrite `manga_stats` to collect the in-flight manga-id set in ONE pass
  over the sync jobs (index-backed), then classify via a hash semi-join —
  O(mangas + jobs), immune to the analyze_page backlog size.
- Add partial index crawler_jobs_sync_chapter_list_manga_idx (migration
  0029) covering the sync_chapter_list manga_id path; mirrors the existing
  sync_manga index (0020). Also speeds the per-row Mangas tab listing.
- statement_timeout backstop (5s) on the aggregate so a pathological plan
  can never pin a backend for minutes again.
- Single-flight + 10s TTL cache on the /admin/overview handler so
  concurrent pollers coalesce instead of stacking.
- Slow the dashboard poll 30s -> 60s (server-cached now anyway).

The in_progress rule in the rewrite is kept in lockstep with the first
arm of MANGA_SYNC_STATE_CASE (documented in both places).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:05:40 +02:00
cde4aca98b feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
All checks were successful
deploy / test-backend (push) Successful in 25m9s
deploy / test-frontend (push) Successful in 10m13s
deploy / build-and-push (push) Successful in 10m25s
deploy / deploy (push) Successful in 12s
2026-06-16 18:46:28 +00:00
8445f338f6 test(auth): deflake login rate-limit burst test under CPU load (#10)
All checks were successful
deploy / test-backend (push) Successful in 25m44s
deploy / test-frontend (push) Successful in 10m15s
deploy / build-and-push (push) Successful in 14s
deploy / deploy (push) Successful in 12s
2026-06-16 18:04:53 +00:00
32fcebd47a fix(vision-manager): LOW watermark must not idle-stop a running vision (#9)
Some checks failed
deploy / test-backend (push) Failing after 22m32s
deploy / test-frontend (push) Successful in 11m37s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
2026-06-16 16:57:49 +00:00
d85fba7056 feat(vision-manager): memory-pressure yield gate (+ retain analysis gate) (#8)
All checks were successful
deploy / test-backend (push) Successful in 23m1s
deploy / test-frontend (push) Successful in 10m1s
deploy / build-and-push (push) Successful in 16s
deploy / deploy (push) Successful in 22s
2026-06-16 14:34:25 +00:00
cf62dae2c9 test(crawler): deflake sync serialization concurrency test (#7)
All checks were successful
deploy / test-frontend (push) Successful in 9m54s
deploy / test-backend (push) Successful in 21m44s
deploy / build-and-push (push) Successful in 10m9s
deploy / deploy (push) Successful in 39s
2026-06-16 13:38:15 +00:00
d51ab2a049 feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
Some checks failed
deploy / test-backend (push) Failing after 19m59s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
2026-06-16 12:21:13 +00:00
MechaCat02
790549636f feat(storage): admin storage-usage stats + per-manga/chapter sizes
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
Persist blob sizes (covers + chapter pages) and surface upload storage
usage to admins in two places:

- Admin System tab: total/covers/chapters totals, ratio of disk, average
  image sizes, and top-5 largest mangas/chapters leaderboards, behind a
  new GET /api/v1/admin/storage endpoint (separate from /admin/system so
  the cheap DB aggregates aren't coupled to its 250ms CPU sample).
- Manga detail page: total chapter-content size and per-chapter size,
  with an em-dash for uncrawled or not-yet-measured chapters.

Sizes are captured at write time (crawler put_stream return, uploaded
page/cover byte length) into nullable pages.size_bytes /
mangas.cover_size_bytes columns; NULL means "not yet measured", distinct
from a real 0. A one-shot, idempotent admin "Backfill sizes" action
(POST /api/v1/admin/storage/backfill) stats pre-existing blobs in
keyset-paginated, capped batches and reports more_remaining so a large
legacy library can be drained over multiple runs.

Aggregates and leaderboards treat NULL honestly (only fully-measured
entities are ranked); a dashboard banner flags unmeasured rows so partial
figures aren't read as complete. persist_pages now prunes stale page rows
on a shrinking re-crawl so totals and page_count stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:18:41 +02:00
4b6c19979a fix(tests): add user_agent field to crawler_browser_smoke LaunchOptions
All checks were successful
deploy / test-backend (push) Successful in 19m46s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Successful in 9m34s
deploy / deploy (push) Successful in 15s
Follow-up to the UA fix — the ignored browser smoke test builds LaunchOptions
as a struct literal and needs the new user_agent field to compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:49:22 +02:00
91d058c426 fix(crawler): send a realistic browser User-Agent (Cloudflare evasion)
Some checks failed
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
deploy / test-backend (push) Failing after 6m10s
The crawler's headless Chromium advertised the default HeadlessChrome UA,
which Cloudflare's bot detection challenges on sight — over Tor that meant an
unsolvable 'Just a moment' page on every fetch, so session verification kept
classifying pages as transient and the metadata pass failed at
'acquire browser lease'. (Confirmed empirically: same Tor exit + a normal
Chrome UA returns the real page with the #logo sentinel; the HeadlessChrome
UA returns the challenge.) The existing user_agent setting only fed reqwest,
never the browser. Override the launcher's UA with a realistic non-headless
default (CRAWLER_USER_AGENT to customize); the UA may contain spaces, unlike
CRAWLER_BROWSER_ARGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:41:00 +02:00
4262efeff7 ci: build backend with system chromium for the arm64 crawler
All checks were successful
deploy / test-backend (push) Successful in 19m59s
deploy / test-frontend (push) Successful in 9m52s
deploy / build-and-push (push) Successful in 2m34s
deploy / deploy (push) Successful in 12s
chromiumoxide's fetcher has no linux/arm64 build, so on the Pi the crawler's
browser launch failed with 'OS linux aarch64 is not supported'. Build the
backend image with INSTALL_CHROMIUM=true (Debian chromium-headless-shell,
verified present + runnable on trixie/arm64 at /usr/bin/chromium-headless-shell)
and set CRAWLER_CHROMIUM_BINARY to it in .env so the launcher uses the system
binary instead of the fetcher. Backend image only; frontend unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:17:47 +02:00
39fcfc813a chore(ci): persist deployed SHA into .env after deploy
All checks were successful
deploy / test-backend (push) Successful in 20m42s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Successful in 15s
deploy / deploy (push) Successful in 12s
The deploy job exports MANGALORD_TAG in-shell only, so .env kept its
`latest` placeholder and the live SHA was only visible via docker inspect.
sed the built SHA into .env after a successful `up -d` (non-fatal) so .env
reflects what's actually deployed and manual `docker compose up` is
deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:33:13 +02:00
MechaCat02
ce6d96c5e1 fix(crawler): harden crash & shutdown job recovery
All checks were successful
deploy / test-backend (push) Successful in 19m5s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Successful in 10m11s
deploy / deploy (push) Successful in 12s
Three failure-mode fixes surfaced by a crawler recovery audit:

- Graceful shutdown mid-dispatch now releases the in-flight job back to
  pending without burning a retry attempt. process_lease wraps the
  dispatch in a biased tokio::select! cancel arm that aborts the
  heartbeat and calls jobs::release; previously a mid-job SIGTERM left
  the row 'running' until lease expiry and cost one of max_attempts.

- Boot-time jobs::reclaim_orphaned resets 'running' jobs with an expired
  lease back to pending (attempt refunded), run once at daemon startup
  before workers start. Crash recovery is now immediate instead of
  waiting a full lease window for the lazy lease-expiry path. Safe under
  multi-replica: only already-expired leases are touched, which a
  healthy heartbeating peer never has.

- Manga-list parsing now warns when listing anchors are dropped for a
  missing/empty href or title (split into parse_manga_list_anchors so
  the drop count is testable), turning silent source markup drift into
  an observable signal. Returned refs are byte-identical to before.

Tests added: shutdown_mid_dispatch_releases_lease_without_burning_attempt,
reclaim_orphaned_resets_only_expired_running_jobs, and a drop-count unit
test. Patch bump 0.81.0 -> 0.81.1 (both manifests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:04:00 +02:00
54530d67ef fix(tests): query admin_audit by its real timestamp column 'at'
Some checks failed
deploy / test-frontend (push) Has been cancelled
deploy / test-backend (push) Has been cancelled
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
Two requeue-audit tests in api_admin_crawler ordered by created_at, but
the admin_audit table's timestamp column is 'at' (migration 0019), so the
queries failed at runtime with 42703 (column does not exist). This was the
last pre-0.81 test breakage gating deploy; full suite now green locally
(cargo test --no-fail-fast).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:59:18 +02:00
d92acb17e7 fix(tests): stop DATABASE_URL env race flaking sqlx lib tests
Some checks failed
deploy / test-backend (push) Failing after 9m5s
deploy / test-frontend (push) Successful in 9m55s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
The private_mode_* config unit tests set then remove_var(DATABASE_URL).
That env is process-global, and the #[sqlx::test] lib tests
(crawler::content, crawler::session_control, ...) read it in parallel,
so on a multi-core runner they intermittently saw it unset and panicked
'DATABASE_URL must be set: NotPresent' (6 failures on the Pi's 4 cores).
Never overwrite/remove it: set only if absent via ensure_database_url(),
leaving CI's real URL stable for the parallel DB tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:15:38 +02:00
e6fcff5eea fix(tests): update api_admin_role AppState to post-0.80 fields
Some checks failed
deploy / test-backend (push) Failing after 8m1s
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
The admin-settings refactor replaced AppState's resync/crawler/
analysis_enabled fields with runtime/reloader/crawler_base/
analysis_base, but tests/api_admin_role.rs still built the old shape,
so `cargo test` failed to compile (E0560) and blocked every deploy
since 0.80.0. Mirror the no-daemon harness in tests/common/mod.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:01:09 +02:00
MechaCat02
f441425519 fix(analysis): harden readiness gate and vision-manager per self-review
Some checks failed
deploy / test-backend (push) Failing after 7m32s
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
Backend:
- Log on readiness state transitions (ready<->not-ready) so a misconfigured
  ANALYSIS_VISION_HEALTH_URL, which otherwise parks the worker silently
  forever, is diagnosable.
- Add unit tests for HttpVisionReadiness: 2xx->ready, non-2xx->not-ready,
  connection error->not-ready (the production status mapping had no test).

vision-manager:
- Guard the backlog count against non-numeric output before `-gt`, so a stray
  value can't exit-2 and kill the loop under `set -e`.
- Throttle the crawl-mutex "deferring start" log to once per episode.
- Only reset the idle/uptime timers when `docker stop` actually succeeds, so a
  failed stop retries next tick instead of waiting a full debounce window.
- Decouple the per-probe curl timeout (HEALTH_TIMEOUT) from the warm-up poll
  cadence.

Docs:
- Correct the docker-socket-proxy comment: CONTAINERS+POST permits the full
  container lifecycle (not just start/stop); state the real trust boundary.
- Document that the externally-defined mangalord-vision container must share
  the compose network for name resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:15:40 +02:00
MechaCat02
64a9dceb67 feat(ops): vision-manager sidecar to autoscale the llama.cpp container
Add a tiny privileged sidecar (vision-manager/) that polls the analysis
backlog in Postgres and starts/stops the mangalord-vision container by name:
start when analyze_page jobs are pending, stop after STOP_DEBOUNCE idle.
It is the single owner of the vision lifecycle and the only component with
Docker access — scoped through tecnativa/docker-socket-proxy (CONTAINERS+POST
only) on an internal-only network, so the internet-facing backend never
touches the socket.

Both helpers sit behind `profiles: [ai]` (vanilla `compose up` is
unaffected). The manager debounces the stop, gates the first request on
GET /health==200 after a cold start, honours a crawl RAM-mutex on the 8 GiB
box, and owns its own idle timer (leak-safe if the backend dies). Backlog
query uses the real schema (state + payload->>'kind'); a read-only DB role
(readonly-role.sql) keeps it off the backend creds.

Bump 0.80.0 -> 0.81.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:03:53 +02:00
MechaCat02
b86aa80c87 feat(analysis): gate page leasing on a vision readiness probe
The analysis worker leases an analyze_page job (incrementing attempts in
SQL) before calling vision, so a stopped/loading vision burns all retries
and writes a permanent failed page_analysis row. With the vision-manager
autoscaler idle-stopping the container, that would poison the queue on
every cold start.

Add an optional VisionReadiness seam (HTTP GET /health in production) wired
via the new env-only ANALYSIS_VISION_HEALTH_URL. When set, the worker parks
without leasing until the probe answers 2xx; jobs stay pending with
attempts untouched and resume the instant vision is ready. None preserves
today's behavior for always-on endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:57:48 +02:00
MechaCat02
2b7a11b480 feat(admin): runtime-editable crawler & analysis config in the dashboard
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.

- New `app_settings` table (migration 0026) holds one JSONB row per group;
  env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
  DB-overlay conversion and field-level validation; env-only/secret fields
  (browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
  cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
  env-managed view + prompt defaults; PUT validates (422 + per-field
  details), persists + audits in one tx, then gracefully respawns the
  affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
  gate behind shared runtime cells so reloads take effect with no restart;
  a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
  (defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
  reset-to-default, env-managed read-only panel, save-&-apply confirm, and
  per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:32:20 +02:00
MechaCat02
d3b827421f fix(analysis): guardrails against model repetition loops
Small vision models (qwen3-vl-4b) sometimes loop the OCR array, running
the response into the token ceiling (finish_reason: length) and failing to
parse. Layered guardrails:

- Prompts: explicit "transcribe each element once, never repeat/loop, at
  most N, STOP when done" in the OCR, grounding and combined prompts.
- Schema: hard maxItems on ocr_results/tagging_results/content_type and
  maxLength on text/scene — LM Studio's grammar enforces these, so a loop
  is grammar-bounded rather than relying on max_tokens.
- Sampling: a configurable frequency_penalty (ANALYSIS_FREQUENCY_PENALTY,
  default 0.3) sent with each request — the decode-time lever that actually
  breaks loops.
- Code: sanitize() collapses runaway consecutive duplicates (same
  normalized text + kind) so a loop that slips through still can't flood
  the row.

Tests: schema caps present, frequency_penalty included only when nonzero,
sanitize collapses consecutive repeats; config default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:41:42 +02:00
MechaCat02
25aba3ac58 feat(analysis): position-aware OCR seam dedup for sliced pages
Boundary text duplicated across slices (often with slightly different,
cropped transcriptions) survived the text-only merge. The OCR pass now
asks for a per-piece vertical position and the merge uses it:

- OcrResult gains optional `y` (fraction 0..1 of the slice); the Pass-A
  OCR schema/prompt request it (combined path leaves it None). Not
  persisted.
- merge_ocr takes each slice's working-image y-band and maps `y` to a
  page-global position. A seam pair is a duplicate when position-close
  (same kind) OR text-similar, so a mis-OCR'd boundary line is caught even
  when the text differs. The kept copy is the one more central in its slice
  (less cropped); falls back to keep-longer when positions are missing.
- Self-calibrates the model's y values (pixels vs. fraction) and ignores
  degenerate columns, so a bad localizer can't over-merge.

Tests: position pairs differing texts and keeps the less-cropped copy;
text-only fallback (dedup/keep-longer/non-adjacent/order) still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:17:55 +02:00
MechaCat02
85d65f5eda feat(analysis): slice tall pages to the pixel budget for reliable OCR
Very tall webtoon pages were squashed by the fixed longest-edge downscale,
so OCR was garbage. The vision client now slices to the model's pixel
budget at native resolution and assembles one result:

- plan_slices: native-resolution bands sized to max_pixels (portrait OR
  landscape), only when height/width > tall_aspect_threshold; min_slice_
  height guards pathologically wide pages (reduce width); max_slices caps
  the count (coarser fallback). Normal pages still take one combined call.
- Two-pass, OCR-first: Pass A OCRs each band (near-native), merge_ocr
  stitches them with seam-scoped fuzzy dedup (keep the longer transcript,
  preserve order/kind, don't collapse non-adjacent repeats); Pass B feeds
  the whole downscaled image + the merged OCR text to get tags/scene/safety
  grounded in the real dialogue. Only OCR is merged.
- Config: replace max_image_dim with max_pixels / min_slice_height /
  slice_overlap / tall_aspect_threshold / max_slices (+ env_f64); bump
  job_timeout default 180->600 (N sequential slice calls per page); raise
  MAX_OCR_PIECES 60->200. New prompts/schemas: OCR_PROMPT/ocr_json_schema,
  GROUNDING_PROMPT/grounding_json_schema.

Tests: plan_slices (single/portrait/landscape/pathological-wide/cap +
coverage/overlap), merge_ocr (seam dedup, keep-longer, non-adjacent
repeats, order), render_whole/render_slice, the new request builders, and
updated config defaults/env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:36:23 +02:00
MechaCat02
ab9b8fb172 fix(analysis): raise default ANALYSIS_MAX_TOKENS 900 -> 4096
A text-dense page's full analysis JSON exceeded the 900-token output cap,
so the model stopped mid-object (finish_reason: length) and the truncated
response failed to parse ("EOF while parsing a list"). The page image +
prompt are only a few hundred tokens, so a larger output budget still fits
comfortably in an 8k context window. Still overridable via
ANALYSIS_MAX_TOKENS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:33:35 +02:00
MechaCat02
8b5bd99446 feat(analysis): live SSE updates in the admin coverage dashboard
The Analysis section now reflects worker progress in real time:

- Opens an EventSource on the live stream while mounted; a "Live" pill
  reflects connection state and reconnects on drop (probes after repeated
  failures so a lost session logs out).
- Applies events incrementally: enqueued marks in-scope loaded pages
  queued; started flips a chip to a pulsing "analyzing"; completed flips
  it green and live-bumps the manga + chapter coverage badges (guarded so
  no double count); failed flips it red. A compact activity ticker shows
  the last few events.
- Server numbers stay authoritative: re-loading the overview or a page
  grid clears the matching live overrides.

API client: analysisStatusStreamUrl() + AnalysisEvent type.

Tests: vitest for the stream URL; Playwright asserts SSE frames flow into
the ticker (existing coverage/drill/queue specs get a default quiet
stream). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:12:56 +02:00
MechaCat02
d0cd31c9a7 feat(analysis): live SSE event stream for the admin dashboard
Broadcasts analysis progress so the dashboard updates live:

- analysis::events: AnalysisEvents broadcaster + AnalysisEvent
  (Enqueued / Started / Completed / Failed), carrying the
  manga/chapter/page breadcrumb.
- The worker daemon resolves each page's breadcrumb (repo::page::locate)
  and publishes Started before dispatch and Completed/Failed after.
- The admin reenqueue publishes Enqueued (scoped by manga/chapter).
- GET /v1/admin/analysis/status/stream — SSE (RequireAdmin) forwarding
  each event as a named `analysis` frame; broadcast lag emits a `lagged`
  frame. AppState carries the always-present events bus.

Tests: worker publishes started+completed (with breadcrumb) and failed;
SSE route is admin-gated (403 non-admin) and returns text/event-stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:06:20 +02:00
MechaCat02
6bb72b8775 feat(analysis): admin coverage overview + per-page result inspector UI
Reworks /admin/analysis from a blind enqueue form into a coverage browser:

- Loads a paginated overview of mangas with a CoverageBadge
  (Full/Partial/None, analyzed/total) per row; debounced search filters it.
- Drill manga → chapters (coverage badge each) → page grid (chips colored
  done/queued/failed/none) with a legend.
- Click a page chip → Modal showing the full result: status, model, time,
  NSFW + content-warning chips, scene description, tags, and OCR lines
  (labeled by kind). Unanalyzed/failed pages get a "Queue this page"
  action (force re-analyze).
- Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed
  toggle; enqueue refreshes the open chapter's grid so queued pages show.

API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages /
PageDetail + types. New CoverageBadge component.

Tests: vitest for the four clients; Playwright for overview+badges+queue-all,
drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:39:54 +02:00
MechaCat02
824f5acf22 feat(analysis): admin coverage + per-page inspection API
Read-only admin endpoints (admin-gated, not analysis-enabled-gated) for
the dashboard's coverage overview and page-detail view:

- GET /v1/admin/analysis/mangas?search= — paginated per-manga coverage
  (analyzed/total pages; only mangas with pages).
- GET /v1/admin/analysis/mangas/:id/chapters — per-chapter coverage.
- GET /v1/admin/analysis/chapters/:id/pages — per-page status grid
  (done | failed | queued | none).
- GET /v1/admin/analysis/pages/:id — full result (status, is_nsfw, scene,
  model, analyzed_at, error, OCR lines with kind, tags, content warnings);
  "none" for an existing-but-unanalyzed page, 404 for a missing page.

repo::page_analysis gains manga_coverage / chapter_coverage /
chapter_page_status / page_detail; domain adds the matching row types.

Tests: coverage counts (manga + chapter), per-page status, full detail +
unanalyzed "none" + 404, non-admin 403.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:34:05 +02:00
MechaCat02
268e8cc6c2 fix(analysis): use json_schema response_format + surface vision error bodies
The worker hardcoded response_format={type:json_object}, which LM Studio
rejects with 400 ('must be json_schema or text'); it also leaves
reasoning models (Gemma) with an empty `content`. Fix:

- Default to response_format=json_schema with the real analysis schema
  (prompt::output_json_schema), which LM Studio accepts and which forces
  clean JSON into message.content. Configurable via ANALYSIS_RESPONSE_FORMAT
  = json_schema (default) | json_object | none (config::ResponseFormat).
- build_request_body is now a pure, unit-tested function.
- Vision errors now include the server's response body (status + text)
  instead of a bare status, so a 400's reason is visible in logs.

Tests: response-format body shapes (none/json_object/json_schema), schema
shape + round-trip with the DTO, config parsing/defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:14:38 +02:00
MechaCat02
7e675b72cc feat(analysis): admin analysis section uses manga search + chapter picker
Replaces the raw manga-id / chapter-id UUID inputs with a real picker:

- Search a manga (debounced, reuses listAdminMangas) → result rows show
  title + chapter count; "Queue manga" enqueues the whole manga.
- Expand a result to load its chapters (listAdminChapters) and "Queue
  chapter" enqueues a single chapter.
- Keeps the "Queue all (whole library)" action and a single global
  "include already-analyzed" toggle applied to every action; per-action
  busy state + a result message naming what was queued.

Tests: rewrote the Playwright spec to drive the search → manga/chapter
flow (whole library, search+queue manga, expand+queue chapter with
include-analyzed). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:05:09 +02:00
MechaCat02
8b7ea2e1b2 feat(analysis): admin UI — reader context-menu action + dashboard section
Surfaces the scoped re-enqueue and per-page force-analyze in the UI:

- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
  analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
  (canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
  endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
  (whole library / one manga / one chapter), id input, and an
  "include already-analyzed" toggle (default off), with busy/notice/error.

Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:57:03 +02:00
MechaCat02
9607488278 feat(analysis): scoped admin re-enqueue (all/manga/chapter) + force-on-include
Generalizes the admin re-enqueue beyond global backfill:

- repo::page_analysis::enqueue_pages(scope, only_unanalyzed) with
  ReenqueueScope::{All,Manga,Chapter}. Fixes include-analyzed semantics:
  only_unanalyzed=false now enqueues jobs with force=true so the worker
  actually re-analyzes already-done pages instead of skipping them.
- POST /v1/admin/analysis/reenqueue accepts optional manga_id / chapter_id
  (mutually exclusive, 404 on unknown target) alongside only_unanalyzed.

Tests: manga/chapter scope counts, include-analyzed sets force=true on the
done page, mutual-exclusivity 422, unknown-target 404. Existing global
backfill tests still green (13 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:49:54 +02:00
MechaCat02
d36f24e9af feat(search): frontend content search (text + warnings) + manga warning banner
Surfaces the analysis pipeline in the UI:

- api clients: searchPages() + PageSearchItem/ContentWarning types;
  listMangas cwInclude/cwExclude; MangaDetail.content_warnings.
- /search gains a content-search mode: free-text over OCR+scene and
  tri-state content-warning toggles (include/exclude), driven by URL
  params (text, cw_include, cw_exclude) and the new /me/page-search
  endpoint. Tag browsing is preserved when no content filter is active.
- manga detail renders a deduped content-warning banner.

Tests: vitest param serialization (searchPages CSV/text/cw, listMangas
cw); Playwright text-search + cw-toggle flows (route-mocked). svelte-check
+ build clean; full vitest (258) + search e2e (7) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:12:34 +02:00
MechaCat02
bd39476ac7 feat(mangas): content-warning filter on search + deduped banner on detail
Surfaces the analysis worker's NSFW moderation at the manga level:

- repo::manga FILTER_WHERE gains cw_include (AND, page-content-warning
  join to mangas) and cw_exclude (NONE) clauses; ListQuery + binds
  renumbered ($6/$7, LIMIT/OFFSET $8/$9).
- repo::page_analysis::warnings_for_manga: deduped, alphabetical union
  across all the manga's pages.
- domain::MangaDetail gains content_warnings; get_detail populates it.
- api::mangas list accepts cw_include/cw_exclude (reusing the shared
  parse_warnings_csv validator).

Tests: detail union (deduped/sorted) + empty case; list include/exclude
filter; unknown-warning 422. Existing manga tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:03:26 +02:00
MechaCat02
af870bd157 feat(search): page content-search endpoint (multi-tag AND + text + warnings)
New GET /v1/me/page-search surfacing the analysis worker's output:

- repo::page_analysis::page_search + PageSearchQuery: multi-tag AND across
  (user page_tags ∪ global page_auto_tags) via the unnest double-negative
  idiom, weighted OCR/scene text ranking (ts_rank over search_doc), and
  content-warning include/exclude. One row per page with is_nsfw +
  deduped content_warnings + rank.
- domain::PageSearchItem.
- api::page_tags: /me/page-search handler with CSV tag/warning parsing
  (parse_tags_csv reuses normalize_tag; parse_warnings_csv validates the
  closed vocabulary), requiring at least one positive filter (422 else).
  This is where the reserved OCR text search lands for pages.

Tests: multi-tag AND user∪auto, speech>sfx ranking, cw include/exclude
(+ row flags), text-only (no tags), missing-filter 422, unknown-warning
422, auth required.

Note: the /me/page-tags/chapters|mangas aggregations keep single-tag
behavior + the reserved text=501 for now; page-level search is the
primary text surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:58:07 +02:00
MechaCat02
7d80a437bf feat(analysis): worker daemon + real dispatcher + app wiring
The background analysis worker that drains analyze_page jobs:

- analysis::daemon: a lean sibling of the crawler daemon — leases only
  KIND_ANALYZE_PAGE, skip-if-done-unless-force, lease heartbeat, panic +
  timeout isolation, ack done/failed, and a failed page_analysis row on
  terminal (dead-lettered) failure. AnalyzeDispatcher trait seam.
- RealAnalyzeDispatcher: load page → storage.get → VisionClient.analyze →
  persist_analysis (skips a deleted page; caps image bytes).
- app::build spawns the daemon (own plain reqwest client) when
  ANALYSIS_ENABLED; AppHandle/main shut it down alongside the crawler.
- repo::page::find_by_id.

Tests: dispatch+ack-done, skip-when-done, force re-dispatch, terminal
failure writes failed row, panic isolation, ignores non-analyze jobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:51:53 +02:00
MechaCat02
dbde6e02c4 feat(analysis): vision client + system prompt + AnalysisConfig
The OpenAI-compatible vision client and the bounded prompt/output handling
for the analysis worker (no DB, fully unit-tested):

- analysis::prompt: terse system prompt carrying the JSON schema, the OCR
  kind / content-warning vocabularies, and the output-size caps.
- analysis::vision: VisionClient (downscale via the new `image` dep →
  base64 data-URL → chat/completions with an image_url part), plus pure
  parse_chat_completion (fence/prose-tolerant) and sanitize (drop empty
  OCR, truncate, clamp tags to 10 via the shared page-tag normalizer,
  filter unknown warnings).
- config::AnalysisConfig extended with endpoint/model/api_key/timeouts/
  max_tokens/max_image_dim/max_image_bytes + from_env.
- Deps: add `image` (jpeg/png/webp), reqwest `json` feature. Expose
  api::page_tags::normalize_tag as pub(crate) for reuse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:45:57 +02:00
MechaCat02
2bbf1595ff feat(analysis): enqueue page-analysis on upload/crawl + admin backfill endpoints
Wires the analyze_page job kind into the page-create paths and adds admin
controls, all gated on a new ANALYSIS_ENABLED flag (AppState.analysis_enabled,
config::AnalysisConfig):

- Chapter upload (api::chapters) enqueues one analyze_page job per page
  after the tx commits (best-effort; failures logged, never fatal).
- Crawler content sync (persist_pages → RETURNING id) enqueues per
  persisted page when the daemon dispatcher's analysis_enabled flag is set;
  threaded through sync_chapter_content (CLI/resync pass false).
- repo::page_analysis::enqueue_all_pages: bulk backfill via INSERT..SELECT,
  skipping done pages (only_unanalyzed) and existing pending jobs.
- New admin endpoints (RequireAdmin, 503 when disabled, audited):
  POST /admin/analysis/reenqueue and POST /admin/pages/:id/analyze (force).

Tests: upload enqueues N jobs / no-op when disabled; crawler persist_pages
enqueue gate; admin reenqueue (backfill, idempotent, only-unanalyzed, 503,
non-admin 403) and force-analyze (force flag, 404).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:39:03 +02:00
MechaCat02
63e1aa5484 feat(analysis): page-analysis schema, domain types, and analyze_page job kind
Adds the persistence foundation for the AI content-analysis worker:

- Migration 0025: page_analysis (status + scene + is_nsfw + weighted
  search_doc tsvector), page_ocr_text (kind-tagged pieces), global
  page_auto_tags (shared tags vocabulary, separate from per-user
  page_tags), and page_content_warnings (closed vocabulary).
- domain::page_analysis: AnalysisStatus/OcrKind/ContentWarning enums with
  kind->tsvector-weight mapping and lenient model-string parsing, plus the
  VisionAnalysis response DTOs.
- JobPayload::AnalyzePage { page_id, force } + KIND_ANALYZE_PAGE, reusing
  the existing crawler_jobs queue.
- repo::page_analysis: enqueue_for_page, load, mark_failed, and the single
  transactional persist_analysis (delete+reinsert; idempotent; never
  touches user page_tags; computes the A/B/C/D-weighted search_doc).

Tests: repo integration (persist/idempotency/user-tags-untouched/enqueue/
mark_failed/speech>sfx ranking) + unit (weights, parsing, DTO, job serde).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:27:08 +02:00
MechaCat02
f30600162e feat(page-tags): normalize page_tags onto the shared tags table
page_tags previously stored the tag inline as free-form text — the lone
un-normalized tag concept, while authors/genres/manga-tags all went
through a lookup table + FK. Fold page tags into the SAME shared `tags`
table that manga_tags uses: `page_tags.tag` becomes `page_tags.tag_id`
referencing `tags(id)`. Manga tags and page tags now share one global
vocabulary.

The HTTP contract is unchanged — the API still speaks tag *names*; the
repo resolves name<->id via `repo::tag::upsert_by_name` (the manga-tag
helper) and keeps the stricter page-tag `normalize_tag` at the boundary.
Frontend, DTOs, and response shapes are untouched. User-visible effect:
page-tag names now appear in the manga-tag autocomplete and vice versa.

Migration 0024 backfills `tags` from existing inline values (deduping
case-insensitively), repoints rows, swaps the unique/secondary indexes
to tag_id, and drops the inline column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:44:38 +02:00
MechaCat02
33f684887d feat(mangas): add tag-similarity "Similar" section on manga detail
Add GET /v1/mangas/:id/similar returning the top 5 mangas ranked by
Jaccard tag overlap (shared / union), excluding self, as MangaCard items
in a plain { items } object. Surface them in a hidden-when-empty
"Similar" section on the manga detail page, reusing MangaCard.

Backend: new repo::manga::list_similar with a manga_tags self-join; a
shared cards_from_rows helper (also used by list_cards) that loads
authors+genres concurrently; single-sourced column list via MANGA_COLS +
manga_cols(alias) to replace the fragile SELECT_COLS string-splitting.

Frontend: getSimilarMangas client fn (guards a malformed body), loader
fetch with non-critical .catch fallback, and the catalog grid hoisted to
a shared global .manga-grid (lib/styles/tokens.css), de-duplicating the
per-route copies.

Bump version 0.62.0 -> 0.63.0 (backend + frontend in lockstep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:52:37 +02:00
MechaCat02
6c901e64c9 feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by
per-user page tags and per-page collections: schema (migration 0023),
backend endpoints for page tags/collections and tagged-page aggregations
(with the OCR text-search param reserved at 501), plus the frontend API
clients, library Page-tags tab, collection page sections, page context
menu / AddTagsSheet, and reader long-press wiring. Includes the
continuous-reader navigation fixes (?page=N handling, chapter-reset
timing, back-button pops history) and tag-normalization hardening
accumulated on the branch.

Bump version 0.60.2 -> 0.62.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:51:38 +02:00
MechaCat02
9910a0a995 fix(frontend): reader back button pops history instead of pushing (0.60.2)
The reader's "back to manga" link was a naked `<a href="/manga/{id}">`
that pushed a new history entry on every tap, so browser-back kept
ping-ponging between detail and reader instead of walking out to
home:

  home → detail → reader → reader-back (push detail) → detail-back
       (pop reader) → reader-back (push detail) → … loop forever

The fix intercepts left-click and calls `window.history.back()`
directly when there's same-tab history to pop. Middle-click /
cmd-click / right-click pass through so "open in new tab" still
works, and a deep-link / fresh-tab visit where `history.length ===
1` falls through to the href so the button still leads somewhere
useful.

An earlier attempt gated the back on
`document.referrer.startsWith(origin)` — but SvelteKit's SPA
`pushState` doesn't update `document.referrer`, so that check was
always false in practice and the default href fired anyway. The
e2e regression added here uses real link clicks (not
`window.location.href`) so it actually exercises the SPA-nav path
the bug lives on.

Verified: home → detail → reader, then reader-back keeps
`history.length` at 4 (popped, not pushed), and a second tap on the
detail back button walks out to /.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 20:59:10 +02:00
MechaCat02
00577071fd fix(frontend): mobile layout polish + overflow handling (0.60.1)
Visual cleanup pass after wiring up real data — the placeholder
content used during the five mobile phases hid a few sharp edges
that became obvious as soon as user-supplied tags / author handles
/ descriptions hit the screen.

- Mobile gutter unified at 16px (was 12px) — matches iOS / Material
  standard and gives chips, description text, and chapter rows
  visible breathing room from the screen edge. Hero negative margin
  in /manga/[id] tracks the change so the bleed still hits viewport.
- Catalog grid switched from 2 columns to 4 with
  `repeat(4, minmax(0, 1fr))` (per user preference). The `minmax(0,
  ...)` is load-bearing — without it the implicit `minmax(auto, 1fr)`
  lets a long single-word card title push the column past the
  viewport edge. MangaCard's `.author` and `.genres` lines hide
  below 640px so every card in the 4-col layout has identical height
  (cover + 2-line title clamp).
- Same minmax(0, 1fr) trick applied to the detail page's
  `.overview` grid track, plus defensive `min-width: 0` /
  `max-width: 100%` on `.meta` and `.chip-row` so a long
  unbreakable tag or romanized URL can't drag the metadata column
  past the article gutter.
- BottomNav swapped from `grid-auto-columns` (= `minmax(auto, 1fr)`)
  to an explicit `repeat(4, minmax(0, 1fr))`, plus `min-width: 0` on
  `.tab` and an ellipsizing `.label` so a long tab label stays
  inside the bar. The Browse placeholder is replaced with Upload —
  a real action, no longer a no-op duplicate of Home.
- Hero appbar gets 16px all-around inset (was 8px) so the back / ⋯
  circle buttons aren't kissing the screen edge or the hero top.
  Hero content gets 20px horizontal padding so the cover thumb +
  title sit visibly off the hero scrim. Description gains
  `overflow-wrap: anywhere` for long URLs / romanized titles.
- Chip now allows `overflow-wrap: anywhere` on its label and uses
  `max-width: 100%` + `min-width: 0` so a long token wraps to its
  own line within the chip-row and ellipsizes inside the chip.
- Mobile catalog search-row gains `flex-basis: calc(100% - 36px -
  --space-2)` so the search input takes its own line — the Filter
  and Sort chips wrap to a second row instead of squeezing the
  input down to a few characters.
- Global safety net: `html, body { overflow-x: hidden }` below
  640px so any future bug that lets an element overflow the
  viewport doesn't expose touch-pan that shifts the whole layout.
  Fixed descendants (bottom nav, CTA bar) are unaffected.
- audit.mjs — small dev helper that walks all 12 user-facing
  routes at 390px and reports horizontal overflow, internal
  scrollers, and edge-bleeding controls. Used to drive these fixes
  and useful for future mobile changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 20:58:21 +02:00
MechaCat02
1e3fd27308 feat(frontend): mobile account hub + library wrapper (0.60.0)
Phase 5 (final) of the mobile redesign: /profile/account becomes an
inset-grouped iOS-style hub on mobile and the new /library route hosts
a SegmentedControl over Bookmarks / Collections / History. Profile-
layout horizontal tabs hide below 640px. Desktop layout is unchanged.

- New /library/+page.ts loads bookmarks, collections, and read-history
  in parallel so segmented-control tabs swap instantly without a new
  round trip. 401 → unauthenticated path with sign-in prompt.
- New /library/+page.svelte renders the SegmentedControl with ?tab=
  as the source of truth. Bookmarks reuses the existing BookmarkList,
  Collections reuses CollectionsGrid, History inlines a slim cover +
  title + "Continue Ch. N" row list. `goto(..., { replaceState: true })`
  drives the URL — plain `replaceState` from $app/navigation doesn't
  reliably re-trigger $page-derived state.
- BottomNav's Library tab now points at /library (Phase 1 placeholder
  was /bookmarks). The Phase 1 mobile-chrome spec is updated to expect
  the new target and mocks the additional library data endpoints.
- /profile/+layout.svelte hides the horizontal tabs below 640px — the
  bottom-nav Library/Account tabs plus the account hub carry mobile
  cross-section navigation.
- /profile/account/+page.svelte gains a mobile hub: a centered avatar
  + username + "Member since" header, a row group with Profile /
  Preferences / Change password rows (the last opening a bottom
  Sheet hosting the existing password form snippet), and a separate
  group with a red "Log out" row that reuses the layout's logout
  flow (logout API → session.setUser(null) → preferences.clearForLogout
  → goto('/login')). Desktop keeps the inline card with the form.
- matchMedia gates the hub vs. desktop card so the password form
  testids never duplicate on the page — the snippet is rendered in
  exactly one mount at a time.
- 9 Playwright tests cover the Library nav handoff, segmented-control
  sub-tab swap with URL, sign-in CTAs on both routes, account hub
  composition, password sheet open flow, logout flow (POST /auth/
  logout + redirect to /login), profile tabs hiding on mobile, and
  the desktop regression where the inline card is the only password
  surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:35:07 +02:00
MechaCat02
f5842510b7 feat(frontend): mobile reader with tap zones + settings sheet (0.59.0)
Phase 4 of the mobile redesign: the reader gets a mobile-native
interaction model — invisible tap zones for prev / next / toggle, a
chapter-jump bottom sheet, a reader settings sheet (mode / gap /
brightness), a fixed bottom page scrubber, a brightness overlay
driven by a CSS variable, and an idle-timer auto-hide for the chrome
after 3s of inactivity. Desktop keyboard shortcuts and chevrons are
preserved above 640px.

- New TapZone primitive: invisible 3-column grid that splits the
  viewport into prev / toggle / next thirds (Mihon convention). Wired
  to the existing `prev()` / `next()` / new `toggleChrome()` so
  chapter-edge behavior, page preload, and read-progress tracking
  all keep working. Has its own vitest coverage.
- matchMedia gates every mobile addition so the same DOM never
  carries two copies of the chapter selector or two sets of nav
  controls — the desktop <select>, mode toggle, and gap field stay
  inline above 640px; below 640px they swap to a chapter-jump
  button and a settings ⋯ button hosting the Sheets.
- Chapter jump Sheet lists every chapter with the current one
  highlighted; tapping a row navigates and dismisses.
- Reader settings Sheet uses the SegmentedControl primitive for mode
  and (continuous-only) page gap. Brightness lives next to them as a
  range slider 0.3..1; its value publishes `--reader-dim` as a CSS
  variable that drives the always-rendered fixed dim overlay
  (pointer-events: none so taps fall through). Brightness is
  client-side only in localStorage — the Preferences table doesn't
  carry it and Phase 4 deferred a backend migration.
- Idle auto-hide: 3s timer scoped to mobile + single mode, reuses
  the existing focus-mode CSS for the actual slide-off. The timer
  resets on every index/mode/viewport/fullscreen change via a tracked
  $effect.
- Bottom page scrubber: a styled <input type="range"> at the
  viewport foot, single + multi-page only, sliding off in focus mode
  alongside the chrome. Honors env(safe-area-inset-bottom).
- Reader joins the layout's `data-mobile-full-bleed` attribute so the
  hero/top reader-nav can sit at viewport top under main's cleared
  padding-top on mobile.
- Playwright spec covers tap left/right/center, the 3s auto-hide, the
  chapter-jump sheet, the settings sheet swapping to continuous, the
  brightness slider driving `--reader-dim`, and a desktop regression
  for the unchanged inline controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:22:54 +02:00
MechaCat02
780632bee3 feat(frontend): mobile manga detail with hero + sticky CTA (0.58.0)
Phase 3 of the mobile redesign: the manga detail page gets a mobile-
native chrome — blurred-cover hero with a transparent app bar overlay,
secondary actions in an overflow Sheet, a 3-line description clamp
with Read more, and a sticky bottom CTA whose wording reflects read
progress. Desktop layout is untouched.

- /manga/[id] joins the layout's HIDE_MOBILE_CHROME route set so the
  global AppBar and BottomNav step aside for the page-specific hero
  chrome. A new `html[data-mobile-full-bleed]` opt-in zeroes main's
  mobile top padding without touching horizontal/bottom gutters.
- The hero block (mobile-only via CSS) renders a blurred cover
  backdrop, a transparent app bar (back / bookmark / overflow ⋯), and
  a sharp cover thumbnail beside the title, authors, and status. The
  bookmark icon swaps between Bookmark and BookmarkCheck based on the
  current bookmark state — reusing the existing toggleBookmark flow.
- Secondary actions (Add to collection / Edit / Upload chapter /
  Force resync) move into an overflow Sheet opened from the ⋯
  button. The desktop action-row stays.
- Description gets a `.clamped` modifier (-webkit-line-clamp: 3) and
  a Read more toggle on mobile, gated by a >200-char heuristic so
  short blurbs don't render a dangling button. Desktop shows the
  full description as before.
- Sticky bottom CTA bar reads "Continue {chapterLabel}" when
  data.readProgress has a chapter_id, "Read first chapter" when
  the manga has chapters but no progress, and hides otherwise. The
  bar honors env(safe-area-inset-bottom) so it sits above the iOS
  home indicator.
- New Playwright spec covers the CTA wording state machine
  (no-progress → Read first chapter, has-progress → Continue),
  Read more expand/collapse, overflow sheet contents, and a desktop
  regression for the unchanged action-row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:07:30 +02:00
MechaCat02
be6b974150 feat(frontend): mobile catalog with sheet filters + sort (0.57.0)
Phase 2 of the mobile redesign: the catalog at `/` adapts to phone
viewports without disrupting the desktop layout. The same filter form
renders inline on desktop and inside a bottom sheet on mobile, and a
dedicated sort sheet replaces the inline `<select>` below 640px.

- MangaCard gains optional `unreadCount` and `progress` props that
  render a top-right pill badge and a bottom progress overlay on the
  cover. Both are no-ops when omitted, so existing callers don't
  change. Counts past 99 cap at "99+".
- The filter form body is extracted into a Svelte 5 snippet and
  rendered conditionally — inline desktop panel OR mobile sheet, never
  both — so no testid is duplicated and the focus trap can't double-
  fire. A matchMedia listener tracks the 640px breakpoint and drives
  the snippet target.
- Mobile catalog adds: Filter / Sort chip buttons, an always-visible
  active-filter chip row with per-facet remove buttons, full-width
  search, and a 2-column grid.
- Auto-expanding the filter panel from URL params is now desktop-only
  — on mobile the active-filter chips signal applied facets without
  hiding the catalog under a scrim on first paint.
- Vitest suite covers MangaCard badge / overlay behavior including
  clamp / hide edge cases. Playwright spec covers the mobile filter +
  sort sheet flow at 390px viewport, with a hydration gate to keep
  click dispatch from racing the SSR'd-but-not-yet-reactive button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 10:54:58 +02:00
MechaCat02
6dcb720a0f feat(frontend): mobile primitives + bottom-nav chrome (0.56.0)
Phase 1 of the mobile redesign: add the reusable primitives every
later phase plugs into, and switch the layout to a 4-tab bottom nav
+ compact AppBar on phone viewports while leaving the desktop header
untouched above 640px.

- New primitives: BottomNav, Sheet, AppBar, SegmentedControl, all
  with vitest unit tests.
- Layout swaps the desktop header for AppBar + BottomNav under the
  existing 640px breakpoint. Login / register / reader routes opt
  out of the mobile chrome.
- Library tab currently points at /bookmarks; the curated /library
  wrapper lands in Phase 5.
- Independent ResizeObservers publish --app-header-h,
  --mobile-app-bar-h, --app-bottom-nav-h, with a zero-height skip so
  hidden bars don't clobber the visible one's value.
- viewport-fit=cover + env(safe-area-inset-*) tokens for notched
  devices and the iOS home indicator.
- Playwright spec covers visibility at 390px / 1280px viewports and
  tab navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:46:16 +02:00
MechaCat02
d6ac648ac9 feat(admin): crawler observability dashboard + reliability hardening (0.55.0)
Admin-only crawler dashboard backed by an SSE live-status stream,
coordinated browser restart, runtime PHPSESSID refresh, dead-letter
requeue, and a batch of reliability fixes. Closes everything from
the two-pass audit (10 commits' worth) and bumps 0.52.0 -> 0.55.0.

Backend:

- New /admin/crawler/* surface (cookie-auth, RequireAdmin) split
  into status / control / dead_jobs / backlog modules. SSE stream
  composes in-memory status with DB-derived queue counts, memoizes
  the counts for 1s and debounces watch pokes for 250ms (~10x QPS
  reduction per subscriber). One-shot GET /admin/crawler shares the
  same compose path.
- POST /admin/crawler/run gated by manual_pass_lock try_lock_owned
  (409 Conflict on overlapping click); browser restart goes through
  the coordinated_restart gate (drain + relaunch + auto-clear of the
  sticky session_expired flag on Ok).
- Runtime PHPSESSID refresh via SessionController (allow-list
  validation, never logged, audit row carries SHA-256 fingerprint).
  Storage layer is repo::crawler::runtime_session_{load,persist}.
- Dead-letter requeue with four scopes (all/manga/chapter/job);
  scope=all requires confirm:true; DISTINCT ON dedup keeps the
  partial unique index from rejecting requeues for chapters with
  multiple dead rows. SQL is four &'static str constants per scope.
- StatusHandle + ChapterGuard / CoverGuard RAII model survives
  panics; last-writer-wins on cover so concurrent dispatches don't
  clobber each other's slot. Pure functions (should_stop /
  should_mark_clean_exit / should_abort_pass) with named regression
  tests.
- Reliability bundle: per-lease heartbeat, jitter on retries,
  per-job timeout, circuit breaker on consecutive failures, BrowserManager
  coordinated restart gate, request fingerprint changes.
- Streaming page download: Storage::put_stream trait method,
  LocalStorage impl atomic via temp + fsync + UUID-suffixed rename.
  Pages stream through with peak memory ~one HTTP chunk + 64-byte
  sniff prefix instead of one full image per dispatch.
- New partial indexes (migration 0022): mangas_missing_cover_idx
  and crawler_jobs_dead_idx, both ordered by updated_at DESC to
  match the dashboard's LIMIT/OFFSET reads.
- Security hardening: admin_csrf_guard (Origin/Referer allowlist
  on /admin/* mutations, opt-in via ADMIN_ALLOWED_ORIGINS),
  admin_no_store_guard (Cache-Control: no-store on admin
  responses), audit rows carry per-scope target_id.

Frontend:

- /admin/crawler page decomposed into lib/components/crawler/
  (11 components: ProgressBar, SearchBar, CrawlerHero,
  CrawlerControls, ActiveChaptersCard, ActiveJobsTable,
  MissingCoversTable, DeadJobsTable, RestartConfirmModal,
  RequeueAllConfirmModal, SessionModal). Page is 532 LOC of
  orchestration; each component 22-148 LOC.
- EventSource lifecycle wired to visibilitychange / pagehide /
  pageshow (BFCache); after 5 consecutive errors probes the status
  endpoint so a 401 routes through the global on401Hook instead of
  infinite silent reconnects.
- Backlog $effect refetches debounced 500ms with per-loader
  AbortControllers; refresh after a control action only runs when
  the SSE stream is dead.
- Inline requeue button on /admin/mangas patches the affected row's
  sync_state locally (no full chapter-list refetch); proper
  aria-label. Requeue-all gets its own confirm modal; both confirm
  modals autofocus Cancel.
- SvelteKit reverse proxy bypasses its 5-minute AbortController
  for Accept: text/event-stream; pure shouldBypassProxyTimeout
  helper covered by unit tests.

Config / docs:

- New env vars (.env.example): ADMIN_ALLOWED_ORIGINS,
  CRAWLER_JOB_TIMEOUT_SECS, CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES,
  CRAWLER_BROWSER_RESTART_THRESHOLD.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 18:49:56 +02:00
MechaCat02
679abae736 feat(chapter): preserve source-site order in chapter list (0.52.0)
Some checks failed
deploy / test-backend (push) Failing after 11m48s
deploy / test-frontend (push) Successful in 9m45s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
The user-facing chapter list ordered by (number ASC, created_at ASC),
which broke the source site's order in two ways: non-numeric entries
("notice. : Officials") parsed to number=0 and clustered at the top,
even though the site placed them mid-list, and variants sharing a
number ("Ch.14 : PH" / "Ch.14 : Official") were torn apart by the
created_at tiebreak.

Capture each chapter's position in the source DOM as `source_index`
(0 = first = newest on this site) on every crawler sync, including the
UPDATE branch so a new chapter prepended on the source shifts every
existing row down by one on the next tick. The list query reverses
this with `ORDER BY source_index DESC NULLS LAST, number ASC,
created_at ASC` so the oldest chapter appears first, variants stay
adjacent in the order the site shows them, and non-numeric entries
land where the site placed them. User-uploaded chapters and pre-
migration rows keep their NULL source_index and fall through to the
prior number/created_at tiebreak via NULLS LAST.

The reader's client-side `[...chapters].sort((a,b) => a.number - b.number)`
is dropped; prev/next now walks the server-ordered array positionally
so it traverses variants and non-numeric entries in display order.

Existing data populates on the next cron tick or via admin force-resync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:25:09 +02:00
MechaCat02
b812c6d16c fix(reader): drop "Chapter N:" prefix from chapter title display (0.51.2)
The chapter list on the manga detail page, the reader's chapter-select
dropdown, the continuous-mode chapter bar, the browser tab title, and
the profile upload-history entries all prepended "Chapter {number}:"
in front of the crawled site title. Source titles already include
"Ch.N" themselves and the manga page renders chapters inside an <ol>,
so the prefix duplicated information the user could already see.

A small chapterLabel(c) helper in $lib/api/chapters returns the site
title as-is, falling back to "Chapter {number}" only when the
crawler captured an empty title (link/option stays non-empty). The
five render sites now call it. The previous-/next-chapter nav
buttons still read "Previous chapter (Ch. N)" / "Next chapter (Ch. N)"
since those are wayfinding labels, not title display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:22:17 +02:00
MechaCat02
e93eec89e5 fix(crawler): queue chapter content in ascending number order (0.51.1)
Both enqueue paths now order by chapters.number so the cron tick and the
bookmark hook insert jobs from chapter 1 upward instead of source-discovery
or random-UUID order. The lease query tiebreaks on created_at so jobs
sharing a batch's scheduled_at come off the queue in insertion order,
propagating the enqueue intent through to dequeue. Concurrent workers
and per-CDN latency can still drift actual completion order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 21:13:51 +02:00
MechaCat02
8818c890c5 feat(reader): chapter select dropdown for direct chapter jumps (0.51.0)
Adds a chapter `<select>` to the reader's top nav listing every chapter
of the current manga, defaulting to the open chapter; picking another
entry navigates straight to it without going back to the manga detail
page. Options use the "Ch. N — Title" form to match the existing
chapter tile and prev/next buttons in the reader bar.

Covered by a new Playwright spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 07:09:30 +02:00
MechaCat02
c134bdbbde feat: cover retry backfill + admin force-resync for manga & chapter (0.50.0)
Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose
cover download failed on first attempt get retried — the metadata pass's
early-stop optimisation otherwise prevents the walk from revisiting them.

Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync
that refetch metadata + cover (or chapter content with force_refetch) from the
crawler source synchronously and return the refreshed row. Surfaced in the
UI as "Force resync" buttons on the manga detail and reader pages,
admin-only via session.user.is_admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:00:09 +02:00
MechaCat02
5c22dfdb41 feat: paginate list views, fix stale page titles, tidy admin filter bar
Bundle of small UI/UX fixes plus a build hygiene tweak.

* List pagination — Home (`/`) and `/authors/[id]` silently capped at
  the backend default of 50 with no UI to advance. New reusable
  `Pager.svelte` (Prev/Next + numbered with ellipsis), URL-synced
  `?page=N`, and filter/search/sort reset to page 1 so users aren't
  stranded on an out-of-range page. Count label now shows a range
  ("Showing 51–100 of 237").

* Stale page title — Pages without a `<svelte:head><title>` left the
  document title at whatever the last manga / author / collection page
  set it to. Move static-route titles into a route-id → title map in
  the root layout and invert every dynamic title to brand-first
  (`Mangalord | {X}`) for consistency.

* Admin filter bar — `/admin/mangas` search input had `flex: 1` and
  ballooned across the row, shoving the sync-state select + Search
  button to the far right. Cap at 24rem, vertical-align the row, and
  promote the previously aria-only "Sync state" label to visible text.

* Build hygiene — `backend/target` had grown to 68 GiB. Cleaned and
  added `[profile.dev] debug = "line-tables-only"` (and `[profile.test]`
  too) to cut future dev builds by ~50–70% while keeping line numbers
  in backtraces.

Also: configure vitest to resolve Svelte's browser entry so
`@testing-library/svelte` can mount components in jsdom — needed for
the new `Pager.svelte.test.ts`.

Bump 0.48.0 -> 0.49.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:18:53 +02:00
MechaCat02
e50fc093c3 feat: add PRIVATE_MODE site-wide auth gate (0.48.0)
When `PRIVATE_MODE=true`, every API path except a small allowlist
(`/health`, `/auth/{config,login,logout,register}`) requires a valid
session cookie or bearer token — anonymous reads are rejected with
401. Self-registration is force-disabled in private mode regardless
of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a
single switch (admins still mint accounts via `POST /admin/users`).

The backend gate is a tower middleware that reuses the existing
`CurrentUser` extractor, so the cookie + bearer paths cannot drift
from per-handler auth. `/auth/config` now exposes the flag plus the
effective `self_register_enabled` value so the frontend can render
the navbar correctly on the first paint.

On the frontend, a new universal root `+layout.ts` fetches the
config and redirects anonymous visitors to `/login?next=<path>`
before page-specific loads fire. The redirect is UX only — the
backend middleware is the source of truth, so crafted requests
still 401.

Defaults stay public (`PRIVATE_MODE=false`); existing deployments
need no env change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:11:22 +02:00
MechaCat02
72756cfef2 feat(crawler): honour CRAWLER_LIMIT in the in-process daemon (0.47.0)
The CLI binary already capped runs at CRAWLER_LIMIT mangas, but the
daemon's RealMetadataPass passed a hardcoded `0` (no cap) to
`pipeline::run_metadata_pass`, so the env var was silently ignored once
the daemon took over the metadata pass.

Adds `manga_limit` to `CrawlerConfig`, reads it from `CRAWLER_LIMIT`
(default 0 = no cap), and threads it through `RealMetadataPass::run`
so a daemon-driven sweep stops at the same boundary as a CLI run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:07:01 +02:00
MechaCat02
4e20350645 fix(crawler): translate socks5h:// → socks5:// for Chromium --proxy-server
All checks were successful
deploy / test-backend (push) Successful in 19m30s
deploy / test-frontend (push) Successful in 9m42s
deploy / build-and-push (push) Successful in 8m10s
deploy / deploy (push) Successful in 15s
Chromium doesn't know the socks5h scheme (curl/reqwest convention)
and bails navigations with ERR_NO_SUPPORTED_PROXIES. It does, however,
send destination hostnames over SOCKS5 by default, so stripping the
`h` is a pure scheme rename — remote-DNS behaviour is preserved.

reqwest keeps the user's original CRAWLER_PROXY string (`socks5h://...`
remains valid and meaningful for it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:56:45 +02:00
MechaCat02
713ca139c4 feat(deploy): add optional tor service to dev compose for native-backend dev
Mirrors the prod tor service but with 127.0.0.1-only host port bindings
so a `cargo run` on the host can reach 127.0.0.1:9050 / 9051. Default
password baked in (overridable via TOR_CONTROL_PASSWORD env) since
host-loopback is the only exposure surface — same friction-free posture
as the postgres entry in this file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:56:45 +02:00
MechaCat02
e3cff9d874 fix(deploy): pivot tor service to password auth + wrapper entrypoint
Some checks failed
deploy / test-backend (push) Successful in 20m29s
deploy / test-frontend (push) Successful in 9m42s
deploy / deploy (push) Has been cancelled
deploy / build-and-push (push) Has been cancelled
Dockurr/tor's stock entrypoint binds the control port to localhost
(unreachable from a sibling container), refuses to run as a
non-default user (its setup chowns dirs and su-execs down to its
`tor` user, both requiring root), and skips its own
HashedControlPassword injection whenever the user's torrc declares
a ControlPort. The combination meant the original cookie-via-shared-
volume design couldn't work without fighting the image.

This commit:

- Adds tor/entrypoint.sh, a small wrapper that hashes $PASSWORD
  with `tor --hash-password`, appends the hash to a writable copy
  of /etc/tor/torrc, then execs tor. Container runs as root only
  for that bring-up; the torrc's `User tor` directive drops privs
  after port binding.
- Adds a healthcheck on the tor service that gates downstream
  containers on both 9050 + 9051 actually listening (was
  service_started, which fires before tor finishes bootstrap).
- Loosens MaxCircuitDirtiness 60 → 600. The 60s value would have
  rotated mid-chapter for any chapter with > ~50 images, which is
  exactly the kind of fingerprint we're trying to avoid.
- Wires TOR_CONTROL_PASSWORD as a REQUIRED .env var on both sides
  (PASSWORD on tor, CRAWLER_TOR_CONTROL_PASSWORD on backend).
  docker-compose.yml fails fast if unset.
- Removes the tor-data shared volume on backend (cookie auth is no
  longer the default; operators wanting cookie can mount it back).
- Documents the pivot + the cookie-vs-password tradeoff in
  .env.example.

End-to-end validated: `docker compose up -d tor`, then
`printf 'AUTHENTICATE "test"\r\nSIGNAL NEWNYM\r\nQUIT\r\n' | nc tor 9051`
returns three `250 OK` lines.

Audit ref: #2, #3, #6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:54 +02:00
MechaCat02
d47e832613 fix(crawler): redact TorAuth::Password in Debug, drop NEWNYM info→debug
The startup log line in app.rs and bin/crawler.rs `?t`-debug-formats
the TorController, which through the derived Debug on TorAuth would
expand TorAuth::Password(p) and leak the plaintext password to logs.
Implement Debug manually on TorAuth — None / Password(<redacted>) /
Cookie(<path>) — and lock the redaction with a regression test.

Drop the per-NEWNYM success log from info to debug: a busy crawl
rotates circuits many times per minute. Failed NEWNYMs already log
at warn — those stay loud.

Tightens the closed_connection_mid_reply_is_an_error assertion which
was tautological (`closed connection` OR `AUTHENTICATE`) by driving
the mock to read the AUTH line then drop, exercising only the
EOF-mid-reply path.

Audit ref: #7, #9, nit on tautological test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:36 +02:00
MechaCat02
c30c7a546f fix(crawler): unify recircuit budget semantics — N = total attempts
The three retry-with-recircuit sites disagreed: detect.rs's
retry_on_transient_with_hook used "N = total attempts" (3 → 3
fetches), but session.rs's unauth branch and content.rs's chapter
loop used "N = recircuits" (3 → 4 fetches). At the same wall-clock
"max=3", different sites hit the upstream a different number of times.

Unify on N = total attempts (matching the existing
retry_on_transient convention). The CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS
env var now means exactly what its name suggests. Disabling the
recircuit feature collapses to max_attempts=1 (single attempt, no
retry) — bit-for-bit pre-TOR behavior preserved.

Adds a debug_assert!(max >= 1) on both helpers and a new
content.rs test exercising the mixed Transient → Unauth → Ok
sequence to lock in the shared-counter invariant.

Audit ref: #5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:25 +02:00
MechaCat02
a0db7beb81 chore: bump to 0.46.0 for TOR proxy + recircuit feature
CRAWLER_TOR_CONTROL_URL, _PASSWORD, _COOKIE_PATH,
_RECIRCUIT_MAX_ATTEMPTS are new feature env vars; treat per CLAUDE.md
as a minor bump (feat:).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:01:57 +02:00
MechaCat02
ecbbebafc4 feat(deploy): dockurr/tor service + torrc; wire crawler to use it by default
Adds a `tor` service to the compose stack (dockurr/tor) with a torrc
tuned for the crawler — SOCKS5 on 9050 with IsolateDestAddr +
IsolateDestPort so NEWNYM picks up promptly, control port on 9051
with cookie auth, MaxCircuitDirtiness 60.

Backend defaults CRAWLER_PROXY → socks5h://tor:9050 and
CRAWLER_TOR_CONTROL_URL → tcp://tor:9051 so TOR + recircuit are on
out-of-the-box. Operators can override both to empty in .env to opt
out without removing the service.

The tor-data named volume is mounted ro on the backend so it can read
/var/lib/tor/control_auth_cookie; CookieAuthFileGroupReadable handles
the permissions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:01:04 +02:00
MechaCat02
8c6378b877 feat(crawler): recircuit TOR on transient pages and unauthenticated probes
- target.rs swaps retry_on_transient → retry_on_transient_with_hook,
  signaling NEWNYM via ctx.tor between attempts when configured.
- session.rs gains verify_session_with_recircuit; the bare
  verify_session is now a one-line wrapper passing tor=None,
  unauth_max_recircuit=0. The inner run_session_probe_loop is
  pure-over-IO and unit-tested with closure-based fakes.
- content.rs extracts fetch_chapter_html_once + the closure-driven
  fetch_chapter_html_with_recircuit, used by sync_chapter_content to
  retry on Transient or Unauthenticated up to a recircuit_budget.
  Budget = 0 (no TOR) preserves original behavior bit-for-bit.
- app.rs and bin/crawler.rs construct the controller before on_launch
  and pass it into verify_session_with_recircuit, so a transient
  hiccup at startup no longer requires PHPSESSID rotation.

Recircuit budget defaults to CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS (3).
Errors from NEWNYM are logged and swallowed — failing to recircuit
should not take down the crawl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:01:04 +02:00
MechaCat02
8557e432a2 feat(crawler): plumb TorController through FetchContext and pipelines
Adds CRAWLER_TOR_CONTROL_URL / _PASSWORD / _COOKIE_PATH /
_RECIRCUIT_MAX_ATTEMPTS to CrawlerConfig and to bin/crawler.rs's
env reads. Constructs an Option<Arc<TorController>> at daemon /
CLI startup and threads it through FetchContext,
pipeline::run_metadata_pass, and content::sync_chapter_content as
Option<&TorController>.

Pure scaffolding — the controller isn't used yet; behavior is
unchanged. Next commit wires the retry hooks and session-probe
recircuit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:59:47 +02:00
MechaCat02
d6d84dedcb feat(crawler): retry_on_transient_with_hook for between-retry side effects
Adds a sibling fn that fires a caller-supplied async hook between a
transient failure and the next attempt. The existing
retry_on_transient becomes a thin wrapper over it (no-op hook), so
no call sites churn yet.

Hook contract: fires only between attempts (N-1 times for N
attempts), never after a non-transient error or after the final
attempt. Designed for TOR NEWNYM, but the signature is generic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:59:47 +02:00
MechaCat02
d37b94871e feat(crawler): TorController for control-port NEWNYM signaling
Minimal client over tokio::net::TcpStream — AUTHENTICATE then
SIGNAL NEWNYM, one-shot connection. Supports cookie-file and
password auth (cookie preferred when both provided); covers the
multi-line `250-...\r\n250 OK` reply form so future torrc tweaks
won't confuse the parser.

Not yet wired into the crawler — that lands in the next commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:59:47 +02:00
437 changed files with 66333 additions and 2514 deletions

View File

@@ -25,6 +25,12 @@ DATABASE_URL=postgres://mangalord:mangalord@postgres:5432/mangalord
BIND_ADDRESS=0.0.0.0:8080
STORAGE_DIR=/var/lib/mangalord/storage
RUST_LOG=info,mangalord=debug,chromiumoxide::conn=off,chromiumoxide::handler=off
# Postgres connection-pool sizing. One pool serves HTTP handlers and the
# crawler/analysis daemons. DB_MAX_CONNECTIONS caps open connections;
# DB_ACQUIRE_TIMEOUT_SECS is how long a request waits for a free connection
# before failing fast (rather than hanging on the driver's 30s default).
DB_MAX_CONNECTIONS=20
DB_ACQUIRE_TIMEOUT_SECS=10
# ----- Auth / cookies -----
# COOKIE_SECURE controls whether the `Secure` flag is set on the session
@@ -45,6 +51,13 @@ SESSION_TTL_DAYS=30
# rate-limiting reverse proxy that already enforces a budget).
AUTH_RATE_PER_SEC=5
AUTH_RATE_BURST=10
# Trust a proxy-supplied X-Forwarded-For as the client IP for per-IP auth
# rate limiting. Enable ONLY when the backend sits behind a trusted proxy
# that overrides the header (the compose deploy: SvelteKit forwards the real
# peer IP). When false, the header is ignored and a single shared bucket is
# used — a directly-exposed backend MUST keep this off or clients could spoof
# their IP to dodge the limit.
AUTH_TRUSTED_PROXY=false
# ----- CORS -----
# Comma-separated origins allowed to call the API with credentials.
@@ -52,6 +65,49 @@ AUTH_RATE_BURST=10
# on different hosts. Example: https://app.example.com,https://app.example.de
CORS_ALLOWED_ORIGINS=
# ----- Admin CSRF allowlist -----
# Browser origins (scheme + host[:port]) permitted to POST to
# /api/v1/admin/* mutating endpoints. Defends the session-cookie-
# authenticated admin surface against SameSite=Lax form-POST CSRF.
# Same shape as CORS_ALLOWED_ORIGINS (comma-separated). Compare against
# the request's Origin header (falling back to Referer when absent);
# safe methods (GET/HEAD/OPTIONS) are not checked, and requests with
# neither Origin nor Referer (curl, server-to-server callers) are
# always allowed.
#
# Empty does NOT mean "off" for browsers: cookie-authenticated admin
# mutations FAIL CLOSED (403) when this is empty, since there's no
# allowlist to check the Origin against. Non-cookie callers (curl,
# bots with a Bearer token, no Origin/Referer) are still allowed.
# So set this to the SvelteKit origin for any browser-exposed deploy,
# e.g. https://app.example.com. For a same-origin docker-compose deploy
# set the same value the browser uses.
# Local dev (native `npm run dev`): the Vite origin is
# http://localhost:5173 — set ADMIN_ALLOWED_ORIGINS=http://localhost:5173
# or admin toggles (e.g. enabling the analysis worker) return 403.
ADMIN_ALLOWED_ORIGINS=
# ----- Admin bootstrap -----
# When BOTH are set and non-empty, the backend ensures a user with this
# username exists at startup, promotes it to admin, and (for a brand-new
# row only) sets the password. An existing user's password is NEVER
# overwritten by this — rotate via the dashboard or `/auth/me/password`.
# Leave BOTH empty in a fully provisioned deploy.
ADMIN_USERNAME=
ADMIN_PASSWORD=
# ----- Site-wide auth gates (env-ONLY) -----
# PRIVATE_MODE: when `true`, every endpoint except a small public allowlist
# (/health, /auth/config, /auth/login, /auth/logout) demands a valid
# session — anonymous reads return 401. Self-registration is also
# force-disabled regardless of ALLOW_SELF_REGISTER. Default `false`.
PRIVATE_MODE=false
# ALLOW_SELF_REGISTER: when `false`, /auth/register returns 403 and the
# frontend hides its register affordance. Admins can still mint accounts
# via /admin/users. Default `true` (open registration). Forced to `false`
# when PRIVATE_MODE=true.
ALLOW_SELF_REGISTER=true
# ----- Upload limits -----
# Per-request body cap. axum rejects oversized requests with 413 before
# our handlers run. Default 200 MiB.
@@ -60,6 +116,13 @@ MAX_REQUEST_BYTES=209715200
# oversized image is rejected even when the total request fits.
# Default 20 MiB.
MAX_FILE_BYTES=20971520
# Max page images accepted in one chapter upload. Bounds how many parts
# the handler will stage before rejecting the request with 413, so a
# client can't pin a worker with an unbounded page count. Default 2000.
# Setting 0 disables THIS cap — the total is then bounded only by
# MAX_REQUEST_BYTES (the whole-request body limit above), so leave 0 only
# if you intend that body limit to be the sole backstop.
MAX_PAGES_PER_CHAPTER=2000
# ----- Crawler download safety -----
# Hosts the crawler is allowed to fetch images/covers from, in addition
@@ -74,6 +137,75 @@ CRAWLER_DOWNLOAD_ALLOWLIST=
CRAWLER_ALLOW_ANY_HOST=false
# Hard cap on a single image body. Default 32 MiB.
CRAWLER_MAX_IMAGE_BYTES=33554432
# Hard cap on the number of page images in one crawled chapter. The
# per-image byte cap doesn't stop a hostile reader page listing thousands
# of <img> tags; an over-cap chapter is acked failed instead of downloaded.
# 0 disables the cap. Default 2000.
CRAWLER_MAX_IMAGES_PER_CHAPTER=2000
# Max manga detail fetches per metadata pass (both the in-process daemon
# and the `bin/crawler` CLI). 0 means no cap — let the source walker run
# to completion. Useful for capped test runs against a new source.
CRAWLER_LIMIT=0
# ----- Crawler reliability knobs -----
# Hard upper bound on a single chapter-content job dispatch (seconds).
# A job that exceeds the budget is acked failed (with exponential
# backoff) instead of wedging a worker. Default 600s.
CRAWLER_JOB_TIMEOUT_SECS=600
# Idle seconds the daemon waits for new jobs before parking (between
# scheduled passes). Lower for snappier dev loops; higher for fewer
# wakeups in steady state. Default 600.
CRAWLER_IDLE_TIMEOUT_S=600
# Concurrent chapter-content workers. Each pulls one chapter at a time
# from the queue. Higher = faster on multi-core hosts BUT also more
# pressure on the source (raise CRAWLER_RATE_MS in tandem). Default 1.
CRAWLER_CHAPTER_WORKERS=1
# Days of completed `crawler_jobs` rows kept before vacuum (per-row
# audit trail; failed rows survive to make retries traceable). Default 7.
CRAWLER_JOB_RETENTION_DAYS=7
# Days of `crawl_metrics` rows kept (per-run aggregate dashboard
# data). Longer history → bigger dashboard charts; shorter → less DB.
# Default 90.
CRAWL_METRICS_RETENTION_DAYS=90
# Politeness rate limit between requests to the source (milliseconds).
# Pause inserted after each fetch. Raising slows the crawl but is
# friendlier to small sources. Default 1000ms.
CRAWLER_RATE_MS=1000
# Politeness rate for the CDN host (CRAWLER_CDN_HOST). Defaults to
# CRAWLER_RATE_MS when unset — set lower if the CDN tolerates faster
# pulls, or higher if it rate-limits aggressively. Default 1000ms.
CRAWLER_CDN_RATE_MS=1000
# User-Agent string for crawler HTTP requests. Leave unset for a
# generic default. Some sources fingerprint UA — set a realistic
# browser UA if the source 403s the default.
CRAWLER_USER_AGENT=
# Source session cookie value (PHPSESSID). Required when the source
# gates listings behind a logged-in session. Treat as a secret.
CRAWLER_PHPSESSID=
# Cookie domain the crawler scopes its session to. Usually the source
# host (e.g. `mangadex.org`). Leave unset to let reqwest infer it.
CRAWLER_COOKIE_DOMAIN=
# Consecutive metadata-pass `fetch_manga` failures that abort the pass
# (circuit breaker for a source outage). The pass does NOT mark a clean
# exit, so the next tick does a recovery sweep. Default 10.
CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES=10
# Consecutive transient chapter failures (after TOR recircuit is
# exhausted) that trigger an automatic coordinated browser restart.
# Default 3.
CRAWLER_BROWSER_RESTART_THRESHOLD=3
# CDP Fetch interception that re-validates every headless-browser
# navigation/redirect/subresource against the SSRF check (blocks a scraped page
# that drives the browser — via a redirect OR page JS/subresource — to an
# internal target such as the cloud metadata service or postgres). Default TRUE:
# with it off, only the top-level URL string is checked and page JS/subresources
# reach internal IPs (the reqwest SafeResolver does not cover Chromium's own
# stack). The CDP Fetch hook can't be exercised in CI (no Chromium); before
# relying on a fresh deploy, validate it doesn't wedge navigation:
# CRAWLER_CHROMIUM_BINARY=/usr/bin/chromium \
# cargo test -p mangalord --test crawler_browser_smoke -- --ignored \
# ssrf_interception_does_not_wedge_allowed_navigation
# Set to false only as a break-glass if the hook destabilizes a deployment.
CRAWLER_SSRF_INTERCEPT=true
# Path to a system Chromium binary. When set, the crawler skips the
# bundled-fetcher download. Required on platforms without a usable
# upstream Chromium build (notably Linux_arm64 / Raspberry Pi). On
@@ -83,6 +215,48 @@ CRAWLER_MAX_IMAGE_BYTES=33554432
# the image actually contains the binary.
CRAWLER_CHROMIUM_BINARY=
# ----- Crawler TOR proxy + recircuit -----
# The compose stack ships a `tor` service (dockurr/tor) and defaults
# CRAWLER_PROXY to it, so by default all crawler traffic exits via the
# TOR network. To opt out, set CRAWLER_PROXY= (empty) AND
# CRAWLER_TOR_CONTROL_URL= (empty) below — the tor service can stay
# running, it just won't be used.
#
# Going through TOR adds latency to every fetch; image downloads in
# particular slow noticeably. The win is on sites that rate-limit or
# fingerprint by exit IP — NEWNYM recirculation makes a fresh exit
# cheap to reach for.
#
# CRAWLER_PROXY: SOCKS5(h) URL. Use `socks5h://` (not `socks5://`) so
# DNS resolution also goes through TOR, avoiding leaks via the host's
# resolver. Leave unset to talk to the upstream directly.
CRAWLER_PROXY=socks5h://tor:9050
# Control-port URL for SIGNAL NEWNYM ("get a fresh circuit"). Triggered
# automatically on bad pages (broken-page body, missing #logo) and on
# the Unauthenticated session probe outcome. Leave unset to disable
# the recircuit feature (the SOCKS proxy still works).
CRAWLER_TOR_CONTROL_URL=tcp://tor:9051
# Max NEWNYM-and-retry cycles per recircuit-eligible failure. Default 3.
CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS=3
# Path to a tor cookie-auth file. Alternative to TOR_CONTROL_PASSWORD —
# the TorController prefers cookie when both are present. Useful when
# running against an externally managed tor daemon that uses
# `CookieAuthentication 1` instead of HashedControlPassword. Default unset.
CRAWLER_TOR_CONTROL_COOKIE_PATH=
# ----- TOR control-port password -----
# Shared between the bundled dockurr/tor service (which hashes it into
# its HashedControlPassword) and the backend's
# CRAWLER_TOR_CONTROL_PASSWORD. REQUIRED — docker-compose.yml fails
# fast if absent. Generate a strong random string; rotate by setting
# a new value and restarting both `tor` and `backend`.
#
# Operators running their own non-dockurr tor daemon with cookie-file
# auth can ignore this var and instead set
# CRAWLER_TOR_CONTROL_COOKIE_PATH on the backend — the TorController
# prefers cookie when both are present.
TOR_CONTROL_PASSWORD=change-me-to-a-strong-random-string
# ----- Frontend -----
# The frontend container runs SvelteKit's Node adapter on :3000 and
# proxies /api/* to BACKEND_URL via src/hooks.server.ts. In compose the
@@ -95,3 +269,172 @@ BACKEND_URL=http://backend:8080
# 25 Mbps; raise for users on slower upstream links or lower if a
# tighter front proxy already bounds the request lifetime.
BACKEND_PROXY_TIMEOUT_MS=300000
# ----- Runtime settings (crawler + analysis) -----
# The crawler and analysis subsystems are configured at runtime from the
# `app_settings` table and edited live in the admin dashboard
# (Admin → Settings) — no restart needed. The CRAWLER_* / ANALYSIS_* env
# vars below act as the BOOT SEED: on first boot (when the row is absent)
# the env values populate the DB; thereafter the DB is the source of
# truth and env changes are ignored for those fields.
#
# Host/infra and secret fields stay env-ONLY (never persisted, shown
# read-only in the dashboard): the chromium binary/dir, browser mode/args,
# CRAWLER_PROXY, all CRAWLER_TOR_CONTROL_*, the cookie domain, and the
# analysis ANALYSIS_API_KEY. The important site levers (PRIVATE_MODE,
# ALLOW_SELF_REGISTER) also remain env-only by design.
#
# Crawler boot seeds (effective only on first boot, then editable from the
# dashboard):
# CRAWLER_START_URL The catalog listing URL the crawler walks. Without
# it, the cron is disabled (no daemon-driven sweeps;
# force-resync from the dashboard still works).
# CRAWLER_CDN_HOST Distinct host the source serves images from. Used to
# set a slower rate limit (CRAWLER_CDN_RATE_MS) and
# seed the download allowlist.
CRAWLER_START_URL=
CRAWLER_CDN_HOST=
# CRAWLER_DAEMON / CRAWLER_DAILY_AT / CRAWLER_TZ — the daemon sweep
# schedule. CRAWLER_DAEMON=false keeps the in-process daemon off; clicks
# in the dashboard still work. DAILY_AT is `HH:MM` in the chosen TZ;
# TZ is an IANA name (e.g. `Europe/Berlin`). Default: daemon on, runs
# at 00:00 UTC.
CRAWLER_DAEMON=true
CRAWLER_DAILY_AT=00:00
CRAWLER_TZ=UTC
#
# New analysis knobs (all optional, all seed defaults):
# ANALYSIS_TEMPERATURE Sampling temperature. Default 0 (deterministic).
# ANALYSIS_SYSTEM_PROMPT Override the single-call vision system prompt.
# ANALYSIS_OCR_PROMPT Override the tall-page OCR (pass A) prompt.
# ANALYSIS_GROUNDING_PROMPT Override the tags/scene/safety (pass B) prompt.
# Leave the prompt vars unset to use the built-in defaults (also editable,
# with a per-prompt "reset to default", in the dashboard).
# ----- Analysis (vision) — boot seeds + env-only secret -----
# The page-analysis worker (off by default) POSTs each page image at a
# Chat-Completions-compatible vision endpoint and persists OCR + tags.
# All fields except ANALYSIS_API_KEY are boot seeds for the app_settings
# row and switch to dashboard-editable once persisted.
#
# ANALYSIS_ENABLED Turn the worker on at first boot. Toggleable live in
# the dashboard. Default `false`.
ANALYSIS_ENABLED=false
# ANALYSIS_BACKEND Which engine the worker runs. env-ONLY (deploy-time).
# `ocr` (default) = the in-process ocrs engine: fast,
# CPU-only, text-only, ideal for a Pi.
# NOTE: the `vision` backend (local LLM at ANALYSIS_VISION_URL,
# full OCR + tags + scene + safety) is TEMPORARILY DISABLED —
# its code is kept intact but the worker forces OCR regardless,
# logging a warning if `vision` is requested. So setting
# `vision` here currently has no effect; the ANALYSIS_VISION_* /
# ANALYSIS_API_KEY knobs below are dormant until it's re-enabled.
ANALYSIS_BACKEND=ocr
# OCRS_DETECTION_MODEL / OCRS_RECOGNITION_MODEL Paths to the ocrs `.rten`
# text-detection / -recognition models (only read when ANALYSIS_BACKEND=ocr).
# The backend image bakes both into /models, so the defaults work unchanged;
# override only to point at custom-trained models.
OCRS_DETECTION_MODEL=/models/text-detection.rten
OCRS_RECOGNITION_MODEL=/models/text-recognition.rten
# ANALYSIS_VISION_URL /v1/chat/completions endpoint. Required when enabled.
# For the bundled vision container, use
# http://mangalord-vision:8000/v1/chat/completions.
ANALYSIS_VISION_URL=
# ANALYSIS_VISION_MODEL Model id the endpoint expects (`model:` field).
ANALYSIS_VISION_MODEL=
# ANALYSIS_WORKERS Concurrent vision dispatches. Default 1.
ANALYSIS_WORKERS=1
# ANALYSIS_JOB_TIMEOUT_SECS Hard upper bound per vision call before the
# job is acked failed (backoff). Default 600.
ANALYSIS_JOB_TIMEOUT_SECS=600
# ANALYSIS_API_KEY env-ONLY. Bearer-attached to every vision call; never
# persisted, never logged.
ANALYSIS_API_KEY=
# Analysis tuning — boot seeds (live-editable from the dashboard once
# persisted). Defaults are tuned for a 4 GiB-class local vision model;
# raise/lower as the model and budget allow.
# ANALYSIS_MAX_TOKENS Output-token cap per call. Default 4096.
# ANALYSIS_MAX_PIXELS Per-image pixel budget; we resize to fit.
# Default 1_000_000 (~1 MP).
# ANALYSIS_MIN_SLICE_HEIGHT Slice grid height for tall pages, px.
# Default 640.
# ANALYSIS_SLICE_OVERLAP Fractional overlap between adjacent slices
# so text on a cut survives. Default 0.12.
# ANALYSIS_TALL_ASPECT Slice only when `height/width` exceeds this;
# normal pages take a single call. Default 1.6.
# ANALYSIS_MAX_SLICES Hard cap on slices per page (coarsens
# beyond cap). Default 16.
# ANALYSIS_MAX_IMAGE_BYTES Per-image byte cap before downscale.
# Default 8388608 (8 MiB).
# ANALYSIS_OCR_MAX_DECODE_PIXELS Decompression-bomb guard for the OCR
# backend: hard cap on a page's *decoded* pixel
# count (encoded size is bounded separately).
# Default 100000000 (100 MP).
# ANALYSIS_RESPONSE_FORMAT `json_schema` | `json_object` | `none`.
# Default `json_schema`.
# ANALYSIS_FREQUENCY_PENALTY Discourages repetition loops. Default 0.3.
# ANALYSIS_TEMPERATURE Sampling temperature; 0 = deterministic.
# Default 0.
ANALYSIS_MAX_TOKENS=4096
ANALYSIS_MAX_PIXELS=1000000
ANALYSIS_MIN_SLICE_HEIGHT=640
ANALYSIS_SLICE_OVERLAP=0.12
ANALYSIS_TALL_ASPECT=1.6
ANALYSIS_MAX_SLICES=16
ANALYSIS_MAX_IMAGE_BYTES=8388608
ANALYSIS_OCR_MAX_DECODE_PIXELS=100000000
ANALYSIS_RESPONSE_FORMAT=json_schema
ANALYSIS_FREQUENCY_PENALTY=0.3
ANALYSIS_TEMPERATURE=0.0
# ----- Vision autoscaling (the `ai` compose profile) -----
# The mangalord-vision (llama.cpp) container pins ~4.4 GiB and has no
# idle-unload, so the `vision-manager` sidecar starts it on demand and
# idle-stops it. Bring the two helper containers up with:
# docker compose --profile ai up -d
# The vision container itself is NOT defined in compose — it lives elsewhere
# on the host and the manager drives it by name. See VISION-AUTOSCALE.md.
#
# ANALYSIS_VISION_HEALTH_URL — env-ONLY readiness gate for the analysis
# worker. When set, the worker refuses to lease a page until this answers
# 2xx, so the manager can stop vision mid-idle without jobs burning their
# retries / landing `failed` rows. MUST point at the same vision instance the
# worker analyses against. Leave empty to disable the gate (always-on setups).
ANALYSIS_VISION_HEALTH_URL=http://mangalord-vision:8000/health
#
# vision-manager knobs:
# VISION_MANAGER_DATABASE_URL — REQUIRED for the `ai` profile. A read-only
# role, NOT the backend creds: apply vision-manager/readonly-role.sql once,
# then set e.g.
# VISION_MANAGER_DATABASE_URL=postgres://vision_manager:<pw>@postgres:5432/mangalord
VISION_MANAGER_DATABASE_URL=
# VISION_CONTAINER Container name the manager starts/stops. Default mangalord-vision.
# VISION_HEALTH_URL /health URL the manager polls after start. Default http://mangalord-vision:8000/health.
# VISION_POLL_INTERVAL Backlog poll cadence, seconds. Default 20.
# VISION_STOP_DEBOUNCE Idle seconds before stopping vision. Default 600 (debounces bursty enqueues).
# VISION_START_HEALTH_TIMEOUT Max seconds to wait for /health 200 after start. Default 300 (cold model load).
# VISION_RESPECT_CRAWL_MUTEX 1 = don't start vision while a crawl runs (RAM mutex on the 8 GiB box). Default 1.
# VISION_MAX_UPTIME >0 = force-stop vision after N seconds running (backstop). Default 0 (disabled).
#
# Memory-pressure yield — the dynamic generalization of the crawl mutex. The
# manager reads host-wide used% (from /proc/meminfo MemAvailable) and stops a
# RUNNING vision when memory gets tight, so a spike elsewhere (a crawl, CI, a
# backend burst) can finish instead of the kernel OOM-killer shooting something
# stateful. Two watermarks give hysteresis; the cooldown prevents stop/restart
# thrash when vision itself is the hog. See VISION-MEMORY-YIELD.md.
# VISION_MEM_YIELD_ENABLED 1 = enable the gate, 0 = disable entirely. Default 1.
# VISION_MEM_HIGH_WATERMARK_PCT used% >= this → actively stop a running vision. Default 92.
# VISION_MEM_LOW_WATERMARK_PCT used% >= this → inhibit starts (keep HIGH - LOW >= ~10 so the band beats jitter). Default 80.
# VISION_MEM_YIELD_COOLDOWN Seconds after a pressure-stop during which restart is refused regardless of backlog. Default 300.
# VISION_MEM_POLL_INTERVAL Mem sub-poll cadence, seconds (<= VISION_POLL_INTERVAL); catches spikes between backlog ticks. Default 5.
# ---- Deployment resource bounds & exposure (docker-compose.yml) ----
# Host interface the frontend port publishes on. Default 127.0.0.1 so SvelteKit
# (plain HTTP) is reachable only by a host-local reverse proxy. Set 0.0.0.0 only
# if your TLS terminator runs on a different host.
FRONTEND_PUBLISH_ADDR=127.0.0.1
# Per-container memory ceilings (docker mem_limit). Generous defaults; tune to
# your host. The backend runs OCR + headless Chromium and is the heavy one.
BACKEND_MEM_LIMIT=4g
FRONTEND_MEM_LIMIT=512m
POSTGRES_MEM_LIMIT=1g

View File

@@ -113,7 +113,13 @@ jobs:
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USERNAME" --password-stdin
for svc in backend frontend; do
img="$REGISTRY_URL/mangalord-$svc"
docker build -t "$img:$IMAGE_TAG" -t "$img:latest" -t "$img:$VERSION" "./$svc"
# The backend bundles Debian's headless chromium for the crawler:
# chromiumoxide's fetcher has no arm64 build, so the Pi must use a
# system binary (pair with the runtime env
# CRAWLER_CHROMIUM_BINARY=/usr/bin/chromium-headless-shell).
build_args=""
if [ "$svc" = "backend" ]; then build_args="--build-arg INSTALL_CHROMIUM=true"; fi
docker build $build_args -t "$img:$IMAGE_TAG" -t "$img:latest" -t "$img:$VERSION" "./$svc"
for tag in "$IMAGE_TAG" latest "$VERSION"; do docker push "$img:$tag"; done
done
docker logout "$REGISTRY_URL"
@@ -149,5 +155,12 @@ jobs:
export MANGALORD_TAG="$IMAGE_TAG"
docker compose pull mangalord-backend mangalord-frontend
docker compose up -d mangalord-backend mangalord-frontend
# Persist the deployed SHA into .env so it reflects what's actually
# live. The export above is shell-only; without this, .env keeps its
# `latest` placeholder and `docker inspect` is the only way to see the
# running SHA. Runs only after a successful `up -d` (set -eu); kept
# non-fatal so a write hiccup can't fail an otherwise-good deploy.
sed -i "s/^MANGALORD_TAG=.*/MANGALORD_TAG=${IMAGE_TAG}/" .env \
|| echo "::warning::could not persist MANGALORD_TAG to .env"
docker image prune -f
docker logout "$REGISTRY_URL"

10
.gitignore vendored
View File

@@ -8,6 +8,10 @@
/frontend/build
/frontend/test-results
/frontend/playwright-report
# Vite writes these transient files next to vite.config.ts while the dev
# server is running, then deletes them on clean shutdown — but a crashed
# server leaves them behind. Keep them out of `git status` either way.
/frontend/vite.config.ts.timestamp-*.mjs
# Local storage volume (manga files). `/data` is the root path the
# compose volume mounts at; `/backend/data` is where the dev backend
@@ -20,6 +24,12 @@
.env
.env.local
.env.*.local
.env.dev
# Node install / Playwright output when run from the repo root (the
# canonical copies live under /frontend, already ignored above).
/node_modules
/test-results
# Claude Code (personal overrides only; .claude/settings.json is committed)
.claude/settings.local.json

View File

@@ -136,6 +136,8 @@ 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 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.
- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change.

136
HYBRID-OCR-VISION.md Normal file
View File

@@ -0,0 +1,136 @@
# Hybrid OCR→Vision analysis pipeline — design brief for the dev agent
**Status: design only (not implemented).** Forward-looking model for a future
`ANALYSIS_BACKEND=hybrid`, on top of the shipped `ocr` (default) and `vision`
backends. Constraint that shapes it: `ocrs` and the vision LLM must **not** be
resident at the same time (Pi RAM ceiling). OCR has priority; vision is a
preemptible, only-when-OCR-is-drained background phase.
## The key realization: this is the crawl mutex, again
The repo already runs a **`vision-manager`** sidecar
([vision-manager/manager.sh](vision-manager/manager.sh),
[VISION-AUTOSCALE.md](VISION-AUTOSCALE.md)) that owns the `mangalord-vision`
(llama.cpp, ~4 GiB) container lifecycle via a scoped docker-socket-proxy — the
backend gets no Docker access. It already:
- **Starts** vision when `pending_analysis()` > 0 (counts `analyze_page` jobs).
- **Idle-stops** it after `STOP_DEBOUNCE` (anti-thrash) once the queue drains.
- **Defers starting** vision while `crawl_running()` > 0 — the **"RAM mutex"**:
the crawler's browser and the LLM can't both fit, so vision waits for crawl.
- **Memory-yield**: SIGTERMs a running vision at a HIGH host-RAM watermark,
blocks starting at a LOW watermark (see [VISION-MEMORY-YIELD.md](VISION-MEMORY-YIELD.md)).
The OCR↔vision relationship is the relationship that already exists between
crawl and vision. OCR is just a third RAM-competing, higher-priority workload
that vision must defer to. Extend the existing arbiter rather than build a new one.
## Job model: split one queue into two
Today there is one job kind, `analyze_page`. Split it:
- **`ocr_page`** — the in-process ocrs pass (cheap, fast, no container).
- **`ground_page`** — the vision grounding pass (needs the LLM container).
**Pipeline (hybrid mode):** upload → enqueue `ocr_page`. The OCR daemon runs
ocrs, writes `page_ocr_text` + `search_doc` (text searchable *immediately*), then
enqueues `ground_page` for that page. The grounding daemon later adds
tags/scene/safety. Each kind gets its own dedup index (mirror migration 0031).
Two daemons, two queues.
## Arbitration: OCR priority + memory exclusivity
Responsibilities split cleanly between the in-backend daemons and the manager:
**Backend — OCR daemon** (already built): leases `ocr_page`, always allowed to
run (ocrs is small/local). This is the priority phase.
**Backend — grounding daemon:** leases `ground_page`, but gates leasing on
**both**:
1. `VisionReadiness` (the existing `/health` gate — vision container up), AND
2. **`ocr_backlog == 0`** (a cheap `count(ocr_page WHERE state IN pending,running)`).
Gate #2 is the whole priority mechanism, in-process, no aborts: the instant OCR
work appears, the grounding daemon **finishes its current page** (the in-flight
lease completes normally) and then **stops leasing** new pages and parks — "it
finishes, then yields." It resumes only when OCR has fully drained.
**vision-manager:** two one-line changes to the existing logic:
1. `pending_analysis()` counts **`ground_page`** (not `ocr_page`) — vision only
starts when there is *grounding* work.
2. Add an **OCR start-block mutex** identical to `RESPECT_CRAWL_MUTEX`: defer
starting vision while `ocr_page` backlog > 0. (`RESPECT_OCR_MUTEX=1`.)
**Why this yields memory exclusivity:** while OCR backlog > 0, the grounding
daemon won't lease → vision goes idle → the manager's idle-debounce stops the
container (and never restarts it under the OCR mutex). Only ocrs (small) is
resident. When OCR drains, grounding resumes, `pending_analysis()` > 0 again, the
manager starts vision. Only the *big* consumer (the LLM container) is ever
mutually exclusive with active OCR — which is the actual RAM constraint.
**The one overlap window:** if OCR work arrives mid-grounding-page, ocrs (small,
in-process) briefly coexists with the one in-flight vision page before the
grounding daemon parks. ocrs's footprint makes this a non-issue in practice. If a
deployment needs *hard* exclusivity even there, the OCR daemon can additionally
wait for `vision_running == false` before its first dispatch. No deadlock: OCR's
wait is transient (one grounding page, bounded by `job_timeout`), while vision's
deferral on OCR backlog is the persistent, priority-respecting side.
**Anti-thrash:** the manager's `STOP_DEBOUNCE` already prevents a stray OCR page
from cold-cycling the 4 GiB model. Chapter uploads arrive as bursts, so OCR
drains a whole batch in one phase before vision resumes — the natural good case.
## Data-model split (the real refactor)
`persist_analysis` currently writes OCR + tags + scene + safety in one
transaction and derives `search_doc` from the OCR rows (+ scene weight C). Two
passes means splitting it, sharing the tsvector builder:
- **`persist_ocr(page, lines)`** — writes `page_ocr_text`, sets
`search_doc` from OCR (A/B/D buckets), marks the page text-searchable. Status
reflects OCR completion.
- **`persist_grounding(page, tags, scene, safety)`** — writes auto-tags,
warnings, scene; **recomputes** `search_doc` to fold in the scene (weight C) on
top of the existing OCR buckets. Records grounding completion (e.g. a
`grounded_at` column or tags-present sentinel).
This keeps text search live after the cheap OCR phase, with semantic search/tags
arriving after the (deferred) grounding phase.
## Reusable pieces
- OCR side: the shipped `OcrsEngine` + `OcrAnalyzeDispatcher`
([backend/src/analysis/ocr.rs](backend/src/analysis/ocr.rs)) — retarget to
`ocr_page`, and on success enqueue `ground_page`.
- Vision side: factor `VisionClient::ground(image, mime, ocr_text)` out of
`analyze()`'s Pass-B block
([backend/src/analysis/vision.rs](backend/src/analysis/vision.rs) ~240261) —
pure refactor, existing tests cover it. The grounding daemon is the existing
analysis daemon retargeted to `ground_page` with the extra `ocr_backlog==0`
lease gate.
- Manager: `pending_analysis()` kind swap + `RESPECT_OCR_MUTEX` block, mirroring
the crawl-mutex branch already at [vision-manager/manager.sh](vision-manager/manager.sh).
## Scope / sequencing
Larger than an inline hybrid dispatcher: job-kind split + dedup migrations,
`persist_*` split + search_doc-builder extraction, pipeline enqueue (OCR→ground),
grounding daemon lease gate, and the manager mutex. No new privileged surface
(reuses the docker-socket-proxy). Suggested order:
1. Split `persist_analysis``persist_ocr` / `persist_grounding` (+ tests).
2. Split job kinds + dedup migrations; retarget the OCR daemon to `ocr_page`.
3. `VisionClient::ground()` refactor + grounding daemon (`ground_page`, gated on
`ocr_backlog==0`).
4. OCR→ground enqueue on OCR success (hybrid mode only).
5. vision-manager: `ground_page` counting + `RESPECT_OCR_MUTEX`.
Pure-`ocr` (shipped default) and `vision` (legacy) backends are unaffected; this
is the `hybrid` backend's runtime model.
## Open decision
Hard exclusivity in the one overlap window (OCR daemon waits for `vision_running
== false` before its first dispatch) — needed only if ocrs's few-hundred-MB
footprint can't coexist with a single in-flight grounding page on the target
Pi's RAM. Default: don't gate (rely on the grounding daemon parking fast).

View File

@@ -81,7 +81,7 @@ Everything is namespaced under `/api/v1/`. `/api/*` outside the version prefix i
| Method | Path | Description |
| ------ | ------------------------------------------------------- | ------------------------------------------ |
| GET | `/api/v1/health` | Liveness probe. |
| GET | `/api/v1/mangas?search=&sort=recent|title&limit=&offset=` | List/search mangas. Trigram fuzzy search. |
| GET | `/api/v1/mangas?search=&sort=created\|updated\|title\|author&order=asc\|desc&limit=&offset=` | List/search mangas. Trigram fuzzy search. `sort` defaults to `updated`; `order` defaults per field (dates `desc`, text `asc`). `sort=recent` is a back-compat alias for `created`. |
| GET | `/api/v1/mangas/{id}` | Single manga. |
| GET | `/api/v1/mangas/{id}/chapters` | Paginated chapter list, ordered by number. |
| GET | `/api/v1/mangas/{id}/chapters/{n}` | Single chapter. |

138
VISION-AUTOSCALE.md Normal file
View File

@@ -0,0 +1,138 @@
# Vision auto start/stop — design brief for the dev agent
**Goal:** the `mangalord-vision` (llama.cpp) container should only run while there
is analysis work, and stop (freeing its ~4 GiB) once the queue drains —
*without* handing the internet-facing backend control of the host Docker daemon.
`llama-server` has **no idle-unload** (unlike Ollama). The only lever is the
container lifecycle: start it when work appears, stop it when work is gone.
> **Status: implemented.** The sidecar lives in [vision-manager/](vision-manager/)
> (`manager.sh` + `Dockerfile` + `readonly-role.sql`), wired into
> [docker-compose.yml](docker-compose.yml) behind `profiles: [ai]` alongside a
> scoped `docker-socket-proxy`. The companion **readiness gate** is in the
> analysis worker: when `ANALYSIS_VISION_HEALTH_URL` is set the worker will not
> lease a page until vision answers `GET /health` 2xx, so an idle-stopped or
> still-loading vision never burns a job's retries or writes a `failed` row
> (the gotcha called out below). Configure via the `ai`-profile vars in
> [.env.example](.env.example).
>
> **Operator prerequisite:** the `mangalord-vision` container is defined
> *outside* this compose project (the manager only drives it by name). For the
> manager and backend to resolve it by name for the `/health` probe, attach it
> to this project's default network (`<project>_default`, e.g.
> `mangalord_default`) — or point `VISION_HEALTH_URL` /
> `ANALYSIS_VISION_HEALTH_URL` at an address that resolves. If the container is
> unreachable the manager silently treats it as "not running / not ready" and
> will loop trying to start a container it cannot see.
## Chosen approach — Option 2: a "vision-manager" sidecar
A tiny, single-purpose container that:
1. **Watches the analysis backlog** in Postgres (read-only DB user).
2. **Starts** `mangalord-vision` when there is pending work.
3. **Stops** it after the backlog has been empty for a debounce window.
The backend (`mangalord-backend`) is **not modified and gets no Docker access**
all privilege is isolated in the manager. This is the whole point: a popped
backend can't reach the Docker socket.
```
┌────────────────┐ reads pending count ┌────────────┐
│ vision-manager │ ───────────────────────▶│ postgres │
│ (has socket) │ └────────────┘
└──────┬─────────┘
start/stop one │ (raw socket, OR scoped via docker-socket-proxy)
container only ▼
┌────────────────┐
│ mangalord-vision│ (profiles: [ai], started by name)
└────────────────┘
```
### What to poll (concrete)
The analysis daemon consumes the **shared `crawler_jobs` queue, filtered by the
analysis job kind**, and every unanalyzed page has a `page_analysis` row with
`status='pending'`. Either is a valid "is there work?" signal:
- **Queue-accurate (what the manager uses):** the queue column is `state`
(not `status`) and the kind lives in the JSONB payload — see
[backend/migrations/0012_crawler.sql](backend/migrations/0012_crawler.sql):
```sql
SELECT count(*) FROM crawler_jobs
WHERE payload->>'kind' = 'analyze_page'
AND state IN ('pending','running');
```
- **Backlog-simple:** `SELECT count(*) FROM page_analysis WHERE status='pending'`.
Prefer a **read-only** DB role scoped to those tables. Don't reuse the backend's
DB credentials.
### Lifecycle logic (sketch)
```
loop every POLL_INTERVAL (e.g. 20s):
pending = count_pending_work()
if pending > 0 and vision is stopped:
docker start mangalord-vision
wait for GET /health == 200 (up to a few minutes — cold model load)
reset idle timer
if pending == 0 and vision is running:
if idle for >= STOP_DEBOUNCE (e.g. 510 min):
docker stop mangalord-vision
```
## Recommendations
- **Scope the Docker access.** Best: run `tecnativa/docker-socket-proxy` on an
internal-only network with everything disabled except container start/stop,
and point the manager at the proxy. Acceptable for a tiny trusted manager:
mount the raw socket *into the manager only* (never the backend).
- **Keep the manager dumb and small.** ~100 lines. A shell loop with
`docker`/`curl`, or a small Go/Rust binary. No web surface.
- **Make it the single source of truth** for the vision lifecycle. Don't also
have the backend or a cron poke the same container.
- **Start by container name**, not `compose up` (the service is `profiles:[ai]`;
name-based `docker start/stop` works and won't fight a separate `compose up`).
- Optionally expose the toggle as a runtime setting ("auto-manage vision: on/off")
so it can be disabled without redeploying.
## Pitfalls / gotchas (flag all of these)
- **Cold start is expensive (~minutes)** to mmap 3.2 GiB + warm up. So:
- **Debounce the STOP** (510 min idle) — bursty uploads/re-analysis enqueue
in waves; stopping the instant the queue hits zero thrashes the load cycle
and loses more time than it saves.
- **Gate the first request on `GET /health == 200`** after start, or the first
jobs fail with connection-refused. (The backend already has request/job
timeouts — 300s/1800s — but a multi-minute cold start can still exceed a
single request timeout if a job is dispatched too eagerly.)
- **Idempotency / single-flight:** starting an already-running container must be
a no-op; never issue concurrent starts. One manager instance only.
- **Leak safety / don't depend on a "drained" signal from the backend:** the
manager's own idle timer must stop the container even if the backend crashes
mid-batch. Conversely, if the manager dies, the container keeps running
(harmless, just RAM) — consider a max-uptime backstop.
- **RAM headroom on the 8 GiB Pi:** vision sits ~4.4 GiB; `mem_limit: 6g` is set
on the service. The manager must not start vision while another big consumer
(a crawl with Chromium) is running, or the box OOMs. Consider a simple mutual
exclusion / total-RAM check.
- **Health vs readiness:** `/health` returns 200 once the model is loaded; that
is the readiness signal. Don't treat "container running" as "ready".
- **Re-analysis storms:** an admin "re-enqueue all" can flood the queue; the
manager will (correctly) start vision and keep it up for a long backfill —
expected, but make sure the STOP debounce doesn't bounce it mid-backfill if
the queue briefly empties between batches.
- **Profile interaction:** `docker compose --profile ai up` / CI deploys name
only the two mangalord services, so they won't start/stop vision — but a
human running a full `compose --profile ai up -d` could. Document that the
manager owns the lifecycle.
## Why not give the backend the socket directly
`mangalord-backend` is internet-facing (behind Caddy at manga.mc02.dev). Mounting
the raw Docker socket there makes any backend RCE a full host takeover
(postgres, gitea, vaultwarden, …). If the backend *must* drive it, use the
docker-socket-proxy scoped to start/stop of the single container — never the raw
socket. Option 2 sidesteps the question entirely by keeping the backend clean.

230
VISION-MEMORY-YIELD.md Normal file
View File

@@ -0,0 +1,230 @@
# Vision-manager — memory-pressure yield (design outline)
**Status:** implemented in `vision-manager/manager.sh` (feat/vision-mem-yield).
Verifications V1V4 (§5) all passed against the current backend — expired
leases are reclaimed continuously on every claim poll (not boot-only), connection
errors requeue with backoff, the readiness gate parks before leasing, and admin
re-enqueue recovers any straggler — so no backend changes were required.
`RESPECT_CRAWL_MUTEX` is **kept** for now (retiring it is deferred until the
watermarks are tuned on the real box).
**Extends:** `VISION-AUTOSCALE.md` (the `vision-manager` sidecar) and
`vision-manager/manager.sh`.
**One-liner:** let the manager *proactively stop* `mangalord-vision` when host
memory gets dangerously tight, so a transient spike elsewhere (a Chromium crawl,
a CI build, a backend burst) can complete instead of the kernel OOM-killer
shooting something stateful (postgres / backend).
---
## 1. Motivation
`llama-server` has no idle-unload and pins ~4.4 GiB while up. On the 8 GiB Pi
the existing autoscaler already stops it when the **backlog** is empty and (new)
when **analysis is disabled**. Neither reacts to *actual memory pressure*:
- `mem_limit: 6g` on the vision service caps only **vision's own** growth. It
does nothing about **aggregate host** pressure across all services.
- The static `RESPECT_CRAWL_MUTEX` only **defers a start** while a crawl runs —
it can't stop a vision that's **already running**, and it's blind to every
non-crawl memory consumer (CI build, backend spike, a second crawl worker).
- The only backstop under true pressure today is the **kernel OOM killer**,
which may pick postgres or the backend (long-lived, stateful, painful) rather
than the one process that is cheap and safe to lose.
A manager-driven stop is the friendly version: it picks the **right,
restartable victim** and does it **gracefully (SIGTERM)** before the kernel does
something violent.
This is the **dynamic generalization of `RESPECT_CRAWL_MUTEX`**: "back off when
RAM is actually tight" strictly dominates "never co-run with a crawl." If this
ships, `RESPECT_CRAWL_MUTEX` can likely be retired.
---
## 2. Feasibility (confirmed)
`/proc/meminfo` inside the manager container reports **host-wide** memory (it is
not namespaced), so the manager already sees the truth with **zero new
privilege** — no socket-proxy `docker info`, no host mount. Verified live from
inside `vision-manager`:
```
MemTotal: 8256576 kB (7.87 GiB)
MemAvailable: 1754384 kB → ~79% used, 21% available (vision up + 560 queued)
```
**Use `MemAvailable`, never `MemFree`.** Linux spends "free" RAM on page cache by
design, so a `MemFree`-based "used %" sits near 90 % during normal operation and
would false-trigger constantly. `MemAvailable` already nets out reclaimable
cache. The honest metric is:
```
used_pct = 100 * (1 - MemAvailable / MemTotal)
```
Read it in bash with `awk` over `/proc/meminfo` (both values are in kB):
```sh
read_mem_used_pct() {
awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2}
END { if (t>0) printf "%d", 100*(1-a/t); else print "ERR" }' /proc/meminfo
}
```
---
## 3. Behaviour
A third gate in the existing poll loop, evaluated **before** the
backlog/enable start/stop logic:
1. **Stop gate (active).** If `used_pct >= HIGH_WATERMARK` (e.g. 92) **and**
vision is running → `docker stop mangalord-vision`, record a cooldown
deadline, log loudly. This is the new capability the crawl mutex never had.
2. **Start inhibit.** While `used_pct >= LOW_WATERMARK` (e.g. 80) **or** a
cooldown is in effect → refuse to start vision even if backlog > 0 (same
shape as the crawl-mutex deferral: log once per episode, not every tick).
3. **Resume.** Once `used_pct < LOW_WATERMARK` **and** the cooldown has elapsed,
normal autoscaler behaviour resumes (start on backlog, idle-stop, etc.).
**Hysteresis is mandatory** (two distinct watermarks). A single threshold
oscillates around the boundary.
**The cooldown is mandatory** and is the subtle part. If vision *itself* is the
4.4 GiB hog, stopping it drops pressure → manager sees backlog → restarts it →
pressure climbs → stop … infinite thrash, each cycle paying the ~minutes cold
reload. The cooldown (refuse-to-restart for N minutes after a pressure-stop,
*regardless of backlog*) is what gives the **other** workload — the one that
actually needed the RAM — room to finish before vision muscles back in. The
yield only makes sense when something else needs the memory; the cooldown
encodes that.
### Gate ordering in the loop
```
loop every POLL_INTERVAL:
used = read_mem_used_pct()
pending = pending_analysis()
if not analysis_enabled(): pending = 0 # existing gate
if mem_yield_active(used): pending = 0 # NEW: inhibit starts
if used >= HIGH_WATERMARK and vision running: # NEW: active stop
stop_vision(); start_cooldown(); continue
... existing start-on-backlog / idle-debounce-stop logic on `pending` ...
```
Note the symmetry with the analysis-disabled gate already in `manager.sh`:
forcing `pending = 0` reuses the existing idle/stop path for the *inhibit* case,
while the explicit `stop_vision()` handles the *active* case the idle path can't.
---
## 4. Proposed configuration (all env, matching manager.sh style)
| Var | Default | Meaning |
|---|---|---|
| `MEM_YIELD_ENABLED` | `1` | Master switch for the whole feature. |
| `MEM_HIGH_WATERMARK_PCT` | `92` | `used_pct ≥` this → active-stop a running vision. |
| `MEM_LOW_WATERMARK_PCT` | `80` | `used_pct ≥` this → inhibit starts (no restart until below). |
| `MEM_YIELD_COOLDOWN` | `300` | Seconds after a pressure-stop during which restart is refused regardless of backlog. |
| `MEM_POLL_INTERVAL` | `POLL_INTERVAL` | Optional faster cadence for the mem check (pressure can spike between 20 s ticks). |
Watermarks want tuning on the real box; 92/80 are starting points. Keep
`HIGH LOW ≥ ~10` so the band is wider than normal jitter.
---
## 5. Required verifications (do these BEFORE building)
The queue is built for expendability — `crawler_jobs` is a lease queue:
```
state | attempts | max_attempts(=5) | leased_until | last_error | scheduled_at
```
A graceful `docker stop` (SIGTERM → clean llama-server exit) means at most the
**one** in-flight `analyze_page` job is affected; the readiness gate
(`ANALYSIS_VISION_HEALTH_URL`) then parks the worker so it won't hammer the dead
endpoint or burn the other queued jobs. But two things must be confirmed in
`backend/src/analysis/daemon.rs` (not visible from the DB schema alone):
- [ ] **V1 — reclaim cadence.** The `bugfix/crawler-recovery-hardening` branch
added *reclaim-orphaned at startup*. Confirm expired leases are **also**
reclaimed on the normal claim poll (i.e. the claim query includes something
like `OR (state='running' AND leased_until < now())`). If reclaim is
**boot-only**, a pressure-kill strands that one job as `running` with a dead
lease until the next backend restart → the page silently never analyses.
**This decides how aggressive we can safely be.**
- [ ] **V2 — error path requeue.** When the worker's HTTP call to vision is
refused (vision killed), confirm the job is returned to `pending`
(`attempts++`) and not marked permanently `failed` on a connection error.
A connection-refused/transport error should be *retryable*, distinct from a
model-returned bad response.
- [ ] **V3 — park does not deadlock.** Confirm that when the readiness gate
parks the worker, it is **not holding an un-renewed lease** that blocks the
job indefinitely (it should either release on park or let the lease expire +
be reclaimed per V1).
- [ ] **V4 — attempts accounting.** A pressure-kill is "our fault," yet still
burns one of the 5 attempts. Confirm repeated yields on the *same* in-flight
page can't quietly exhaust retries → permanent `failed`. Mitigations if it's a
concern: bump `max_attempts`, or rely on the admin re-enqueue (all/manga/
chapter) you already have to recover stragglers.
**Empirical check** (once V1V3 look right): with analysis on and one job
`running`, `docker stop mangalord-vision`, then watch
`SELECT state, attempts, last_error FROM crawler_jobs WHERE state IN
('running','pending') AND payload->>'kind'='analyze_page'` — the killed job
should reappear as `pending` with `attempts` incremented, and resume when vision
restarts. This is the acceptance test for the whole feature.
---
## 6. Pitfalls
- **`MemFree` vs `MemAvailable`** — using `MemFree` makes it fire constantly
(page cache). Already addressed by §2, but it's the #1 way to get this wrong.
- **Thrash / feedback loop** — if vision is the hog, stop→restart oscillates.
The cooldown (§3) is the guard; without it the feature is worse than nothing.
- **Spikes between ticks** — a Chromium burst can blow past 92 % inside a 20 s
poll gap and the kernel OOM-kills before the manager's next read. The valve
*reduces* OOM risk, it doesn't *eliminate* it. Consider a shorter
`MEM_POLL_INTERVAL` for just the mem read, and treat this as defence-in-depth
alongside (not instead of) `mem_limit` and swap/zram headroom.
- **Measuring the wrong scope** — confirm `/proc/meminfo` in the container keeps
showing **host** totals under this kernel/cgroup setup (it does today:
MemTotal 8256576 kB == the Pi). If a future runtime namespaces it, the metric
silently becomes the container's tiny cgroup and the gate never/always fires.
- **Cold-start cost** — every pressure-stop pays ~minutes to mmap + warm 3.2 GiB
on the next start. Don't set the watermarks so tight that normal operation
trips them; the backlog will stall during each reload.
- **Interaction with the idle-stop and disable gate** — make sure the three
reasons vision can be down (idle-drained, analysis-disabled, memory-yield) are
**distinctly logged** (flip-only, like the existing gates) so a stop is never
misdiagnosed. A single muddy "stopped vision" line will cost debugging time.
- **Double-stop / start races** — `docker stop` then an immediate backlog-driven
`docker start` in the same or next tick. The cooldown plus checking
`vision_running` before acting prevents this; don't drop those guards.
- **OOM-killer still owns the tail** — `mem_limit: 6g` caps vision's own growth
but the host can still be pushed over by the *sum* of everything else. This
feature lowers the probability of a bad OOM kill; pairing it with a little
**swap or zram** gives the kernel a cushion to survive the gap between a spike
and the manager's reaction.
---
## 7. Recommendation
Worth building, as a **third gate** in the existing `manager.sh` poll loop:
`MemAvailable`-based metric, two watermarks (hysteresis), a restart cooldown,
an **active stop** (the capability the crawl mutex lacks), and distinct
flip-only logging. It supersedes `RESPECT_CRAWL_MUTEX` (which can then be
removed).
**Gate it on verification V1 first** — whether expired leases are reclaimed
continuously or only at boot decides whether a yielded job cleanly retries or
strands. If V1/V2 hold, this is a low-risk, high-value safety valve; if reclaim
is boot-only, fix that in the backend before enabling memory-yield, otherwise
each pressure-stop quietly drops a page until the next backend restart.
Keep the manager dumb: this is ~30 lines of bash (one `awk` reader, two
watermark comparisons, one cooldown timestamp) — no new dependencies, no new
privilege, no web surface.

4
backend/.gitignore vendored
View File

@@ -1,3 +1,7 @@
/target
/.sqlx
.env
# Local OCR models for native dev (downloaded, not source)
models/
*.rten

355
backend/Cargo.lock generated
View File

@@ -214,6 +214,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -256,12 +262,24 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -502,6 +520,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -626,7 +663,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"objc2",
]
@@ -745,12 +782,31 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flatbuffers"
version = "24.12.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096"
dependencies = [
"bitflags 1.3.2",
"rustc_version",
]
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1038,6 +1094,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1323,6 +1385,32 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1401,7 +1489,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.5",
@@ -1470,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.45.1"
version = "0.128.25"
dependencies = [
"anyhow",
"argon2",
@@ -1486,17 +1574,19 @@ dependencies = [
"futures-core",
"futures-util",
"http-body-util",
"image",
"infer",
"mime",
"nix 0.29.0",
"ocrs",
"rand 0.8.6",
"reqwest",
"rten",
"scraper",
"serde",
"serde_json",
"sha2",
"sqlx",
"subtle",
"sysinfo",
"tempfile",
"thiserror 1.0.69",
@@ -1582,6 +1672,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1611,7 +1711,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -1623,7 +1723,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -1699,6 +1799,16 @@ dependencies = [
"libm",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -1714,7 +1824,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@@ -1735,7 +1845,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"dispatch2",
"objc2",
]
@@ -1746,7 +1856,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -1779,7 +1889,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -1797,7 +1907,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -1810,7 +1920,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
]
@@ -1821,7 +1931,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -1833,7 +1943,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -1858,6 +1968,21 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "ocrs"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5379fdd3f11522b5a2ff53017a189463dabf5d0a9c915cb3eb97fabec4ea11c"
dependencies = [
"anyhow",
"rayon",
"rten",
"rten-imageproc",
"rten-tensor",
"thiserror 2.0.18",
"wasm-bindgen",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2078,6 +2203,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.1",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2143,6 +2281,18 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2278,13 +2428,33 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -2293,7 +2463,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -2413,19 +2583,135 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rten"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c230fa4ade87c913f61dbd911b7eb0d49460ceff3f1e4fabc837fac191137c"
dependencies = [
"flatbuffers",
"num_cpus",
"rayon",
"rten-base",
"rten-gemm",
"rten-model-file",
"rten-onnx",
"rten-shape-inference",
"rten-simd",
"rten-tensor",
"rten-vecmath",
"rustc-hash",
"smallvec",
"typeid",
"wasm-bindgen",
]
[[package]]
name = "rten-base"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2738cf8bb4c27f828ac788d01ccf4e367e8e773cfec6851f81851b5211de6a79"
dependencies = [
"rayon",
]
[[package]]
name = "rten-gemm"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a81a0ca209fb5ce21bd17efa0bd287d5881c6cebfbff0b21c4294a1a14a9e"
dependencies = [
"rayon",
"rten-base",
"rten-simd",
"rten-tensor",
]
[[package]]
name = "rten-imageproc"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5f148e7e941fb5727b9046a5fa1b45525543d5105f14b384fd9261df0ee49bc"
dependencies = [
"rten-tensor",
]
[[package]]
name = "rten-model-file"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2f8d270f07ab1bbfff47250c6039f6caa5da59d6da7d74f66aa48559aa6fea"
dependencies = [
"flatbuffers",
"rten-base",
]
[[package]]
name = "rten-onnx"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23086eef75bfb55278cb0b45cf9f5a877d466d914914aafebee4ffca9b24d20c"
[[package]]
name = "rten-shape-inference"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e8a913c7ca40e2bfbb2a0cd447cce56b33ab19435f56693271a2ef37cf58984"
dependencies = [
"rten-tensor",
"smallvec",
]
[[package]]
name = "rten-simd"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19a0032dfcb70dd20960c1c51a37674b237586cbc1ce586f45b46605d108e82"
[[package]]
name = "rten-tensor"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05dc744a270aa32d154f1a3df8e48740ccc1be9dfbcf23295ada66d83aa98de6"
dependencies = [
"rayon",
"rten-base",
"smallvec",
"typeid",
]
[[package]]
name = "rten-vecmath"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9574ddebf5671bc08ceb76e2e1638fadc57fdeff318634eab2c29e9a803cff64"
dependencies = [
"rten-base",
"rten-simd",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -2438,7 +2724,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.12.1",
@@ -2520,7 +2806,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"cssparser",
"derive_more",
"fxhash",
@@ -2828,7 +3114,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags",
"bitflags 2.11.1",
"byteorder",
"bytes",
"chrono",
@@ -2872,7 +3158,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags",
"bitflags 2.11.1",
"byteorder",
"chrono",
"crc",
@@ -3234,7 +3520,7 @@ version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"bytes",
"futures-util",
"http",
@@ -3345,6 +3631,12 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
@@ -3585,7 +3877,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -4013,7 +4305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
@@ -4169,3 +4461,18 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.45.1"
version = "0.128.25"
edition = "2021"
default-run = "mangalord"
@@ -35,8 +35,10 @@ dotenvy = "0.15"
argon2 = "0.5"
rand = "0.8"
sha2 = "0.10"
subtle = "2"
base64 = "0.22"
# Image decode + downscale for the analysis worker (keep the page image
# under the local vision model's token budget). Only the manga page formats.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
axum-extra = { version = "0.9", features = ["cookie", "typed-header"] }
time = "0.3"
infer = "0.16"
@@ -45,10 +47,12 @@ futures-core = "0.3"
futures-util = "0.3"
bytes = "1"
chromiumoxide = { version = "0.7", features = ["tokio-runtime", "_fetcher-rusttls-tokio"], default-features = false }
sysinfo = { version = "0.32", default-features = false, features = ["system"] }
sysinfo = { version = "0.32", default-features = false, features = ["system", "component"] }
nix = { version = "0.29", features = ["fs"] }
scraper = "0.20"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream", "json"] }
ocrs = "0.12"
rten = "0.24"
[dev-dependencies]
tempfile = "3"
@@ -57,3 +61,14 @@ 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 +
# axum + tokio dep tree the default ("full") leaves backend/target on the
# order of tens of GiB; this typically cuts ~5070% off that.
[profile.dev]
debug = "line-tables-only"
[profile.test]
debug = "line-tables-only"

View File

@@ -58,6 +58,20 @@ WORKDIR /app
COPY --from=builder /app/target/release/mangalord /usr/local/bin/mangalord
COPY --from=builder /app/migrations /app/migrations
# OCR models for the default `ANALYSIS_BACKEND=ocr` (ocrs) engine. The two
# `.rten` files are pulled at build time into /models, where the runtime's
# `OCRS_DETECTION_MODEL` / `OCRS_RECOGNITION_MODEL` defaults point. They're a
# few MB each and pure data (no native code), so they bake cleanly into the
# image and need no manual setup on the Pi. Set INSTALL_OCR_MODELS=false to
# skip (e.g. for a vision-only deploy that never runs ocrs).
ARG INSTALL_OCR_MODELS=true
ARG OCRS_MODEL_BASE_URL=https://ocrs-models.s3-accelerate.amazonaws.com
RUN if [ "$INSTALL_OCR_MODELS" = "true" ]; then \
mkdir -p /models \
&& curl -fsSL "${OCRS_MODEL_BASE_URL}/text-detection.rten" -o /models/text-detection.rten \
&& curl -fsSL "${OCRS_MODEL_BASE_URL}/text-recognition.rten" -o /models/text-recognition.rten; \
fi
ENV STORAGE_DIR=/var/lib/mangalord/storage
# Pre-create the storage dir so the entrypoint doesn't need to
# mkdir-as-root and so the named volume mount inherits the right

View File

@@ -0,0 +1,18 @@
-- Capture each chapter's position in the source site's chapter list so
-- the user-facing list can preserve site order: variants of the same
-- chapter number (e.g. "Ch.14 : PH" next to "Ch.14 : Official") stay
-- adjacent, and non-numeric entries like "notice. : Officials" land
-- where the site placed them rather than clustering at the top under
-- number = 0.
--
-- Lower source_index = closer to the top of the source DOM = newer
-- chapter on this site (it renders newest-first). The list query
-- reverses this with ORDER BY source_index DESC so the oldest chapter
-- appears first in our UI.
--
-- NULL is the sentinel for user-uploaded chapters (no source row) and
-- for crawled rows that pre-date this migration. The list query keeps
-- the existing (number, created_at) tiebreak via NULLS LAST so those
-- fall through to the prior behaviour until the next crawler tick
-- populates the column.
ALTER TABLE chapters ADD COLUMN source_index INTEGER;

View File

@@ -0,0 +1,19 @@
-- Partial indexes that back the admin crawler dashboard hot reads:
-- * mangas with no cover — drives count_missing_covers /
-- list_missing_cover_mangas (the cover backlog the metadata pass drains).
-- * dead jobs — drives list_dead_jobs / requeue_dead_jobs.
-- Both filters are highly selective in steady state (the working sets are a
-- tiny fraction of the full tables), so the partials stay small and hot.
-- ORDER BY updated_at DESC matches the LIMIT/OFFSET page reads.
--
-- Not CONCURRENTLY: sqlx::migrate! wraps each migration in a transaction;
-- CREATE INDEX CONCURRENTLY can't run inside one. Tables are small at our
-- scale (online deploy still safe).
CREATE INDEX IF NOT EXISTS mangas_missing_cover_idx
ON mangas (updated_at DESC)
WHERE cover_image_path IS NULL;
CREATE INDEX IF NOT EXISTS crawler_jobs_dead_idx
ON crawler_jobs (updated_at DESC)
WHERE state = 'dead';

View File

@@ -0,0 +1,48 @@
-- Per-page collections and tags. Collections become heterogeneous: the
-- existing `collection_mangas` join holds whole mangas, this new
-- `collection_pages` join holds individual pages. A page tagged or
-- collected references the stable `pages.id` UUID, so re-uploading a
-- chapter (which deletes and recreates the page rows) silently drops
-- any saves attached to it — cascade is intentional, matching the
-- semantics of the cover-image references in existing collections.
CREATE TABLE collection_pages (
collection_id uuid NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (collection_id, page_id)
);
-- Reverse lookup: "which of my collections contain this page?" — the
-- reader's context menu pre-checks the matching collection rows on
-- open.
CREATE INDEX collection_pages_page_idx ON collection_pages (page_id);
-- Per-user, per-page free-form tags. Distinct from the manga-level
-- shared `tags` taxonomy — these are personal annotations. Length cap
-- 64 leaves room for `namespace:value` conventions (e.g.
-- `character:askeladd`) without making the schema enforce them.
CREATE TABLE page_tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
tag text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT page_tags_tag_nonempty CHECK (length(tag) > 0 AND length(tag) <= 64)
);
-- One row per (user, page, tag). The repo upserts via ON CONFLICT DO
-- NOTHING so re-tagging is a no-op (handler returns 200 instead of 201).
CREATE UNIQUE INDEX page_tags_user_page_tag_uniq
ON page_tags (user_id, page_id, tag);
-- "Show me everything I've tagged" — newest-first. Drives the library
-- Page-tags tab.
CREATE INDEX page_tags_user_idx ON page_tags (user_id, created_at DESC);
-- "Show me what I've tagged X" — same tab, when a chip filter is
-- active.
CREATE INDEX page_tags_user_tag_idx ON page_tags (user_id, tag, created_at DESC);
-- Per-page lookup for the context menu's contextual line.
CREATE INDEX page_tags_page_idx ON page_tags (page_id);

View File

@@ -0,0 +1,48 @@
-- Normalize per-page tags. Originally (0023) `page_tags.tag` stored the
-- tag inline as free-form text, the lone un-normalized tag concept while
-- authors/genres/manga-tags all went through a lookup table + FK. This
-- migration folds page tags into the SAME shared `tags` table that
-- `manga_tags` uses (0009): `page_tags.tag` becomes `page_tags.tag_id`
-- referencing `tags(id)`. After this there is one global tag vocabulary
-- shared by manga tags and page tags.
--
-- The HTTP contract is unchanged — the API still speaks tag *names*; the
-- repo resolves name<->id via `repo::tag::upsert_by_name`, exactly as the
-- manga-tag path does.
-- 1. New FK column, nullable until backfilled.
ALTER TABLE page_tags
ADD COLUMN tag_id uuid REFERENCES tags(id) ON DELETE CASCADE;
-- 2. Seed the lookup table from the existing inline values, deduping
-- case-insensitively via the tags (lower(name)) unique index. Mirrors
-- the author backfill in 0009.
INSERT INTO tags (name)
SELECT DISTINCT tag FROM page_tags
ON CONFLICT (lower(name)) DO NOTHING;
-- 3. Point every page_tags row at its canonical tag row.
UPDATE page_tags pt
SET tag_id = t.id
FROM tags t
WHERE lower(t.name) = lower(pt.tag);
-- 4. The FK is now mandatory.
ALTER TABLE page_tags ALTER COLUMN tag_id SET NOT NULL;
-- 5. Swap the inline-tag indexes/constraints for tag_id equivalents.
DROP INDEX IF EXISTS page_tags_user_page_tag_uniq;
CREATE UNIQUE INDEX page_tags_user_page_tag_uniq
ON page_tags (user_id, page_id, tag_id);
DROP INDEX IF EXISTS page_tags_user_tag_idx;
CREATE INDEX page_tags_user_tag_idx
ON page_tags (user_id, tag_id, created_at DESC);
-- page_tags_user_idx (user_id, created_at DESC) and page_tags_page_idx
-- (page_id) are unaffected and stay as-is.
-- 6. Drop the now-dead inline column. This also drops the
-- page_tags_tag_nonempty CHECK; the 1..=64 length bound is now
-- enforced by `repo::tag::upsert_by_name` before a row is created.
ALTER TABLE page_tags DROP COLUMN tag;

View File

@@ -0,0 +1,81 @@
-- AI content-analysis / enrichment / moderation results, one analysis
-- pass per page image. A background worker calls a local vision model
-- and writes four kinds of output here: OCR text (weighted by kind),
-- global auto-tags, a scene description, and content-warning flags.
--
-- Everything cascades with `pages` (like page_tags / collection_pages in
-- 0023): re-uploading a chapter recreates its page rows, intentionally
-- dropping the stale analysis so it gets re-enqueued and re-derived.
--
-- The auto-tags live in their OWN table (`page_auto_tags`), NOT in the
-- per-user `page_tags`, so re-analysis is a clean delete+reinsert that
-- never touches a user's personal tags. They reference the SAME shared
-- `tags` vocabulary (0009 / 0024) that manga tags and page tags use, so
-- model-proposed tags and human tags share one global namespace.
-- One row per analyzed page. `search_doc` is the worker-computed,
-- kind-weighted tsvector that the page text-search ranks against; it is
-- filled in the same transaction that writes the OCR rows (a generated
-- column can't aggregate the child `page_ocr_text` rows, and a trigger
-- would re-fire on every child insert during the delete+reinsert).
CREATE TABLE page_analysis (
page_id uuid PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'done', 'failed')),
scene_description text,
is_nsfw boolean NOT NULL DEFAULT false,
model text,
error text,
analyzed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
search_doc tsvector
);
-- Full-text ranking over the weighted document.
CREATE INDEX page_analysis_search_idx ON page_analysis USING gin (search_doc);
-- Partial index for "is this page flagged?" lookups (content-warning
-- joins and NSFW filters touch only the flagged minority).
CREATE INDEX page_analysis_nsfw_idx ON page_analysis (page_id) WHERE is_nsfw;
-- Extracted text pieces, each tagged with its kind. Kind drives the
-- tsvector weight (speech/title=A, narration/thought/caption=B,
-- sfx=D; scene_description is weighted C from page_analysis). `ord`
-- preserves reading order within the page.
CREATE TABLE page_ocr_text (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
kind text NOT NULL
CHECK (kind IN ('speech', 'thought', 'narration', 'sfx', 'title', 'caption')),
text text NOT NULL,
ord integer NOT NULL DEFAULT 0
);
CREATE INDEX page_ocr_text_page_idx ON page_ocr_text (page_id);
-- Global, model-derived page tags — visible to every user and searchable
-- alongside personal page tags. Keyed to the shared `tags` table by id.
CREATE TABLE page_auto_tags (
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (page_id, tag_id)
);
-- tag -> pages (search by auto-tag) and page -> tags (render a page's
-- auto-tags); the PK already covers (page_id, tag_id) but a standalone
-- tag_id index serves the reverse direction.
CREATE INDEX page_auto_tags_tag_idx ON page_auto_tags (tag_id);
-- Per-page content warnings from the closed moderation vocabulary. The
-- manga detail page shows the deduplicated union across all its pages;
-- search filters include/exclude by these.
CREATE TABLE page_content_warnings (
page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
warning text NOT NULL
CHECK (warning IN ('sexual', 'nudity', 'gore', 'violence', 'disturbing')),
PRIMARY KEY (page_id, warning)
);
-- warning -> pages, for the manga/page content-warning filters.
CREATE INDEX page_content_warnings_warning_idx ON page_content_warnings (warning);

View File

@@ -0,0 +1,15 @@
-- Runtime-editable application settings, keyed by subsystem group.
--
-- Mirrors the small key-value `crawler_state` table (0015): one row per
-- group ('crawler' | 'analysis'), the `value` JSONB holding a serialized
-- settings DTO. Env vars seed a row on first boot (when absent); after that
-- the DB row is the source of truth and the admin dashboard edits it live.
--
-- Only operationally-safe fields are stored here — host/infra and secrets
-- (chromium paths, proxy, TOR control, vision API key, cookie domain) stay
-- env-only and are never persisted.
CREATE TABLE app_settings (
key text PRIMARY KEY,
value jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,20 @@
-- Persist blob sizes so storage-usage stats are a pure DB aggregate
-- rather than a filesystem stat per page. Sizes are captured at write
-- time (the crawler's put_stream already returns bytes written; uploads
-- and covers know their byte length). Pre-existing rows carry NULL until
-- the admin "backfill sizes" action stats their blobs.
-- NULL = "size unknown / not yet backfilled". A measured value (even 0)
-- is distinct from unknown, so a crawled-but-un-backfilled page is never
-- mistaken for a genuinely empty one. SUM/AVG ignore NULL, so an
-- un-backfilled library reports honest partial totals rather than zeros.
ALTER TABLE pages ADD COLUMN size_bytes BIGINT;
-- Nullable for the same reason, mirroring the nullable cover_image_path.
ALTER TABLE mangas ADD COLUMN cover_size_bytes BIGINT;
-- Covering index: makes the per-chapter / per-manga SUM(size_bytes)
-- roll-ups (chapter list, leaderboards) index-only. The existing
-- pages_chapter_idx (chapter_id, page_number) can serve the WHERE filter
-- but not the SUM without heap visits.
CREATE INDEX pages_chapter_size_idx ON pages (chapter_id) INCLUDE (size_bytes);

View File

@@ -0,0 +1,39 @@
-- Durable timing log for crawler operations + a duration column for page
-- analysis. The dashboard's job history shows *what* ran; this records *how
-- long it took* so operators can see per-operation durations and averages
-- broken down by type/granularity (manga list walk, manga detail, cover,
-- whole chapter — per-page crawl timing is derived from the chapter row's
-- `items` count rather than stored per image).
--
-- This table is durable on purpose: it outlives the `crawler_jobs` done-job
-- reaper so averages stay meaningful, and it captures the INLINE operations
-- (list walk, manga detail, cover) that are not queue jobs at all. Volume is
-- low (one row per manga / chapter / pass, not per image), so a generous
-- retention reaper is hygiene rather than a necessity.
CREATE TABLE crawl_metrics (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Operation granularity. analyze_page is intentionally absent — analysis
-- duration lives on page_analysis.duration_ms (one row per page already).
op text NOT NULL
CHECK (op IN ('manga_list', 'manga_detail', 'manga_cover', 'chapter')),
-- Best-effort target context for labeling/drill. SET NULL so deleting a
-- manga/chapter doesn't erase its historical timings.
manga_id uuid REFERENCES mangas(id) ON DELETE SET NULL,
chapter_id uuid REFERENCES chapters(id) ON DELETE SET NULL,
outcome text NOT NULL CHECK (outcome IN ('ok', 'failed')),
duration_ms bigint NOT NULL,
-- Unit count for the op: chapter = pages stored; manga_list = mangas
-- discovered. Drives the derived "per page" average. NULL when N/A.
items integer,
error text,
finished_at timestamptz NOT NULL DEFAULT now()
);
-- Per-type averages + the recent-ops log both filter by op and order by time.
CREATE INDEX crawl_metrics_op_time_idx ON crawl_metrics (op, finished_at DESC);
-- The window filter (last 24h / 7d / 30d) and the retention reaper scan by time.
CREATE INDEX crawl_metrics_time_idx ON crawl_metrics (finished_at);
-- Wall-clock the analysis worker spent on a page (vision dispatch). NULL for
-- pre-existing rows and any page analyzed before this migration.
ALTER TABLE page_analysis ADD COLUMN duration_ms bigint;

View File

@@ -0,0 +1,24 @@
-- Index the `sync_chapter_list` in-flight path used by the admin sync-state
-- derivation (overview `manga_stats` + the Mangas tab listing).
--
-- A manga is "in_progress" if a pending/running job targets it. For the
-- `sync_manga` kind that join is already covered by
-- crawler_jobs_sync_manga_key_idx (0020). The OTHER kind, `sync_chapter_list`,
-- carries the target `manga_id` directly in its payload and had NO index — so
-- the EXISTS fell back to a full seqscan of crawler_jobs. Per manga that is
-- cheap; across the whole library (overview scans every manga) it is O(mangas
-- x jobs), and the disabled-analysis backlog (thousands of pending
-- `analyze_page` rows that can never match this filter) inflates every scan.
--
-- Partial on the same `state IN ('pending','running') AND kind = ...`
-- predicate as the sibling indexes so it stays tiny (only in-flight list
-- jobs) and Postgres can probe it instead of scanning. Mirrors 0020.
--
-- Not CONCURRENTLY: sqlx::migrate! wraps each migration in a transaction;
-- CREATE INDEX CONCURRENTLY can't run inside one. The table is small at our
-- scale so a brief build lock on deploy is safe. IF NOT EXISTS keeps it
-- idempotent with any operator who pre-created it on the live DB.
CREATE INDEX IF NOT EXISTS crawler_jobs_sync_chapter_list_manga_idx
ON crawler_jobs ((payload->>'manga_id'))
WHERE state IN ('pending', 'running')
AND payload->>'kind' = 'sync_chapter_list';

View File

@@ -0,0 +1,8 @@
-- Index for the Analysis trend-chart series, which buckets terminal page
-- analyses by `analyzed_at` (date_trunc) over a recent window. Without it the
-- bucketed GROUP BY falls back to a full scan of page_analysis (one row per
-- page in the library). Partial to the rows the series query actually reads
-- (terminal status with a timestamp), keeping the index small.
CREATE INDEX IF NOT EXISTS page_analysis_analyzed_at_idx
ON page_analysis (analyzed_at)
WHERE status <> 'pending' AND analyzed_at IS NOT NULL;

View File

@@ -0,0 +1,50 @@
-- Dedup analyze_page jobs in flight (mirrors 0014 for sync_chapter_content).
--
-- Without this, `repo::page_analysis::enqueue_pages`' `NOT EXISTS(... pending
-- | running ...)` read-then-insert race let concurrent admin POSTs land
-- duplicate jobs for the same page — visible as duplicate "now analyzing"
-- SSE events and inflated `pending` counters in the admin dashboard, even
-- though the worker's `skip-if-done` net (analysis/daemon.rs:210-217)
-- prevented the actual duplicate vision call.
--
-- The partial unique index lets enqueue_pages drop the NOT EXISTS subquery
-- and rely on `ON CONFLICT DO NOTHING` instead. At most one (pending|running)
-- job per page_id can exist; the slot frees the moment the job transitions
-- to done/failed/dead, so a force re-analyze still re-enqueues cleanly.
-- **Pre-dedup step.** Production runs of the prior buggy producer can
-- have left duplicate rows tied on (kind='analyze_page', payload->>'page_id',
-- state IN ('pending','running')). Adding the UNIQUE INDEX onto a dirty
-- table would fail to create and crash `sqlx::migrate!` at boot.
--
-- Demote all-but-the-lowest-id duplicates to `dead` first. Why dead and
-- not done: the dropped sibling never actually ran, and `dead` is the
-- terminal state operators inspect via the dead-jobs requeue endpoint —
-- so a curator can recover any genuinely-needed page. The lowest-id row
-- is the one a worker most likely already leased / has heartbeat'd, so
-- keeping it minimises lease-state thrash.
UPDATE crawler_jobs
SET state = 'dead',
last_error = COALESCE(last_error, '') ||
CASE WHEN last_error IS NULL OR last_error = '' THEN ''
ELSE ' | ' END ||
'pre-0031 duplicate analyze_page; superseded by earlier sibling',
updated_at = now()
WHERE id IN (
SELECT id FROM (
SELECT id,
row_number() OVER (
PARTITION BY payload->>'page_id'
ORDER BY id
) AS rn
FROM crawler_jobs
WHERE payload->>'kind' = 'analyze_page'
AND state IN ('pending', 'running')
) ranked
WHERE rn > 1
);
CREATE UNIQUE INDEX crawler_jobs_analyze_page_dedup_idx
ON crawler_jobs ((payload->>'page_id'))
WHERE state IN ('pending', 'running')
AND payload->>'kind' = 'analyze_page';

View File

@@ -0,0 +1,23 @@
-- Per-lease generation token to close the lease-identity race that
-- 0.87.20 only partially addressed.
--
-- Before this, `ack_done` / `ack_failed` / `renew` matched on
-- `(id, state='running')`. A worker whose lease was released mid-
-- flight (force-analyze, reclaim) could still ack-done the row once a
-- successor leased it back to `state='running'` — clobbering the
-- successor's lease with the original's stale result. The bug class is
-- ack-from-a-dead-lease.
--
-- Adding `lease_generation` makes lease identity = `(id, generation)`:
-- * `lease`/`lease_kinds` bump generation by 1 at lease time
-- * `release` (force-analyze, graceful shutdown) bumps generation too
-- * `reclaim_orphaned` bumps generation for every reclaimed row
-- * `ack_done` / `ack_failed` / `renew` match on `(id, generation,
-- state='running')`, so a stale ack from a prior generation finds
-- no row and falls back to the existing warn-and-skip branch.
--
-- BIGINT so the counter can't realistically overflow even for a row
-- that's been crash-leased thousands of times.
ALTER TABLE crawler_jobs
ADD COLUMN lease_generation BIGINT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,16 @@
-- Back the default catalog ordering. Since 0.88.0 the catalog list defaults to
-- `ORDER BY updated_at DESC, id` (the no-filter default path), but the only
-- existing updated_at index on mangas is the PARTIAL `mangas_missing_cover_idx`
-- (WHERE cover_image_path IS NULL), which Postgres can't use for the unfiltered
-- ordering. `created_at` and `lower(title)` already have full indexes from
-- 0001; this gives the new default sort column the same treatment.
--
-- `id` is included as a trailing key so the index also covers the stable
-- `, id` tie-break the listing appends for deterministic pagination.
--
-- Not CONCURRENTLY: sqlx::migrate! wraps each migration in a transaction, and
-- CREATE INDEX CONCURRENTLY can't run inside one. The table is small at our
-- scale, so a brief lock on an online deploy is acceptable.
CREATE INDEX IF NOT EXISTS mangas_updated_at_idx
ON mangas (updated_at DESC, id);

View File

@@ -0,0 +1,9 @@
-- Optional expiry for bot API tokens. NULL = never expires (the prior
-- behaviour, preserved for all existing rows). When set, `find_active`
-- rejects the token past this instant, mirroring the sessions table's
-- `expires_at > now()` gate.
ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMPTZ;
-- Partial index to keep the active-token lookup cheap once expiries exist.
CREATE INDEX api_tokens_expires_at_idx ON api_tokens (expires_at)
WHERE expires_at IS NOT NULL;

View File

@@ -0,0 +1,15 @@
-- Per-user like/dislike reactions on mangas — a private taste signal that
-- powers content-based recommendations. One row per (user, manga); the
-- `reaction` column toggles between 'like' and 'dislike', and clearing a
-- reaction deletes the row. Reactions are never exposed publicly (no counts).
CREATE TABLE manga_reactions (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
reaction text NOT NULL CHECK (reaction IN ('like', 'dislike')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, manga_id)
);
-- Recommendations aggregate a user's reacted mangas by tag; the PK covers
-- per-user lookups, this covers the reverse (all reactions on a manga).
CREATE INDEX manga_reactions_manga_idx ON manga_reactions (manga_id);

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

@@ -0,0 +1,44 @@
-- Enforce case-insensitive genre uniqueness, matching `authors` and `tags`
-- (both got `UNIQUE (lower(name))` in 0009). `genres` only had a case-SENSITIVE
-- `name UNIQUE`, but the crawler's `sync_genres` treats genres as
-- case-insensitive (`WHERE lower(name) = lower($1)`) and can INSERT a new row
-- from a source string. Under a race (or across ticks) two different-cased
-- strings — "Isekai" vs "isekai" — could both miss the pre-check and both
-- insert, since the exact-name unique doesn't collide. That leaves duplicate
-- genre rows for one logical genre.
-- Heal any pre-existing case-variant duplicates before adding the index, so
-- `sqlx::migrate!` can't crash on a dirty table (mirrors 0031's pre-dedup).
-- Canonical = lowest id per lower(name).
-- 1. Ensure every manga linked to any case-variant also has the canonical link.
-- INSERT ... ON CONFLICT DO NOTHING is collision-proof: SELECT DISTINCT
-- collapses multiple variants of one manga to a single canonical row, and the
-- ON CONFLICT absorbs a canonical link that already exists. (A prior
-- UPDATE-repoint could set two non-canonical rows of the SAME manga to the
-- same canonical id in a single statement and violate the manga_genres PK,
-- rolling the migration back and wedging startup.)
INSERT INTO manga_genres (manga_id, genre_id)
SELECT DISTINCT mg.manga_id, k.keep_id
FROM manga_genres mg
JOIN genres g ON mg.genre_id = g.id
JOIN (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id
FROM genres GROUP BY lower(name)) k
ON lower(g.name) = k.lname
WHERE g.id <> k.keep_id
ON CONFLICT (manga_id, genre_id) DO NOTHING;
-- 2. Every non-canonical link now has a canonical sibling — drop the variants.
DELETE FROM manga_genres mg
USING genres g,
(SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id
FROM genres GROUP BY lower(name)) k
WHERE mg.genre_id = g.id AND lower(g.name) = k.lname AND g.id <> k.keep_id;
-- Remove the now-orphaned duplicate genre rows.
DELETE FROM genres g
USING (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id
FROM genres GROUP BY lower(name)) k
WHERE lower(g.name) = k.lname AND g.id <> k.keep_id;
CREATE UNIQUE INDEX genres_name_lower_uniq ON genres (lower(name));

View File

@@ -0,0 +1,18 @@
-- Index the crashed-worker-recovery arm of the job lease predicate.
--
-- lease/lease_kinds match:
-- WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
-- crawler_jobs_ready_idx (0016) is `ON (scheduled_at) WHERE state = 'pending'`,
-- so it covers only the pending arm. The `state = 'running' AND leased_until`
-- arm had no usable index, so Postgres could not BitmapOr the two arms and
-- degraded to a sequential scan of the ENTIRE crawler_jobs table — including all
-- done/dead rows not yet reaped — on every lease poll, by every worker,
-- continuously (audit H2, the headline performance finding).
--
-- A partial index on leased_until over just the running rows makes the recovery
-- arm index-backed. `state = 'running'` is an immutable predicate (now() stays
-- in the query, not the index). The running set is tiny (in-flight jobs only),
-- so the index is cheap to maintain.
CREATE INDEX crawler_jobs_running_lease_idx
ON crawler_jobs (leased_until)
WHERE state = 'running';

View File

@@ -0,0 +1,9 @@
-- Index the retention reaper's scan. reap_terminal deletes
-- WHERE state IN ('done','dead') AND updated_at < now() - interval
-- which had no supporting index, so each daily sweep sequential-scanned the whole
-- crawler_jobs table to find expired terminal rows (audit M5). A partial index on
-- updated_at over just the terminal states makes the batched reaper's per-batch
-- SELECT index-backed.
CREATE INDEX crawler_jobs_terminal_reap_idx
ON crawler_jobs (updated_at)
WHERE state IN ('done', 'dead');

View File

@@ -0,0 +1,592 @@
//! The AI page-analysis worker daemon.
//!
//! A lean sibling of [`crate::crawler::daemon`]: it leases `analyze_page`
//! jobs from the SAME `crawler_jobs` queue (filtered by kind, so it never
//! contends with crawl jobs), dispatches each through an [`AnalyzeDispatcher`]
//! seam, and acks done/failed. No cron, no browser, no session state — it
//! runs whenever `ANALYSIS_ENABLED` is set, independent of the crawler.
//!
//! Per job: skip if already `done` (unless the payload sets `force`),
//! heartbeat the lease while the (slow) vision call runs, isolate panics
//! and an outer timeout, and on terminal failure write a `failed`
//! `page_analysis` row so the page's state is observable.
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures_util::FutureExt;
use sqlx::PgPool;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::analysis::events::{AnalysisEvent, AnalysisEvents};
use crate::analysis::vision::VisionClient;
use crate::crawler::jobs::{self, JobPayload, Lease, KIND_ANALYZE_PAGE};
use crate::repo;
use crate::storage::Storage;
/// Lease window; continuously extended by the per-job heartbeat.
const LEASE_DURATION: Duration = Duration::from_secs(60);
/// Heartbeat cadence — a third of the lease window.
const LEASE_HEARTBEAT: Duration = Duration::from_secs(20);
/// How long to wait before re-probing vision when the readiness gate is
/// closed. Short enough to resume promptly after an autoscaler cold start,
/// long enough not to hammer `/health` while vision is down.
const READINESS_POLL: Duration = Duration::from_secs(2);
/// Longest an idle analysis worker waits between lease polls, bounding the
/// exponential [`idle_backoff`].
const IDLE_BACKOFF_CAP: Duration = Duration::from_secs(30);
/// Exponential idle backoff (1s, 2s, 4s … capped at [`IDLE_BACKOFF_CAP`]).
/// `consecutive_empty` is the number of empty lease polls 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 worker isn't firing a `SELECT … FOR UPDATE SKIP LOCKED` lease
/// query every second (audit: analysis daemon idle poll). Mirrors the crawler
/// daemon's backoff.
fn idle_backoff(consecutive_empty: u32) -> Duration {
let cap = IDLE_BACKOFF_CAP.as_secs();
let secs = 1u64.checked_shl(consecutive_empty).unwrap_or(cap).min(cap);
Duration::from_secs(secs)
}
/// The unit of work: analyze one page. Implemented by
/// [`RealAnalyzeDispatcher`] in production and stubbed in tests.
#[async_trait]
pub trait AnalyzeDispatcher: Send + Sync {
async fn dispatch(&self, page_id: Uuid) -> anyhow::Result<()>;
}
/// Probe answering "is the vision backend up and the model loaded?". When a
/// probe is wired in, the worker refuses to *lease* a job until it reports
/// ready — so an idle-stopped vision (the autoscaler's normal state) never
/// burns a job's retries or writes a `failed` row. Implemented by
/// [`HttpVisionReadiness`] in production (a `GET /health`) and stubbed in
/// tests.
#[async_trait]
pub trait VisionReadiness: Send + Sync {
async fn ready(&self) -> bool;
}
/// Production readiness probe: `GET {health_url}`, ready iff it answers 2xx.
/// Any transport error (connection refused while stopped, 503 while the
/// model loads) is treated as not-ready.
pub struct HttpVisionReadiness {
pub http: reqwest::Client,
pub health_url: String,
}
#[async_trait]
impl VisionReadiness for HttpVisionReadiness {
async fn ready(&self) -> bool {
match self.http.get(&self.health_url).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
}
pub struct AnalysisDaemonConfig {
pub dispatcher: Arc<dyn AnalyzeDispatcher>,
pub workers: usize,
pub job_timeout: Duration,
/// Live-event sink (Started/Completed/Failed) for admin SSE.
pub events: Arc<AnalysisEvents>,
/// Optional vision readiness gate. `None` ⇒ no gate (today's behavior,
/// for always-on endpoints). `Some` ⇒ the worker parks instead of
/// leasing while the probe is not ready.
pub readiness: Option<Arc<dyn VisionReadiness>>,
}
pub struct AnalysisDaemonHandle {
cancel: CancellationToken,
join: JoinSet<()>,
}
impl AnalysisDaemonHandle {
pub async fn shutdown(mut self) {
self.cancel.cancel();
while self.join.join_next().await.is_some() {}
}
}
/// Spawn the analysis workers. Returns immediately; tasks run in the
/// background until the handle is shut down (or `cancel` fires).
pub fn spawn(
pool: PgPool,
cancel: CancellationToken,
cfg: AnalysisDaemonConfig,
) -> AnalysisDaemonHandle {
let mut join = JoinSet::new();
for id in 0..cfg.workers.max(1) {
let ctx = WorkerContext {
pool: pool.clone(),
cancel: cancel.clone(),
dispatcher: Arc::clone(&cfg.dispatcher),
job_timeout: cfg.job_timeout,
events: Arc::clone(&cfg.events),
readiness: cfg.readiness.clone(),
id,
};
join.spawn(async move { ctx.run().await });
}
AnalysisDaemonHandle { cancel, join }
}
struct WorkerContext {
pool: PgPool,
cancel: CancellationToken,
dispatcher: Arc<dyn AnalyzeDispatcher>,
job_timeout: Duration,
events: Arc<AnalysisEvents>,
readiness: Option<Arc<dyn VisionReadiness>>,
id: usize,
}
impl WorkerContext {
async fn run(self) {
// Last observed readiness, so we log only on transitions (not every
// poll). `None` until the first probe.
let mut was_ready: Option<bool> = None;
// Empty lease polls seen in a row, driving the idle backoff. Reset to 0
// the moment a job is leased.
let mut consecutive_empty: u32 = 0;
loop {
if self.cancel.is_cancelled() {
tracing::info!(worker = self.id, "analysis worker: shutdown");
return;
}
// Readiness gate: park (without leasing) while vision is down or
// still loading its model, so the autoscaler's idle-stop never
// costs a job its retries. Probe *before* the lease — leasing
// increments `attempts` in SQL and can't be undone.
if let Some(readiness) = &self.readiness {
let ready = readiness.ready().await;
// Log on flip so a typo'd health URL (which parks the worker
// forever) is diagnosable, without spamming every poll.
if was_ready != Some(ready) {
if ready {
tracing::info!(worker = self.id, "analysis worker: vision ready — resuming");
} else {
tracing::warn!(
worker = self.id,
"analysis worker: vision not ready — parking until /health is 2xx"
);
}
was_ready = Some(ready);
}
if !ready {
if self.sleep_or_cancel(READINESS_POLL).await {
return;
}
continue;
}
}
let leases =
match jobs::lease(&self.pool, Some(KIND_ANALYZE_PAGE), 1, LEASE_DURATION).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(worker = self.id, ?e, "analysis worker: lease failed");
if self.sleep_or_cancel(Duration::from_secs(5)).await {
return;
}
continue;
}
};
let Some(lease) = leases.into_iter().next() else {
let wait = idle_backoff(consecutive_empty);
consecutive_empty = consecutive_empty.saturating_add(1);
if self.sleep_or_cancel(wait).await {
return;
}
continue;
};
// Leased work — drop back to a tight poll cadence.
consecutive_empty = 0;
self.process_lease(lease).await;
}
}
/// Sleep `dur` or return `true` if cancelled while waiting.
async fn sleep_or_cancel(&self, dur: Duration) -> bool {
tokio::select! {
_ = tokio::time::sleep(dur) => false,
_ = self.cancel.cancelled() => true,
}
}
async fn process_lease(&self, lease: Lease) {
let JobPayload::AnalyzePage { page_id, force } = lease.payload else {
// Shouldn't happen — we lease only analyze_page — but ack done
// so a misrouted job doesn't loop forever.
tracing::warn!(worker = self.id, "analysis worker: non-analyze payload leased");
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
return;
};
// Skip-if-done net: a non-forced job for an already-analyzed page is
// a no-op (e.g. a duplicate enqueue). Re-analysis goes through
// `force` or a fresh page row.
if !force {
if let Ok(Some(row)) = repo::page_analysis::load(&self.pool, page_id).await {
if row.status == crate::domain::page_analysis::AnalysisStatus::Done {
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
return;
}
}
}
// Resolve the labeled page breadcrumb once so live events carry the
// manga/chapter/number AND human labels the dashboard's "now
// analyzing" banner names. A missing breadcrumb (page deleted) just
// suppresses events — the dispatch still runs and acks normally.
let breadcrumb = repo::page::locate_labeled(&self.pool, page_id)
.await
.ok()
.flatten();
if let Some((manga_id, manga_title, chapter_id, chapter_number, page_number)) =
breadcrumb.clone()
{
self.events.publish(AnalysisEvent::Started {
page_id,
manga_id,
manga_title,
chapter_id,
chapter_number,
page_number,
});
}
// Heartbeat the lease while the vision call runs.
let heartbeat = {
let hb_pool = self.pool.clone();
let hb_id = lease.id;
let hb_gen = lease.lease_generation;
tokio::spawn(async move {
loop {
tokio::time::sleep(LEASE_HEARTBEAT).await;
match jobs::renew(&hb_pool, hb_id, hb_gen, LEASE_DURATION).await {
Ok(true) => {}
Ok(false) => break,
Err(e) => tracing::warn!(lease_id = %hb_id, ?e, "heartbeat renew failed"),
}
}
})
};
let started = std::time::Instant::now();
let dispatch = AssertUnwindSafe(self.dispatcher.dispatch(page_id)).catch_unwind();
// Race the vision call against shutdown. Without this, shutdown
// (SIGTERM / `Supervisors::reload_analysis` toggling analysis off)
// blocks for up to `job_timeout` (default 600s) on whichever call
// happens to be in flight. On cancel we drop the dispatch future
// and `jobs::release` the lease — next tick picks it up without
// burning an attempt.
let cancel_result = tokio::select! {
biased;
_ = self.cancel.cancelled() => None,
o = tokio::time::timeout(self.job_timeout, dispatch) => Some(o),
};
let elapsed_ms = started.elapsed().as_millis() as i64;
heartbeat.abort();
let Some(outcome) = cancel_result else {
tracing::info!(
worker = self.id,
lease_id = %lease.id,
"analysis worker: cancelled mid-dispatch — releasing lease"
);
let _ = jobs::release(&self.pool, lease.id, lease.lease_generation).await;
// Don't publish Failed (the page didn't actually fail) and
// don't write a duration row — neither outcome is true. DO
// publish Cancelled so the dashboard's "now analyzing" banner
// can clear the page we previously sent Started for. Without
// this the banner stuck on the cancelled page until a later
// page kicked off and overwrote it.
if let Some((manga_id, manga_title, chapter_id, chapter_number, page_number)) =
breadcrumb
{
self.events.publish(AnalysisEvent::Cancelled {
page_id,
manga_id,
manga_title,
chapter_id,
chapter_number,
page_number,
});
}
return;
};
// Flatten timeout / panic / dispatch error into one Result + message.
let result: Result<(), String> = match outcome {
Err(_elapsed) => Err("analysis dispatch timed out".to_string()),
Ok(Err(_panic)) => Err("analysis dispatcher panicked".to_string()),
Ok(Ok(Err(e))) => Err(format!("{e:#}")),
Ok(Ok(Ok(()))) => Ok(()),
};
if let Some((manga_id, manga_title, chapter_id, chapter_number, page_number)) =
breadcrumb
{
let event = if result.is_ok() {
AnalysisEvent::Completed {
page_id,
manga_id,
manga_title,
chapter_id,
chapter_number,
page_number,
}
} else {
AnalysisEvent::Failed {
page_id,
manga_id,
manga_title,
chapter_id,
chapter_number,
page_number,
}
};
self.events.publish(event);
}
// Stamp how long the vision dispatch took — but ONLY when a row
// was actually written this attempt. Otherwise a non-terminal
// failure on a force-re-analyze (page already has a `done` row)
// would overwrite the prior duration with the duration of a
// failed retry that wrote nothing new.
let wrote_row = match &result {
Ok(()) => true,
Err(_) if lease.attempts >= lease.max_attempts => true, // mark_failed wrote below
Err(_) => false,
};
match result {
Ok(()) => {
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
}
Err(msg) => {
tracing::warn!(
worker = self.id,
%page_id,
error = %msg,
"analysis worker: dispatch failed — ack failed"
);
let _ = jobs::ack_failed(
&self.pool,
lease.id,
&msg,
lease.attempts,
lease.max_attempts,
lease.lease_generation,
)
.await;
// Terminal failure (retries exhausted) → record a `failed`
// row so the page's analysis state is observable rather than
// silently absent.
if lease.attempts >= lease.max_attempts {
let _ = repo::page_analysis::mark_failed(&self.pool, page_id, &msg).await;
}
}
}
if wrote_row {
let _ = repo::page_analysis::record_duration(&self.pool, page_id, elapsed_ms).await;
}
}
}
/// Production dispatcher: load the page, read its image from storage, call
/// the vision model, and persist the analysis.
pub struct RealAnalyzeDispatcher {
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub vision: VisionClient,
pub model: String,
pub max_image_bytes: usize,
}
#[async_trait]
impl AnalyzeDispatcher for RealAnalyzeDispatcher {
async fn dispatch(&self, page_id: Uuid) -> anyhow::Result<()> {
let Some(page) = repo::page::find_by_id(&self.db, page_id).await? else {
// Page was deleted between enqueue and dispatch — nothing to do.
return Ok(());
};
// Stream through the byte cap so an oversized blob is rejected as it's
// read rather than after it's fully buffered into memory (mirrors the
// OCR dispatcher).
let file = self
.storage
.get_stream(&page.storage_key)
.await
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
let bytes =
crate::crawler::safety::accumulate_capped(file.stream, self.max_image_bytes)
.await
.map_err(|e| {
anyhow::anyhow!("page image {} over the byte cap: {e}", page.storage_key)
})?;
let analysis = self.vision.analyze(&bytes, &page.content_type).await?;
repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, &self.model).await?;
Ok(())
}
}
/// Stubs for the daemon's integration tests. Public because the tests live
/// in the `tests/` dir (a separate crate).
pub mod test_support {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// A readiness probe whose answer can be flipped from the test thread, to
/// drive the "vision is down, then comes up" transition.
pub struct ToggleReadiness {
ready: AtomicBool,
}
impl ToggleReadiness {
pub fn new(ready: bool) -> Arc<Self> {
Arc::new(Self { ready: AtomicBool::new(ready) })
}
pub fn set(&self, ready: bool) {
self.ready.store(ready, Ordering::Release);
}
}
#[async_trait]
impl VisionReadiness for ToggleReadiness {
async fn ready(&self) -> bool {
self.ready.load(Ordering::Acquire)
}
}
/// Counts dispatch calls and returns a configurable result.
pub struct CountingDispatcher {
pub calls: AtomicUsize,
pub fail: bool,
pub panic: bool,
}
impl CountingDispatcher {
pub fn ok() -> Arc<Self> {
Arc::new(Self { calls: AtomicUsize::new(0), fail: false, panic: false })
}
pub fn failing() -> Arc<Self> {
Arc::new(Self { calls: AtomicUsize::new(0), fail: true, panic: false })
}
pub fn panicking() -> Arc<Self> {
Arc::new(Self { calls: AtomicUsize::new(0), fail: false, panic: true })
}
pub fn call_count(&self) -> usize {
self.calls.load(Ordering::Acquire)
}
}
/// A dispatcher that sleeps for `delay` before returning `Ok`. Lets a
/// test fire shutdown WHILE a dispatch is in flight, to exercise the
/// cancellation race in `process_lease`.
pub struct SlowDispatcher {
pub calls: AtomicUsize,
pub delay: Duration,
}
impl SlowDispatcher {
pub fn new(delay: Duration) -> Arc<Self> {
Arc::new(Self { calls: AtomicUsize::new(0), delay })
}
pub fn call_count(&self) -> usize {
self.calls.load(Ordering::Acquire)
}
}
#[async_trait]
impl AnalyzeDispatcher for SlowDispatcher {
async fn dispatch(&self, _page_id: Uuid) -> anyhow::Result<()> {
self.calls.fetch_add(1, Ordering::AcqRel);
tokio::time::sleep(self.delay).await;
Ok(())
}
}
#[async_trait]
impl AnalyzeDispatcher for CountingDispatcher {
async fn dispatch(&self, _page_id: Uuid) -> anyhow::Result<()> {
self.calls.fetch_add(1, Ordering::AcqRel);
if self.panic {
panic!("intentional analysis dispatcher panic");
}
if self.fail {
anyhow::bail!("intentional analysis dispatch failure");
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn idle_backoff_grows_exponentially_and_caps() {
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(4), Duration::from_secs(16));
// Caps at IDLE_BACKOFF_CAP (30s) and never overflows on large counts.
assert_eq!(idle_backoff(5), IDLE_BACKOFF_CAP);
assert_eq!(idle_backoff(100), IDLE_BACKOFF_CAP);
}
/// Bind an ephemeral port that answers every request with `status_line`
/// (e.g. `"200 OK"`), and return its `/health` URL. The probe under test
/// only inspects the status code, so a zero-length body is enough.
async fn serve(status_line: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let resp = format!(
"HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = sock.write_all(resp.as_bytes()).await;
}
});
format!("http://{addr}/health")
}
fn probe(url: String) -> HttpVisionReadiness {
HttpVisionReadiness {
http: reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.build()
.unwrap(),
health_url: url,
}
}
#[tokio::test]
async fn http_readiness_2xx_is_ready() {
assert!(probe(serve("200 OK").await).ready().await);
}
#[tokio::test]
async fn http_readiness_non_2xx_is_not_ready() {
// llama-server answers 503 while the model is still loading.
assert!(!probe(serve("503 Service Unavailable").await).ready().await);
}
#[tokio::test]
async fn http_readiness_connection_error_is_not_ready() {
// Nothing listening (vision stopped) → not ready, never an error.
assert!(!probe("http://127.0.0.1:1/health".to_string()).ready().await);
}
}

View File

@@ -0,0 +1,123 @@
//! Live analysis events broadcast to admin SSE subscribers.
//!
//! A thin wrapper over a `tokio::sync::broadcast` channel. The worker
//! publishes per-page progress (`Started`/`Completed`/`Failed`) and the
//! admin enqueue path publishes `Enqueued`; the
//! `GET /v1/admin/analysis/status/stream` SSE endpoint forwards each event.
//! Discrete events (rather than the crawler's snapshot-on-change) map
//! directly onto "this page/chapter/manga just changed state", which the
//! dashboard applies incrementally.
use serde::Serialize;
use tokio::sync::broadcast;
use uuid::Uuid;
/// One live analysis event. Serialized with a `kind` discriminator so the
/// frontend can switch on it.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AnalysisEvent {
/// A bulk re-enqueue happened. `manga_id` / `chapter_id` scope it (both
/// `None` = whole library). The UI optimistically marks in-scope pages
/// as queued.
Enqueued {
count: u64,
manga_id: Option<Uuid>,
chapter_id: Option<Uuid>,
},
/// The worker began analyzing a page. Carries the manga title and
/// chapter number (alongside the ids) so the dashboard's "now
/// analyzing" banner can name the target without a lookup.
Started {
page_id: Uuid,
manga_id: Uuid,
manga_title: String,
chapter_id: Uuid,
chapter_number: i32,
page_number: i32,
},
/// The worker finished a page successfully.
Completed {
page_id: Uuid,
manga_id: Uuid,
manga_title: String,
chapter_id: Uuid,
chapter_number: i32,
page_number: i32,
},
/// The worker failed a page (this attempt).
Failed {
page_id: Uuid,
manga_id: Uuid,
manga_title: String,
chapter_id: Uuid,
chapter_number: i32,
page_number: i32,
},
/// The worker cancelled an in-flight dispatch (daemon shutdown or
/// settings reload toggling analysis off). The lease was released,
/// not failed. The dashboard's "now analyzing" banner clears on
/// `Completed`/`Failed`/`Cancelled`; before this variant existed it
/// listened only to the first two, so a cancel-mid-dispatch left
/// the banner stuck on the cancelled page until a later one
/// kicked off and overwrote it.
Cancelled {
page_id: Uuid,
manga_id: Uuid,
manga_title: String,
chapter_id: Uuid,
chapter_number: i32,
page_number: i32,
},
}
/// Broadcaster shared on `AppState`. Cheap to clone via `Arc`. Publishing
/// when there are no subscribers is a no-op (the send error is ignored).
pub struct AnalysisEvents {
tx: broadcast::Sender<AnalysisEvent>,
}
impl AnalysisEvents {
pub fn new() -> Self {
// Buffer is generous; a slow subscriber that lags is dropped frames
// (the SSE handler treats `Lagged` as "skip and continue").
let (tx, _rx) = broadcast::channel(512);
Self { tx }
}
pub fn publish(&self, event: AnalysisEvent) {
let _ = self.tx.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<AnalysisEvent> {
self.tx.subscribe()
}
}
impl Default for AnalysisEvents {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn started_serializes_kind_and_labels() {
let ev = AnalysisEvent::Started {
page_id: Uuid::nil(),
manga_id: Uuid::nil(),
manga_title: "Berserk".into(),
chapter_id: Uuid::nil(),
chapter_number: 12,
page_number: 7,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["kind"], "started");
assert_eq!(v["manga_title"], "Berserk");
assert_eq!(v["chapter_number"], 12);
assert_eq!(v["page_number"], 7);
}
}

View File

@@ -0,0 +1,14 @@
//! AI content-analysis worker: calls a local OpenAI-compatible vision
//! model on each page image and turns the result into OCR text, global
//! auto-tags, a scene description, and NSFW content warnings.
//!
//! * [`prompt`] — the system prompt + the bounded output vocabulary and
//! sanitization helpers (pure, unit-tested).
//! * [`vision`] — the HTTP client: downscale → request → parse → sanitize.
//! * [`daemon`] — the job-leasing worker loop (added in the worker phase).
pub mod daemon;
pub mod events;
pub mod ocr;
pub mod prompt;
pub mod vision;

388
backend/src/analysis/ocr.rs Normal file
View File

@@ -0,0 +1,388 @@
//! The in-process OCR analysis backend (the `ocrs` engine).
//!
//! A lightweight alternative to [`crate::analysis::vision`]: instead of a slow
//! local LLM, each page is run through `ocrs` — a pure-Rust detect→recognize
//! OCR pipeline on the `rten` runtime. It extracts **text only** (no tags,
//! scene description or NSFW flags — those stay the vision backend's job), then
//! reuses [`repo::page_analysis::persist_analysis`] so the OCR lines land in
//! `page_ocr_text` and the weighted `search_doc` tsvector exactly as the vision
//! path produces them. That makes the existing text-search surfaces
//! (`/v1/me/page-search` and the tag aggregations) work with no further wiring.
//!
//! The engine is split behind the [`OcrEngine`] trait so the dispatcher is
//! unit-testable without shipping the (multi-MB) `.rten` model files: tests use
//! [`test_support::StubOcrEngine`], production uses [`OcrsEngine`].
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::analysis::daemon::AnalyzeDispatcher;
use crate::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis};
use crate::repo;
use crate::storage::Storage;
/// The `model` label stamped onto `page_analysis` rows written by this backend.
pub const OCR_MODEL_LABEL: &str = "ocrs";
/// Extracts text lines from a decoded-or-encoded page image. The production
/// impl ([`OcrsEngine`]) decodes the bytes itself; the trait takes the raw
/// stored image bytes so the dispatcher stays engine-agnostic.
pub trait OcrEngine: Send + Sync {
/// Run OCR over one page image (the bytes as stored, e.g. PNG/JPEG/WebP).
/// Returns the recognized text lines in reading order (top→bottom).
fn recognize(&self, image: &[u8]) -> anyhow::Result<Vec<String>>;
}
/// Turn the OCR engine's ordered text lines into the [`VisionAnalysis`] shape
/// that [`repo::page_analysis::persist_analysis`] consumes. OCR-only: the tag,
/// scene and safety fields are left empty/default. The line `kind` is left
/// blank — `persist_analysis` maps an empty kind to the neutral mid-weight
/// `OcrKind::Narration` bucket (classifying speech/sfx/… is the deferred
/// vision backend's job).
pub fn lines_to_analysis(lines: Vec<String>) -> VisionAnalysis {
let ocr_results = lines
.into_iter()
.map(|text| OcrResult { text, kind: String::new(), y: None })
.collect();
VisionAnalysis {
ocr_results,
tagging_results: Vec::new(),
scene_description: String::new(),
safety_flag: SafetyFlag::default(),
}
}
/// Production OCR engine: an `ocrs` detect+recognize pipeline with the two
/// `.rten` models loaded once at startup. Cheap to share across workers — the
/// recognize path borrows `&self`.
pub struct OcrsEngine {
engine: ocrs::OcrEngine,
/// Hard cap on decoded pixel count (decompression-bomb guard). See
/// [`decode_rgb8_within`].
max_decode_pixels: u64,
}
impl OcrsEngine {
/// Load the detection + recognition models from disk and build the engine.
/// Fails (at startup) if either model file is missing or unreadable, so a
/// misconfigured path is a loud boot error rather than a per-page failure.
///
/// `max_decode_pixels` bounds the decoded image size (see
/// [`decode_rgb8_within`]) — wired from `AnalysisConfig::ocr_max_decode_pixels`.
pub fn from_model_paths(
detection: &str,
recognition: &str,
max_decode_pixels: u64,
) -> anyhow::Result<Self> {
use anyhow::Context;
let detection_model = rten::Model::load_file(detection)
.with_context(|| format!("load ocrs detection model {detection}"))?;
let recognition_model = rten::Model::load_file(recognition)
.with_context(|| format!("load ocrs recognition model {recognition}"))?;
let engine = ocrs::OcrEngine::new(ocrs::OcrEngineParams {
detection_model: Some(detection_model),
recognition_model: Some(recognition_model),
..Default::default()
})
.context("construct ocrs engine")?;
Ok(Self { engine, max_decode_pixels })
}
}
/// Decode encoded image bytes to RGB8 while refusing decompression bombs.
///
/// `image::load_from_memory` allocates the full decoded buffer up front, so a
/// tiny file declaring enormous dimensions (e.g. a 50000×50000 PNG) inflates
/// to billions of bytes and OOM-kills the process. We cap the decoder's
/// allocation at `max_decode_pixels` worth of RGBA (4 bytes/px headroom over
/// the RGB8 result), which the `image` crate checks against the header
/// *before* allocating — so an over-size image fails fast instead of dying.
fn decode_rgb8_within(image: &[u8], max_decode_pixels: u64) -> anyhow::Result<image::RgbImage> {
use anyhow::Context;
use std::io::Cursor;
let mut reader = image::ImageReader::new(Cursor::new(image))
.with_guessed_format()
.context("guess page image format for OCR")?;
let mut limits = image::Limits::default();
// 4 bytes/px (RGBA) gives headroom over the eventual RGB8 buffer and any
// single intermediate the decoder allocates per pixel.
limits.max_alloc = Some(max_decode_pixels.saturating_mul(4));
reader.limits(limits);
Ok(reader
.decode()
.context("decode page image for OCR")?
.into_rgb8())
}
impl OcrEngine for OcrsEngine {
fn recognize(&self, image: &[u8]) -> anyhow::Result<Vec<String>> {
// Decode to RGB8 so `ImageSource` gets a known channel layout,
// bounding the decoded size against the decompression-bomb cap.
let rgb = decode_rgb8_within(image, self.max_decode_pixels)?;
let source = ocrs::ImageSource::from_bytes(rgb.as_raw(), rgb.dimensions())
.map_err(|e| anyhow::anyhow!("build OCR image source: {e}"))?;
let input = self.engine.prepare_input(source)?;
// detect words → group into lines → recognize each line. Mirrors
// `OcrEngine::get_text`, but keeps the lines as a Vec instead of
// joining them, so each becomes its own `page_ocr_text` row.
let words = self.engine.detect_words(&input)?;
let line_rects = self.engine.find_text_lines(&input, &words);
let lines = self
.engine
.recognize_text(&input, &line_rects)?
.into_iter()
.filter_map(|line| line.map(|l| l.to_string()))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(lines)
}
}
/// Bound on concurrent CPU-bound OCR inferences, given the number of analysis
/// workers and the host's available parallelism.
///
/// Each worker's `dispatch` fires one `spawn_blocking` OCR run, so without a
/// bound `ANALYSIS_WORKERS` blocking tasks can run at once. OCR is fully
/// CPU-bound, so running more than there are cores just thrashes the scheduler
/// (and balloons the blocking pool). Cap at the core count, but never below 1
/// and never above the worker count (more permits than workers is pointless).
pub fn ocr_concurrency_limit(workers: usize, cores: usize) -> usize {
workers.min(cores.max(1)).max(1)
}
/// Run a CPU-bound OCR closure on the blocking pool while holding an
/// **owned** permit for the entire duration of the work.
///
/// The permit is acquired with `acquire_owned` and moved *into* the
/// blocking task rather than being held by the caller's future. This
/// matters on cancellation: if the dispatcher future is dropped (graceful
/// shutdown), a borrowed permit would be released the instant the future
/// unwinds — but `spawn_blocking` work is not cancellable and keeps
/// running detached, so the concurrency bound (ANALYSIS_WORKERS) would be
/// briefly exceeded. Moving the permit into the task ties the slot's
/// lifetime to the actual CPU work.
async fn run_ocr_blocking<T, F>(
permits: Arc<tokio::sync::Semaphore>,
f: F,
) -> anyhow::Result<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let permit = permits
.acquire_owned()
.await
.map_err(|e| anyhow::anyhow!("OCR semaphore closed: {e}"))?;
tokio::task::spawn_blocking(move || {
let _permit = permit; // held until f() returns, even if the caller is cancelled
f()
})
.await
.map_err(|e| anyhow::anyhow!("OCR task join error: {e}"))
}
/// Production dispatcher for the OCR backend: load the page, read its image
/// from storage, run OCR on the blocking pool, and persist the lines. Mirrors
/// [`crate::analysis::daemon::RealAnalyzeDispatcher`] but with no network I/O.
pub struct OcrAnalyzeDispatcher {
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub engine: Arc<dyn OcrEngine>,
pub max_image_bytes: usize,
/// Caps concurrent CPU-bound OCR inferences across all workers (see
/// [`ocr_concurrency_limit`]). Shared via the `Arc<dyn AnalyzeDispatcher>`,
/// so one permit pool covers every worker.
pub ocr_permits: Arc<tokio::sync::Semaphore>,
}
#[async_trait]
impl AnalyzeDispatcher for OcrAnalyzeDispatcher {
async fn dispatch(&self, page_id: Uuid) -> anyhow::Result<()> {
let Some(page) = repo::page::find_by_id(&self.db, page_id).await? else {
// Page was deleted between enqueue and dispatch — nothing to do.
return Ok(());
};
// Stream the blob through the byte cap so the read itself bails once
// the running total exceeds `max_image_bytes` — a plain `get()` would
// buffer the whole (possibly huge) image into memory before the cap
// could reject it.
let file = self
.storage
.get_stream(&page.storage_key)
.await
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
let bytes =
crate::crawler::safety::accumulate_capped(file.stream, self.max_image_bytes)
.await
.map_err(|e| {
anyhow::anyhow!("page image {} over the byte cap: {e}", page.storage_key)
})?;
// OCR inference is CPU-bound and synchronous — keep it off the async
// worker's runtime thread, and gate it behind the shared permit pool so
// ANALYSIS_WORKERS > cores can't oversubscribe the blocking pool.
let engine = Arc::clone(&self.engine);
let lines =
run_ocr_blocking(Arc::clone(&self.ocr_permits), move || engine.recognize(&bytes))
.await??;
let analysis = lines_to_analysis(lines);
repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, OCR_MODEL_LABEL).await?;
Ok(())
}
}
/// Stubs for the OCR dispatcher's integration tests. Public because the tests
/// live in the `tests/` dir (a separate crate).
pub mod test_support {
use super::*;
/// An [`OcrEngine`] that returns a fixed set of lines regardless of input,
/// so the dispatcher's storage→persist path can be tested without models.
pub struct StubOcrEngine {
pub lines: Vec<String>,
}
impl StubOcrEngine {
pub fn new(lines: &[&str]) -> Arc<Self> {
Arc::new(Self { lines: lines.iter().map(|s| s.to_string()).collect() })
}
}
impl OcrEngine for StubOcrEngine {
fn recognize(&self, _image: &[u8]) -> anyhow::Result<Vec<String>> {
Ok(self.lines.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn run_ocr_blocking_holds_permit_until_work_completes_despite_cancellation() {
use std::sync::mpsc;
use tokio::sync::{Notify, Semaphore};
let permits = Arc::new(Semaphore::new(1));
let (release_tx, release_rx) = mpsc::channel::<()>();
let started = Arc::new(Notify::new());
// Model the dispatch path: acquire an owned permit and run blocking
// work that we hold open via a channel.
let sem = Arc::clone(&permits);
let started2 = Arc::clone(&started);
let caller = tokio::spawn(async move {
run_ocr_blocking(sem, move || {
// Signal that the blocking task now holds the permit, then
// block until the test releases us.
started2.notify_one();
release_rx.recv().ok();
})
.await
.unwrap();
});
// Wait until the blocking task is running and owns the permit.
started.notified().await;
assert_eq!(permits.available_permits(), 0, "permit taken by blocking work");
// Cancel the caller future (simulates graceful shutdown). The
// blocking task is not cancellable and keeps running detached; with
// an *owned* permit the slot must stay occupied. A borrowed permit
// would have been released here, regressing the concurrency bound.
caller.abort();
let _ = caller.await;
assert_eq!(
permits.available_permits(),
0,
"owned permit must remain held by the still-running blocking task"
);
// Let the blocking work finish; the permit is then returned.
release_tx.send(()).unwrap();
let permit = tokio::time::timeout(std::time::Duration::from_secs(5), permits.acquire())
.await
.expect("permit should be released once blocking work completes")
.unwrap();
drop(permit);
}
#[test]
fn lines_to_analysis_maps_lines_in_order_and_leaves_rest_empty() {
let v = lines_to_analysis(vec!["Hello".to_string(), "world!".to_string()]);
assert_eq!(v.ocr_results.len(), 2);
assert_eq!(v.ocr_results[0].text, "Hello");
assert_eq!(v.ocr_results[1].text, "world!");
// OCR-only: kind blank (→ Narration at persist), no tags/scene/safety.
assert!(v.ocr_results.iter().all(|r| r.kind.is_empty()));
assert!(v.tagging_results.is_empty());
assert_eq!(v.scene_description, "");
assert!(!v.safety_flag.is_nsfw);
assert!(v.safety_flag.content_type.is_empty());
}
#[test]
fn lines_to_analysis_handles_no_text() {
let v = lines_to_analysis(Vec::new());
assert!(v.ocr_results.is_empty());
}
/// A minimal valid PNG of `w`×`h` (single black pixel scaled via IHDR is
/// not valid; instead encode a real tiny image, then we patch the IHDR
/// dimensions for the bomb case). For the happy path we just encode a real
/// small image.
fn encode_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::new(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn decode_rgb8_within_accepts_normal_page() {
// A perfectly ordinary page decodes fine under a generous cap.
let png = encode_png(64, 96);
let rgb = decode_rgb8_within(&png, 100_000_000).unwrap();
assert_eq!(rgb.dimensions(), (64, 96));
}
#[test]
fn decode_rgb8_within_rejects_oversize_image() {
// The same image, but the cap is set below its pixel count: the
// allocation limit must trip rather than the decode succeeding. This
// is the decompression-bomb guard in miniature — a real bomb declares
// a huge size in a few bytes; here we shrink the budget instead.
let png = encode_png(2000, 2000); // 4 MP
let err = decode_rgb8_within(&png, 1_000).unwrap_err();
assert!(
err.to_string().contains("decode page image"),
"expected a decode error, got: {err}"
);
}
#[test]
fn ocr_concurrency_limit_caps_at_cores() {
// More workers than cores → clamp to cores (don't oversubscribe).
assert_eq!(ocr_concurrency_limit(8, 4), 4);
}
#[test]
fn ocr_concurrency_limit_caps_at_workers() {
// Fewer workers than cores → only `workers` ever run anyway.
assert_eq!(ocr_concurrency_limit(2, 16), 2);
}
#[test]
fn ocr_concurrency_limit_never_zero() {
// Degenerate inputs still yield at least one permit.
assert_eq!(ocr_concurrency_limit(0, 0), 1);
assert_eq!(ocr_concurrency_limit(1, 0), 1);
}
}

View File

@@ -0,0 +1,274 @@
//! The vision model's system prompt and the bounds applied to its output.
//!
//! The system prompt is deliberately terse: with a context budget as low
//! as ~8192 tokens, the page image dominates, so the instructions + schema
//! must stay small and the output is capped via `max_tokens` plus the
//! length bounds enforced in [`crate::analysis::vision::sanitize`].
/// OCR text kinds the model may emit. Kept in sync with the
/// `page_ocr_text.kind` CHECK and [`crate::domain::page_analysis::OcrKind`].
pub const OCR_KINDS: [&str; 6] =
["speech", "thought", "narration", "sfx", "title", "caption"];
/// Closed content-warning vocabulary. Kept in sync with the
/// `page_content_warnings.warning` CHECK and
/// [`crate::domain::page_analysis::ContentWarning`].
pub const CONTENT_WARNINGS: [&str; 5] =
["sexual", "nudity", "gore", "violence", "disturbing"];
/// Target tag count we ask the model for (a hint in the prompt; not
/// hard-enforced beyond the [`MAX_TAGS`] cap).
pub const MIN_TAGS: usize = 5;
pub const MAX_TAGS: usize = 10;
/// Output bounds enforced at sanitize time so one verbose response can't
/// bloat the row or the search document. The OCR cap is generous because a
/// long sliced page legitimately accumulates many text pieces (the tsvector
/// handles it).
pub const MAX_OCR_PIECES: usize = 200;
pub const MAX_OCR_TEXT_CHARS: usize = 500;
pub const MAX_SCENE_CHARS: usize = 1000;
/// Hard per-call caps baked into the JSON schemas (`maxItems`/`maxLength`),
/// so a repetition-prone model is grammar-bounded and can't loop a single
/// call into the token ceiling. Kept per-call/per-slice; the cross-slice
/// total is bounded separately by [`MAX_OCR_PIECES`].
pub const MAX_OCR_PER_CALL: usize = 50;
pub const MAX_TAGS_PER_CALL: usize = 12;
/// The default system prompt. Self-contained: it carries the exact JSON
/// schema so the same string drives both the model and (implicitly) the
/// [`crate::domain::page_analysis::VisionAnalysis`] parser. An admin can
/// override it at runtime (see [`crate::config::AnalysisConfig::system_prompt`]);
/// this const is the fallback and the "reset to default" target.
pub const SYSTEM_PROMPT_DEFAULT: &str = concat!(
"You are a manga/comic page analyzer. Look at the single page image and ",
"return ONLY one minified JSON object — no prose, no markdown fences.\n",
"Schema:\n",
"{\"ocr_results\":[{\"text\":string,\"kind\":",
"\"speech|thought|narration|sfx|title|caption\"}],",
"\"tagging_results\":[string],",
"\"scene_description\":string,",
"\"safety_flag\":{\"is_nsfw\":boolean,\"content_type\":[",
"\"sexual|nudity|gore|violence|disturbing\"]}}\n",
"Rules: transcribe every visible text element verbatim into ocr_results ",
"with its kind, each ONCE (never repeat or loop), at most 50; if there is ",
"no text use []. tagging_results: 5-10 short, lowercase, distinct content ",
"tags (characters, actions, setting, mood, genre); include explicit/",
"sexual tags when present. scene_description: one or two sentences ",
"describing setting, characters, and action. safety_flag.content_type: ",
"only values from the listed set, [] if none; set is_nsfw true if the ",
"page contains sexual, nudity, gore, violence, or disturbing content. ",
"CRITICAL: do not repeat elements, tags or words; STOP when done. Output ",
"must be valid minified JSON and nothing else."
);
// --- Two-pass prompts (long-page slicing) -----------------------------------
/// Pass A: OCR-only over a single vertical slice of a tall page. Kept
/// narrow so each slice call is fast and never truncates. Runtime-overridable
/// default — see [`crate::config::AnalysisConfig::ocr_prompt`].
pub const OCR_PROMPT_DEFAULT: &str = concat!(
"You are an OCR engine for manga/comic pages. The image is one vertical ",
"slice of a larger page. Transcribe EVERY visible text element verbatim ",
"into ocr_results, each with its kind ",
"(speech|thought|narration|sfx|title|caption) and y — the vertical center ",
"of the text as a fraction from 0.0 (top) to 1.0 (bottom) of THIS image. ",
"List them ONCE each, in top-to-bottom order. CRITICAL: transcribe each ",
"distinct text element exactly once — never repeat an element, never loop ",
"or pad the output. At most 50 elements. The moment you have transcribed ",
"all visible text, STOP. If there is no text, return {\"ocr_results\":[]}. ",
"Output ONLY one minified JSON object, no prose, no markdown."
);
/// Pass B: tags + scene + safety for the whole page, grounded in the merged
/// OCR text (supplied as a separate user message part by the client).
/// Runtime-overridable default — see
/// [`crate::config::AnalysisConfig::grounding_prompt`].
pub const GROUNDING_PROMPT_DEFAULT: &str = concat!(
"You are a manga/comic page analyzer. You are given the page image ",
"(possibly downscaled) AND the OCR text already extracted from it. Using ",
"both, return ONLY one minified JSON object with: tagging_results (5-10 ",
"short lowercase content tags — characters, actions, setting, mood, ",
"genre; include explicit/sexual tags when present; each tag distinct, no ",
"duplicates); scene_description (one or two sentences on setting, ",
"characters, and action, referencing the dialogue where relevant); ",
"safety_flag (is_nsfw boolean + content_type from ",
"sexual|nudity|gore|violence|disturbing, [] if none). CRITICAL: do not ",
"repeat tags, words or sentences; keep each field concise and STOP when ",
"done. No prose, no markdown."
);
/// Pass-A schema: `{ ocr_results }` only.
pub fn ocr_json_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"ocr_results": {
"type": "array",
"maxItems": MAX_OCR_PER_CALL,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": { "type": "string", "maxLength": MAX_OCR_TEXT_CHARS },
"kind": { "type": "string", "enum": OCR_KINDS },
"y": { "type": "number", "minimum": 0, "maximum": 1 }
},
"required": ["text", "kind", "y"]
}
}
},
"required": ["ocr_results"]
})
}
/// Pass-B schema: `{ tagging_results, scene_description, safety_flag }`.
pub fn grounding_json_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"tagging_results": {
"type": "array",
"maxItems": MAX_TAGS_PER_CALL,
"items": { "type": "string", "maxLength": 64 }
},
"scene_description": { "type": "string", "maxLength": MAX_SCENE_CHARS },
"safety_flag": {
"type": "object",
"additionalProperties": false,
"properties": {
"is_nsfw": { "type": "boolean" },
"content_type": {
"type": "array",
"maxItems": CONTENT_WARNINGS.len(),
"items": { "type": "string", "enum": CONTENT_WARNINGS }
}
},
"required": ["is_nsfw", "content_type"]
}
},
"required": ["tagging_results", "scene_description", "safety_flag"]
})
}
/// JSON Schema for the analysis output, used in `response_format:
/// json_schema` mode (OpenAI structured outputs / LM Studio). Mirrors
/// [`crate::domain::page_analysis::VisionAnalysis`]; `additionalProperties:
/// false` + all-required keeps it valid under OpenAI strict mode while
/// staying compatible with LM Studio.
pub fn output_json_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"ocr_results": {
"type": "array",
"maxItems": MAX_OCR_PER_CALL,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": { "type": "string", "maxLength": MAX_OCR_TEXT_CHARS },
"kind": { "type": "string", "enum": OCR_KINDS }
},
"required": ["text", "kind"]
}
},
"tagging_results": {
"type": "array",
"maxItems": MAX_TAGS_PER_CALL,
"items": { "type": "string", "maxLength": 64 }
},
"scene_description": { "type": "string", "maxLength": MAX_SCENE_CHARS },
"safety_flag": {
"type": "object",
"additionalProperties": false,
"properties": {
"is_nsfw": { "type": "boolean" },
"content_type": {
"type": "array",
"maxItems": CONTENT_WARNINGS.len(),
"items": { "type": "string", "enum": CONTENT_WARNINGS }
}
},
"required": ["is_nsfw", "content_type"]
}
},
"required": ["ocr_results", "tagging_results", "scene_description", "safety_flag"]
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::page_analysis::{ContentWarning, OcrKind, VisionAnalysis};
#[test]
fn vocabularies_match_the_domain_enums() {
// Every prompt OCR kind must parse back to a real OcrKind (no
// fallback to narration via a typo).
for k in OCR_KINDS {
assert_eq!(
OcrKind::from_model_str(k),
match k {
"speech" => OcrKind::Speech,
"thought" => OcrKind::Thought,
"narration" => OcrKind::Narration,
"sfx" => OcrKind::Sfx,
"title" => OcrKind::Title,
"caption" => OcrKind::Caption,
_ => unreachable!(),
}
);
}
for w in CONTENT_WARNINGS {
assert!(
ContentWarning::from_model_str(w).is_some(),
"prompt warning {w} not in the domain vocabulary"
);
}
}
#[test]
fn system_prompt_mentions_the_schema_keys() {
for key in ["ocr_results", "tagging_results", "scene_description", "safety_flag"] {
assert!(SYSTEM_PROMPT_DEFAULT.contains(key), "prompt missing {key}");
}
}
#[test]
fn output_schema_top_level_requires_all_four_keys() {
let schema = output_json_schema();
let required = schema["required"].as_array().unwrap();
for key in ["ocr_results", "tagging_results", "scene_description", "safety_flag"] {
assert!(
required.iter().any(|v| v == key),
"schema missing required {key}"
);
}
// The kind enum carries exactly the OCR vocabulary.
let kind_enum =
&schema["properties"]["ocr_results"]["items"]["properties"]["kind"]["enum"];
assert_eq!(kind_enum, &serde_json::json!(OCR_KINDS));
}
#[test]
fn output_schema_accepts_a_real_analysis_shape() {
// A response matching the domain DTO must deserialize — the schema
// and the parser describe the same shape.
let sample = serde_json::json!({
"ocr_results": [{ "text": "Hi", "kind": "speech" }],
"tagging_results": ["action"],
"scene_description": "A street.",
"safety_flag": { "is_nsfw": false, "content_type": [] }
});
let _: VisionAnalysis = serde_json::from_value(sample).unwrap();
// Schema names the same object key the parser reads.
assert!(output_json_schema()["properties"]
.get("safety_flag")
.is_some());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
//! Admin controls for the AI content-analysis worker.
//!
//! Two endpoints, both admin-only (`RequireAdmin`, cookie-only) and gated
//! on `AppState.analysis_enabled` (503 when the feature is off):
//!
//! * `POST /admin/analysis/reenqueue` — bulk backfill: enqueue
//! `analyze_page` jobs for existing pages. Body `{ only_unanalyzed }`
//! (default true) skips pages that already have a completed analysis.
//! * `POST /admin/pages/:id/analyze` — force re-analysis of a single page
//! (re-runs even if already `done`).
use std::convert::Infallible;
use axum::extract::{Path, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::Stream;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::broadcast::error::RecvError;
use uuid::Uuid;
use crate::analysis::events::AnalysisEvent;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::domain::page_analysis::{
AnalysisHistoryRow, AnalysisMetrics, ChapterCoverage, MangaCoverage, PageAnalysisDetail,
PageStatusItem,
};
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/admin/analysis/reenqueue", post(reenqueue))
.route("/admin/pages/:id/analyze", post(analyze_page))
// Coverage / inspection (admin-gated, but NOT analysis-enabled-gated
// — an operator can browse coverage with the worker off).
.route("/admin/analysis/mangas", get(coverage_mangas))
.route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters))
.route("/admin/analysis/chapters/:id/pages", get(chapter_pages))
.route("/admin/analysis/pages/:id", get(page_detail))
.route("/admin/analysis/history", get(list_history))
.route("/admin/analysis/metrics", get(metrics))
.route("/admin/analysis/metrics/series", get(metrics_series))
.route("/admin/analysis/status/stream", get(stream_status))
}
/// Live analysis events (SSE). Forwards each `AnalysisEvent` as a named
/// `analysis` event; on broadcast lag (a slow client) emits a `lagged`
/// event so the dashboard can refresh. EventSource sends the session
/// cookie, so `RequireAdmin` gates the stream like any other admin route.
async fn stream_status(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.analysis_events.subscribe();
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
// One recv per unfold step; the stream driver re-enters for the next
// event, so no explicit loop is needed here.
match rx.recv().await {
Ok(ev) => {
let event = Event::default()
.event("analysis")
.json_data(&ev)
.unwrap_or_else(|_| Event::default().comment("serialize error"));
Some((Ok(event), rx))
}
Err(RecvError::Lagged(_)) => {
Some((Ok(Event::default().event("lagged").data("")), rx))
}
Err(RecvError::Closed) => None,
}
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
#[derive(Debug, Deserialize)]
pub struct CoverageParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default = "default_coverage_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_coverage_limit() -> i64 {
25
}
async fn coverage_mangas(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<CoverageParams>,
) -> AppResult<Json<PagedResponse<MangaCoverage>>> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let search = params
.search
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let (items, total) =
repo::page_analysis::manga_coverage(&state.db, search, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn coverage_chapters(
State(state): State<AppState>,
_admin: RequireAdmin,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<ItemsResponse<ChapterCoverage>>> {
if !repo::manga::exists(&state.db, manga_id).await? {
return Err(AppError::NotFound);
}
let items = repo::page_analysis::chapter_coverage(&state.db, manga_id).await?;
Ok(Json(ItemsResponse { items }))
}
async fn chapter_pages(
State(state): State<AppState>,
_admin: RequireAdmin,
Path(chapter_id): Path<Uuid>,
) -> AppResult<Json<ItemsResponse<PageStatusItem>>> {
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM chapters WHERE id = $1)")
.bind(chapter_id)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(AppError::NotFound);
}
let items = repo::page_analysis::chapter_page_status(&state.db, chapter_id).await?;
Ok(Json(ItemsResponse { items }))
}
async fn page_detail(
State(state): State<AppState>,
_admin: RequireAdmin,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<PageAnalysisDetail>> {
let detail = repo::page_analysis::page_detail(&state.db, page_id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(detail))
}
#[derive(Debug, Serialize)]
struct ItemsResponse<T> {
items: Vec<T>,
}
/// Status/NSFW filters + pagination/search for the analysis history list.
#[derive(Debug, Deserialize, Default)]
pub struct HistoryParams {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub nsfw: bool,
#[serde(default)]
pub search: Option<String>,
#[serde(default = "default_coverage_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
async fn list_history(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<HistoryParams>,
) -> AppResult<Json<PagedResponse<AnalysisHistoryRow>>> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let status = params
.status
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let search = params
.search
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let (items, total) = repo::page_analysis::list_history(
&state.db,
repo::page_analysis::AnalysisHistoryFilter {
status,
nsfw_only: params.nsfw,
search,
},
limit,
offset,
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
/// `?days=` window for the metrics endpoints. `0` (or absent) = all time.
#[derive(Debug, Deserialize, Default)]
pub struct MetricsParams {
#[serde(default)]
pub days: i64,
}
/// Convert a `days` window param into a lower-bound timestamp. `<= 0` → all
/// time (`None`); otherwise `now - days` (capped at a year).
pub(super) fn window_since(days: i64) -> Option<chrono::DateTime<chrono::Utc>> {
if days <= 0 {
None
} else {
Some(chrono::Utc::now() - chrono::Duration::days(days.min(365)))
}
}
// --- shared trend-series plumbing (crawler + analysis charts) ---------------
/// Query params for the bucketed trend-series endpoints. `bucket` is
/// optional; when absent it's derived from `days`.
#[derive(Debug, Deserialize, Default)]
pub struct SeriesParams {
#[serde(default)]
pub days: i64,
#[serde(default)]
pub bucket: Option<String>,
}
/// Envelope for a trend series.
#[derive(Debug, Serialize)]
pub struct SeriesResponse<T> {
pub buckets: Vec<T>,
}
/// Resolve the `date_trunc` granularity: an explicit `bucket` (validated to
/// `hour`|`day`, else 400) wins; otherwise short windows bucket by hour and
/// longer ones by day so the point count stays chart-friendly.
pub(super) fn resolve_bucket(
days: i64,
explicit: Option<&str>,
) -> Result<crate::repo::crawl_metrics::Bucket, AppError> {
use crate::repo::crawl_metrics::Bucket;
match explicit {
Some("hour") => Ok(Bucket::Hour),
Some("day") => Ok(Bucket::Day),
Some(other) => Err(AppError::InvalidInput(format!(
"invalid bucket '{other}' (expected 'hour' or 'day')"
))),
None => Ok(if days > 0 && days <= 2 {
Bucket::Hour
} else {
Bucket::Day
}),
}
}
async fn metrics(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<MetricsParams>,
) -> AppResult<Json<AnalysisMetrics>> {
let since = window_since(params.days);
let m = repo::page_analysis::analysis_metrics(&state.db, since).await?;
Ok(Json(m))
}
async fn metrics_series(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<SeriesParams>,
) -> AppResult<Json<SeriesResponse<crate::domain::crawl_metrics::MetricsBucket>>> {
let bucket = resolve_bucket(params.days, params.bucket.as_deref())?;
let buckets =
repo::page_analysis::analysis_series(&state.db, bucket, window_since(params.days)).await?;
Ok(Json(SeriesResponse { buckets }))
}
#[derive(Debug, Deserialize)]
pub struct ReenqueueBody {
/// Skip pages that already have a `done` analysis row. Defaults to
/// true so a routine backfill only touches the gaps; `false` forces a
/// full re-analysis of in-scope pages.
#[serde(default = "default_true")]
pub only_unanalyzed: bool,
/// Limit the re-enqueue to one manga (all its chapters' pages).
#[serde(default)]
pub manga_id: Option<Uuid>,
/// Limit the re-enqueue to one chapter's pages. Mutually exclusive
/// with `manga_id`.
#[serde(default)]
pub chapter_id: Option<Uuid>,
}
impl Default for ReenqueueBody {
fn default() -> Self {
Self { only_unanalyzed: true, manga_id: None, chapter_id: None }
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Serialize)]
pub struct ReenqueueResponse {
pub enqueued: u64,
}
#[derive(Debug, Serialize)]
pub struct AnalyzePageResponse {
pub enqueued: bool,
}
/// Reject the request with 503 when the analysis worker is disabled, so
/// an operator doesn't pile up jobs that nothing will ever drain.
fn ensure_enabled(state: &AppState) -> AppResult<()> {
if state.analysis_enabled() {
Ok(())
} else {
Err(AppError::ServiceUnavailable(
"content-analysis worker is disabled (ANALYSIS_ENABLED=false)".into(),
))
}
}
async fn reenqueue(
State(state): State<AppState>,
admin: RequireAdmin,
raw: axum::body::Bytes,
) -> AppResult<Json<ReenqueueResponse>> {
ensure_enabled(&state)?;
// An ABSENT/empty body means the default "All" scope. A PRESENT body must be
// valid JSON — `Option<Json<T>>` would silently collapse a malformed body to
// None and run the full-library default the caller never intended, so parse
// the raw bytes ourselves and 422 on a real parse error.
let body: ReenqueueBody = if raw.iter().all(u8::is_ascii_whitespace) {
ReenqueueBody::default()
} else {
serde_json::from_slice(&raw).map_err(|e| AppError::ValidationFailed {
message: "reenqueue body is not valid JSON".into(),
details: json!({ "body": e.to_string() }),
})?
};
// Resolve the scope. manga_id and chapter_id are mutually exclusive;
// an unknown target is a 404 rather than a silent zero-enqueue.
if body.manga_id.is_some() && body.chapter_id.is_some() {
return Err(AppError::ValidationFailed {
message: "manga_id and chapter_id are mutually exclusive".into(),
details: json!({ "scope": "pick at most one" }),
});
}
let (scope, target_type, target_id) = if let Some(chapter_id) = body.chapter_id {
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM chapters WHERE id = $1)")
.bind(chapter_id)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(AppError::NotFound);
}
(
repo::page_analysis::ReenqueueScope::Chapter(chapter_id),
"chapter",
Some(chapter_id),
)
} else if let Some(manga_id) = body.manga_id {
if !repo::manga::exists(&state.db, manga_id).await? {
return Err(AppError::NotFound);
}
(
repo::page_analysis::ReenqueueScope::Manga(manga_id),
"manga",
Some(manga_id),
)
} else {
(repo::page_analysis::ReenqueueScope::All, "analysis", None)
};
// Enqueue + audit in one transaction so a failed audit insert rolls the
// enqueue back too — the audit trail can't silently miss a re-enqueue that
// actually landed. Both are plain DB writes, so they share the tx cleanly
// (the live event + response are emitted only after commit).
let mut tx = state.db.begin().await?;
let enqueued =
repo::page_analysis::enqueue_pages(&mut *tx, scope, body.only_unanalyzed).await?;
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"analysis_reenqueue",
target_type,
target_id,
json!({
"only_unanalyzed": body.only_unanalyzed,
"manga_id": body.manga_id,
"chapter_id": body.chapter_id,
"enqueued": enqueued,
}),
)
.await?;
tx.commit().await?;
// Push a live event so connected dashboards mark the in-scope pages as
// queued. Skip the no-op (nothing actually enqueued). After commit so a
// rolled-back enqueue never emits a phantom event.
if enqueued > 0 {
state.analysis_events.publish(AnalysisEvent::Enqueued {
count: enqueued,
manga_id: body.manga_id,
chapter_id: body.chapter_id,
});
}
Ok(Json(ReenqueueResponse { enqueued }))
}
async fn analyze_page(
State(state): State<AppState>,
admin: RequireAdmin,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<AnalyzePageResponse>> {
ensure_enabled(&state)?;
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pages WHERE id = $1)")
.bind(page_id)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(AppError::NotFound);
}
// Surface the actual outcome so the admin (and audit log) sees
// whether the click actually moved anything. Before 0.87.11 the
// partial unique index could silently swallow a force request
// when a `force=false` job was already pending — the worker would
// then pick up the non-force row, hit skip-if-done, ack done, and
// the admin saw "queued for re-analysis" with no re-analysis.
//
// Enqueue + audit in one transaction (via the `_conn` form) so a failed
// audit insert rolls the enqueue/upgrade back too — the audit trail can't
// miss a force-reanalyze that landed.
let mut tx = state.db.begin().await?;
let outcome =
repo::page_analysis::enqueue_for_page_conn(&mut tx, page_id, true).await?;
let outcome_label = match outcome {
repo::page_analysis::EnqueueForPageOutcome::Inserted => "inserted",
repo::page_analysis::EnqueueForPageOutcome::UpgradedToForce => "upgraded_pending_to_force",
repo::page_analysis::EnqueueForPageOutcome::AlreadyEnqueued => "already_force_enqueued",
};
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"analysis_force_page",
"page",
Some(page_id),
json!({ "force": true, "outcome": outcome_label }),
)
.await?;
tx.commit().await?;
Ok(Json(AnalyzePageResponse { enqueued: true }))
}

View File

@@ -0,0 +1,70 @@
//! GET /admin/audit — paginated, filterable admin-action audit log.
//!
//! A pure DB read over the `admin_audit` table joined to the actor's
//! username. Drives the "Audit" admin tab: who did what, when. Every
//! mutating admin action writes a row here; this is the only reader.
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
use crate::repo::admin_audit::{AuditEntryRow, AuditFilter};
// Reuse the analysis window helper so `days` clamps identically everywhere.
use crate::api::admin::analysis::window_since;
pub fn routes() -> Router<AppState> {
Router::new().route("/admin/audit", get(list_audit))
}
fn default_limit() -> i64 {
50
}
#[derive(Debug, Deserialize, Default)]
struct AuditParams {
#[serde(default)]
action: Option<String>,
#[serde(default)]
target_kind: Option<String>,
#[serde(default)]
actor_user_id: Option<Uuid>,
#[serde(default)]
days: i64,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_audit(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<AuditParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<AuditEntryRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let action = params.action.filter(|s| !s.trim().is_empty());
let target_kind = params.target_kind.filter(|s| !s.trim().is_empty());
let (items, total) = repo::admin_audit::list(
&state.db,
AuditFilter {
action: action.as_deref(),
target_kind: target_kind.as_deref(),
actor_user_id: params.actor_user_id,
since: window_since(params.days),
},
limit,
offset,
)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -0,0 +1,67 @@
//! GET /admin/crawler/active-jobs — paginated `pending|running` chapters
//! GET /admin/crawler/covers — paginated mangas missing a cover
//!
//! These are pure DB-derived reads that drive two of the three
//! backlog tables on the dashboard. The third (dead jobs) lives in
//! the [`super::dead_jobs`] module because it also exposes a write.
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
use crate::repo::crawler::{ActiveJob, MissingCoverRow};
use super::default_limit;
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/admin/crawler/active-jobs", get(list_active_jobs))
.route("/admin/crawler/covers", get(list_covers))
}
/// Pagination + title-search params shared by the backlog list endpoints.
#[derive(Debug, Deserialize, Default)]
struct ListParams {
#[serde(default)]
search: Option<String>,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_active_jobs(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<ListParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<ActiveJob>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let search = params.search.filter(|s| !s.trim().is_empty());
let (items, total) =
repo::crawler::list_active_jobs(&state.db, search.as_deref(), limit, offset).await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}
async fn list_covers(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<ListParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<MissingCoverRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let search = params.search.filter(|s| !s.trim().is_empty());
let (items, total) =
repo::crawler::list_missing_cover_mangas(&state.db, search.as_deref(), limit, offset)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -0,0 +1,246 @@
//! POST /admin/crawler/run — trigger an out-of-cycle metadata pass
//! POST /admin/crawler/browser/restart — coordinated restart
//! POST /admin/crawler/session — refresh PHPSESSID
//! POST /admin/crawler/session/clear-expired — clear sticky expired flag
//!
//! All four mutate live in-process state on `CrawlerControl` (browser
//! manager, session controller, manual-pass mutex). They each emit an
//! `admin_audit` row and `status.poke()` so SSE subscribers see the
//! change instantly without polling.
use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::{AppError, AppResult};
use crate::repo;
use super::require_crawler;
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/admin/crawler/run", post(run_now))
.route("/admin/crawler/reconcile", post(reconcile_now))
.route("/admin/crawler/browser/restart", post(restart_browser))
.route("/admin/crawler/session", post(update_session))
.route(
"/admin/crawler/session/clear-expired",
post(clear_session_expired),
)
}
#[derive(Debug, Serialize)]
struct RunResponse {
started: bool,
}
async fn run_now(
State(state): State<AppState>,
admin: RequireAdmin,
) -> AppResult<Json<RunResponse>> {
let c = require_crawler(&state)?;
let mp = c.metadata_pass.as_ref().ok_or_else(|| {
AppError::ServiceUnavailable("no source configured (CRAWLER_START_URL unset)".into())
})?;
// Operator-click dedup. A pass holds the lock for its entire run
// (minutes); a second click while it's in flight returns 409 instead
// of fanning a second pass onto the single browser lease. The daily
// cron does NOT take this lock — cron is single-fire by definition,
// and its own contention with a manual pass already serialises
// through the browser lease + advisory lock at a lower layer.
let pass_guard = c
.manual_pass_lock
.clone()
.try_lock_owned()
.map_err(|_| AppError::Conflict("manual metadata pass already running".into()))?;
let mp = std::sync::Arc::clone(mp);
// Fire-and-forget: the pass can run for minutes; the dashboard
// streams progress over SSE. The guard moves into the task so the
// lock is released only when the pass finishes (or the task panics).
tokio::spawn(async move {
let _pass_guard = pass_guard;
if let Err(e) = mp.run().await {
tracing::warn!(error = ?e, "manual metadata pass failed");
}
});
repo::admin_audit::insert(&state.db, admin.0.id, "crawler_run", "crawler", None, json!({}))
.await?;
Ok(Json(RunResponse { started: true }))
}
async fn reconcile_now(
State(state): State<AppState>,
admin: RequireAdmin,
) -> AppResult<Json<RunResponse>> {
let c = require_crawler(&state)?;
let rp = c.reconcile_pass.as_ref().ok_or_else(|| {
AppError::ServiceUnavailable("no source configured (CRAWLER_START_URL unset)".into())
})?;
// Share `manual_pass_lock` with the metadata pass: reconcile's list walk
// and a metadata pass both contend for the single browser lease, so they
// must not run concurrently. A click while either is in flight gets 409.
let pass_guard = c
.manual_pass_lock
.clone()
.try_lock_owned()
.map_err(|_| AppError::Conflict("a metadata or reconcile pass is already running".into()))?;
let rp = std::sync::Arc::clone(rp);
// Fire-and-forget: the full list walk runs for minutes; progress streams
// over SSE (the Reconciling phase). The guard moves into the task so the
// lock releases only when the walk + enqueue finish.
tokio::spawn(async move {
let _pass_guard = pass_guard;
match rp.run().await {
Ok(stats) => tracing::info!(?stats, "manual reconcile pass complete"),
Err(e) => tracing::warn!(error = ?e, "manual reconcile pass failed"),
}
});
repo::admin_audit::insert(
&state.db,
admin.0.id,
"crawler_reconcile",
"crawler",
None,
json!({}),
)
.await?;
Ok(Json(RunResponse { started: true }))
}
#[derive(Debug, Serialize)]
struct RestartResponse {
ok: bool,
error: Option<String>,
}
async fn restart_browser(
State(state): State<AppState>,
admin: RequireAdmin,
) -> AppResult<Json<RestartResponse>> {
let c = require_crawler(&state)?;
let result = c.browser_manager.coordinated_restart(c.drain_deadline).await;
// A successful coordinated_restart re-runs on_launch, which re-injects
// PHPSESSID and re-probes — i.e. the session is live. Drop the sticky
// `session_expired` flag so chapter workers stop idling without
// requiring a second click on "Clear expired".
if result.is_ok() {
c.session.clear_expired();
}
// Push the post-restart browser phase to live subscribers immediately.
c.status.poke();
repo::admin_audit::insert(
&state.db,
admin.0.id,
"crawler_browser_restart",
"crawler",
None,
json!({ "ok": result.is_ok() }),
)
.await?;
Ok(Json(match result {
Ok(()) => RestartResponse {
ok: true,
error: None,
},
Err(e) => RestartResponse {
ok: false,
error: Some(format!("{e:#}")),
},
}))
}
#[derive(Debug, Deserialize)]
struct UpdateSessionRequest {
phpsessid: String,
}
#[derive(Debug, Serialize)]
struct UpdateSessionResponse {
/// Whether the post-update browser relaunch + session probe succeeded.
valid: bool,
error: Option<String>,
}
async fn update_session(
State(state): State<AppState>,
admin: RequireAdmin,
Json(body): Json<UpdateSessionRequest>,
) -> AppResult<Json<UpdateSessionResponse>> {
let c = require_crawler(&state)?;
// Fingerprint BEFORE move so the raw value never reaches tracing or
// the audit row. SHA256-prefix is opaque to anyone reading the audit
// log but deterministic enough to correlate two updates of the same
// session value.
let fingerprint = phpsessid_fingerprint(&body.phpsessid);
c.session
.update(&body.phpsessid)
.await
.map_err(|e| AppError::InvalidInput(format!("{e:#}")))?;
// Relaunch the browser so on_launch re-injects the new cookie and
// re-probes — the restart's success IS the session-validity signal.
let probe = c.browser_manager.coordinated_restart(c.drain_deadline).await;
// Session + browser state changed — push to live subscribers.
c.status.poke();
repo::admin_audit::insert(
&state.db,
admin.0.id,
"crawler_session_update",
"crawler",
None,
json!({ "valid": probe.is_ok(), "phpsessid_fingerprint": fingerprint }),
)
.await?;
Ok(Json(match probe {
Ok(()) => UpdateSessionResponse {
valid: true,
error: None,
},
Err(e) => UpdateSessionResponse {
valid: false,
error: Some(format!("{e:#}")),
},
}))
}
#[derive(Debug, Serialize)]
struct ClearExpiredResponse {
cleared: bool,
}
async fn clear_session_expired(
State(state): State<AppState>,
admin: RequireAdmin,
) -> AppResult<Json<ClearExpiredResponse>> {
let c = require_crawler(&state)?;
c.session.clear_expired();
// session.expired flipped — push to live subscribers.
c.status.poke();
repo::admin_audit::insert(
&state.db,
admin.0.id,
"crawler_session_clear_expired",
"crawler",
None,
json!({}),
)
.await?;
Ok(Json(ClearExpiredResponse { cleared: true }))
}
/// Opaque, short fingerprint of a PHPSESSID for the admin audit log.
/// The first 8 hex chars of SHA-256 — enough to correlate two updates
/// of the same value without revealing the raw cookie. Reading the audit
/// row does not give an operator anything that can re-construct the
/// session.
fn phpsessid_fingerprint(sid: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(sid.as_bytes());
let digest = h.finalize();
let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
hex
}

View File

@@ -0,0 +1,121 @@
//! GET /admin/crawler/dead-jobs — paginated dead-letter list
//! POST /admin/crawler/dead-jobs/requeue — flip dead jobs back to pending
//!
//! Requeue scopes:
//! - `all` (requires `confirm: true` so a careless click / CSRF bait
//! can't flip the whole pile in one shot)
//! - `manga` (all dead jobs whose chapter belongs to a manga)
//! - `chapter` (all dead jobs for a single chapter)
//! - `job` (a single dead row by id)
//!
//! Each requeue emits an `admin_audit` row with the relevant
//! `target_id` populated, so an operator review post-incident can pin
//! exactly which scope was acted on.
use axum::extract::{Query, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::crawler::{DeadJob, RequeueScope};
use super::default_limit;
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/admin/crawler/dead-jobs", get(list_dead_jobs))
.route("/admin/crawler/dead-jobs/requeue", post(requeue_dead_jobs))
}
#[derive(Debug, Deserialize, Default)]
struct DeadJobsParams {
#[serde(default)]
search: Option<String>,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_dead_jobs(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<DeadJobsParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<DeadJob>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let search = params.search.filter(|s| !s.trim().is_empty());
let (items, total) =
repo::crawler::list_dead_jobs(&state.db, search.as_deref(), limit, offset).await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}
#[derive(Debug, Deserialize)]
#[serde(tag = "scope", rename_all = "snake_case")]
enum RequeueRequest {
/// `confirm: true` is required so a careless click / CSRF bait can't
/// flip the entire dead pile in one shot. Narrow scopes don't need it.
All {
#[serde(default)]
confirm: bool,
},
Manga { manga_id: Uuid },
Chapter { chapter_id: Uuid },
Job { job_id: Uuid },
}
#[derive(Debug, Serialize)]
struct RequeueResponse {
requeued: u64,
}
async fn requeue_dead_jobs(
State(state): State<AppState>,
admin: RequireAdmin,
Json(body): Json<RequeueRequest>,
) -> AppResult<Json<RequeueResponse>> {
// Reject scope=all without an explicit confirm so a single click or
// CSRF bait can't flip the whole dead pile. Narrow scopes don't need
// the confirmation — the operator already named a specific target.
if let RequeueRequest::All { confirm: false } = &body {
return Err(AppError::InvalidInput(
"confirm: true is required for scope=all".into(),
));
}
let (scope, target_kind, target_id) = match &body {
RequeueRequest::All { .. } => (RequeueScope::All, "crawler", None),
RequeueRequest::Manga { manga_id } => (RequeueScope::Manga(*manga_id), "manga", Some(*manga_id)),
RequeueRequest::Chapter { chapter_id } => {
(RequeueScope::Chapter(*chapter_id), "chapter", Some(*chapter_id))
}
RequeueRequest::Job { job_id } => (RequeueScope::Job(*job_id), "crawler_job", Some(*job_id)),
};
let requeued = repo::crawler::requeue_dead_jobs(&state.db, scope).await?;
repo::admin_audit::insert(
&state.db,
admin.0.id,
"crawler_dead_jobs_requeue",
target_kind,
target_id,
json!({ "requeued": requeued, "scope": scope_label(&body) }),
)
.await?;
Ok(Json(RequeueResponse { requeued }))
}
fn scope_label(r: &RequeueRequest) -> &'static str {
match r {
RequeueRequest::All { .. } => "all",
RequeueRequest::Manga { .. } => "manga",
RequeueRequest::Chapter { .. } => "chapter",
RequeueRequest::Job { .. } => "job",
}
}

View File

@@ -0,0 +1,63 @@
//! GET /admin/crawler/history — unified, searchable, filterable job log.
//!
//! A pure DB-derived read over the `crawler_jobs` table across every state
//! and kind. Drives the "History" tab on the crawler dashboard. Depth is
//! bounded by the done-job reaper (recent window); `dead` jobs persist.
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
use crate::repo::crawler::{JobHistoryFilter, JobHistoryRow};
use super::default_limit;
pub(super) fn routes() -> Router<AppState> {
Router::new().route("/admin/crawler/history", get(list_history))
}
/// State + kind filters and pagination/search for the history list.
#[derive(Debug, Deserialize, Default)]
struct HistoryParams {
#[serde(default)]
state: Option<String>,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
search: Option<String>,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_history(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<HistoryParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<JobHistoryRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let state_f = params.state.filter(|s| !s.trim().is_empty());
let kind_f = params.kind.filter(|s| !s.trim().is_empty());
let search_f = params.search.filter(|s| !s.trim().is_empty());
let (items, total) = repo::crawler::list_job_history(
&state.db,
JobHistoryFilter {
state: state_f.as_deref(),
kind: kind_f.as_deref(),
search: search_f.as_deref(),
},
limit,
offset,
)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -0,0 +1,99 @@
//! GET /admin/crawler/metrics — per-type average durations + success
//! GET /admin/crawler/metrics/ops — paginated recent timed-operations log
//!
//! Pure DB reads over the durable `crawl_metrics` table. Drive the "Metrics"
//! tab on the crawler dashboard. `days` windows the rows (`0`/absent = all).
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::domain::crawl_metrics::{MetricsBucket, OpRow, OpSummary};
use crate::error::AppResult;
use crate::repo;
use crate::repo::crawl_metrics::OpFilter;
use super::default_limit;
// Reuse the analysis handler's window + bucket helpers so all the metrics
// endpoints clamp `days` and resolve `bucket` identically.
use crate::api::admin::analysis::{resolve_bucket, window_since, SeriesParams, SeriesResponse};
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/admin/crawler/metrics", get(summary))
.route("/admin/crawler/metrics/ops", get(list_ops))
.route("/admin/crawler/metrics/series", get(series))
}
async fn series(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<SeriesParams>,
) -> AppResult<Json<SeriesResponse<MetricsBucket>>> {
let bucket = resolve_bucket(params.days, params.bucket.as_deref())?;
let buckets = repo::crawl_metrics::series(&state.db, bucket, window_since(params.days)).await?;
Ok(Json(SeriesResponse { buckets }))
}
#[derive(Debug, Deserialize, Default)]
struct SummaryParams {
#[serde(default)]
days: i64,
}
#[derive(Debug, Serialize)]
struct SummaryResponse {
summary: Vec<OpSummary>,
}
async fn summary(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<SummaryParams>,
) -> AppResult<Json<SummaryResponse>> {
let since = window_since(params.days);
let summary = repo::crawl_metrics::summary(&state.db, since).await?;
Ok(Json(SummaryResponse { summary }))
}
#[derive(Debug, Deserialize, Default)]
struct OpsParams {
#[serde(default)]
op: Option<String>,
#[serde(default)]
outcome: Option<String>,
#[serde(default)]
days: i64,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_ops(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<OpsParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<OpRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let op = params.op.filter(|s| !s.trim().is_empty());
let outcome = params.outcome.filter(|s| !s.trim().is_empty());
let (items, total) = repo::crawl_metrics::list_ops(
&state.db,
OpFilter {
op: op.as_deref(),
outcome: outcome.as_deref(),
since: window_since(params.days),
},
limit,
offset,
)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -0,0 +1,54 @@
//! Admin-only crawler observability + control endpoints.
//!
//! Mounted under `/api/v1/admin/crawler*`, cookie-only via `RequireAdmin`.
//! All control endpoints return 503 when the crawler daemon is disabled
//! (`AppState.crawler == None`). Reads compose the live in-process status
//! ([`crate::crawler::status`]) with DB-derived queue counts and the
//! session/browser flags.
//!
//! Split into four siblings for navigability — the surface area grew
//! past the point where keeping it in one file made review harder:
//! - [`status`] — SSE stream + composed status snapshot
//! - [`control`] — run / restart / session endpoints
//! - [`dead_jobs`] — dead-letter list + requeue
//! - [`backlog`] — pending-chapters and missing-covers backlog reads
mod backlog;
mod control;
mod dead_jobs;
mod history;
mod metrics;
mod status;
use axum::Router;
use crate::app::{AppState, CrawlerControl};
use crate::error::AppError;
pub fn routes() -> Router<AppState> {
Router::new()
.merge(status::routes())
.merge(control::routes())
.merge(dead_jobs::routes())
.merge(backlog::routes())
.merge(history::routes())
.merge(metrics::routes())
}
/// Default page size for the backlog list endpoints.
pub(super) fn default_limit() -> i64 {
50
}
/// Shared 503 helper: the daemon-only control + observability endpoints
/// gate on a running crawler daemon. Returns the same code and body across
/// every caller so the frontend can rely on `service_unavailable` rather
/// than each handler returning its own variant. Yields an owned `Arc` (the
/// control handle is swapped on a config reload).
pub(super) fn require_crawler(
state: &AppState,
) -> Result<std::sync::Arc<CrawlerControl>, AppError> {
state.crawler().ok_or_else(|| {
AppError::ServiceUnavailable("crawler daemon is disabled".into())
})
}

View File

@@ -0,0 +1,245 @@
//! GET /admin/crawler — composed status snapshot
//! GET /admin/crawler/stream — Server-Sent Events live status
//!
//! The composed response merges the in-process status surface
//! ([`crate::crawler::status`]) with two DB-derived counts
//! (job-state breakdown and missing-cover backlog) so the dashboard
//! reads the same shape from one one-shot fetch and from each SSE
//! frame. The streaming path debounces a burst of pokes into a single
//! frame and memoizes the DB counts for the [`QUEUE_MEMO_TTL`] window
//! to avoid hammering Postgres.
use std::convert::Infallible;
use std::time::Duration;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::get;
use axum::{Json, Router};
use futures_util::stream::Stream;
use serde::Serialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::crawler::browser_manager::RestartPhase;
use crate::crawler::status::{ActiveChapter, CoverTarget, LastPass, Phase};
use crate::error::AppResult;
use crate::repo;
/// Backstop recompose interval for the SSE stream. Phase/worker/session
/// changes push instantly via the status `watch`; this only bounds the
/// staleness of DB-derived queue counts and the browser phase when those
/// change without an accompanying status poke.
const SSE_BACKSTOP: Duration = Duration::from_secs(5);
/// Coalesce a burst of status pokes (e.g. one per stored page during a
/// chapter download) into a single SSE frame. After the first change
/// fires we sleep this long and absorb any further changes that arrive
/// in the window before emitting. Tighter than human-perceivable jitter
/// (~16-100 ms is the rule of thumb for "instant").
const SSE_DEBOUNCE: Duration = Duration::from_millis(250);
/// Per-connection memo TTL for DB-derived queue counts. With a busy
/// chapter pass bumping the watch up to several times per second, the
/// memo collapses N stream wakeups into one round-trip per second
/// — typically a ~10x reduction in steady-state DB QPS per subscriber.
const QUEUE_MEMO_TTL: Duration = Duration::from_millis(1000);
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/admin/crawler", get(get_status))
.route("/admin/crawler/stream", get(stream_status))
}
#[derive(Debug, Serialize)]
struct QueueCounts {
pending: i64,
running: i64,
dead: i64,
}
#[derive(Debug, Serialize)]
struct SessionStatus {
/// Whether the sticky session-expired flag is set (chapter workers idle).
expired: bool,
/// Whether a PHPSESSID is currently configured at all.
configured: bool,
}
#[derive(Debug, Serialize)]
struct CrawlerStatusResponse {
/// `"running"` | `"disabled"`.
daemon: &'static str,
phase: Option<Phase>,
/// Configured chapter-worker count (for "N busy / M workers").
worker_count: usize,
/// Chapters being crawled right now, with live page counts.
active_chapters: Vec<ActiveChapter>,
/// The cover being fetched right now, if any.
current_cover: Option<CoverTarget>,
/// Mangas still queued for a cover fetch.
covers_queued: i64,
last_pass: LastPass,
session: SessionStatus,
/// `"healthy"` | `"draining"` | `"restarting"` | `"down"`.
browser: &'static str,
queue: QueueCounts,
}
/// Per-stream memo of the two DB-derived counts shared by every SSE
/// frame: crawler-job state breakdown and missing-cover backlog. Held
/// across iterations of the unfold loop so a burst of status pokes
/// emits one frame from cached counts. A fresh memo (`new`) always
/// misses on first call, so `get_status` (one-shot) sees no stale data.
struct QueueCountsMemo {
cached: Option<(std::time::Instant, (i64, i64, i64), i64)>,
}
impl QueueCountsMemo {
fn new() -> Self {
Self { cached: None }
}
async fn get(
&mut self,
db: &sqlx::PgPool,
) -> sqlx::Result<((i64, i64, i64), i64)> {
if let Some((at, qc, cv)) = self.cached.as_ref() {
if at.elapsed() < QUEUE_MEMO_TTL {
return Ok((*qc, *cv));
}
}
let qc = repo::crawler::job_state_counts(db).await?;
let cv = repo::crawler::count_missing_covers(db).await?;
self.cached = Some((std::time::Instant::now(), qc, cv));
Ok((qc, cv))
}
}
fn browser_phase_str(p: RestartPhase) -> &'static str {
match p {
RestartPhase::Healthy => "healthy",
RestartPhase::Draining => "draining",
RestartPhase::Restarting => "restarting",
}
}
/// Compose a full status snapshot from the in-memory status, the
/// browser/session flags, and DB queue-count queries (routed through
/// a memo so a burst of pokes doesn't hammer Postgres). Shared by
/// `get_status` (with a fresh per-call memo) and `stream_status`
/// (with a per-stream memo held across iterations).
async fn compose_status(
state: &AppState,
memo: &mut QueueCountsMemo,
) -> AppResult<CrawlerStatusResponse> {
let ((pending, running, dead), covers_queued) = memo.get(&state.db).await?;
let queue = QueueCounts {
pending,
running,
dead,
};
Ok(match state.crawler().as_ref() {
None => CrawlerStatusResponse {
daemon: "disabled",
phase: None,
worker_count: 0,
active_chapters: Vec::new(),
current_cover: None,
covers_queued,
last_pass: LastPass::default(),
session: SessionStatus {
expired: false,
configured: false,
},
browser: "down",
queue,
},
Some(c) => {
let snap = c.status.snapshot().await;
CrawlerStatusResponse {
daemon: "running",
phase: Some(snap.phase),
worker_count: snap.worker_count,
active_chapters: snap.active_chapters,
current_cover: snap.current_cover,
covers_queued,
last_pass: snap.last_pass,
session: SessionStatus {
expired: c.session.is_expired(),
configured: c.session.current().await.is_some(),
},
browser: browser_phase_str(c.browser_manager.phase()),
queue,
}
}
})
}
async fn get_status(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<CrawlerStatusResponse>> {
// Fresh memo — one-shot calls always query the DB. The wrapper exists
// only so compose_status can share its signature with the streaming
// path; there is no caching across one-shot calls.
let mut memo = QueueCountsMemo::new();
Ok(Json(compose_status(&state, &mut memo).await?))
}
/// Push live status to the dashboard instead of polling. Emits a snapshot
/// immediately on connect, then on every status change (instant, via the
/// `watch` notifier) and on a [`SSE_BACKSTOP`] tick (to refresh DB queue
/// counts / browser phase that change without a status poke). The browser
/// opens this only while the crawler page is mounted and closes it on
/// navigate-away, so the subscription is scoped to the active page.
async fn stream_status(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// Subscribe before the first emit so no change between the initial
// snapshot and the first await is lost.
let rx = state.crawler().as_ref().map(|c| c.status.subscribe());
let memo = QueueCountsMemo::new();
let stream = futures_util::stream::unfold(
(state, rx, memo, true),
|(state, mut rx, mut memo, first)| async move {
// After the first immediate emit, wait for a change or the
// backstop tick before recomposing. On a change, debounce a
// short window so a burst of pokes (one per stored page
// during a chapter download, etc.) coalesces into a single
// frame instead of hammering subscribers.
if !first {
match rx.as_mut() {
Some(rx) => {
tokio::select! {
_ = rx.changed() => {
tokio::time::sleep(SSE_DEBOUNCE).await;
// Mark any pokes that arrived during the
// debounce window as observed so the next
// iteration only fires on NEW changes.
rx.borrow_and_update();
}
_ = tokio::time::sleep(SSE_BACKSTOP) => {}
}
}
None => tokio::time::sleep(SSE_BACKSTOP).await,
}
}
// Compose; on a transient DB error, emit a keep-alive comment
// rather than tearing down the stream.
let event = match compose_status(&state, &mut memo).await {
Ok(resp) => Event::default()
.event("status")
.json_data(&resp)
.unwrap_or_else(|_| Event::default().comment("serialize error")),
Err(_) => Event::default().comment("status unavailable"),
};
Some((Ok(event), (state, rx, memo, false)))
},
);
Sse::new(stream).keep_alive(KeepAlive::default())
}

View File

@@ -0,0 +1,444 @@
//! GET /admin/health — aggregated operational health checks
//! PUT /admin/health/thresholds — edit the alerting thresholds
//!
//! A single read that rolls up signals already collected elsewhere (system
//! sensors, queue depths, recent failure rates, cron freshness, the live
//! crawler session/browser flags) into a flat list of checks with an
//! overall status. Thresholds live in `app_settings['health_thresholds']`
//! (JSONB, merged over compiled defaults) and are admin-editable + audited.
use axum::extract::State;
use axum::routing::{get, put};
use axum::{Json, Router};
use chrono::{Duration, Utc};
use futures_util::TryFutureExt;
use serde::{Deserialize, Serialize};
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::crawler::browser_manager::RestartPhase;
use crate::error::{AppError, AppResult};
use crate::repo;
const KEY: &str = "health_thresholds";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/admin/health", get(health))
.route("/admin/health/thresholds", put(update_thresholds))
}
// ---------------------------------------------------------------------------
// Thresholds (persisted, editable)
// ---------------------------------------------------------------------------
fn d_disk() -> f64 {
90.0
}
fn d_mem() -> f64 {
90.0
}
fn d_dead() -> i64 {
100
}
fn d_crawl_fail() -> f64 {
20.0
}
fn d_analysis_fail() -> f64 {
20.0
}
fn d_cron() -> i64 {
26
}
fn d_covers() -> i64 {
500
}
/// Alerting thresholds. Each field has a `serde(default)` so a stored row
/// that predates a newly-added field still deserializes (the missing field
/// takes its compiled default — forward-compatible).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct HealthThresholds {
#[serde(default = "d_disk")]
pub disk_pct: f64,
#[serde(default = "d_mem")]
pub mem_pct: f64,
#[serde(default = "d_dead")]
pub dead_jobs_max: i64,
#[serde(default = "d_crawl_fail")]
pub crawl_fail_pct: f64,
#[serde(default = "d_analysis_fail")]
pub analysis_fail_pct: f64,
#[serde(default = "d_cron")]
pub cron_freshness_hours: i64,
#[serde(default = "d_covers")]
pub missing_covers_max: i64,
}
impl Default for HealthThresholds {
fn default() -> Self {
Self {
disk_pct: d_disk(),
mem_pct: d_mem(),
dead_jobs_max: d_dead(),
crawl_fail_pct: d_crawl_fail(),
analysis_fail_pct: d_analysis_fail(),
cron_freshness_hours: d_cron(),
missing_covers_max: d_covers(),
}
}
}
impl HealthThresholds {
/// Reject nonsensical values so a check can't be silently disabled by a
/// fat-fingered edit. Percentages 1..=100; counts/hours must be positive.
fn validate(&self) -> Result<(), String> {
let pct = |v: f64, name: &str| {
if (1.0..=100.0).contains(&v) {
Ok(())
} else {
Err(format!("{name} must be between 1 and 100"))
}
};
pct(self.disk_pct, "disk_pct")?;
pct(self.mem_pct, "mem_pct")?;
pct(self.crawl_fail_pct, "crawl_fail_pct")?;
pct(self.analysis_fail_pct, "analysis_fail_pct")?;
if self.dead_jobs_max < 0 {
return Err("dead_jobs_max must be >= 0".into());
}
if self.missing_covers_max < 0 {
return Err("missing_covers_max must be >= 0".into());
}
if self.cron_freshness_hours < 1 {
return Err("cron_freshness_hours must be >= 1".into());
}
Ok(())
}
}
async fn load_thresholds(state: &AppState) -> AppResult<HealthThresholds> {
Ok(match repo::app_settings::get(&state.db, KEY).await? {
// serde(default) on each field fills anything the stored object omits.
Some(v) => serde_json::from_value(v).unwrap_or_default(),
None => HealthThresholds::default(),
})
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Ok,
Warn,
Critical,
}
impl CheckStatus {
fn rank(self) -> u8 {
match self {
CheckStatus::Ok => 0,
CheckStatus::Warn => 1,
CheckStatus::Critical => 2,
}
}
}
/// `Ok` while `value` is strictly below `limit`, else `Warn`. Used for
/// "near a ceiling" gauges (disk/memory/fail-rate percentages, cron-age
/// hours) where hitting the threshold is itself the alert. Pure so the
/// rule is unit-testable without the DB/sensors.
pub(crate) fn over_limit(value: f64, limit: f64) -> CheckStatus {
if value < limit {
CheckStatus::Ok
} else {
CheckStatus::Warn
}
}
/// `Ok` until `value` strictly exceeds `limit`. Used for "max allowed"
/// backlog counts so a limit of `0` warns on the first item — and `0` items
/// against a `0` limit stays `Ok` (no false alarm at exactly the boundary).
pub(crate) fn exceeds(value: i64, limit: i64) -> CheckStatus {
if value > limit {
CheckStatus::Warn
} else {
CheckStatus::Ok
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthCheck {
pub id: &'static str,
pub label: &'static str,
pub status: CheckStatus,
pub value: String,
pub threshold: Option<String>,
pub hint: Option<String>,
/// Admin route that remediates this check, when one applies.
pub action_href: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthReport {
/// Worst status across all checks (drives the page/summary badge).
pub status: CheckStatus,
pub checks: Vec<HealthCheck>,
pub thresholds: HealthThresholds,
}
async fn health(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<HealthReport>> {
let thresholds = load_thresholds(&state).await?;
let since_24h = Utc::now() - Duration::hours(24);
// DB-derived signals run concurrently — all hit existing indexes.
let ((pending, running, dead), missing_covers, crawl, analysis, last_tick) = tokio::try_join!(
repo::crawler::job_state_counts(&state.db).map_err(AppError::from),
repo::crawler::count_missing_covers(&state.db).map_err(AppError::from),
repo::crawl_metrics::summary(&state.db, Some(since_24h)).map_err(AppError::from),
repo::page_analysis::analysis_metrics(&state.db, Some(since_24h)),
repo::crawler::last_metadata_tick_at(&state.db).map_err(AppError::from),
)?;
let _ = (pending, running); // queue depth shown elsewhere; not a check yet
let mut checks: Vec<HealthCheck> = Vec::new();
// --- system sensors ---
if let Some(d) = state.storage.local_root().and_then(super::system::disk_stats_for) {
checks.push(HealthCheck {
id: "disk",
label: "Disk usage",
status: over_limit(d.percent_used, thresholds.disk_pct),
value: format!("{:.0}%", d.percent_used),
threshold: Some(format!("{:.0}%", thresholds.disk_pct)),
hint: Some("free space on the storage volume".into()),
action_href: None,
});
}
let mem = super::system::memory_percent_used();
checks.push(HealthCheck {
id: "memory",
label: "Memory usage",
status: over_limit(mem, thresholds.mem_pct),
value: format!("{mem:.0}%"),
threshold: Some(format!("{:.0}%", thresholds.mem_pct)),
hint: None,
action_href: None,
});
// --- queue / backlog ---
checks.push(HealthCheck {
id: "dead_jobs",
label: "Dead jobs",
status: exceeds(dead, thresholds.dead_jobs_max),
value: dead.to_string(),
threshold: Some(thresholds.dead_jobs_max.to_string()),
hint: Some("jobs that exhausted their retries".into()),
action_href: Some("/admin/crawler"),
});
checks.push(HealthCheck {
id: "missing_covers",
label: "Missing covers",
status: exceeds(missing_covers, thresholds.missing_covers_max),
value: missing_covers.to_string(),
threshold: Some(thresholds.missing_covers_max.to_string()),
hint: Some("mangas still awaiting a cover download".into()),
action_href: Some("/admin/crawler"),
});
// --- failure rates (last 24h) ---
let crawl_total: i64 = crawl.iter().map(|s| s.n).sum();
let crawl_failed: i64 = crawl.iter().map(|s| s.failed).sum();
let crawl_pct = pct(crawl_failed, crawl_total);
checks.push(HealthCheck {
id: "crawl_fail_rate",
label: "Crawl failure rate (24h)",
status: if crawl_total == 0 {
CheckStatus::Ok
} else {
over_limit(crawl_pct, thresholds.crawl_fail_pct)
},
value: format!("{crawl_pct:.0}% ({crawl_failed}/{crawl_total})"),
threshold: Some(format!("{:.0}%", thresholds.crawl_fail_pct)),
hint: None,
action_href: Some("/admin/crawler"),
});
let an_pct = pct(analysis.failed, analysis.n);
checks.push(HealthCheck {
id: "analysis_fail_rate",
label: "Analysis failure rate (24h)",
status: if analysis.n == 0 {
CheckStatus::Ok
} else {
over_limit(an_pct, thresholds.analysis_fail_pct)
},
value: format!("{an_pct:.0}% ({}/{})", analysis.failed, analysis.n),
threshold: Some(format!("{:.0}%", thresholds.analysis_fail_pct)),
hint: None,
action_href: Some("/admin/analysis"),
});
// --- crawler daemon liveness (only when the daemon is configured) ---
if let Some(c) = state.crawler().as_ref() {
// Cron freshness: stale tick = the daily pass hasn't run on time.
match last_tick {
Some(t) => {
let age_h = (Utc::now() - t).num_hours();
checks.push(HealthCheck {
id: "cron_freshness",
label: "Metadata cron freshness",
status: over_limit(age_h as f64, thresholds.cron_freshness_hours as f64),
value: format!("{age_h}h ago"),
threshold: Some(format!("{}h", thresholds.cron_freshness_hours)),
hint: Some("time since the last metadata pass tick".into()),
action_href: Some("/admin/crawler"),
});
}
None => checks.push(HealthCheck {
id: "cron_freshness",
label: "Metadata cron freshness",
status: CheckStatus::Ok,
value: "no tick yet".into(),
threshold: None,
hint: Some("the daemon has not completed a pass yet".into()),
action_href: Some("/admin/crawler"),
}),
}
let expired = c.session.is_expired();
checks.push(HealthCheck {
id: "crawler_session",
label: "Crawler session",
status: if expired { CheckStatus::Critical } else { CheckStatus::Ok },
value: if expired { "expired".into() } else { "ok".into() },
threshold: None,
hint: expired.then(|| "chapter workers are idle until the session is refreshed".into()),
action_href: Some("/admin/crawler"),
});
let healthy = matches!(c.browser_manager.phase(), RestartPhase::Healthy);
let phase = match c.browser_manager.phase() {
RestartPhase::Healthy => "healthy",
RestartPhase::Draining => "draining",
RestartPhase::Restarting => "restarting",
};
checks.push(HealthCheck {
id: "crawler_browser",
label: "Crawler browser",
status: if healthy { CheckStatus::Ok } else { CheckStatus::Warn },
value: phase.to_string(),
threshold: None,
hint: (!healthy).then(|| "Chromium is restarting or unavailable".into()),
action_href: Some("/admin/crawler"),
});
}
let status = checks
.iter()
.map(|c| c.status)
.max_by_key(|s| s.rank())
.unwrap_or(CheckStatus::Ok);
Ok(Json(HealthReport {
status,
checks,
thresholds,
}))
}
/// Percentage `numerator/denominator`, guarding divide-by-zero.
fn pct(numerator: i64, denominator: i64) -> f64 {
if denominator <= 0 {
0.0
} else {
(numerator as f64) * 100.0 / (denominator as f64)
}
}
async fn update_thresholds(
State(state): State<AppState>,
admin: RequireAdmin,
Json(body): Json<HealthThresholds>,
) -> AppResult<Json<HealthReport>> {
body.validate().map_err(AppError::InvalidInput)?;
let value = serde_json::to_value(body).map_err(|e| AppError::Other(e.into()))?;
let mut tx = state.db.begin().await?;
repo::app_settings::upsert(&mut *tx, KEY, &value).await?;
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"update_health_thresholds",
"settings",
None,
value,
)
.await?;
tx.commit().await?;
// Re-evaluate so the client immediately sees checks recomputed against
// the new thresholds.
health(State(state), admin).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn over_limit_is_ok_below_warn_at_or_above() {
assert_eq!(over_limit(10.0, 20.0), CheckStatus::Ok);
assert_eq!(over_limit(19.999, 20.0), CheckStatus::Ok);
assert_eq!(over_limit(20.0, 20.0), CheckStatus::Warn);
assert_eq!(over_limit(99.0, 20.0), CheckStatus::Warn);
}
#[test]
fn exceeds_is_strict_and_safe_at_zero() {
assert_eq!(exceeds(0, 0), CheckStatus::Ok); // 0 backlog, 0 limit → no false alarm
assert_eq!(exceeds(1, 0), CheckStatus::Warn);
assert_eq!(exceeds(100, 100), CheckStatus::Ok);
assert_eq!(exceeds(101, 100), CheckStatus::Warn);
}
#[test]
fn pct_guards_zero_denominator() {
assert_eq!(pct(0, 0), 0.0);
assert_eq!(pct(5, 0), 0.0);
assert_eq!(pct(1, 4), 25.0);
}
#[test]
fn worst_status_ranking() {
assert!(CheckStatus::Critical.rank() > CheckStatus::Warn.rank());
assert!(CheckStatus::Warn.rank() > CheckStatus::Ok.rank());
}
#[test]
fn thresholds_validate_rejects_out_of_range() {
let mut t = HealthThresholds::default();
assert!(t.validate().is_ok());
t.disk_pct = 0.0;
assert!(t.validate().is_err());
t = HealthThresholds::default();
t.dead_jobs_max = -1;
assert!(t.validate().is_err());
t = HealthThresholds::default();
t.cron_freshness_hours = 0;
assert!(t.validate().is_err());
}
#[test]
fn thresholds_merge_defaults_for_missing_fields() {
// A stored object that predates `missing_covers_max` still decodes.
let partial = serde_json::json!({ "disk_pct": 80.0 });
let t: HealthThresholds = serde_json::from_value(partial).unwrap();
assert_eq!(t.disk_pct, 80.0);
assert_eq!(t.missing_covers_max, d_covers());
assert_eq!(t.cron_freshness_hours, d_cron());
}
}

View File

@@ -4,7 +4,15 @@
//! bot/API tokens cannot reach admin routes (see
//! `crate::auth::extractor::RequireAdmin`).
pub mod analysis;
pub mod audit;
pub mod crawler;
pub mod health;
pub mod mangas;
pub mod overview;
pub mod resync;
pub mod settings;
pub mod storage;
pub mod system;
pub mod users;
@@ -16,5 +24,13 @@ pub fn routes() -> Router<AppState> {
Router::new()
.merge(users::routes())
.merge(mangas::routes())
.merge(resync::routes())
.merge(storage::routes())
.merge(system::routes())
.merge(overview::routes())
.merge(audit::routes())
.merge(health::routes())
.merge(crawler::routes())
.merge(analysis::routes())
.merge(settings::routes())
}

View File

@@ -0,0 +1,113 @@
//! Admin overview dashboard aggregates.
//!
//! One composed endpoint feeding the Overview tab's per-section summary
//! cards (Users, Mangas, Analysis). Pure DB aggregates — no system
//! sampling — so it's cheap enough to poll. The System and Crawler cards
//! reuse their own existing endpoints (`/admin/system`, `/admin/crawler`),
//! and the Settings strip reuses the settings endpoints; only these three
//! sections needed new aggregation, so they live together here.
//!
//! Admin-only (`RequireAdmin`, cookie-only).
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::Serialize;
use tokio::sync::Mutex;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new().route("/admin/overview", get(overview))
}
#[derive(Debug, Clone, Serialize)]
pub struct OverviewStats {
pub users: UsersOverview,
pub mangas: repo::admin_view::MangaStats,
pub analysis: AnalysisOverview,
}
#[derive(Debug, Clone, Serialize)]
pub struct UsersOverview {
pub total: i64,
pub admins: i64,
pub newest_username: Option<String>,
pub newest_created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisOverview {
pub analyzed_pages: i64,
pub total_pages: i64,
}
/// Process-wide single-flight cache for the overview aggregate.
///
/// The admin dashboard polls this endpoint on a timer; without coalescing, a
/// slow DB (e.g. a deep crawl/analysis backlog) lets identical requests stack
/// and pin Postgres. Holding the async mutex across the recompute gives BOTH a
/// short-TTL cache and single-flight: concurrent pollers block on the lock,
/// then read the freshly cached value instead of each launching their own
/// aggregate. Resets on process restart, which is fine for a dashboard stat.
///
/// Unlike per-`AppState` shared state (e.g. `auth_limiter`), this is a single
/// production-instance cache, so a process global is adequate. It is
/// test-safe because the cache lives in the handler *body*: the two auth tests
/// are rejected at the `RequireAdmin` extractor before reaching it, and the
/// one aggregate test issues a single request. A future test that mutates data
/// and re-reads within the TTL would need to account for this.
static OVERVIEW_CACHE: OnceLock<Mutex<Option<(Instant, OverviewStats)>>> = OnceLock::new();
const OVERVIEW_TTL: Duration = Duration::from_secs(10);
async fn overview(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<OverviewStats>> {
let cache = OVERVIEW_CACHE.get_or_init(|| Mutex::new(None));
let mut guard = cache.lock().await;
if let Some((at, cached)) = guard.as_ref() {
if at.elapsed() < OVERVIEW_TTL {
return Ok(Json(cached.clone()));
}
}
// Independent reads — run concurrently so latency is the slowest
// query, not their sum (mirrors the storage handler).
let (user_counts, user_newest, mangas, coverage) = tokio::try_join!(
repo::user::counts(&state.db),
repo::user::newest(&state.db),
repo::admin_view::manga_stats(&state.db),
repo::page_analysis::library_coverage(&state.db),
)?;
let (total, admins) = user_counts;
let (newest_username, newest_created_at) = match user_newest {
Some((username, created_at)) => (Some(username), Some(created_at)),
None => (None, None),
};
let (analyzed_pages, total_pages) = coverage;
let stats = OverviewStats {
users: UsersOverview {
total,
admins,
newest_username,
newest_created_at,
},
mangas,
analysis: AnalysisOverview {
analyzed_pages,
total_pages,
},
};
*guard = Some((Instant::now(), stats.clone()));
Ok(Json(stats))
}

View File

@@ -0,0 +1,174 @@
//! Admin-triggered force resync of a single manga's metadata + cover,
//! or a single chapter's content.
//!
//! Both endpoints are admin-only (`RequireAdmin`, cookie-only) and run
//! synchronously with the request — the response carries the refreshed
//! resource so the UI can swap it in without a follow-up GET. The work
//! itself is delegated to [`ResyncService`] (set on AppState by
//! `app::build` when the crawler daemon is enabled); when the daemon
//! is disabled, both handlers return 503.
use axum::extract::{Path, State};
use axum::routing::post;
use axum::{Json, Router};
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::crawler::resync::{ChapterResyncOutcome, ResyncError};
use crate::domain::manga::MangaDetail;
use crate::domain::Chapter;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::crawler::UpsertStatus;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/admin/mangas/:id/resync", post(resync_manga))
.route("/admin/chapters/:id/resync", post(resync_chapter))
}
#[derive(Debug, Serialize)]
pub struct MangaResyncResponse {
pub manga: MangaDetail,
/// `"new" | "updated" | "unchanged"` — mirrors [`UpsertStatus`].
pub metadata_status: &'static str,
pub cover_fetched: bool,
}
#[derive(Debug, Serialize)]
pub struct ChapterResyncResponse {
pub chapter: Chapter,
/// `"fetched" | "skipped"` — whether new pages landed or the
/// service short-circuited (e.g. chapter already had pages and the
/// session was lost so force was downgraded).
pub outcome: &'static str,
/// Page count when `outcome == "fetched"`. `None` for `skipped`.
pub pages: Option<usize>,
}
async fn resync_manga(
State(state): State<AppState>,
admin: RequireAdmin,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaResyncResponse>> {
if !repo::manga::exists(&state.db, manga_id).await? {
return Err(AppError::NotFound);
}
let resync = state
.resync()
.ok_or_else(|| AppError::ServiceUnavailable(
"crawler daemon is disabled; force resync unavailable".into(),
))?;
let outcome = resync.resync_manga(manga_id).await.map_err(map_resync_err)?;
// Audit the action with the actor + the resync outcome so an
// operator-of-operators can answer "who refetched this manga, and
// did the cover land?" from the log alone.
repo::admin_audit::insert(
&state.db,
admin.0.id,
"manga_resync",
"manga",
Some(manga_id),
json!({
"metadata_status": status_str(outcome.metadata_status),
"cover_fetched": outcome.cover_fetched,
}),
)
.await?;
let manga = repo::manga::get_detail(&state.db, manga_id).await?;
Ok(Json(MangaResyncResponse {
manga,
metadata_status: status_str(outcome.metadata_status),
cover_fetched: outcome.cover_fetched,
}))
}
async fn resync_chapter(
State(state): State<AppState>,
admin: RequireAdmin,
Path(chapter_id): Path<Uuid>,
) -> AppResult<Json<ChapterResyncResponse>> {
let resync = state
.resync()
.ok_or_else(|| AppError::ServiceUnavailable(
"crawler daemon is disabled; force resync unavailable".into(),
))?;
// Look up the manga the chapter belongs to so we can return the
// refreshed chapter row in the response and 404 for unknown ids.
let manga_id: Option<Uuid> =
sqlx::query_scalar("SELECT manga_id FROM chapters WHERE id = $1")
.bind(chapter_id)
.fetch_optional(&state.db)
.await?;
let Some(manga_id) = manga_id else {
return Err(AppError::NotFound);
};
let outcome = resync
.resync_chapter(chapter_id)
.await
.map_err(map_resync_err)?;
let (outcome_str, pages) = match &outcome {
ChapterResyncOutcome::Fetched { pages, .. } => ("fetched", Some(*pages)),
ChapterResyncOutcome::Skipped { .. } => ("skipped", None),
};
repo::admin_audit::insert(
&state.db,
admin.0.id,
"chapter_resync",
"chapter",
Some(chapter_id),
json!({
"outcome": outcome_str,
"pages": pages,
}),
)
.await?;
let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(ChapterResyncResponse {
chapter,
outcome: outcome_str,
pages,
}))
}
fn status_str(s: UpsertStatus) -> &'static str {
match s {
UpsertStatus::New => "new",
UpsertStatus::Updated => "updated",
UpsertStatus::Unchanged => "unchanged",
}
}
/// Map [`ResyncError`] (and the anyhow envelopes wrapping it) onto the
/// right [`AppError`]. Anything else surfaces as a generic 500 via the
/// `Other` arm — the operator sees the underlying anyhow chain in
/// server logs, the client sees a clean envelope.
fn map_resync_err(err: anyhow::Error) -> AppError {
if let Some(rerr) = err.downcast_ref::<ResyncError>() {
match rerr {
ResyncError::NoMangaSource => AppError::ValidationFailed {
message: "manga has no live crawler source — cannot resync".into(),
details: json!({ "manga": "no_source" }),
},
ResyncError::NoChapterSource => AppError::ValidationFailed {
message: "chapter has no live crawler source — cannot resync".into(),
details: json!({ "chapter": "no_source" }),
},
}
} else {
AppError::Other(err)
}
}

View File

@@ -0,0 +1,239 @@
//! Admin endpoints for runtime-editable crawler / analysis configuration.
//!
//! `GET` returns the current editable settings DTO plus a read-only view of
//! the env-managed (host/infra + secret) fields, so the dashboard can show
//! the full picture while only letting the operator edit the safe knobs.
//!
//! `PUT` validates the incoming DTO against the env-derived base (re-parsing
//! timezone/time, rebuilding the download allowlist), persists a normalized
//! DTO and an audit row in one transaction, then asks the [`DaemonReloader`]
//! to gracefully respawn the affected daemon with the new config — so the
//! change takes effect without a restart. Validation failures return 422 with
//! per-field details (the established `validation_failed` envelope).
//!
//! `PUT` is a **full replace**, not a patch: the body is deserialized with
//! field-level defaults, so any omitted field is reset to its compiled
//! default rather than retaining the stored value. The dashboard always
//! sends the complete DTO (it round-trips the one it loaded); programmatic
//! callers must do the same.
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use serde::Serialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::config::CrawlerConfig;
use crate::crawler::browser::BrowserMode;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::settings::{
AnalysisSettings, CrawlerSettings, FieldErrors, PromptDefaults, KEY_ANALYSIS, KEY_CRAWLER,
};
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/admin/settings/crawler",
get(get_crawler).put(put_crawler),
)
.route(
"/admin/settings/analysis",
get(get_analysis).put(put_analysis),
)
}
// ---------------------------------------------------------------------------
// Response shapes
// ---------------------------------------------------------------------------
/// Read-only mirror of the crawler fields managed via environment (host/infra
/// + secrets), surfaced so the admin sees what's in effect without editing it.
#[derive(Debug, Serialize)]
struct CrawlerEnvOnly {
browser_mode: &'static str,
browser_args: Vec<String>,
proxy: Option<String>,
tor_control_url: Option<String>,
/// Whether a TOR control credential (password or cookie file) is set.
tor_credentials_configured: bool,
/// Whether an initial `CRAWLER_PHPSESSID` is configured (the live session
/// is managed separately via the crawler dashboard).
session_configured: bool,
}
impl CrawlerEnvOnly {
fn from_base(base: &CrawlerConfig) -> Self {
Self {
browser_mode: match base.browser.mode {
BrowserMode::Headed => "headed",
BrowserMode::Headless => "headless",
},
browser_args: base.browser.extra_args.clone(),
proxy: base.proxy.clone(),
tor_control_url: base.tor_control_url.clone(),
tor_credentials_configured: base.tor_control_password.is_some()
|| base.tor_control_cookie_path.is_some(),
session_configured: base.phpsessid.is_some(),
}
}
}
#[derive(Debug, Serialize)]
struct CrawlerSettingsResponse {
editable: CrawlerSettings,
env_only: CrawlerEnvOnly,
}
/// Read-only mirror of analysis env-managed fields (just the secret).
#[derive(Debug, Serialize)]
struct AnalysisEnvOnly {
api_key_configured: bool,
}
#[derive(Debug, Serialize)]
struct AnalysisSettingsResponse {
editable: AnalysisSettings,
env_only: AnalysisEnvOnly,
/// The compiled prompt defaults so the UI can show placeholders and
/// implement per-prompt "reset to default".
prompt_defaults: PromptDefaults,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
fn validation_error(errs: FieldErrors) -> AppError {
AppError::ValidationFailed {
message: "invalid settings".to_string(),
details: serde_json::json!({ "fields": errs.errors }),
}
}
/// Current effective crawler settings: the stored DTO when present, otherwise
/// derived from the env base (the row is seeded at boot, so the stored branch
/// is the norm).
async fn load_crawler_dto(state: &AppState) -> AppResult<CrawlerSettings> {
Ok(match repo::app_settings::get(&state.db, KEY_CRAWLER).await? {
Some(v) => serde_json::from_value(v)
.unwrap_or_else(|_| CrawlerSettings::from_config(&state.crawler_base)),
None => CrawlerSettings::from_config(&state.crawler_base),
})
}
async fn load_analysis_dto(state: &AppState) -> AppResult<AnalysisSettings> {
Ok(match repo::app_settings::get(&state.db, KEY_ANALYSIS).await? {
Some(v) => serde_json::from_value(v)
.unwrap_or_else(|_| AnalysisSettings::from_config(&state.analysis_base)),
None => AnalysisSettings::from_config(&state.analysis_base),
})
}
async fn get_crawler(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<CrawlerSettingsResponse>> {
let editable = load_crawler_dto(&state).await?;
Ok(Json(CrawlerSettingsResponse {
editable,
env_only: CrawlerEnvOnly::from_base(&state.crawler_base),
}))
}
async fn put_crawler(
State(state): State<AppState>,
admin: RequireAdmin,
Json(incoming): Json<CrawlerSettings>,
) -> AppResult<Json<CrawlerSettingsResponse>> {
// Validate by converting against the env base; this also rebuilds the
// download allowlist and re-parses tz / time.
let cfg = incoming
.to_config(&state.crawler_base)
.map_err(validation_error)?;
// Persist a normalized DTO (so the stored/returned allowlist reflects the
// effective hosts, prompts are canonicalized, etc.).
let normalized = CrawlerSettings::from_config(&cfg);
let value = serde_json::to_value(&normalized).map_err(|e| AppError::Other(e.into()))?;
let mut tx = state.db.begin().await?;
repo::app_settings::upsert(&mut *tx, KEY_CRAWLER, &value).await?;
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"update_crawler_settings",
"settings",
None,
value.clone(),
)
.await?;
tx.commit().await?;
// Apply live (graceful respawn) when a reloader is wired up.
if let Some(reloader) = &state.reloader {
reloader
.reload_crawler(cfg)
.await
.map_err(AppError::Other)?;
}
Ok(Json(CrawlerSettingsResponse {
editable: normalized,
env_only: CrawlerEnvOnly::from_base(&state.crawler_base),
}))
}
async fn get_analysis(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<AnalysisSettingsResponse>> {
let editable = load_analysis_dto(&state).await?;
Ok(Json(AnalysisSettingsResponse {
editable,
env_only: AnalysisEnvOnly {
api_key_configured: state.analysis_base.api_key.is_some(),
},
prompt_defaults: PromptDefaults::get(),
}))
}
async fn put_analysis(
State(state): State<AppState>,
admin: RequireAdmin,
Json(incoming): Json<AnalysisSettings>,
) -> AppResult<Json<AnalysisSettingsResponse>> {
let cfg = incoming
.to_config(&state.analysis_base)
.map_err(validation_error)?;
let normalized = AnalysisSettings::from_config(&cfg);
let value = serde_json::to_value(&normalized).map_err(|e| AppError::Other(e.into()))?;
let mut tx = state.db.begin().await?;
repo::app_settings::upsert(&mut *tx, KEY_ANALYSIS, &value).await?;
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"update_analysis_settings",
"settings",
None,
value.clone(),
)
.await?;
tx.commit().await?;
if let Some(reloader) = &state.reloader {
reloader
.reload_analysis(cfg)
.await
.map_err(AppError::Other)?;
}
Ok(Json(AnalysisSettingsResponse {
editable: normalized,
env_only: AnalysisEnvOnly {
api_key_configured: state.analysis_base.api_key.is_some(),
},
prompt_defaults: PromptDefaults::get(),
}))
}

View File

@@ -0,0 +1,253 @@
//! Admin storage-usage stats + the one-shot size backfill.
//!
//! Kept separate from `/admin/system` on purpose: that handler eats a
//! 250 ms CPU sample on every call. Storage figures are pure DB
//! aggregates with no such cost, so they get their own endpoint.
//!
//! Both handlers are admin-only (`RequireAdmin`, cookie-only).
use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::domain::StorageStats;
use crate::error::AppResult;
use crate::repo;
use crate::repo::storage_stats::BACKFILL_BATCH;
use crate::storage::StorageError;
/// How many entries each leaderboard returns.
const TOP_N: i64 = 5;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/admin/storage", get(storage))
.route("/admin/storage/backfill", post(backfill))
}
async fn storage(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<StorageStats>> {
// Independent reads — run concurrently so dashboard latency is the
// slowest query, not their sum.
let (totals, averages, top_mangas, top_chapters, unmeasured) = tokio::try_join!(
repo::storage_stats::totals(&state.db),
repo::storage_stats::averages(&state.db),
repo::storage_stats::top_mangas(&state.db, TOP_N),
repo::storage_stats::top_chapters(&state.db, TOP_N),
repo::storage_stats::unmeasured_counts(&state.db),
)?;
let (covers_bytes, chapters_bytes) = totals;
let (avg_image_bytes, avg_cover_bytes, avg_chapter_page_bytes) = averages;
let (unmeasured_pages, unmeasured_covers) = unmeasured;
let total_bytes = covers_bytes + chapters_bytes;
// Reuse the System tab's statvfs formula so the disk total and the
// ratio can never drift apart.
let disk_total_bytes = state
.storage
.local_root()
.and_then(super::system::disk_stats_for)
.map(|d| d.total_bytes);
let ratio_of_disk = disk_total_bytes.and_then(|d| {
if d > 0 {
Some(total_bytes as f64 / d as f64)
} else {
None
}
});
Ok(Json(StorageStats {
total_bytes,
covers_bytes,
chapters_bytes,
disk_total_bytes,
ratio_of_disk,
avg_image_bytes,
avg_cover_bytes,
avg_chapter_page_bytes,
top_mangas,
top_chapters,
unmeasured_pages,
unmeasured_covers,
}))
}
/// Upper bound on blobs stat-ed in a single backfill request. Each stat
/// is a syscall (or, for a future S3 backend, a network round-trip), so
/// an unbounded run over a huge legacy library could exceed a proxy /
/// client timeout. Capping keeps each request bounded in wall-clock; the
/// run is idempotent and `more_remaining` tells the caller to run again.
const MAX_PER_RUN: usize = 20_000;
#[derive(Debug, Serialize)]
pub struct BackfillResponse {
/// Pages whose size was written this run (rows actually updated).
pub pages: usize,
/// Covers whose size was written this run.
pub covers: usize,
/// Blobs the row points at but storage reports as absent
/// (deleted/orphaned). Expected during cleanup; left unmeasured.
pub missing: usize,
/// Blobs that errored for another reason (bad key, transient IO).
/// Distinct from `missing` so an operator doesn't mistake a retryable
/// failure for a genuinely orphaned blob. Re-running may resolve these.
pub errored: usize,
/// `true` when the per-run cap was hit before the backlog drained, so
/// the caller should run the backfill again to finish.
pub more_remaining: bool,
}
/// Idempotent: only touches unmeasured rows (`size_bytes IS NULL`,
/// `cover_size_bytes IS NULL`). Keyset-paginated in batches so the whole
/// backlog is never held in memory; capped at `MAX_PER_RUN` stats so the
/// request stays bounded in wall-clock. The batched UPDATEs are
/// `IS NULL`-guarded so a concurrent write is never clobbered, and the
/// reported counts are the rows actually written. Blobs that can't be
/// stat-ed are counted and skipped — never fatal — so a single orphan
/// doesn't abort the run.
async fn backfill(
State(state): State<AppState>,
admin: RequireAdmin,
) -> AppResult<Json<BackfillResponse>> {
let mut resp = BackfillResponse {
pages: 0,
covers: 0,
missing: 0,
errored: 0,
more_remaining: false,
};
// Stats attempted this run, across pages + covers, against the cap.
let mut budget = MAX_PER_RUN;
// Pages. `pages_after` ends at the max id processed (rows are scanned in
// id order), so it cleanly separates attempted (id <= cursor) from
// not-yet-reached (id > cursor) rows.
let mut pages_after = Uuid::nil();
while budget > 0 {
let limit = (budget as i64).min(BACKFILL_BATCH);
let batch =
repo::storage_stats::pages_needing_backfill_after(&state.db, pages_after, limit)
.await?;
if batch.is_empty() {
break;
}
let mut ids = Vec::with_capacity(batch.len());
let mut sizes = Vec::with_capacity(batch.len());
for (id, key) in &batch {
pages_after = *id; // advance past every row, including failures
budget -= 1;
if let Some(size) =
classify(state.storage.size(key).await, &mut resp.missing, &mut resp.errored)
{
ids.push(*id);
sizes.push(size);
}
}
resp.pages +=
repo::storage_stats::batch_set_page_sizes(&state.db, &ids, &sizes).await? as usize;
}
// Covers.
let mut covers_after = Uuid::nil();
while budget > 0 {
let limit = (budget as i64).min(BACKFILL_BATCH);
let batch =
repo::storage_stats::covers_needing_backfill_after(&state.db, covers_after, limit)
.await?;
if batch.is_empty() {
break;
}
let mut ids = Vec::with_capacity(batch.len());
let mut sizes = Vec::with_capacity(batch.len());
for (id, key) in &batch {
covers_after = *id;
budget -= 1;
if let Some(size) =
classify(state.storage.size(key).await, &mut resp.missing, &mut resp.errored)
{
ids.push(*id);
sizes.push(size);
}
}
resp.covers +=
repo::storage_stats::batch_set_cover_sizes(&state.db, &ids, &sizes).await? as usize;
}
// `more_remaining` = the scan stopped at the cap AND unmeasured rows
// remain *beyond the cursor we reached*. The keyset peek (LIMIT 1 past
// the cursor) excludes rows we already attempted this run, so a
// permanently un-stat-able orphan we passed over (id <= cursor) never
// keeps this true — it converges instead of looping forever. It's also
// exact at the boundary: a backlog of exactly the cap drains to "no rows
// past the cursor" → false (no spurious follow-up prompt).
if budget == 0 {
let more_pages = !repo::storage_stats::pages_needing_backfill_after(
&state.db,
pages_after,
1,
)
.await?
.is_empty();
let more_covers = !repo::storage_stats::covers_needing_backfill_after(
&state.db,
covers_after,
1,
)
.await?
.is_empty();
resp.more_remaining = more_pages || more_covers;
}
// Audit is written after the action here *by necessity*, not oversight
// (unlike reenqueue/analyze_page, which wrap their single DB mutation +
// audit in one tx). The backfill is a budgeted scan that interleaves
// filesystem `storage.size()` reads with per-batch DB writes across many
// iterations; wrapping it in a transaction would hold one open across all
// that I/O (a long-running tx). The batch size-writes are also idempotent
// recomputations, so a lost audit row has negligible impact.
repo::admin_audit::insert(
&state.db,
admin.0.id,
"storage_backfill",
"system",
None,
json!({
"pages": resp.pages,
"covers": resp.covers,
"missing": resp.missing,
"errored": resp.errored,
}),
)
.await?;
Ok(Json(resp))
}
/// Turn a `Storage::size` result into the byte count to persist, bumping
/// the right counter on failure. `NotFound` → `missing` (orphaned);
/// anything else (bad key, IO) → `errored` (possibly retryable).
fn classify(
result: Result<u64, StorageError>,
missing: &mut usize,
errored: &mut usize,
) -> Option<i64> {
match result {
Ok(size) => Some(size as i64),
Err(StorageError::NotFound) => {
*missing += 1;
None
}
Err(e) => {
tracing::warn!(error = ?e, "storage backfill: cannot stat blob");
*errored += 1;
None
}
}
}

View File

@@ -18,7 +18,7 @@ use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use serde::Serialize;
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
@@ -35,6 +35,10 @@ pub struct SystemStats {
pub disk: Option<DiskStats>,
pub memory: MemoryStats,
pub cpu: CpuStats,
/// Hardware temperature sensors. Empty when the platform doesn't
/// expose any (common inside containers without `/sys` access) —
/// the frontend renders an "unavailable" state, not an error.
pub temperatures: Vec<TempStat>,
pub alerts: Vec<Alert>,
}
@@ -56,6 +60,26 @@ pub struct MemoryStats {
#[derive(Debug, Serialize)]
pub struct CpuStats {
pub percent_used: f64,
pub load_avg: LoadAvg,
/// Per-core usage percentages, one entry per logical core.
pub per_core: Vec<f64>,
}
#[derive(Debug, Serialize)]
pub struct LoadAvg {
pub one: f64,
pub five: f64,
pub fifteen: f64,
}
#[derive(Debug, Serialize)]
pub struct TempStat {
pub label: String,
pub celsius: f64,
/// Highest reading seen since boot, when the kernel exposes it.
pub max_celsius: Option<f64>,
/// Vendor critical/shutdown threshold, when available.
pub critical_celsius: Option<f64>,
}
#[derive(Debug, Serialize)]
@@ -76,6 +100,7 @@ async fn system(
) -> AppResult<Json<SystemStats>> {
let disk = state.storage.local_root().and_then(disk_stats_for);
let (memory, cpu) = memory_and_cpu().await;
let temperatures = temperatures();
let mut alerts = Vec::new();
if let Some(d) = &disk {
if d.percent_used >= ALERT_THRESHOLD_PERCENT {
@@ -97,15 +122,69 @@ async fn system(
),
});
}
for t in &temperatures {
if let Some(crit) = t.critical_celsius {
if t.celsius >= crit {
alerts.push(Alert {
level: AlertLevel::Warning,
message: format!(
"{} temperature critical ({:.0}°C / {:.0}°C)",
t.label, t.celsius, crit
),
});
}
}
}
Ok(Json(SystemStats {
disk,
memory,
cpu,
temperatures,
alerts,
}))
}
fn disk_stats_for(root: &Path) -> Option<DiskStats> {
/// Hardware temperature sensors via `sysinfo`'s `Components`. Components
/// whose temperature can't be read report `f32::NAN` on Linux — we skip
/// those so the dashboard never shows a bogus "NaN°C" row. Returns an
/// empty Vec when no sensors are exposed at all.
fn temperatures() -> Vec<TempStat> {
let components = Components::new_with_refreshed_list();
components
.list()
.iter()
.filter_map(|c| {
let celsius = c.temperature();
if celsius.is_nan() {
return None;
}
let max = c.max();
Some(TempStat {
label: c.label().to_string(),
celsius: celsius as f64,
max_celsius: (!max.is_nan()).then_some(max as f64),
critical_celsius: c.critical().map(|v| v as f64),
})
})
.collect()
}
/// Lightweight memory-utilization percentage for the health check. Unlike
/// [`memory_and_cpu`] this skips CPU sampling and its 250ms settle delay —
/// health is polled more often and only needs the memory figure.
pub(crate) fn memory_percent_used() -> f64 {
let mut sys =
System::new_with_specifics(RefreshKind::new().with_memory(MemoryRefreshKind::everything()));
sys.refresh_memory();
let total = sys.total_memory();
if total == 0 {
0.0
} else {
(sys.used_memory() as f64) * 100.0 / (total as f64)
}
}
pub(crate) fn disk_stats_for(root: &Path) -> Option<DiskStats> {
let s = nix::sys::statvfs::statvfs(root).ok()?;
// statvfs reports `f_frsize * f_blocks` for total bytes. `f_bavail`
// is "free to non-root callers" which is what an operator actually
@@ -156,8 +235,15 @@ async fn memory_and_cpu() -> (MemoryStats, CpuStats) {
percent_used: mem_pct,
};
let load = System::load_average();
let cpu = CpuStats {
percent_used: sys.global_cpu_usage() as f64,
load_avg: LoadAvg {
one: load.one,
five: load.five,
fifteen: load.fifteen,
},
per_core: sys.cpus().iter().map(|c| c.cpu_usage() as f64).collect(),
};
(memory, cpu)
}

View File

@@ -16,7 +16,7 @@ use crate::api::auth::{validate_password, validate_username};
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::auth::password::hash_password;
use crate::auth::password::hash_password_async;
use crate::domain::User;
use crate::error::{AppError, AppResult};
use crate::repo;
@@ -115,7 +115,7 @@ async fn create_user(
// reject (and vice versa).
validate_username(username)?;
validate_password(&input.password)?;
let pwhash = hash_password(&input.password)?;
let pwhash = hash_password_async(input.password.clone()).await?;
let user = repo::user::admin_create_user(
&state.db,
actor.id,

View File

@@ -17,8 +17,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME};
use crate::auth::password::{hash_password, verify_password};
use crate::auth::extractor::{ClientIp, CurrentUser, SESSION_COOKIE_NAME};
use crate::auth::password::{hash_password_async, verify_password_async};
use crate::auth::token::{generate_token, hash_token};
use crate::config::AuthConfig;
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
@@ -38,22 +38,26 @@ 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))
}
/// Public, unauthenticated. Exposes anonymous-relevant auth policy
/// (currently just whether self-registration is open) so the frontend
/// can render its login / register affordances correctly without a
/// probe request that would conflate "disabled" with "rate-limited".
/// Public, unauthenticated. Exposes anonymous-relevant auth policy so
/// the frontend can render its login / register affordances correctly
/// without a probe request that would conflate "disabled" with
/// "rate-limited". `self_register_enabled` is the *effective* value
/// (`allow_self_register && !private_mode`), so a private-mode
/// instance reports `false` even if the raw flag is on.
#[derive(Debug, Serialize)]
pub struct AuthConfigResponse {
pub self_register_enabled: bool,
pub private_mode: bool,
}
async fn auth_config(State(state): State<AppState>) -> Json<AuthConfigResponse> {
Json(AuthConfigResponse {
self_register_enabled: state.auth.allow_self_register,
self_register_enabled: state.auth.allow_self_register && !state.auth.private_mode,
private_mode: state.auth.private_mode,
})
}
@@ -71,8 +75,16 @@ pub struct AuthResponse {
#[derive(Debug, Deserialize)]
pub struct CreateTokenInput {
pub name: String,
/// Optional lifetime in days. Omit (or `null`) for a non-expiring
/// token (the historical behaviour). When set, must be 1..=3650.
#[serde(default)]
pub expires_in_days: Option<i64>,
}
/// Upper bound on a requested token lifetime (~10 years). A token that needs
/// to outlive this should be rotated, not minted once forever.
const MAX_TOKEN_EXPIRY_DAYS: i64 = 3650;
#[derive(Debug, Deserialize)]
pub struct ChangePassword {
pub current_password: String,
@@ -95,6 +107,7 @@ pub struct CreatedTokenResponse {
async fn register(
State(state): State<AppState>,
ClientIp(client_ip): ClientIp,
jar: CookieJar,
Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> {
@@ -102,15 +115,18 @@ async fn register(
// the toggle can't be probed for the toggle state via timing —
// disabled and enabled paths both consume a token, and disabled
// returns 403 instead of running argon2.
check_auth_rate_limit(&state, "register")?;
if !state.auth.allow_self_register {
check_auth_rate_limit(&state, "register", client_ip)?;
// Private mode force-blocks self-registration regardless of
// ALLOW_SELF_REGISTER — operators of locked-down instances mint
// accounts via `POST /admin/users` instead.
if !state.auth.allow_self_register || state.auth.private_mode {
return Err(AppError::Forbidden);
}
let username = input.username.trim();
validate_username(username)?;
validate_password(&input.password)?;
let pwhash = hash_password(&input.password)?;
let pwhash = hash_password_async(input.password.clone()).await?;
let user = repo::user::create(&state.db, username, &pwhash).await?;
let jar = start_session(&state, &user, jar).await?;
Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
@@ -118,16 +134,21 @@ async fn register(
async fn login(
State(state): State<AppState>,
ClientIp(client_ip): ClientIp,
jar: CookieJar,
Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> {
check_auth_rate_limit(&state, "login")?;
check_auth_rate_limit(&state, "login", client_ip)?;
let username = input.username.trim();
if username.is_empty() || input.password.is_empty() {
return Err(AppError::InvalidInput(
"username and password are required".into(),
));
}
// Bound the password before argon2 runs — guards BOTH the real-verify and
// the dummy-hash timing-equaliser branch below, so a giant password can't
// make every login attempt a CPU-DoS.
reject_oversized_password(&input.password)?;
let user = repo::user::find_by_username(&state.db, username).await?;
let Some(user) = user else {
@@ -135,10 +156,11 @@ async fn login(
// response time matches the wrong-password branch — otherwise
// an attacker can enumerate usernames by timing the no-user
// 401 against the wrong-password 401.
let _ = verify_password(&input.password, dummy_password_hash());
let _ = verify_password_async(input.password.clone(), dummy_password_hash().to_string())
.await;
return Err(AppError::Unauthenticated);
};
if !verify_password(&input.password, &user.password_hash) {
if !verify_password_async(input.password.clone(), user.password_hash.clone()).await {
return Err(AppError::Unauthenticated);
}
@@ -194,16 +216,20 @@ async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
async fn change_password(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
ClientIp(client_ip): ClientIp,
jar: CookieJar,
Json(input): Json<ChangePassword>,
) -> AppResult<impl IntoResponse> {
check_auth_rate_limit(&state, "change_password")?;
if !verify_password(&input.current_password, &user.password_hash) {
check_auth_rate_limit(&state, "change_password", client_ip)?;
// Cap current_password before verify_password runs argon2 (same DoS
// vector as login). new_password is bounded by validate_password below.
reject_oversized_password(&input.current_password)?;
if !verify_password_async(input.current_password.clone(), user.password_hash.clone()).await {
return Err(AppError::Unauthenticated);
}
validate_password(&input.new_password)?;
let new_hash = hash_password(&input.new_password)?;
let new_hash = hash_password_async(input.new_password.clone()).await?;
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
@@ -273,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,
@@ -298,8 +340,22 @@ async fn create_token(
details: serde_json::json!({ "name": "max 64 characters" }),
});
}
let expires_at = match input.expires_in_days {
None => None,
Some(days) if (1..=MAX_TOKEN_EXPIRY_DAYS).contains(&days) => {
Some(Utc::now() + Duration::days(days))
}
Some(_) => {
return Err(AppError::ValidationFailed {
message: "token expiry out of range".into(),
details: serde_json::json!({
"expires_in_days": format!("must be between 1 and {MAX_TOKEN_EXPIRY_DAYS}")
}),
});
}
};
let (raw, hash) = generate_token();
let token = repo::api_token::create(&state.db, user.id, name, &hash).await?;
let token = repo::api_token::create(&state.db, user.id, name, &hash, expires_at).await?;
Ok((
StatusCode::CREATED,
Json(CreatedTokenResponse { token, bearer: raw }),
@@ -380,9 +436,13 @@ fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> {
/// any one of them in a tight loop should trip the limit. `endpoint`
/// is included in the rate-limit-hit log line so operators can tell
/// which endpoint is being probed.
fn check_auth_rate_limit(state: &AppState, endpoint: &'static str) -> AppResult<()> {
fn check_auth_rate_limit(
state: &AppState,
endpoint: &'static str,
client_ip: Option<std::net::IpAddr>,
) -> AppResult<()> {
use crate::auth::rate_limit::AcquireResult;
match state.auth_limiter.try_acquire() {
match state.auth_limiter.try_acquire(client_ip) {
AcquireResult::Allowed => Ok(()),
AcquireResult::Denied { retry_after_secs } => {
tracing::warn!(
@@ -418,11 +478,67 @@ pub(crate) fn validate_username(u: &str) -> AppResult<()> {
Ok(())
}
/// Upper bound on password length (bytes). argon2 has no inherent length
/// limit, so without a cap an attacker could submit a multi-megabyte password
/// and make every hash a CPU-heavy DoS. 1024 bytes is far longer than any real
/// passphrase. Measured in bytes (like the min check) since that's what argon2
/// actually processes.
pub(crate) const MAX_PASSWORD_BYTES: usize = 1024;
/// Reject an over-cap password *before* any argon2 work runs. The
/// verification paths (login, change-password) don't go through
/// [`validate_password`] — they hash/verify the raw input — so without this
/// an attacker could submit a multi-megabyte password and turn every
/// login/verify into a CPU-DoS. Length-only and independent of whether the
/// account exists, so it leaks nothing (no username enumeration).
pub(crate) fn reject_oversized_password(p: &str) -> AppResult<()> {
if p.len() > MAX_PASSWORD_BYTES {
return Err(AppError::InvalidInput(format!(
"password must be at most {MAX_PASSWORD_BYTES} bytes"
)));
}
Ok(())
}
pub(crate) fn validate_password(p: &str) -> AppResult<()> {
if p.len() < 8 {
return Err(AppError::InvalidInput(
"password must be at least 8 characters".into(),
));
}
if p.len() > MAX_PASSWORD_BYTES {
return Err(AppError::InvalidInput(
format!("password must be at most {MAX_PASSWORD_BYTES} bytes"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_password_rejects_too_short() {
assert!(validate_password("short").is_err());
assert!(validate_password("1234567").is_err()); // 7 chars
}
#[test]
fn validate_password_accepts_in_range() {
assert!(validate_password("hunter2hunter2").is_ok());
// Exactly at the cap is allowed.
assert!(validate_password(&"a".repeat(MAX_PASSWORD_BYTES)).is_ok());
// Minimum length boundary.
assert!(validate_password("12345678").is_ok());
}
#[test]
fn validate_password_rejects_over_cap() {
// One byte past the cap is refused so a giant password can't turn each
// login/register into an argon2 CPU-DoS.
let too_long = "a".repeat(MAX_PASSWORD_BYTES + 1);
let err = validate_password(&too_long).unwrap_err();
assert!(matches!(err, AppError::InvalidInput(_)));
}
}

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

@@ -13,7 +13,7 @@ use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::api::mangas::{next_field, read_field_bytes};
use crate::api::mangas::next_field;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
@@ -21,7 +21,8 @@ use crate::domain::chapter::NewChapter;
use crate::domain::{Chapter, Page};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::upload::{parse_image, UploadedImage};
use crate::storage::Storage;
use crate::upload::{stage_image_part, StagedImage};
pub fn routes() -> Router<AppState> {
Router::new()
@@ -69,6 +70,21 @@ async fn get_one(
Ok(Json(chapter))
}
/// Add a chapter to a manga.
///
/// **Authorization is intentionally open**: any authenticated principal —
/// a browser session *or* a bot API token — may add a chapter to *any*
/// manga, including crawler-imported rows. This is by design: chapters are
/// treated as community contributions, unlike the manga record itself
/// (title/cover/metadata), whose edits gate through `require_can_edit`
/// (see [`crate::api::mangas`]). The `CurrentUser` binding still requires a
/// valid identity, so contributions are attributable, just not owner-scoped.
///
/// This contract is locked by `tests/api_chapters.rs`
/// (`non_owner_can_upload_chapter`); changing it to owner-only is a
/// deliberate decision, not a drive-by tightening. Until a richer
/// contributor/moderation model lands this is acknowledged, intended
/// behaviour — see the auth note in CLAUDE.md.
async fn create(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
@@ -77,86 +93,209 @@ async fn create(
) -> AppResult<(StatusCode, Json<Chapter>)> {
repo::manga::get(&state.db, manga_id).await?;
// Each `page` part is streamed straight to a staging key as it arrives,
// so at most one page's bytes sit in memory — the whole chapter is never
// buffered (previously every page was held at once, bounded only by the
// 200 MiB body limit and amplified by concurrency). The staged blobs are
// promoted to their final chapter-scoped keys in `finalize_chapter` once
// the chapter id exists; any early exit cleans them up.
let upload_id = Uuid::new_v4();
let mut metadata: Option<NewChapter> = None;
let mut pages: Vec<UploadedImage> = Vec::new();
let mut staged: Vec<StagedImage> = Vec::new();
while let Some(field) = next_field(&mut multipart).await? {
match field.name() {
Some("metadata") => {
let bytes = read_field_bytes(field).await?;
metadata =
Some(serde_json::from_slice(&bytes).map_err(|e| {
let stage_result: AppResult<NewChapter> = async {
while let Some(field) = next_field(&mut multipart).await? {
match field.name() {
Some("metadata") => {
let bytes = crate::upload::read_capped(
field,
crate::upload::MAX_METADATA_BYTES,
"metadata",
)
.await?;
metadata = Some(serde_json::from_slice(&bytes).map_err(|e| {
AppError::ValidationFailed {
message: "metadata is not valid JSON".into(),
details: json!({ "metadata": e.to_string() }),
}
})?);
}
Some("page") => {
if state.upload.max_pages_per_chapter != 0
&& staged.len() >= state.upload.max_pages_per_chapter
{
return Err(AppError::PayloadTooLarge(format!(
"chapter exceeds the {}-page limit",
state.upload.max_pages_per_chapter
)));
}
let field_name = format!("page[{}]", staged.len());
let img = stage_image_part(
state.storage.as_ref(),
field,
upload_id,
staged.len(),
state.upload.max_file_bytes,
&field_name,
)
.await?;
staged.push(img);
}
_ => continue,
}
Some("page") => {
let bytes = read_field_bytes(field).await?.to_vec();
let field_name = format!("page[{}]", pages.len());
pages.push(parse_image(bytes, state.upload.max_file_bytes, &field_name)?);
}
_ => continue,
}
}
let metadata = metadata.ok_or_else(|| AppError::ValidationFailed {
message: "metadata part is required".into(),
details: json!({ "metadata": "required" }),
})?;
// Chapter number is 1-indexed everywhere (URLs, upload form,
// reader). Reject 0 / negative numbers up front so the row never
// makes it into the DB. Mirrors the page>=1 rule on bookmarks.
if metadata.number < 1 {
return Err(AppError::ValidationFailed {
message: "chapter number must be 1 or greater".into(),
details: json!({ "number": "must be >= 1" }),
});
}
if pages.is_empty() {
return Err(AppError::ValidationFailed {
message: "at least one page is required".into(),
details: json!({ "page": "at least one required" }),
});
let metadata = metadata.take().ok_or_else(|| AppError::ValidationFailed {
message: "metadata part is required".into(),
details: json!({ "metadata": "required" }),
})?;
// Chapter number is 1-indexed everywhere (URLs, upload form,
// reader). Reject 0 / negative numbers up front so the row never
// makes it into the DB. Mirrors the page>=1 rule on bookmarks.
if metadata.number < 1 {
return Err(AppError::ValidationFailed {
message: "chapter number must be 1 or greater".into(),
details: json!({ "number": "must be >= 1" }),
});
}
if staged.is_empty() {
return Err(AppError::ValidationFailed {
message: "at least one page is required".into(),
details: json!({ "page": "at least one required" }),
});
}
Ok(metadata)
}
.await;
// Transactional create. If any storage put or page-row insert
// fails mid-loop, the chapter row + any earlier page rows are
// rolled back so we don't leave a chapter with stale page_count=0
// and orphaned page rows. Bytes already written to storage on a
// rolled-back transaction become orphans on disk; a future reaper
// can sweep them. DB consistency wins over storage tidiness here.
let mut tx = state.db.begin().await?;
let mut chapter = repo::chapter::create(
let metadata = match stage_result {
Ok(m) => m,
Err(e) => {
// Reject before any DB write — remove every page we staged.
cleanup_staging(state.storage.as_ref(), &staged).await;
return Err(e);
}
};
finalize_chapter(&state, manga_id, user.id, &metadata, &staged).await
}
/// Promote the staged pages into a real chapter, all-or-nothing. Creates the
/// chapter row, renames each staged blob to its final chapter-scoped key,
/// and inserts the page rows in one transaction. On any failure the DB rolls
/// back and every blob (already-finalized and still-staged) is removed, so a
/// rejected upload leaves neither partial rows nor orphaned files.
async fn finalize_chapter(
state: &AppState,
manga_id: Uuid,
user_id: Uuid,
metadata: &NewChapter,
staged: &[StagedImage],
) -> AppResult<(StatusCode, Json<Chapter>)> {
let storage = state.storage.as_ref();
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
cleanup_staging(storage, staged).await;
return Err(e.into());
}
};
let mut chapter = match repo::chapter::create(
&mut *tx,
manga_id,
metadata.number,
metadata.title.as_deref(),
Some(user.id),
Some(user_id),
)
.await?;
.await
{
Ok(c) => c,
Err(e) => {
cleanup_staging(storage, staged).await;
return Err(e);
}
};
for (idx, page) in pages.iter().enumerate() {
let mut page_ids: Vec<Uuid> = Vec::with_capacity(staged.len());
let mut finalized: Vec<String> = Vec::with_capacity(staged.len());
for (idx, page) in staged.iter().enumerate() {
let page_number = (idx + 1) as i32;
let nnnn = format!("{:04}", page_number);
let key = format!(
"mangas/{}/chapters/{}/pages/{}.{}",
manga_id, chapter.id, nnnn, page.ext
let final_key = format!(
"mangas/{}/chapters/{}/pages/{:04}.{}",
manga_id, chapter.id, page_number, page.ext
);
state.storage.put(&key, &page.bytes).await?;
repo::page::create(&mut *tx, chapter.id, page_number, &key, page.mime).await?;
if let Err(e) = storage.rename(&page.staging_key, &final_key).await {
cleanup_keys(storage, &finalized).await;
cleanup_staging(storage, &staged[idx..]).await;
return Err(e.into());
}
finalized.push(final_key.clone());
match repo::page::create(
&mut *tx,
chapter.id,
page_number,
&final_key,
page.mime,
page.size_bytes,
)
.await
{
Ok(created) => page_ids.push(created.id),
Err(e) => {
cleanup_keys(storage, &finalized).await;
cleanup_staging(storage, &staged[idx + 1..]).await;
return Err(e);
}
}
}
let page_count = pages.len() as i32;
repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
let page_count = staged.len() as i32;
if let Err(e) = repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await {
cleanup_keys(storage, &finalized).await;
return Err(e);
}
chapter.page_count = page_count;
// `repo::chapter::create` returned the row before any pages existed, so
// its `size_bytes` is a stale 0. Each staged page carried its byte
// length, so their sum is the chapter's true storage — set it on the
// response so the 201 body matches the persisted state.
chapter.size_bytes = Some(staged.iter().map(|p| p.size_bytes).sum());
tx.commit().await?;
if let Err(e) = tx.commit().await {
cleanup_keys(storage, &finalized).await;
return Err(e.into());
}
// Enqueue AI content-analysis for each new page. Done after commit so a
// rolled-back upload never leaves jobs pointing at nonexistent pages; a
// failed enqueue is logged but doesn't fail the upload (the admin
// re-enqueue endpoint can backfill).
if state.analysis_enabled() {
for page_id in page_ids {
if let Err(e) = repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await {
tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis");
}
}
}
Ok((StatusCode::CREATED, Json(chapter)))
}
/// Best-effort removal of staged page blobs after a rejected upload.
async fn cleanup_staging(storage: &dyn Storage, staged: &[StagedImage]) {
for page in staged {
let _ = storage.delete(&page.staging_key).await;
}
}
/// Best-effort removal of already-finalized page blobs when the upload
/// fails after some renames have landed.
async fn cleanup_keys(storage: &dyn Storage, keys: &[String]) {
for key in keys {
let _ = storage.delete(key).await;
}
}
#[derive(Debug, serde::Serialize)]
struct PagesResponse {
pages: Vec<Page>,

View File

@@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::collection::{
Collection, CollectionPatch, CollectionSummary, NewCollection,
Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
};
use crate::domain::manga::Manga;
use crate::domain::patch::Patch;
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
"/collections/:id/mangas/:manga_id",
delete(remove_manga),
)
.route("/collections/:id/pages", get(list_pages).post(add_page))
.route("/collections/:id/pages/:page_id", delete(remove_page))
.route(
"/mangas/:id/my-collections",
get(list_my_collections_containing),
)
.route(
"/pages/:id/my-collections",
get(list_my_collections_containing_page),
)
}
const MAX_NAME_LEN: usize = 64;
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
pub manga_id: Uuid,
}
#[derive(Debug, Deserialize)]
pub struct AddPageBody {
pub page_id: Uuid,
}
#[derive(Debug, Serialize)]
pub struct MangaCollectionIds {
pub collection_ids: Vec<Uuid>,
}
#[derive(Debug, Serialize)]
pub struct PageCollectionIds {
pub collection_ids: Vec<Uuid>,
}
fn validate_name(name: &str) -> AppResult<()> {
let trimmed = name.trim();
if trimmed.is_empty() {
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
Ok(Json(MangaCollectionIds { collection_ids: ids }))
}
async fn list_pages(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<CollectionPageItem>>> {
require_owner_id(&state, user.id, id).await?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::collection::list_pages(&state.db, id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn add_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Json(body): Json<AddPageBody>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, id).await?;
// FK violation in `repo::collection::add_page` maps to NotFound, so
// no separate `repo::page::exists` check is needed — the insert is
// the existence check.
let created = repo::collection::add_page(&state.db, id, body.page_id).await?;
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}
async fn remove_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((collection_id, page_id)): Path<(Uuid, Uuid)>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, collection_id).await?;
repo::collection::remove_page(&state.db, collection_id, page_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_my_collections_containing_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<PageCollectionIds>> {
// Mirrors `list_my_collections_containing` for pages: unknown page
// returns an empty list, not 404 — keeps the endpoint side-effect-
// free and skips a distinguishing-status oracle.
let ids = repo::collection::list_collections_containing_page(&state.db, user.id, page_id)
.await?;
Ok(Json(PageCollectionIds { collection_ids: ids }))
}
/// Returns the row iff the caller owns it. Both "doesn't exist" and
/// "exists but belongs to someone else" surface as `NotFound` so the
/// API doesn't disclose collection existence to non-owners — the

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,41 +23,250 @@
//! 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.
let headers = [
(header::CONTENT_TYPE, ct.to_string()),
(header::CONTENT_LENGTH, file.size_bytes.to_string()),
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 = match tokio::task::spawn_blocking(move || make_thumbnail(&original, width, fmt))
.await
.map_err(|e| AppError::Other(anyhow::anyhow!("thumbnail task join: {e}")))?
{
Ok(t) => t,
Err(e) => {
// The blob's magic bytes passed upload's sniff but the body won't
// decode (corrupt/truncated, or an encoder feature we don't support).
// Don't 500 the reader — fall back to streaming the original, which
// serves without decoding.
tracing::warn!(key, error = %format!("{e:#}"), "thumbnail decode failed; serving original");
return serve_original(state, key).await;
}
};
// 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 mut headers = vec![
(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(),
),
// 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.
//
// 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 {
"private, max-age=31536000, immutable".to_string()
} else {
"public, max-age=31536000, immutable".to_string()
},
),
];
Ok((headers, Body::from_stream(file.stream)).into_response())
// Known image types render inline (covers/pages display in the reader). The
// `application/octet-stream` fallback is a blob we couldn't type — it could
// be crafted HTML/JS, so force a download rather than let the browser render
// it inline. Belt to `nosniff`'s braces.
if content_type == "application/octet-stream" {
headers.push((
header::CONTENT_DISPOSITION,
"attachment".to_string(),
));
}
(axum::response::AppendHeaders(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 {
@@ -64,3 +280,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

@@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::auth::extractor::{CurrentSessionUser, CurrentUser};
use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga};
use crate::domain::patch::Patch;
use crate::domain::tag::TagRef;
@@ -21,6 +21,8 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one).patch(update))
.route("/mangas/:id/similar", get(list_similar))
.route("/me/recommendations", get(list_recommendations))
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
.route("/mangas/:id/tags", post(attach_tag))
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
@@ -40,18 +42,68 @@ pub struct ListParams {
pub genre_id: Option<String>,
#[serde(default)]
pub tag_id: Option<String>,
/// Comma-separated content warnings the manga must carry (AND).
#[serde(default)]
pub cw_include: Option<String>,
/// Comma-separated content warnings the manga must NOT carry (any).
#[serde(default)]
pub cw_exclude: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
/// Sort field: `created`, `updated` (default), `title`, or `author`.
/// Also accepts the legacy `recent` alias (== `created`). Anything
/// else → 422.
#[serde(default)]
pub sort: repo::manga::ListSort,
pub sort: Option<String>,
/// `asc` or `desc`. Omitted → the field's natural default
/// (dates `desc`, text `asc`). Anything else → 422.
#[serde(default)]
pub order: Option<String>,
}
fn default_limit() -> i64 {
50
}
/// Parse the `sort` wire value into a [`repo::manga::SortField`]. Validation
/// and the legacy `recent` alias live here (not in serde) so unknown values
/// return the structured 422 envelope rather than axum's plain-text
/// `QueryRejection`, matching the rest of this handler's filters.
fn parse_sort(raw: Option<&str>) -> AppResult<repo::manga::SortField> {
use repo::manga::SortField;
match raw.map(str::trim) {
None | Some("") | Some("updated") => Ok(SortField::Updated),
// `recent` was the pre-0.88 name for "newest created first"; keep it
// as an alias so existing bots/scripts don't break.
Some("created") | Some("recent") => Ok(SortField::Created),
Some("title") => Ok(SortField::Title),
Some("author") => Ok(SortField::Author),
Some(other) => Err(AppError::ValidationFailed {
message: format!(
"sort must be one of created, updated, title, author (got {other:?})"
),
details: json!({ "sort": "invalid" }),
}),
}
}
/// Parse the `order` wire value. `None` means the client omitted it, so the
/// caller applies the field's natural default. An explicit unknown value → 422.
fn parse_order(raw: Option<&str>) -> AppResult<Option<repo::manga::SortOrder>> {
use repo::manga::SortOrder;
match raw.map(str::trim) {
None | Some("") => Ok(None),
Some("asc") => Ok(Some(SortOrder::Asc)),
Some("desc") => Ok(Some(SortOrder::Desc)),
Some(other) => Err(AppError::ValidationFailed {
message: format!("order must be 'asc' or 'desc' (got {other:?})"),
details: json!({ "order": "invalid" }),
}),
}
}
fn parse_uuid_csv(field: &str, raw: Option<&str>) -> AppResult<Vec<Uuid>> {
let Some(raw) = raw else { return Ok(Vec::new()) };
let mut out = Vec::new();
@@ -87,18 +139,27 @@ async fn list(
Some(s.to_string())
}
};
let sort = parse_sort(params.sort.as_deref())?;
// Omitted `order` falls back to the field's natural direction, so a bare
// `?sort=title` reads A→Z just like the UI shows it.
let order = parse_order(params.order.as_deref())?.unwrap_or_else(|| sort.default_order());
let q = repo::manga::ListQuery {
search: params.search.filter(|s| !s.trim().is_empty()),
status,
author_ids: parse_uuid_csv("author_id", params.author_id.as_deref())?,
genre_ids: parse_uuid_csv("genre_id", params.genre_id.as_deref())?,
tag_ids: parse_uuid_csv("tag_id", params.tag_id.as_deref())?,
cw_include: crate::api::page_tags::parse_warnings_csv(params.cw_include.as_deref())?,
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
limit,
offset,
sort: params.sort,
sort,
order,
};
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
Ok(Json(PagedResponse::with_optional_total(
items, limit, offset, total,
)))
}
async fn get_one(
@@ -108,6 +169,50 @@ async fn get_one(
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
/// How many similar mangas the recommendation section shows.
const SIMILAR_LIMIT: i64 = 5;
/// `GET /api/v1/mangas/:id/similar` — top-`SIMILAR_LIMIT` mangas ranked by
/// tag overlap with `:id`, as cards. Read-only and unauthenticated, like
/// `get_one`/`list`. Returns a plain `{ items: [...] }` object (not the
/// paginated envelope) since this is a fixed top-N, not a collection.
///
/// The `exists` check is load-bearing: `list_similar` returns an empty list
/// for both an untagged manga and a nonexistent id, so without it an unknown
/// id would 200 with `[]` instead of 404.
async fn list_similar(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
let items = repo::manga::list_similar(&state.db, id, SIMILAR_LIMIT).await?;
Ok(Json(json!({ "items": items })))
}
const RECOMMENDATIONS_LIMIT: i64 = 12;
#[derive(Debug, Deserialize)]
pub struct RecommendationParams {
#[serde(default)]
pub limit: Option<i64>,
}
/// `GET /api/v1/me/recommendations` — content-based "Recommended for you",
/// ranked by tag overlap with the signed-in user's likes/bookmarks (minus
/// dislikes). Returns `{ "items": [...] }` (a fixed top-N, like `/similar`);
/// empty when the user has no taste signals yet.
async fn list_recommendations(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<RecommendationParams>,
) -> AppResult<Json<serde_json::Value>> {
let limit = params.limit.unwrap_or(RECOMMENDATIONS_LIMIT).clamp(1, 50);
let items = repo::manga::list_recommendations(&state.db, user.id, limit).await?;
Ok(Json(json!({ "items": items })))
}
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
///
/// - `metadata` (required): JSON body matching `NewManga` — title, optional
@@ -130,11 +235,17 @@ async fn create(
while let Some(field) = next_field(&mut multipart).await? {
match field.name() {
Some("metadata") => {
let bytes = read_field_bytes(field).await?;
let bytes = crate::upload::read_capped(
field,
crate::upload::MAX_METADATA_BYTES,
"metadata",
)
.await?;
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,
@@ -175,13 +286,14 @@ async fn create(
)
.await?;
let author_refs = repo::author::set_for_manga(&mut *tx, manga.id, &authors).await?;
repo::genre::set_for_manga(&mut *tx, manga.id, &metadata.genre_ids).await?;
let author_refs = repo::author::set_for_manga(&mut tx, manga.id, &authors).await?;
repo::genre::set_for_manga(&mut tx, manga.id, &metadata.genre_ids).await?;
if let Some(img) = cover {
let key = format!("mangas/{}/cover.{}", manga.id, img.ext);
state.storage.put(&key, &img.bytes).await?;
repo::manga::set_cover_image_path(&mut *tx, manga.id, &key).await?;
repo::manga::set_cover_image_path(&mut *tx, manga.id, &key, img.bytes.len() as i64)
.await?;
manga.cover_image_path = Some(key);
}
@@ -197,13 +309,14 @@ async fn create(
async fn update(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
session: Option<CurrentSessionUser>,
Path(id): Path<Uuid>,
Json(patch): Json<MangaPatch>,
) -> AppResult<Json<MangaDetail>> {
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
require_can_edit(&state, id, user.id).await?;
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
if let Some(ref status) = patch.status {
let trimmed = status.trim();
@@ -239,7 +352,7 @@ async fn update(
let mut tx = state.db.begin().await?;
let _updated = repo::manga::update_basics(
&mut *tx,
&mut tx,
id,
patch.title.as_deref().map(str::trim),
patch.status.as_deref().map(str::trim),
@@ -249,10 +362,10 @@ async fn update(
)
.await?;
if let Some(ref names) = authors_owned {
repo::author::set_for_manga(&mut *tx, id, names).await?;
repo::author::set_for_manga(&mut tx, id, names).await?;
}
if let Some(ref ids) = patch.genre_ids {
repo::genre::set_for_manga(&mut *tx, id, ids).await?;
repo::genre::set_for_manga(&mut tx, id, ids).await?;
}
tx.commit().await?;
@@ -268,18 +381,20 @@ async fn update(
async fn put_cover(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
session: Option<CurrentSessionUser>,
Path(id): Path<Uuid>,
mut multipart: Multipart,
) -> AppResult<Json<MangaDetail>> {
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
require_can_edit(&state, id, user.id).await?;
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
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")?);
}
}
@@ -294,6 +409,17 @@ async fn put_cover(
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
state.storage.put(&new_key, &img.bytes).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;
// Commit the DB pointer to the new blob BEFORE removing the old one. If we
// deleted the old blob first and this write then failed, the row would point
// at a deleted blob (a cover that 404s) and the new blob would be orphaned.
// Ordering it first means a failure here leaves the row pointing at the
// still-present old blob; only a harmless orphan (new blob) can result.
repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?;
if let Some(prev) = old_key.as_deref() {
if prev != new_key {
@@ -304,10 +430,10 @@ 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;
}
}
repo::manga::set_cover_image_path(&state.db, id, &new_key).await?;
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
@@ -317,18 +443,23 @@ async fn put_cover(
async fn delete_cover(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
session: Option<CurrentSessionUser>,
Path(id): Path<Uuid>,
) -> AppResult<Json<MangaDetail>> {
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
require_can_edit(&state, id, user.id).await?;
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
// Clear the DB pointer BEFORE deleting the blob, so a storage-delete
// failure can't leave the row pointing at a removed blob. A failure
// after the clear only orphans the blob (harmless).
repo::manga::clear_cover_image_path(&state.db, id).await?;
match state.storage.delete(&key).await {
Ok(()) | Err(StorageError::NotFound) => {}
Err(e) => return Err(e.into()),
}
repo::manga::clear_cover_image_path(&state.db, id).await?;
crate::api::files::purge_thumbnails(state.storage.as_ref(), &key).await;
}
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
@@ -435,11 +566,24 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
/// exist (the caller runs [`repo::manga::exists`] first so a missing id
/// surfaces as `NotFound`, not `Forbidden`).
///
/// Rule: a non-NULL `uploaded_by` must match the current user. Legacy
/// rows with `uploaded_by IS NULL` (pre-migration-0011) are still
/// editable by any signed-in user — there's nobody to gate on yet, and
/// the historical-data note in 0011 acknowledges the gap. Once an
/// admin role lands the NULL case can flip to admin-only.
/// Rule: a non-NULL `uploaded_by` must match the current user, OR the
/// caller is an admin **via a session cookie**. Rows with
/// `uploaded_by IS NULL` (crawler-imported + legacy pre-0011) are
/// admin-via-session-only.
///
/// Why "via session": [`crate::auth::extractor`] documents that admin
/// authority is unreachable via bearer tokens — a leaked bot token
/// must not yield admin powers. Keep that invariant by reading the
/// `is_admin_session` flag from the optional `CurrentSessionUser`
/// extractor (`None` ⇒ caller authenticated via bearer ⇒ admin path
/// is closed) rather than from `User.is_admin` (which is true whichever
/// way the user authenticated).
///
/// Why NULL is admin-only: every crawler row has NULL `uploaded_by`
/// (see `repo::crawler::upsert_manga`). The earlier "any signed-in
/// user can edit a NULL row" rule meant `register → PATCH
/// /api/v1/mangas/<id>` rewrote the catalog for free, and
/// `delete_cover` removed blobs from `storage`.
///
/// Returns `Forbidden` (not `NotFound`) on owner mismatch — mangas
/// are listable via `GET /mangas`, so existence isn't a secret and
@@ -447,14 +591,32 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
/// `repo::collection::require_owner`, which collapses both states to
/// `NotFound` because collections are private to a user and existence
/// itself is information worth hiding from non-owners.
async fn require_can_edit(state: &AppState, manga_id: Uuid, user_id: Uuid) -> AppResult<()> {
async fn require_can_edit(
state: &AppState,
manga_id: Uuid,
user_id: Uuid,
is_admin_session: bool,
) -> AppResult<()> {
match repo::manga::uploaded_by(&state.db, manga_id).await? {
Some(owner) if owner != user_id => Err(AppError::Forbidden),
// Some(owner) == user_id (good) or None (legacy row, no owner).
_ => Ok(()),
Some(owner) if owner == user_id => Ok(()),
Some(_) if is_admin_session => Ok(()),
Some(_) => Err(AppError::Forbidden),
None if is_admin_session => Ok(()),
None => Err(AppError::Forbidden),
}
}
/// True iff the caller authenticated via session cookie AND is an admin.
/// Used to gate the admin-only branches of [`require_can_edit`]. The
/// `Option<CurrentSessionUser>` extractor returns `None` on a bearer-only
/// request, so admin authority over bot tokens never composes here.
fn admin_via_session(session: &Option<CurrentSessionUser>) -> bool {
session
.as_ref()
.map(|CurrentSessionUser(u)| u.is_admin)
.unwrap_or(false)
}
async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> {
if ids.is_empty() {
return Ok(());
@@ -528,13 +690,7 @@ pub(crate) async fn next_field(
.map_err(map_multipart_error)
}
pub(crate) async fn read_field_bytes(
field: axum::extract::multipart::Field<'_>,
) -> AppResult<axum::body::Bytes> {
field.bytes().await.map_err(map_multipart_error)
}
fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError {
pub(crate) fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError {
let status = e.status();
if status == StatusCode::PAYLOAD_TOO_LARGE {
AppError::PayloadTooLarge("upload exceeds the request size limit".into())

View File

@@ -9,7 +9,9 @@ pub mod genres;
pub mod health;
pub mod history;
pub mod mangas;
pub mod page_tags;
pub mod pagination;
pub mod reactions;
pub mod tags;
use axum::Router;
@@ -28,6 +30,8 @@ pub fn routes() -> Router<AppState> {
.merge(tags::routes())
.merge(authors::routes())
.merge(collections::routes())
.merge(page_tags::routes())
.merge(history::routes())
.merge(reactions::routes())
.merge(admin::routes())
}

View File

@@ -0,0 +1,532 @@
//! Per-page tag endpoints. See `migration 0023` for the underlying
//! schema and `repo::page_tag` for the query layer. All endpoints
//! require `CurrentUser` — every byte of this data is owned by the
//! caller.
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::page_analysis::{ContentWarning, PageSearchItem};
use crate::domain::page_tag::{
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
TaggedPageItem,
};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::page_analysis::PageSearchQuery;
use crate::repo::page_tag::Order;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/pages/:id/tags", post(add))
.route("/pages/:id/tags/:tag", delete(remove))
// GET uses `/my-tags` to mirror the `mangas/:id/my-collections`
// convention — the URL says whose tags we're reading even
// though the cookie already implies it.
.route("/pages/:id/my-tags", get(list_for_page))
.route("/me/page-tags", get(list_mine))
.route("/me/page-search", get(page_search))
.route("/me/page-tags/distinct", get(list_distinct_mine))
.route("/me/page-tags/chapters", get(list_chapters_for_tag))
.route("/me/page-tags/mangas", get(list_mangas_for_tag))
}
const MAX_TAG_LEN: usize = 64;
/// Hard wire-level byte cap, applied before any allocation in
/// `normalize_tag`. A 10MB tag in the JSON body would otherwise
/// allocate twice in `to_lowercase()` + `split_whitespace().collect()`
/// before the 64-char post-normalize cap rejected it. 4 KiB is a
/// generous ~64x the post-normalize char cap and well below anything
/// a user could fairly call a "tag".
const MAX_TAG_BYTES: usize = 4096;
const DEFAULT_LIMIT: i64 = 50;
const DEFAULT_DISTINCT_LIMIT: i64 = 200;
#[derive(Debug, Deserialize)]
pub struct ListMineParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
/// Restrict to this exact tag (chip filter in the library tab).
#[serde(default)]
pub tag: Option<String>,
/// Prefix filter (autocomplete-style).
#[serde(default)]
pub q: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DistinctParams {
#[serde(default)]
pub q: Option<String>,
#[serde(default = "default_distinct_limit")]
pub limit: i64,
}
#[derive(Debug, Serialize)]
struct TagsResponse {
tags: Vec<String>,
}
fn default_limit() -> i64 {
DEFAULT_LIMIT
}
fn default_distinct_limit() -> i64 {
DEFAULT_DISTINCT_LIMIT
}
/// Normalize a user-supplied tag for storage. Lowercases, trims,
/// collapses internal whitespace, rejects control chars and edge
/// colons, caps at 64 chars. Single source of truth so the same input
/// produces the same row regardless of which client sent it.
/// True for Unicode format / invisible chars that would let two
/// visually-identical tags coexist as distinct rows — `"funny"` and
/// `"funny\u{200d}"` are different codepoints but render the same, and
/// the autocomplete + chip cloud would split them. We hardcode the
/// well-known offenders (a subset of the Unicode `Cf` general category
/// plus the bidi-override block) so the file stays free of new deps.
fn is_invisible_format(c: char) -> bool {
matches!(c,
// Soft hyphen, Arabic letter mark, Mongolian vowel separator.
'\u{00AD}' | '\u{061C}' | '\u{180E}'
// ZWSP, ZWNJ, ZWJ, LRM, RLM.
| '\u{200B}'..='\u{200F}'
// LRE / RLE / PDF / LRO / RLO.
| '\u{202A}'..='\u{202E}'
// Word joiner, function-application markers, deprecated
// formatting (U+206A..U+206F).
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
// BOM / ZWNBSP.
| '\u{FEFF}'
// Plane-14 LANGUAGE TAG + TAG characters. These can tunnel
// ASCII semantics invisibly (the same mechanism used in 2024's
// LLM prompt-injection smuggling work). Storing them would
// let two visually-identical tags coexist while carrying
// distinct hidden payloads.
| '\u{E0001}'
| '\u{E0020}'..='\u{E007F}'
)
}
pub(crate) fn normalize_tag(input: &str) -> AppResult<String> {
// Fast-fail on absurd input before allocating in lowercase /
// whitespace passes. Cheap and bounds worst-case work.
if input.len() > MAX_TAG_BYTES {
return Err(AppError::ValidationFailed {
message: "tag too long".into(),
details: json!({ "tag": format!("max {MAX_TAG_BYTES} bytes") }),
});
}
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AppError::ValidationFailed {
message: "tag is required".into(),
details: json!({ "tag": "required" }),
});
}
if trimmed.chars().any(|c| c.is_control()) {
return Err(AppError::ValidationFailed {
message: "tag contains control characters".into(),
details: json!({ "tag": "control_chars" }),
});
}
if trimmed.chars().any(is_invisible_format) {
return Err(AppError::ValidationFailed {
message: "tag contains invisible / format characters".into(),
details: json!({ "tag": "invisible_chars" }),
});
}
// Reject characters that would break the DELETE URL path
// (`/v1/pages/:id/tags/:tag`). axum decodes %2F back to `/` after
// routing, so a tag containing one of these would be silently
// unreachable to the delete handler — store-only. Reject up front
// so every stored tag is removable. `%` and `_` are also rejected
// since they are LIKE wildcards in the `list_for_user` prefix
// filter and would otherwise let a user accidentally search for
// anything.
if trimmed.chars().any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%' | '_')) {
return Err(AppError::ValidationFailed {
message: "tag contains a forbidden character (/ \\ ? # % _)".into(),
details: json!({ "tag": "forbidden_chars" }),
});
}
// Lowercase + collapse internal whitespace runs (any kind: spaces,
// tabs, fullwidth space, etc.) into a single ASCII space. This
// keeps "Foo Bar" and "foo bar" identifying the same tag.
let normalized: String = trimmed
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
// `namespace:value` is permitted but a bare leading/trailing colon
// is nonsense and would break any future split.
if normalized.starts_with(':') || normalized.ends_with(':') {
return Err(AppError::ValidationFailed {
message: "tag must not start or end with ':'".into(),
details: json!({ "tag": "edge_colon" }),
});
}
if normalized.chars().count() > MAX_TAG_LEN {
// "After normalization" because Unicode case-folding can
// expand chars (Turkish capital `İ` → `i\u{307}`, so 33 İ's
// pass the wire-level 33-char input but trip this 64 cap).
return Err(AppError::ValidationFailed {
message: "tag too long after normalization".into(),
details: json!({ "tag": format!("max {MAX_TAG_LEN} characters") }),
});
}
Ok(normalized)
}
async fn add(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
Json(input): Json<NewPageTag>,
) -> AppResult<StatusCode> {
let tag = normalize_tag(&input.tag)?;
let created = repo::page_tag::upsert(&state.db, user.id, page_id, &tag).await?;
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}
async fn remove(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((page_id, tag)): Path<(Uuid, String)>,
) -> AppResult<StatusCode> {
// Normalize the URL-decoded tag the same way add() did, so
// DELETE /pages/.../tags/Funny removes the row stored as "funny".
let normalized = normalize_tag(&tag)?;
repo::page_tag::remove(&state.db, user.id, page_id, &normalized).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_for_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<TagsResponse>> {
let tags = repo::page_tag::list_for_page(&state.db, user.id, page_id).await?;
Ok(Json(TagsResponse { tags }))
}
async fn list_mine(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListMineParams>,
) -> AppResult<Json<PagedResponse<TaggedPageItem>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
// Filters from the wire arrive raw — normalize them so a filter on
// "Funny" matches rows stored as "funny".
let tag_filter = params
.tag
.as_deref()
.map(normalize_tag)
.transpose()?;
let prefix_filter = params
.q
.as_deref()
.map(normalize_tag)
.transpose()?;
let (items, total) = repo::page_tag::list_for_user(
&state.db,
user.id,
tag_filter.as_deref(),
prefix_filter.as_deref(),
limit,
offset,
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
#[derive(Debug, Deserialize)]
pub struct PageSearchParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
/// Comma-separated tags, AND-ed. Matched against the caller's page
/// tags the global auto-tags.
#[serde(default)]
pub tags: Option<String>,
/// Free-text query over the OCR + scene-description search document.
#[serde(default)]
pub text: Option<String>,
/// Comma-separated content warnings the page must carry (all of them).
#[serde(default)]
pub cw_include: Option<String>,
/// Comma-separated content warnings the page must NOT carry (any of).
#[serde(default)]
pub cw_exclude: Option<String>,
}
/// Split a comma-separated tag list into normalized, deduped tag names.
fn parse_tags_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
let Some(raw) = raw else { return Ok(Vec::new()) };
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for part in raw.split(',') {
if part.trim().is_empty() {
continue;
}
let norm = normalize_tag(part)?;
if seen.insert(norm.clone()) {
out.push(norm);
}
}
Ok(out)
}
/// Split + validate a comma-separated content-warning list against the
/// closed vocabulary, returning canonical lowercase names. Unknown values
/// are a 422 rather than a silent drop so a typo'd filter is visible.
pub(crate) fn parse_warnings_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
let Some(raw) = raw else { return Ok(Vec::new()) };
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for part in raw.split(',') {
let t = part.trim();
if t.is_empty() {
continue;
}
let w = ContentWarning::parse_strict(t).ok_or_else(|| AppError::ValidationFailed {
message: format!("unknown content warning {t:?}"),
details: json!({ "content_warning": "must be one of sexual|nudity|gore|violence|disturbing" }),
})?;
let canon = format!("{w:?}").to_lowercase();
if seen.insert(canon.clone()) {
out.push(canon);
}
}
Ok(out)
}
/// Content search over the caller's reachable pages: multi-tag AND (own
/// page tags global auto-tags), weighted OCR/scene text ranking, and
/// content-warning include/exclude. At least one positive filter (tags,
/// text, or cw_include) is required so the endpoint never dumps every page.
async fn page_search(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<PageSearchParams>,
) -> AppResult<Json<PagedResponse<PageSearchItem>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let tags = parse_tags_csv(params.tags.as_deref())?;
let cw_include = parse_warnings_csv(params.cw_include.as_deref())?;
let cw_exclude = parse_warnings_csv(params.cw_exclude.as_deref())?;
let text = params
.text
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if tags.is_empty() && text.is_none() && cw_include.is_empty() {
return Err(AppError::ValidationFailed {
message: "at least one of tags, text, or cw_include is required".into(),
details: json!({ "filter": "required" }),
});
}
let query = PageSearchQuery {
user_id: user.id,
tags,
text,
cw_include,
cw_exclude,
limit,
offset,
};
let (items, total) = repo::page_analysis::page_search(&state.db, &query).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn list_distinct_mine(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<DistinctParams>,
) -> AppResult<Json<DistinctResponse>> {
let limit = params.limit.clamp(1, 500);
let prefix = params
.q
.as_deref()
.map(normalize_tag)
.transpose()?;
let items = repo::page_tag::distinct_tags_for_user(
&state.db,
user.id,
prefix.as_deref(),
limit,
)
.await?;
Ok(Json(DistinctResponse { items }))
}
#[derive(Debug, Serialize)]
struct DistinctResponse {
items: Vec<PageTagSummary>,
}
#[derive(Debug, Deserialize)]
pub struct AggregateParams {
/// Required. Exact tag to aggregate by. Empty / whitespace-only
/// inputs are rejected with 422 via `normalize_tag`.
pub tag: String,
/// `desc` (default) or `asc`. Anything else → 422.
#[serde(default)]
pub order: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
/// OCR text filter. When non-empty, only pages whose analysis
/// `search_doc` matches the query (`plainto_tsquery`) are aggregated.
/// Blank/absent ⇒ tag-only aggregation.
#[serde(default)]
pub text: Option<String>,
}
fn parse_order(raw: Option<&str>) -> AppResult<Order> {
match raw.map(str::trim) {
None | Some("") | Some("desc") => Ok(Order::Desc),
Some("asc") => Ok(Order::Asc),
Some(other) => Err(AppError::ValidationFailed {
message: format!("order must be 'desc' or 'asc' (got {other:?})"),
details: json!({ "order": "invalid" }),
}),
}
}
async fn list_chapters_for_tag(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
let tag = normalize_tag(&params.tag)?;
let order = parse_order(params.order.as_deref())?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
&state.db, user.id, &tag, order, limit, offset, params.text.as_deref(),
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn list_mangas_for_tag(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
let tag = normalize_tag(&params.tag)?;
let order = parse_order(params.order.as_deref())?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
&state.db, user.id, &tag, order, limit, offset, params.text.as_deref(),
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
#[cfg(test)]
mod tests {
use super::normalize_tag;
#[test]
fn lowercases_and_collapses_whitespace() {
assert_eq!(normalize_tag("Funny").unwrap(), "funny");
assert_eq!(normalize_tag(" Foo Bar ").unwrap(), "foo bar");
assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd");
}
#[test]
fn rejects_blank_input() {
assert!(normalize_tag("").is_err());
assert!(normalize_tag(" ").is_err());
}
#[test]
fn rejects_control_chars() {
assert!(normalize_tag("bad\ntag").is_err());
assert!(normalize_tag("a\tb").is_err());
// NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked
// here so a future stdlib drift surfaces as a test fail rather
// than a silent acceptance.
assert!(normalize_tag("a\u{0085}b").is_err());
// NULL byte.
assert!(normalize_tag("a\u{0000}b").is_err());
}
#[test]
fn rejects_edge_colon() {
assert!(normalize_tag(":foo").is_err());
assert!(normalize_tag("foo:").is_err());
}
#[test]
fn rejects_invisible_format_chars() {
// ZWJ — visually indistinguishable from no-zwj, would split
// "funny" and "funny\u{200d}" into distinct rows.
assert!(normalize_tag("funny\u{200d}").is_err());
// RLO — bidi override could let a tag display backwards.
assert!(normalize_tag("a\u{202e}b").is_err());
// ZWNBSP / BOM at the boundary survives `trim()`.
assert!(normalize_tag("a\u{feff}b").is_err());
// ZWSP, RLM also rejected.
assert!(normalize_tag("a\u{200b}b").is_err());
assert!(normalize_tag("a\u{200f}b").is_err());
// Plane-14 LANGUAGE TAG + TAG-character block. These tunnel
// ASCII invisibly via the supplementary tag mechanism.
assert!(normalize_tag("a\u{E0001}b").is_err());
assert!(normalize_tag("a\u{E0020}b").is_err());
assert!(normalize_tag("a\u{E007F}b").is_err());
}
#[test]
fn rejects_path_breaking_and_like_wildcards() {
// `/` would survive %-encoding round-trip but axum decodes
// %2F back to `/` after path routing, so the DELETE path
// would never match. Same for `?`, `#`, `\`.
assert!(normalize_tag("a/b").is_err());
assert!(normalize_tag("a\\b").is_err());
assert!(normalize_tag("a?b").is_err());
assert!(normalize_tag("a#b").is_err());
// `%` and `_` are SQL LIKE wildcards in the prefix filter.
assert!(normalize_tag("a%b").is_err());
assert!(normalize_tag("a_b").is_err());
}
#[test]
fn rejects_too_long() {
let long = "a".repeat(65);
assert!(normalize_tag(&long).is_err());
let ok_len = "a".repeat(64);
assert!(normalize_tag(&ok_len).is_ok());
}
#[test]
fn rejects_pathologically_long_input_before_allocating() {
// 10 MiB of `a` — should fail-fast on the byte cap, not
// allocate twice through the lowercase/whitespace passes.
let huge = "a".repeat(10 * 1024 * 1024);
assert!(normalize_tag(&huge).is_err());
// Just under cap still goes through the rest of the pipeline
// and fails on the char count.
let near_cap = "a".repeat(super::MAX_TAG_BYTES);
assert!(normalize_tag(&near_cap).is_err());
}
}

View File

@@ -34,4 +34,18 @@ impl<T> PagedResponse<T> {
page: PageInfo { limit, offset, total: Some(total) },
}
}
/// For handlers that compute `total` only on some pages (e.g. the first)
/// and leave it `None` elsewhere.
pub fn with_optional_total(
items: Vec<T>,
limit: i64,
offset: i64,
total: Option<i64>,
) -> Self {
Self {
items,
page: PageInfo { limit, offset, total },
}
}
}

View File

@@ -0,0 +1,69 @@
//! Manga reactions (like/dislike) — a private, per-user taste signal.
//! Writes require auth; the read is scoped under `/me/` so the URL can't be
//! used to peek at another user's reactions.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::reaction::{MangaReaction, Reaction};
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/mangas/:id/reaction",
put(set_reaction).delete(clear_reaction),
)
.route("/me/reactions/:manga_id", get(get_reaction))
}
#[derive(Debug, Deserialize)]
pub struct SetReactionBody {
pub reaction: String,
}
async fn set_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
Json(body): Json<SetReactionBody>,
) -> AppResult<Json<MangaReaction>> {
// Validate against the closed vocabulary here so a bad value is a clean
// 422 rather than relying on the DB CHECK to surface as a 500.
let reaction = Reaction::parse(&body.reaction).ok_or_else(|| AppError::ValidationFailed {
message: "reaction must be 'like' or 'dislike'".into(),
details: json!({ "reaction": "must be 'like' or 'dislike'" }),
})?;
// Unknown manga → 404 via the FK-violation mapping in repo::reaction.
repo::reaction::upsert(&state.db, user.id, manga_id, reaction).await?;
Ok(Json(MangaReaction {
manga_id,
reaction: Some(reaction),
}))
}
async fn clear_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<StatusCode> {
repo::reaction::clear(&state.db, user.id, manga_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaReaction>> {
let reaction = repo::reaction::get(&state.db, user.id, manga_id).await?;
Ok(Json(MangaReaction { manga_id, reaction }))
}

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,45 @@ impl FromRequestParts<AppState> for CurrentUser {
}
}
/// Client IP for per-IP auth rate limiting. Resolves to the first hop of
/// `X-Forwarded-For` **only** when [`crate::config::AuthConfig::trusted_proxy`]
/// is set (the backend is behind a proxy that overrides the header — the
/// compose deploy). Otherwise `None`, so the limiter uses its shared bucket.
/// Never fails: a missing or malformed header simply yields `None`.
pub struct ClientIp(pub Option<std::net::IpAddr>);
/// Parse the client IP from an `X-Forwarded-For` value: the left-most hop is
/// the original client (later hops are intermediary proxies). Factored out so
/// the parsing is unit-testable without constructing a request.
pub(crate) fn first_forwarded_ip(header: &str) -> Option<std::net::IpAddr> {
header
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<std::net::IpAddr>().ok())
}
#[async_trait]
impl FromRequestParts<AppState> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
if !state.auth.trusted_proxy {
return Ok(ClientIp(None));
}
let ip = parts
.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(first_forwarded_ip);
Ok(ClientIp(ip))
}
}
/// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted
/// here — this is the substrate for [`RequireAdmin`] and exists precisely
/// to keep admin authority out of bearer-token reach.
@@ -120,3 +159,34 @@ impl FromRequestParts<AppState> for RequireAdmin {
Ok(RequireAdmin(user))
}
}
#[cfg(test)]
mod tests {
use super::first_forwarded_ip;
use std::net::IpAddr;
#[test]
fn parses_left_most_client_hop() {
// The client is the first entry; later entries are intermediary proxies.
assert_eq!(
first_forwarded_ip("203.0.113.5, 10.0.0.1, 10.0.0.2"),
Some("203.0.113.5".parse::<IpAddr>().unwrap())
);
assert_eq!(
first_forwarded_ip(" 198.51.100.9 "),
Some("198.51.100.9".parse::<IpAddr>().unwrap())
);
assert_eq!(
first_forwarded_ip("2001:db8::1, 10.0.0.1"),
Some("2001:db8::1".parse::<IpAddr>().unwrap())
);
}
#[test]
fn rejects_empty_or_garbage() {
assert_eq!(first_forwarded_ip(""), None);
assert_eq!(first_forwarded_ip(" "), None);
assert_eq!(first_forwarded_ip("not-an-ip"), None);
assert_eq!(first_forwarded_ip(", 10.0.0.1"), None);
}
}

View File

@@ -28,6 +28,25 @@ pub fn verify_password(plain: &str, phc: &str) -> bool {
.is_ok()
}
/// Async wrapper around [`hash_password`] that offloads the CPU- and
/// memory-heavy Argon2 work to the blocking pool. Async handlers must call
/// this rather than the sync primitive: at ~15-50 ms per hash, running it
/// inline stalls every other task sharing the runtime worker thread.
pub async fn hash_password_async(plain: String) -> AppResult<String> {
tokio::task::spawn_blocking(move || hash_password(&plain))
.await
.map_err(|e| AppError::Other(anyhow::anyhow!("password hash task join: {e}")))?
}
/// Async wrapper around [`verify_password`]. A join failure (blocking pool
/// gone at shutdown, panic) resolves to `false` — the caller only ever uses
/// the result as a boolean gate, and denying auth is the safe default.
pub async fn verify_password_async(plain: String, phc: String) -> bool {
tokio::task::spawn_blocking(move || verify_password(&plain, &phc))
.await
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -56,4 +75,24 @@ mod tests {
let b = hash_password("same").unwrap();
assert_ne!(a, b, "two hashes of the same password must differ (salt)");
}
#[tokio::test]
async fn async_hash_then_verify_roundtrip() {
let phc = hash_password_async("correct horse battery staple".to_string())
.await
.unwrap();
assert!(phc.starts_with("$argon2id$"));
assert!(verify_password_async("correct horse battery staple".to_string(), phc).await);
}
#[tokio::test]
async fn async_verify_rejects_wrong_password() {
let phc = hash_password_async("hunter2".to_string()).await.unwrap();
assert!(!verify_password_async("hunter3".to_string(), phc).await);
}
#[tokio::test]
async fn async_verify_rejects_malformed_hash() {
assert!(!verify_password_async("anything".to_string(), "not a real phc".to_string()).await);
}
}

View File

@@ -15,9 +15,17 @@
//! tests run in isolated buckets and won't bleed across `#[sqlx::test]`
//! cases that share a process.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::Instant;
/// Upper bound on distinct client IPs tracked at once, so a spray from many
/// spoofed/rotating source IPs can't grow the map without limit. When full,
/// idle (refilled-to-burst) buckets are pruned first; if none are idle a new
/// IP falls back to the shared global bucket for that request.
const MAX_TRACKED_IPS: usize = 10_000;
/// Tunable limits. `per_sec == 0` disables the limiter — used by the
/// test harness and by anyone who wants to opt out via env config.
#[derive(Clone, Copy, Debug)]
@@ -50,6 +58,42 @@ struct Bucket {
last_refill: Instant,
}
impl Bucket {
fn new(burst: u32) -> Self {
Self {
tokens: f64::from(burst),
last_refill: Instant::now(),
}
}
/// Refill by elapsed time then try to consume one token.
fn try_take(&mut self, cfg: &RateLimitConfig, now: Instant) -> AcquireResult {
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens =
(self.tokens + elapsed * f64::from(cfg.per_sec)).min(f64::from(cfg.burst));
self.last_refill = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
AcquireResult::Allowed
} else {
let deficit = 1.0 - self.tokens;
let wait_secs = (deficit / f64::from(cfg.per_sec)).ceil() as u64;
AcquireResult::Denied {
retry_after_secs: wait_secs.max(1),
}
}
}
/// Whether this bucket has fully refilled (i.e. the client has been idle).
/// Such buckets carry no state worth keeping — dropping and lazily
/// recreating one yields an identical full bucket — so they're the safe
/// eviction target under memory pressure.
fn is_idle(&self, cfg: &RateLimitConfig, now: Instant) -> bool {
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
(self.tokens + elapsed * f64::from(cfg.per_sec)) >= f64::from(cfg.burst)
}
}
/// Outcome of [`AuthRateLimiter::try_acquire`]. When `Denied`, the
/// caller can use `retry_after_secs` for a `Retry-After: N` header
/// (RFC 6585 §4) so well-behaved clients back off correctly rather
@@ -60,51 +104,64 @@ pub enum AcquireResult {
Denied { retry_after_secs: u64 },
}
/// Single-bucket token-bucket limiter. `try_acquire` is cheap (one
/// mutex acquire, no allocations) so the auth path doesn't pay a real
/// cost for the check.
/// Token-bucket limiter keyed by client IP, with a shared global bucket for
/// requests whose IP is unknown (no trusted proxy).
///
/// The old design was a single global bucket. That let one attacker at the
/// sustained rate deny auth to *every* user (the bucket stayed drained). With
/// the SvelteKit proxy now forwarding the peer IP (`X-Forwarded-For`, honoured
/// only when `AUTH_TRUSTED_PROXY` is set), each source IP gets its own bucket,
/// so an attacker only throttles themselves. When no trustworthy IP is
/// available the caller passes `None` and the shared `global` bucket applies —
/// exactly the previous behaviour.
pub struct AuthRateLimiter {
cfg: RateLimitConfig,
bucket: Mutex<Bucket>,
global: Mutex<Bucket>,
per_ip: Mutex<HashMap<IpAddr, Bucket>>,
}
impl AuthRateLimiter {
pub fn new(cfg: RateLimitConfig) -> Self {
Self {
cfg,
bucket: Mutex::new(Bucket {
tokens: cfg.burst as f64,
last_refill: Instant::now(),
}),
global: Mutex::new(Bucket::new(cfg.burst)),
per_ip: Mutex::new(HashMap::new()),
}
}
/// Consume one token if available. Returns `Denied` with a
/// rounded-up seconds-until-refill so the caller can emit a
/// `Retry-After` header.
pub fn try_acquire(&self) -> AcquireResult {
/// Consume one token from the bucket for `key` (per-IP when `Some`, the
/// shared global bucket when `None`). Returns `Denied` with a rounded-up
/// seconds-until-refill so the caller can emit a `Retry-After` header.
pub fn try_acquire(&self, key: Option<IpAddr>) -> AcquireResult {
if self.cfg.per_sec == 0 {
return AcquireResult::Allowed;
}
let now = Instant::now();
let mut bucket = self.bucket.lock().expect("rate limiter mutex poisoned");
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
bucket.tokens =
(bucket.tokens + elapsed * f64::from(self.cfg.per_sec)).min(f64::from(self.cfg.burst));
bucket.last_refill = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
AcquireResult::Allowed
} else {
// ceil((1 - tokens) / per_sec), minimum 1 — a `Retry-After: 0`
// would tell clients to retry immediately, which is what we're
// actively trying to discourage.
let deficit = 1.0 - bucket.tokens;
let wait_secs = (deficit / f64::from(self.cfg.per_sec)).ceil() as u64;
AcquireResult::Denied {
retry_after_secs: wait_secs.max(1),
let Some(ip) = key else {
return self
.global
.lock()
.unwrap_or_else(|e| e.into_inner())
.try_take(&self.cfg, now);
};
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 {
// Still saturated with active attackers — degrade to the shared
// bucket rather than grow unbounded. Worst case is the old
// global-bucket behaviour under an extreme distributed flood.
drop(map);
return self
.global
.lock()
.unwrap_or_else(|e| e.into_inner())
.try_take(&self.cfg, now);
}
}
map.entry(ip)
.or_insert_with(|| Bucket::new(self.cfg.burst))
.try_take(&self.cfg, now)
}
}
@@ -119,7 +176,7 @@ mod tests {
burst: 0,
});
for _ in 0..1000 {
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
}
}
@@ -130,10 +187,10 @@ mod tests {
per_sec: 1,
burst: 3,
});
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
match rl.try_acquire() {
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
match rl.try_acquire(None) {
AcquireResult::Denied { retry_after_secs } => {
// Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait.
assert!(
@@ -152,11 +209,11 @@ mod tests {
per_sec: 10,
burst: 1,
});
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(), AcquireResult::Denied { .. }));
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
std::thread::sleep(std::time::Duration::from_millis(150));
assert_eq!(
rl.try_acquire(),
rl.try_acquire(None),
AcquireResult::Allowed,
"token should have refilled"
);
@@ -170,10 +227,95 @@ mod tests {
per_sec: 1,
burst: 1,
});
slow.try_acquire();
match slow.try_acquire() {
slow.try_acquire(None);
match slow.try_acquire(None) {
AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1),
_ => panic!("expected Denied"),
}
}
fn ip(s: &str) -> Option<IpAddr> {
Some(s.parse().unwrap())
}
#[test]
fn per_ip_buckets_are_independent() {
// The whole point of the fix: one IP draining its bucket must not deny
// a different IP.
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 2,
});
let a = ip("203.0.113.7");
let b = ip("198.51.100.9");
assert_eq!(rl.try_acquire(a), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(a), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(a), AcquireResult::Denied { .. }));
// b is untouched.
assert_eq!(rl.try_acquire(b), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(b), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(b), AcquireResult::Denied { .. }));
}
#[test]
fn none_key_shares_the_global_bucket_independent_of_per_ip() {
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 2,
});
// Drain the global (None) bucket.
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
// A real IP is on its own bucket, unaffected by the drained global one.
assert_eq!(rl.try_acquire(ip("203.0.113.1")), AcquireResult::Allowed);
}
#[test]
fn tracked_ip_map_is_bounded() {
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 1,
});
// Distinct IPs beyond the cap must not grow the map without bound —
// excess requests fall back to the shared bucket instead.
for i in 0..(MAX_TRACKED_IPS as u64 + 50) {
let octet_a = (i >> 8) as u8;
let octet_b = (i & 0xff) as u8;
let addr = format!("10.20.{octet_a}.{octet_b}");
let _ = rl.try_acquire(Some(addr.parse().unwrap()));
}
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

@@ -3,15 +3,16 @@
//! `generate_token` draws 32 bytes from the OS CSPRNG, encodes them as
//! URL-safe base64 (no padding), and returns the raw string alongside its
//! SHA-256 hash. Storage holds only the hash; the raw value lives in the
//! cookie or `Authorization` header. Comparison goes through
//! `constant_time_eq` to keep timing side channels off the table.
//! cookie or `Authorization` header. Token lookup is an indexed equality on
//! that 256-bit hash in the database (`WHERE token_hash = $1`), so there's no
//! in-process secret comparison to time-attack: a guess has to match a full
//! SHA-256 digest, and the DB index reveals nothing about how close it came.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
pub const TOKEN_BYTES: usize = 32;
pub const HASH_BYTES: usize = 32;
@@ -30,10 +31,6 @@ pub fn hash_token(raw: &str) -> [u8; HASH_BYTES] {
hasher.finalize().into()
}
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.ct_eq(b).into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -58,11 +55,4 @@ mod tests {
assert_eq!(hash_token("abc"), hash_token("abc"));
assert_ne!(hash_token("abc"), hash_token("abd"));
}
#[test]
fn constant_time_eq_compares_correctly() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
}
}

View File

@@ -78,6 +78,21 @@ async fn main() -> anyhow::Result<()> {
let proxy_url = std::env::var("CRAWLER_PROXY")
.ok()
.filter(|s| !s.trim().is_empty());
let tor_control_url = std::env::var("CRAWLER_TOR_CONTROL_URL")
.ok()
.filter(|s| !s.trim().is_empty());
let tor_control_password = std::env::var("CRAWLER_TOR_CONTROL_PASSWORD")
.ok()
.filter(|s| !s.trim().is_empty());
let tor_control_cookie_path = std::env::var("CRAWLER_TOR_CONTROL_COOKIE_PATH")
.ok()
.filter(|s| !s.trim().is_empty())
.map(std::path::PathBuf::from);
let tor_recircuit_max_attempts: u32 = std::env::var("CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3)
.max(1);
let keep_browser_open = env_bool("CRAWLER_KEEP_BROWSER_OPEN", false);
let db = PgPoolOptions::new()
@@ -97,9 +112,20 @@ async fn main() -> anyhow::Result<()> {
cookie_jar.add_cookie_str(&cookie_str, &seed_url);
tracing::info!(domain, "seeded PHPSESSID into reqwest cookie jar");
}
// SSRF defence: only download from the catalog host + CDN host (plus
// optional CRAWLER_DOWNLOAD_ALLOWLIST extras). Built here so the same
// allowlist guards both the redirect policy and the per-image check.
let allowlist = Arc::new(build_download_allowlist(&start_url, cdn_host.as_deref()));
let mut http_builder = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.no_proxy()
// Re-validate every redirect hop against the allowlist — reqwest's
// default follows up to 10 redirects and `is_safe_url` only guards
// the initial URL, so a 302 to a private IP would otherwise pivot
// inside the deployment (SSRF).
.redirect(mangalord::crawler::safety::safe_redirect_policy(
(*allowlist).clone(),
))
.cookie_provider(cookie_jar);
if let Some(ua) = &user_agent {
http_builder = http_builder.user_agent(ua);
@@ -108,11 +134,29 @@ async fn main() -> anyhow::Result<()> {
http_builder = http_builder
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy URL: {proxy}"))?);
}
// DNS-rebinding guard: attached on the direct path AND on http(s) proxies
// (reqwest resolves the target itself there), skipped only for SOCKS proxies
// where the proxy resolves the target. Use the shared predicate so this CLI
// and the daemon (app.rs) stay in lockstep — previously the CLI attached the
// resolver only on the fully-direct path, so an http(s) proxy silently lost
// the rebinding guard.
if mangalord::crawler::safety::should_attach_safe_resolver(proxy_url.as_deref()) {
http_builder =
http_builder.dns_resolver(mangalord::crawler::safety::safe_dns_resolver());
}
let http = http_builder.build().context("build http client")?;
// Opt-in browser SSRF interception (default off), mirroring the daemon.
mangalord::crawler::intercept::set_enabled(
std::env::var("CRAWLER_SSRF_INTERCEPT")
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
.unwrap_or(false),
);
let mut options = LaunchOptions::from_env();
if let Some(proxy) = &proxy_url {
options.extra_args.push(format!("--proxy-server={proxy}"));
let chromium_proxy = mangalord::crawler::url_utils::chromium_proxy_arg(proxy);
options.extra_args.push(format!("--proxy-server={chromium_proxy}"));
}
let keep_open = match (keep_browser_open, options.mode) {
(true, BrowserMode::Headed) => true,
@@ -144,6 +188,17 @@ async fn main() -> anyhow::Result<()> {
"starting crawler"
);
let tor = mangalord::crawler::tor::TorController::from_parts(
tor_control_url.as_deref(),
tor_control_password.as_deref(),
tor_control_cookie_path.as_deref(),
)
.context("build TorController from CRAWLER_TOR_CONTROL_* env")?
.map(Arc::new);
if let Some(t) = &tor {
tracing::info!(?t, "TOR control configured");
}
// BrowserManager with idle_timeout = ZERO so the CLI keeps Chromium
// alive for the entire run — same lifecycle as the old direct
// `browser::launch()` flow. on_launch re-injects PHPSESSID + runs the
@@ -153,17 +208,24 @@ async fn main() -> anyhow::Result<()> {
let sid = sid.clone();
let domain = domain.clone();
let start_url_clone = start_url.clone();
let tor_for_launch = tor.as_ref().map(Arc::clone);
Arc::new(move |browser| {
let sid = sid.clone();
let domain = domain.clone();
let start_url = start_url_clone.clone();
let tor_for_launch = tor_for_launch.as_ref().map(Arc::clone);
Box::pin(async move {
session::inject_phpsessid(&browser, &sid, &domain)
.await
.context("inject_phpsessid")?;
session::verify_session(&browser, &start_url)
.await
.context("verify_session")?;
session::verify_session_with_recircuit(
&browser,
&start_url,
tor_for_launch.as_deref(),
tor_recircuit_max_attempts,
)
.await
.context("verify_session")?;
Ok(())
})
})
@@ -182,11 +244,13 @@ async fn main() -> anyhow::Result<()> {
rate_ms,
cdn_host.as_deref(),
cdn_rate_ms,
Arc::clone(&allowlist),
limit,
skip_chapters,
skip_chapter_content || !session_ready,
chapter_workers,
force_refetch_chapters,
tor.clone(),
)
.await;
@@ -211,11 +275,13 @@ async fn run(
rate_ms: u64,
cdn_host: Option<&str>,
cdn_rate_ms: u64,
allowlist: Arc<mangalord::crawler::safety::DownloadAllowlist>,
limit: usize,
skip_chapters: bool,
skip_chapter_content: bool,
chapter_workers: usize,
force_refetch_chapters: bool,
tor: Option<Arc<mangalord::crawler::tor::TorController>>,
) -> anyhow::Result<()> {
let mut rate = HostRateLimiters::new(Duration::from_millis(rate_ms));
if let Some(host) = cdn_host {
@@ -223,38 +289,18 @@ async fn run(
}
let rate = Arc::new(rate);
// SSRF defence: only download from the catalog host + CDN host
// (plus optional CRAWLER_DOWNLOAD_ALLOWLIST extras), and cap
// single-image downloads at CRAWLER_MAX_IMAGE_BYTES bytes.
// CRAWLER_ALLOW_ANY_HOST=true short-circuits the host check for
// sharded-CDN sources; private-IP and scheme guards still apply.
let allowlist = if env_bool("CRAWLER_ALLOW_ANY_HOST", false) {
mangalord::crawler::safety::DownloadAllowlist::allow_any()
} else {
let mut allow = mangalord::crawler::safety::DownloadAllowlist::new();
if let Ok(parsed) = reqwest::Url::parse(start_url) {
if let Some(h) = parsed.host_str() {
allow = allow.allow(h);
}
}
if let Some(host) = cdn_host {
allow = allow.allow(host);
}
if let Ok(extras) = std::env::var("CRAWLER_DOWNLOAD_ALLOWLIST") {
for piece in extras.split(',') {
let trimmed = piece.trim();
if !trimmed.is_empty() {
allow = allow.allow(trimmed);
}
}
}
allow
};
// Per-image download cap (the allowlist is built in `main` and passed in
// so the HTTP client's redirect policy and this check share one source).
let max_image_bytes: usize = std::env::var("CRAWLER_MAX_IMAGE_BYTES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(mangalord::crawler::safety::DEFAULT_MAX_IMAGE_BYTES);
let allowlist = Arc::new(allowlist);
// Per-chapter image *count* cap — bounds total disk against a hostile
// reader page listing thousands of <img> tags. `0` disables it.
let max_images_per_chapter: usize = std::env::var("CRAWLER_MAX_IMAGES_PER_CHAPTER")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2000);
let stats = pipeline::run_metadata_pass(
manager.as_ref(),
@@ -267,6 +313,12 @@ async fn run(
skip_chapters,
allowlist.as_ref(),
max_image_bytes,
// Circuit-breaker disabled for the operator-driven CLI: a manual
// sweep should push through transient failures, not self-abort.
0,
// No live status surface for the one-shot CLI.
None,
tor.as_deref(),
)
.await?;
tracing::info!(?stats, "metadata pass complete");
@@ -283,6 +335,8 @@ async fn run(
force_refetch_chapters,
Arc::clone(&allowlist),
max_image_bytes,
max_images_per_chapter,
tor.clone(),
)
.await?;
}
@@ -308,6 +362,8 @@ async fn sync_bookmarked_chapter_content(
force_refetch: bool,
allowlist: Arc<mangalord::crawler::safety::DownloadAllowlist>,
max_image_bytes: usize,
max_images_per_chapter: usize,
tor: Option<Arc<mangalord::crawler::tor::TorController>>,
) -> anyhow::Result<()> {
let pending: Vec<(Uuid, Uuid, String)> = sqlx::query_as(
r#"
@@ -345,6 +401,7 @@ async fn sync_bookmarked_chapter_content(
let rate = Arc::clone(&rate);
let manager = Arc::clone(&manager);
let allowlist = Arc::clone(&allowlist);
let tor = tor.clone();
let stats = &stats;
async move {
if session_expired.load(std::sync::atomic::Ordering::Relaxed) {
@@ -371,6 +428,12 @@ async fn sync_bookmarked_chapter_content(
force_refetch,
allowlist.as_ref(),
max_image_bytes,
max_images_per_chapter,
tor.as_deref(),
// CLI one-shot — no live status surface.
None,
// Standalone CLI doesn't drive the analysis worker.
false,
)
.await;
drop(lease);
@@ -381,6 +444,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,
@@ -447,3 +517,37 @@ fn env_bool(name: &str, default: bool) -> bool {
}
}
/// Build the crawler download allowlist from env + the catalog/CDN hosts.
/// Shared by the HTTP client's redirect policy and the per-image safety
/// check so both agree on which hosts are reachable.
///
/// `CRAWLER_ALLOW_ANY_HOST=true` short-circuits the host check for
/// sharded-CDN sources; private-IP and scheme guards still apply.
fn build_download_allowlist(
start_url: &str,
cdn_host: Option<&str>,
) -> mangalord::crawler::safety::DownloadAllowlist {
use mangalord::crawler::safety::DownloadAllowlist;
if env_bool("CRAWLER_ALLOW_ANY_HOST", false) {
return DownloadAllowlist::allow_any();
}
let mut allow = DownloadAllowlist::new();
if let Ok(parsed) = reqwest::Url::parse(start_url) {
if let Some(h) = parsed.host_str() {
allow = allow.allow(h);
}
}
if let Some(host) = cdn_host {
allow = allow.allow(host);
}
if let Ok(extras) = std::env::var("CRAWLER_DOWNLOAD_ALLOWLIST") {
for piece in extras.split(',') {
let trimmed = piece.trim();
if !trimmed.is_empty() {
allow = allow.allow(trimmed);
}
}
}
allow
}

File diff suppressed because it is too large Load Diff

View File

@@ -54,13 +54,26 @@ pub struct LaunchOptions {
/// defaults. Example: `vec!["--lang=de-DE".into(),
/// "--window-size=1280,800".into()]`.
pub extra_args: Vec<String>,
/// User-Agent for the browser. `None` uses [`DEFAULT_USER_AGENT`].
/// Critically, this is NEVER Chromium's built-in headless UA (which
/// contains the `HeadlessChrome` token that Cloudflare's bot detection
/// flags — over Tor that means an unsolvable "Just a moment" challenge
/// on every page). Sourced from `CRAWLER_USER_AGENT`.
pub user_agent: Option<String>,
}
/// A realistic, non-headless Chrome UA matching the bundled engine major.
/// Replaces Chromium's default `HeadlessChrome/<v>` UA, which anti-bot
/// services (Cloudflare) challenge on sight.
pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
impl LaunchOptions {
pub fn headed() -> Self {
Self {
mode: BrowserMode::Headed,
extra_args: Vec::new(),
user_agent: None,
}
}
@@ -68,13 +81,17 @@ impl LaunchOptions {
Self {
mode: BrowserMode::Headless,
extra_args: Vec::new(),
user_agent: None,
}
}
/// Reads `CRAWLER_BROWSER_MODE` (`headless`|`headed`, default
/// `headless`) and `CRAWLER_BROWSER_ARGS` (whitespace-separated
/// Chromium flags). Flags containing whitespace aren't supported
/// through the env var — use the programmatic API for those.
/// `headless`), `CRAWLER_BROWSER_ARGS` (whitespace-separated
/// Chromium flags), and `CRAWLER_USER_AGENT` (the browser UA;
/// blank/unset → [`DEFAULT_USER_AGENT`]). Flags containing whitespace
/// aren't supported through `CRAWLER_BROWSER_ARGS` — use the
/// programmatic API (or `CRAWLER_USER_AGENT` for the UA, which may
/// contain spaces) for those.
pub fn from_env() -> Self {
let mode = match std::env::var("CRAWLER_BROWSER_MODE").as_deref() {
Ok("headed") => BrowserMode::Headed,
@@ -83,7 +100,16 @@ impl LaunchOptions {
let extra_args = std::env::var("CRAWLER_BROWSER_ARGS")
.map(|s| parse_args(&s))
.unwrap_or_default();
Self { mode, extra_args }
let user_agent = std::env::var("CRAWLER_USER_AGENT")
.ok()
.filter(|s| !s.trim().is_empty());
Self { mode, extra_args, user_agent }
}
/// The effective browser UA: the configured override, else the
/// non-headless [`DEFAULT_USER_AGENT`].
pub fn effective_user_agent(&self) -> &str {
self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT)
}
}
@@ -211,7 +237,11 @@ pub async fn launch(options: LaunchOptions) -> anyhow::Result<Handle> {
// Chromium's sandbox wants. Disable it; the crawler runs in its
// own container anyway.
.arg("--no-sandbox")
.arg("--disable-dev-shm-usage");
.arg("--disable-dev-shm-usage")
// Override Chromium's default headless UA (contains `HeadlessChrome`,
// which Cloudflare challenges) with a realistic one. Placed before
// extra_args so an explicit override there still wins.
.arg(format!("--user-agent={}", options.effective_user_agent()));
for arg in &options.extra_args {
builder = builder.arg(arg);
}
@@ -345,6 +375,29 @@ mod tests {
assert_eq!(LaunchOptions::headed().mode, BrowserMode::Headed);
}
#[test]
fn default_user_agent_is_not_headless() {
// The whole point: never advertise `HeadlessChrome`, which Cloudflare
// challenges (especially over Tor) — that was the crawler's silent
// failure mode. A bare LaunchOptions must still yield a realistic UA.
assert!(
!DEFAULT_USER_AGENT.contains("Headless"),
"default UA must not contain the Headless token"
);
assert_eq!(LaunchOptions::headless().effective_user_agent(), DEFAULT_USER_AGENT);
assert!(!LaunchOptions::default().effective_user_agent().contains("Headless"));
}
#[test]
fn configured_user_agent_overrides_default() {
let opts = LaunchOptions {
mode: BrowserMode::Headless,
extra_args: Vec::new(),
user_agent: Some("Custom/1.0".to_string()),
};
assert_eq!(opts.effective_user_agent(), "Custom/1.0");
}
// Regression: if another Arc<Browser> outlives `Handle::close`, the
// old code awaited the driver task forever because the chromiumoxide
// handler stream doesn't return None on its own. Aborting the driver

View File

@@ -13,7 +13,7 @@
//! until [`BrowserManager::shutdown`].
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -71,12 +71,42 @@ impl ActiveTracker {
}
}
/// Lifecycle gate for a coordinated browser restart. `acquire()` parks
/// while not [`RestartPhase::Healthy`] so no new navigation starts mid-
/// restart; long-lived lease holders (the metadata pass) cooperate by
/// checking [`BrowserManager::is_restart_pending`] at safe boundaries.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RestartPhase {
/// Normal operation — acquires proceed.
Healthy,
/// Restart requested; new acquires park, waiting for in-flight leases
/// to drain.
Draining,
/// Chromium is being closed + relaunched.
Restarting,
}
const PHASE_HEALTHY: u8 = 0;
const PHASE_DRAINING: u8 = 1;
const PHASE_RESTARTING: u8 = 2;
pub struct BrowserManager {
inner: Mutex<Inner>,
active: Arc<ActiveTracker>,
launch_opts: LaunchOptions,
idle_timeout: Duration,
on_launch: OnLaunch,
/// Coarse lifecycle phase (one of the `PHASE_*` constants).
phase: AtomicU8,
/// Woken when the phase returns to `Healthy` so parked acquires resume.
resume: Notify,
/// Serialises coordinated restarts so concurrent requests collapse into
/// a single relaunch.
restart_lock: Mutex<()>,
/// Result of the most recent relaunch, so a caller that coalesced into
/// an in-progress restart reports that restart's real outcome instead
/// of a blind success.
last_restart_ok: AtomicBool,
}
struct Inner {
@@ -99,28 +129,72 @@ impl BrowserManager {
launch_opts,
idle_timeout,
on_launch,
phase: AtomicU8::new(PHASE_HEALTHY),
resume: Notify::new(),
restart_lock: Mutex::new(()),
last_restart_ok: AtomicBool::new(true),
})
}
/// Current restart phase.
pub fn phase(&self) -> RestartPhase {
match self.phase.load(Ordering::Acquire) {
PHASE_DRAINING => RestartPhase::Draining,
PHASE_RESTARTING => RestartPhase::Restarting,
_ => RestartPhase::Healthy,
}
}
fn set_phase(&self, phase: RestartPhase) {
let v = match phase {
RestartPhase::Healthy => PHASE_HEALTHY,
RestartPhase::Draining => PHASE_DRAINING,
RestartPhase::Restarting => PHASE_RESTARTING,
};
self.phase.store(v, Ordering::Release);
}
/// Whether a coordinated restart is in progress. Long-lived lease
/// holders poll this at safe boundaries and yield their lease so the
/// drain can complete promptly.
pub fn is_restart_pending(&self) -> bool {
self.phase() != RestartPhase::Healthy
}
/// Launch Chromium into `guard`, running the `on_launch` hook before
/// publishing the handle so a probe failure doesn't leave a half-
/// initialised browser behind.
async fn launch_into(&self, guard: &mut Inner) -> anyhow::Result<()> {
let handle = browser::launch(self.launch_opts.clone())
.await
.context("BrowserManager: launch chromium")?;
let shared = handle.shared();
if let Err(e) = (self.on_launch)(Arc::clone(&shared)).await {
let _ = handle.close().await;
return Err(e.context("BrowserManager: on_launch hook failed"));
}
guard.handle = Some(handle);
guard.shared = Some(shared);
Ok(())
}
/// Acquire a shared browser lease. The first acquire after a teardown
/// launches a fresh Chromium (and runs `on_launch`); subsequent acquires
/// while a process is alive just bump the counter and clone the `Arc`.
pub async fn acquire(&self) -> anyhow::Result<BrowserLease> {
// Park while a coordinated restart is draining/relaunching so no new
// navigation starts against a browser that's about to be torn down.
// The short sleep fallback guarantees liveness even if a `resume`
// notification is missed (classic Notify lost-wakeup).
while self.phase() != RestartPhase::Healthy {
tokio::select! {
_ = self.resume.notified() => {}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
let mut guard = self.inner.lock().await;
if guard.handle.is_none() {
let handle = browser::launch(self.launch_opts.clone())
.await
.context("BrowserManager: launch chromium")?;
let shared = handle.shared();
// Run the on-launch hook before publishing the handle so a session
// probe failure doesn't leave a half-initialized browser behind.
if let Err(e) = (self.on_launch)(Arc::clone(&shared)).await {
// Close the just-launched browser since we won't be using it.
let _ = handle.close().await;
return Err(e.context("BrowserManager: on_launch hook failed"));
}
guard.handle = Some(handle);
guard.shared = Some(shared);
self.launch_into(&mut guard).await?;
}
let browser = guard
.shared
@@ -134,13 +208,71 @@ impl BrowserManager {
})
}
/// Coordinated restart: block new acquires, wait for in-flight leases
/// to drain (up to `drain_deadline`, then force), close + relaunch
/// Chromium (re-running `on_launch` → re-inject session + probe), then
/// resume parked acquirers. Concurrent calls collapse into one
/// relaunch. The phase is always returned to `Healthy` — even if the
/// relaunch errors — so a failed restart never permanently wedges
/// acquisition (the next acquire retries the launch lazily).
pub async fn coordinated_restart(&self, drain_deadline: Duration) -> anyhow::Result<()> {
// Dedup: if a restart is already running, wait for it and report
// that restart's real outcome (not a blind success).
let _restart_guard = match self.restart_lock.try_lock() {
Ok(g) => g,
Err(_) => {
let _ = self.restart_lock.lock().await;
return if self.last_restart_ok.load(Ordering::Acquire) {
Ok(())
} else {
Err(anyhow::anyhow!("a concurrent coordinated browser restart failed"))
};
}
};
self.set_phase(RestartPhase::Draining);
await_drain(&self.active, drain_deadline).await;
self.set_phase(RestartPhase::Restarting);
// Take the dead handle out under the lock, release the lock, THEN run
// the (slow) Chromium teardown — so a worker that raced past the drain
// can't block on `acquire()` behind one dead browser's close(). Mirrors
// the idle reaper's take-drop-close ordering. Re-acquire to relaunch.
let dead = {
let mut guard = self.inner.lock().await;
guard.shared = None;
guard.handle.take()
};
if let Some(handle) = dead {
let _ = handle.close().await;
}
let relaunch = {
let mut guard = self.inner.lock().await;
self.launch_into(&mut guard).await
};
self.last_restart_ok.store(relaunch.is_ok(), Ordering::Release);
self.set_phase(RestartPhase::Healthy);
self.resume.notify_waiters();
match &relaunch {
Ok(()) => tracing::info!("BrowserManager: coordinated restart complete"),
Err(e) => tracing::error!(error = ?e, "BrowserManager: coordinated restart relaunch failed"),
}
relaunch.context("coordinated_restart: relaunch")
}
/// Forcefully close the cached browser regardless of active count.
/// Used on daemon shutdown. After this returns the next acquire will
/// re-launch from scratch.
pub async fn shutdown(&self) {
let mut guard = self.inner.lock().await;
guard.shared = None;
if let Some(handle) = guard.handle.take() {
// Take-then-drop-then-close: don't hold the lock across Chromium
// teardown (see `invalidate` / the idle reaper).
let handle = {
let mut guard = self.inner.lock().await;
guard.shared = None;
guard.handle.take()
};
if let Some(handle) = handle {
let _ = handle.close().await;
}
}
@@ -159,9 +291,16 @@ impl BrowserManager {
/// Idempotent: calling on an already-invalidated manager is a
/// no-op.
pub async fn invalidate(&self) {
let mut guard = self.inner.lock().await;
guard.shared = None;
if let Some(handle) = guard.handle.take() {
// Take the handle out under the lock, then release the lock BEFORE the
// slow Chromium close() — otherwise every other worker's `acquire()`
// serializes behind one dead browser's teardown. Matches the idle
// reaper's ordering at the bottom of this file.
let handle = {
let mut guard = self.inner.lock().await;
guard.shared = None;
guard.handle.take()
};
if let Some(handle) = handle {
let _ = handle.close().await;
tracing::warn!("BrowserManager: handle invalidated — next acquire will relaunch");
}
@@ -176,6 +315,29 @@ impl BrowserManager {
}
}
/// Wait for the active-lease count to reach zero, up to `deadline`. Wakes
/// on the tracker's idle signal and re-checks on a short poll so a missed
/// signal can't strand the drain. Returns when drained or when the
/// deadline elapses (the caller then force-restarts). Extracted as a free
/// fn so the timing logic is unit-testable without launching Chromium.
async fn await_drain(active: &Arc<ActiveTracker>, deadline: Duration) {
let start = tokio::time::Instant::now();
while active.current() > 0 {
let Some(remaining) = deadline.checked_sub(start.elapsed()) else {
tracing::warn!(
active = active.current(),
"coordinated_restart: drain deadline exceeded — forcing relaunch"
);
return;
};
let nap = remaining.min(Duration::from_millis(250));
tokio::select! {
_ = active.idle_signal().notified() => {}
_ = tokio::time::sleep(nap) => {}
}
}
}
/// Background reaper. Returns immediately when `idle_timeout == 0`.
/// Otherwise spawns a task that:
/// 1. Waits on `idle_signal` (woken when active hits zero).
@@ -270,6 +432,63 @@ mod tests {
mgr.invalidate().await;
}
#[tokio::test]
async fn await_drain_returns_immediately_when_already_idle() {
let active = ActiveTracker::new();
let start = tokio::time::Instant::now();
await_drain(&active, Duration::from_secs(5)).await;
assert!(start.elapsed() < Duration::from_millis(200), "no wait when idle");
}
#[tokio::test]
async fn await_drain_completes_when_lease_released() {
let active = ActiveTracker::new();
active.acquire();
let bg = {
let a = Arc::clone(&active);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
a.release();
})
};
// Generous deadline; should return shortly after the release, not
// at the deadline.
let start = tokio::time::Instant::now();
await_drain(&active, Duration::from_secs(5)).await;
assert!(start.elapsed() < Duration::from_secs(2), "drained on release");
assert_eq!(active.current(), 0);
bg.await.unwrap();
}
#[tokio::test]
async fn await_drain_force_returns_after_deadline_when_stuck() {
let active = ActiveTracker::new();
active.acquire(); // never released
let start = tokio::time::Instant::now();
await_drain(&active, Duration::from_millis(300)).await;
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(250), "waited ~deadline: {elapsed:?}");
assert!(elapsed < Duration::from_secs(2), "but not forever: {elapsed:?}");
assert_eq!(active.current(), 1, "still held — caller force-restarts");
}
#[test]
fn phase_transitions_reflect_is_restart_pending() {
let mgr = BrowserManager::new(
crate::crawler::browser::LaunchOptions::default(),
Duration::ZERO,
noop_on_launch(),
);
assert_eq!(mgr.phase(), RestartPhase::Healthy);
assert!(!mgr.is_restart_pending());
mgr.set_phase(RestartPhase::Draining);
assert!(mgr.is_restart_pending());
mgr.set_phase(RestartPhase::Restarting);
assert!(mgr.is_restart_pending());
mgr.set_phase(RestartPhase::Healthy);
assert!(!mgr.is_restart_pending());
}
#[tokio::test]
async fn active_tracker_signals_idle_only_on_zero_transition() {
let tracker = ActiveTracker::new();

File diff suppressed because it is too large Load Diff

View File

@@ -46,8 +46,9 @@ use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use crate::crawler::content::SyncOutcome;
use crate::crawler::jobs::{self, JobPayload, Lease, KIND_SYNC_CHAPTER_CONTENT};
use crate::crawler::jobs::{self, JobPayload, Lease, KIND_SYNC_CHAPTER_CONTENT, KIND_SYNC_MANGA};
use crate::crawler::pipeline;
use crate::crawler::status::{Phase, StatusHandle};
/// Fixed `pg_try_advisory_lock` key. ASCII "MANGALRD" interpreted as a
/// big-endian i64. Hardcoded so every replica agrees on the lock identity
@@ -56,6 +57,52 @@ pub const CRON_LOCK_KEY: i64 = 0x4D414E47414C5244;
const STATE_KEY_LAST_TICK: &str = "last_metadata_tick_at";
/// Lease window handed to `jobs::lease`. Kept short, but continuously
/// extended by the per-job heartbeat (see [`WorkerContext::process_lease`])
/// so a long-but-healthy job never lapses and gets stolen.
const LEASE_DURATION: Duration = Duration::from_secs(60);
/// How often the heartbeat renews the lease while a job runs. A third of
/// 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>;
@@ -66,6 +113,15 @@ pub trait ChapterDispatcher: Send + Sync {
async fn dispatch(&self, payload: JobPayload) -> anyhow::Result<SyncOutcome>;
}
/// A full list-only walk that enqueues mangas missing from the DB as
/// `SyncManga` jobs. Triggered on demand from the admin `/reconcile`
/// endpoint (not on the cron). Mirrors [`MetadataPass`] so the endpoint can
/// hold a `dyn` and tests can stub it.
#[async_trait]
pub trait ReconcilePass: Send + Sync {
async fn run(&self) -> anyhow::Result<crate::crawler::reconcile::ReconcileStats>;
}
/// Configuration for [`spawn`]. Use `None` for `metadata_pass` to disable
/// the cron entirely (worker-pool-only mode — useful when only the
/// bookmark-triggered enqueue path is wanted).
@@ -76,7 +132,15 @@ pub struct DaemonConfig {
pub daily_at: NaiveTime,
pub tz: Tz,
pub retention_days: u32,
pub metrics_retention_days: u32,
pub session_expired: Arc<AtomicBool>,
/// Live status surface updated by the cron + workers.
pub status: StatusHandle,
/// Hard upper bound on a single job's dispatch. A job that exceeds it
/// is acked failed (exponential backoff) rather than wedging a worker
/// forever. Must exceed [`LEASE_HEARTBEAT`] and the realistic
/// single-job runtime.
pub job_timeout: Duration,
/// Tasks that should run alongside the cron + workers and be cancelled
/// on shutdown. Used to hand the daemon ownership of the browser
/// manager's idle reaper.
@@ -122,7 +186,10 @@ pub fn spawn(pool: PgPool, cancel: CancellationToken, cfg: DaemonConfig) -> Daem
daily_at,
tz,
retention_days,
metrics_retention_days,
session_expired,
status,
job_timeout,
extra_tasks,
} = cfg;
@@ -133,7 +200,9 @@ pub fn spawn(pool: PgPool, cancel: CancellationToken, cfg: DaemonConfig) -> Daem
daily_at,
tz,
retention_days,
metrics_retention_days,
metadata,
status: status.clone(),
};
join.spawn(async move { ctx.run().await });
} else {
@@ -146,6 +215,8 @@ pub fn spawn(pool: PgPool, cancel: CancellationToken, cfg: DaemonConfig) -> Daem
cancel: cancel.clone(),
dispatcher: Arc::clone(&dispatcher),
session_expired: Arc::clone(&session_expired),
status: status.clone(),
job_timeout,
id: worker_id,
};
join.spawn(async move { ctx.run().await });
@@ -168,7 +239,9 @@ struct CronContext {
daily_at: NaiveTime,
tz: Tz,
retention_days: u32,
metrics_retention_days: u32,
metadata: Arc<dyn MetadataPass>,
status: StatusHandle,
}
impl CronContext {
@@ -196,6 +269,11 @@ impl CronContext {
// (NTP step, suspend/resume) don't strand us on a stale instant.
let next = next_fire(Utc::now(), self.daily_at, self.tz);
let wait = (next - Utc::now()).to_std().unwrap_or(Duration::ZERO);
self.status
.set_phase(Phase::Idle {
next_fire: Some(next),
})
.await;
tracing::info!(
next_fire_utc = %next.to_rfc3339(),
wait_seconds = wait.as_secs(),
@@ -239,13 +317,19 @@ impl CronContext {
// no future tick would ever run — workers would keep going but
// no new metadata work would be scheduled until daemon restart.
// The advisory unlock below runs unconditionally so a panicked
// tick doesn't leave the lock held for another replica.
// (or cancelled) tick doesn't leave the lock held for another
// replica.
let metadata = &self.metadata;
let pool = &self.pool;
let retention_days = self.retention_days;
let metrics_retention_days = self.metrics_retention_days;
let status = &self.status;
let body = async move {
match metadata.run().await {
Ok(stats) => tracing::info!(?stats, "cron: metadata pass done"),
Ok(stats) => {
status.record_pass(&stats, Utc::now()).await;
tracing::info!(?stats, "cron: metadata pass done");
}
Err(e) => tracing::error!(?e, "cron: metadata pass failed"),
}
match pipeline::enqueue_bookmarked_pending(pool).await {
@@ -254,16 +338,35 @@ impl CronContext {
}
Err(e) => tracing::error!(?e, "cron: enqueue_bookmarked_pending failed"),
}
match jobs::reap_done(pool, retention_days).await {
Ok(n) => tracing::info!(reaped = n, "cron: done-job reaper finished"),
Err(e) => tracing::error!(?e, "cron: done-job reaper failed"),
match jobs::reap_terminal(pool, retention_days).await {
Ok(n) => tracing::info!(reaped = n, "cron: terminal-job reaper finished"),
Err(e) => tracing::error!(?e, "cron: terminal-job reaper failed"),
}
match crate::repo::crawl_metrics::reap(pool, metrics_retention_days).await {
Ok(n) => tracing::info!(reaped = n, "cron: crawl-metrics reaper finished"),
Err(e) => tracing::error!(?e, "cron: crawl-metrics reaper failed"),
}
if let Err(e) = write_last_tick(pool, Utc::now()).await {
tracing::warn!(?e, "cron: persist last_metadata_tick_at failed");
}
};
if let Err(_panic) = AssertUnwindSafe(body).catch_unwind().await {
tracing::error!("cron: tick body panicked — continuing");
// Race the tick body against shutdown. Without this, a long
// metadata pass on a fresh catalog (minutes) wedges
// `DaemonHandle::shutdown`, `Supervisors::reload_crawler` (under
// the supervisor lock), and SIGTERM responsiveness for the
// entire pass. On cancel, dropping `body` cascades to dropping
// `metadata.run()`, which is what gives Chromium/lease cleanup
// their chance via the Browse Manager's idle reaper.
tokio::select! {
biased;
_ = self.cancel.cancelled() => {
tracing::info!("cron: cancelled mid-tick — abandoning to release advisory lock");
}
r = AssertUnwindSafe(body).catch_unwind() => {
if r.is_err() {
tracing::error!("cron: tick body panicked — continuing");
}
}
}
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
@@ -283,11 +386,16 @@ struct WorkerContext {
cancel: CancellationToken,
dispatcher: Arc<dyn ChapterDispatcher>,
session_expired: Arc<AtomicBool>,
status: StatusHandle,
job_timeout: Duration,
id: usize,
}
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");
@@ -299,11 +407,11 @@ impl WorkerContext {
_ = self.cancel.cancelled() => return,
}
}
let leases = match jobs::lease(
let leases = match jobs::lease_kinds(
&self.pool,
Some(KIND_SYNC_CHAPTER_CONTENT),
&[KIND_SYNC_CHAPTER_CONTENT, KIND_SYNC_MANGA],
1,
Duration::from_secs(60),
LEASE_DURATION,
)
.await
{
@@ -317,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;
}
}
@@ -336,17 +447,122 @@ impl WorkerContext {
.ok()
.flatten();
if matches!(page_count, Some(n) if n > 0) {
let _ = jobs::ack_done(&self.pool, lease.id).await;
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
return;
}
}
let outcome = AssertUnwindSafe(self.dispatcher.dispatch(lease.payload.clone()))
.catch_unwind()
.await;
// Heartbeat: keep the lease fresh while the (potentially long)
// 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) => failures = 0,
Ok(false) => break,
Err(e) => {
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;
}
}
}
}
})
};
// The "currently crawling" chapter (with its live page count) is
// registered by the dispatcher itself (RealChapterDispatcher) so it
// carries the manga/chapter identity + page progress and is removed
// via an RAII guard on every exit path.
// Outer timeout: a dispatch that exceeds `job_timeout` is acked
// failed (exponential backoff) rather than wedging the worker.
let dispatch = AssertUnwindSafe(self.dispatcher.dispatch(lease.payload.clone()))
.catch_unwind();
let outcome = tokio::select! {
// `biased` so a shutdown that arrives while the dispatch is also
// ready still takes the cancel arm and releases the lease.
biased;
_ = self.cancel.cancelled() => {
// Graceful shutdown mid-dispatch: drop the in-flight dispatch
// future (cancelling its browser work) and return the job to
// `pending` WITHOUT burning a retry attempt, so a clean
// restart doesn't march healthy jobs toward `max_attempts`.
// `release` refunds the lease's `attempts` increment, mirroring
// the session-expired path. Previously the worker had no cancel
// branch here, so a mid-dispatch SIGTERM left the row `running`
// until lease expiry and cost one attempt.
heartbeat.abort();
let _ = jobs::release(&self.pool, lease.id, lease.lease_generation).await;
tracing::info!(
worker = self.id,
lease_id = %lease.id,
"worker: shutdown mid-dispatch — released lease without burning an attempt"
);
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();
let outcome = match outcome {
Ok(o) => o,
Err(_elapsed) => {
tracing::warn!(
worker = self.id,
lease_id = %lease.id,
timeout_secs = self.job_timeout.as_secs(),
"worker: dispatch timed out — ack failed"
);
let _ = jobs::ack_failed(
&self.pool,
lease.id,
"dispatch timed out",
lease.attempts,
lease.max_attempts,
lease.lease_generation,
)
.await;
return;
}
};
match outcome {
Ok(Ok(SyncOutcome::Fetched { .. } | SyncOutcome::Skipped)) => {
let _ = jobs::ack_done(&self.pool, lease.id).await;
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
}
Ok(Ok(SyncOutcome::SessionExpired)) => {
tracing::error!(
@@ -355,7 +571,27 @@ impl WorkerContext {
"session expired — workers will idle until restart"
);
self.session_expired.store(true, Ordering::Release);
let _ = jobs::release(&self.pool, lease.id).await;
// Push the session-expired flip to live status subscribers.
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!(
@@ -370,6 +606,7 @@ impl WorkerContext {
&format!("{e:#}"),
lease.attempts,
lease.max_attempts,
lease.lease_generation,
)
.await;
}
@@ -385,6 +622,7 @@ impl WorkerContext {
"worker panicked",
lease.attempts,
lease.max_attempts,
lease.lease_generation,
)
.await;
}
@@ -535,6 +773,35 @@ pub mod test_support {
}
}
/// `MetadataPass` that sleeps for `delay` before returning. Lets a
/// test trigger shutdown WHILE a tick body is in flight, exercising
/// the cancellation race in `CronContext::run_tick`.
pub struct SlowMetadataPass {
pub delay: std::time::Duration,
pub started: AtomicUsize,
}
impl SlowMetadataPass {
pub fn new(delay: std::time::Duration) -> Arc<Self> {
Arc::new(Self {
delay,
started: AtomicUsize::new(0),
})
}
pub fn start_count(&self) -> usize {
self.started.load(Ordering::Acquire)
}
}
#[async_trait]
impl MetadataPass for SlowMetadataPass {
async fn run(&self) -> anyhow::Result<pipeline::MetadataStats> {
self.started.fetch_add(1, Ordering::AcqRel);
tokio::time::sleep(self.delay).await;
Ok(pipeline::MetadataStats::default())
}
}
pub type DispatchFn = Arc<
dyn Fn(JobPayload) -> futures_util::future::BoxFuture<'static, anyhow::Result<SyncOutcome>>
+ Send
@@ -574,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

@@ -80,13 +80,36 @@ pub fn has_logo_sentinel(doc: &scraper::Html) -> bool {
/// caller can fall back on the job system's retry/backoff once the
/// inline budget is exhausted.
pub async fn retry_on_transient<F, Fut, T>(
mut op: F,
op: F,
max_attempts: u32,
delay: Duration,
) -> Result<T, PageError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, PageError>>,
{
retry_on_transient_with_hook(op, max_attempts, delay, || async {}).await
}
/// Like [`retry_on_transient`] but invokes `on_retry` between a
/// transient failure and the subsequent sleep+retry. The hook does
/// **not** fire on the first attempt, after a non-transient error, or
/// after the final attempt (no retry follows). Hook failures are not
/// propagated — return `()` from the future and log inside if needed.
///
/// Wire the TOR controller's `new_identity` here to rotate circuits
/// between page-fetch retries; see [`crate::crawler::tor`].
pub async fn retry_on_transient_with_hook<F, Fut, T, H, HFut>(
mut op: F,
max_attempts: u32,
delay: Duration,
mut on_retry: H,
) -> Result<T, PageError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, PageError>>,
H: FnMut() -> HFut,
HFut: Future<Output = ()>,
{
debug_assert!(max_attempts >= 1, "max_attempts must be at least 1");
let mut attempt = 0u32;
@@ -101,8 +124,9 @@ where
attempt,
max_attempts,
error = %e,
"transient error; sleeping before retry"
"transient error; running on-retry hook and sleeping before retry"
);
on_retry().await;
tokio::time::sleep(delay).await;
}
}
@@ -247,4 +271,92 @@ mod tests {
assert_eq!(result.unwrap(), 7);
assert_eq!(attempt, 1);
}
#[tokio::test]
async fn hook_fires_once_between_transient_and_success() {
let mut attempt = 0u32;
let mut hook_calls = 0u32;
let result: Result<i32, PageError> = retry_on_transient_with_hook(
|| {
attempt += 1;
let n = attempt;
async move {
if n < 2 {
Err(PageError::transient("once"))
} else {
Ok(99)
}
}
},
5,
Duration::from_millis(0),
|| {
hook_calls += 1;
async {}
},
)
.await;
assert_eq!(result.unwrap(), 99);
assert_eq!(attempt, 2);
assert_eq!(hook_calls, 1, "hook fires exactly once between attempts");
}
#[tokio::test]
async fn hook_does_not_fire_when_first_attempt_succeeds() {
let mut hook_calls = 0u32;
let result: Result<i32, PageError> = retry_on_transient_with_hook(
|| async { Ok(1) },
5,
Duration::from_millis(0),
|| {
hook_calls += 1;
async {}
},
)
.await;
assert!(result.is_ok());
assert_eq!(hook_calls, 0);
}
#[tokio::test]
async fn hook_does_not_fire_after_non_transient_error() {
let mut hook_calls = 0u32;
let result: Result<i32, PageError> = retry_on_transient_with_hook(
|| async { Err(PageError::Other(anyhow::anyhow!("permanent"))) },
5,
Duration::from_millis(0),
|| {
hook_calls += 1;
async {}
},
)
.await;
assert!(result.is_err());
assert_eq!(hook_calls, 0, "non-transient must short-circuit before hook");
}
#[tokio::test]
async fn hook_does_not_fire_after_final_failed_attempt() {
// With max_attempts=3 and three persistent transients, the hook
// should run twice (between 1→2 and 2→3) — never a third time,
// because no retry follows attempt 3.
let mut attempt = 0u32;
let mut hook_calls = 0u32;
let result: Result<i32, PageError> = retry_on_transient_with_hook(
|| {
attempt += 1;
async { Err(PageError::transient("always")) }
},
3,
Duration::from_millis(0),
|| {
hook_calls += 1;
async {}
},
)
.await;
assert!(result.is_err());
assert_eq!(attempt, 3);
assert_eq!(hook_calls, 2, "hook fires N-1 times for N attempts that all fail transient");
}
}

View File

@@ -0,0 +1,301 @@
//! Optional CDP-level SSRF guard for headless-browser navigations.
//!
//! The reqwest clients get a DNS-filtering resolver (see
//! [`crate::crawler::safety::SafeResolver`]) so an image/API host that
//! resolves to an internal IP is refused at connect time. Chromium, however,
//! does its **own** DNS and connection handling, so that resolver can't see
//! browser navigations. Without a second guard, a scraped chapter page that
//! `302`-redirects the browser to `http://127.0.0.1:5432/` (or an
//! 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** 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
//! fragile hook in the crawler's critical path. It ships behind
//! `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. 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};
use chromiumoxide::browser::Browser;
use chromiumoxide::cdp::browser_protocol::fetch::{
ContinueRequestParams, EnableParams, EventRequestPaused, FailRequestParams, RequestPattern,
RequestStage,
};
use chromiumoxide::cdp::browser_protocol::network::ErrorReason;
use chromiumoxide::error::Result as CdpResult;
use chromiumoxide::Page;
use futures_util::StreamExt;
use reqwest::Url;
use crate::crawler::safety::{ensure_public_target, is_private_ip};
/// Process-wide toggle, set once at startup from `CRAWLER_SSRF_INTERCEPT`.
/// A single boot-time flag (rather than threading a param through every
/// crawler navigation signature) keeps the off-path a no-op.
static ENABLED: AtomicBool = AtomicBool::new(false);
pub fn set_enabled(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// What to do with a navigation URL, decided without DNS where possible so the
/// security-critical branching is unit-testable.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Verdict {
/// Safe to continue (non-network scheme, or a public IP literal).
Allow,
/// Refuse — bad scheme handled elsewhere, localhost, or a private IP literal.
Block,
/// A hostname that must be resolved to decide (DNS-rebinding check).
ResolveHost { host: String, port: u16 },
}
/// Pure decision over a URL string. `data:` / `blob:` / `about:` and other
/// non-http(s) schemes are allowed (not a network-SSRF vector); http(s) with a
/// private/loopback/localhost literal is blocked; an http(s) hostname needs
/// resolution.
pub(crate) fn verdict(url: &str) -> Verdict {
let Ok(parsed) = Url::parse(url) else {
// Unparseable — let Chromium reject it; not our call to make.
return Verdict::Allow;
};
match parsed.scheme() {
"http" | "https" => {}
// Non-http(s) subresource schemes: block the ones that can reach the
// local filesystem or pivot to another protocol; continue the rest
// (data:/blob:/about: and Chrome-internal schemes) which legitimate page
// loads depend on — blocking those would wedge navigation.
"file" | "ftp" | "gopher" => return Verdict::Block,
_ => return Verdict::Allow,
}
// Literal private IP / localhost / missing host → block (string check).
if ensure_public_target(url).is_err() {
return Verdict::Block;
}
match parsed.host_str() {
Some(host) if !is_ip_literal(host) => Verdict::ResolveHost {
host: host.to_string(),
port: parsed.port_or_known_default().unwrap_or(80),
},
// Public IP literal (ensure_public_target already passed it).
_ => Verdict::Allow,
}
}
/// Whether `host` (as reqwest's `host_str()` yields it) is an IP literal.
/// IPv6 literals arrive bracketed (`[::1]`), which don't parse as `IpAddr`
/// directly — strip the brackets first.
fn is_ip_literal(host: &str) -> bool {
let unbracketed = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
unbracketed.parse::<IpAddr>().is_ok()
}
/// Async decision: resolves hostnames and blocks if any resolved address is
/// private (DNS rebinding). A resolution failure is *not* treated as blocked —
/// Chromium won't be able to connect either, so there's nothing to exfiltrate.
pub(crate) async fn is_blocked(url: &str) -> bool {
match verdict(url) {
Verdict::Allow => false,
Verdict::Block => true,
Verdict::ResolveHost { host, port } => match tokio::net::lookup_host((host.as_str(), port))
.await
{
Ok(addrs) => addrs.map(|a| a.ip()).any(|ip| is_private_ip(&ip)),
Err(_) => false,
},
}
}
/// Open a page for navigation. With interception off, this is exactly
/// `browser.new_page(url)`. With it on, the page is created blank (no network),
/// the navigation guard is installed, and only then does it navigate — so the
/// initial request and any redirects pass through the guard.
pub async fn open_page(browser: &Browser, url: &str) -> CdpResult<Page> {
if !is_enabled() {
return browser.new_page(url).await;
}
let page = browser.new_page("about:blank").await?;
if let Err(e) = install_navigation_guard(&page).await {
// 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)
}
/// 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<()> {
page.execute(EnableParams {
patterns: Some(interception_patterns()),
handle_auth_requests: None,
})
.await?;
let mut paused = page.event_listener::<EventRequestPaused>().await?;
let handler_page = page.clone();
tokio::spawn(async move {
while let Some(ev) = paused.next().await {
let request_id = ev.request_id.clone();
let outcome = if is_blocked(&ev.request.url).await {
tracing::warn!(
url = %ev.request.url,
"SSRF guard blocked browser navigation to an internal target"
);
handler_page
.execute(FailRequestParams::new(request_id, ErrorReason::BlockedByClient))
.await
.map(|_| ())
} else {
handler_page
.execute(ContinueRequestParams::new(request_id))
.await
.map(|_| ())
};
if let Err(e) = outcome {
// The page/session is gone (page closed) — the stream will end
// too; stop handling so the task exits rather than spins.
tracing::debug!(error = %e, "fetch interceptor: continue/fail failed, ending handler");
break;
}
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_allows_non_network_schemes() {
for url in ["about:blank", "data:text/html,hi", "blob:abc", "chrome://version"] {
assert_eq!(verdict(url), Verdict::Allow, "{url} should be allowed");
}
}
#[test]
fn verdict_blocks_dangerous_non_http_schemes() {
// file:/ftp:/gopher: can reach the local filesystem or pivot protocols;
// block them explicitly rather than falling through to Allow.
for url in ["file:///etc/passwd", "ftp://internal/x", "gopher://internal:70/1"] {
assert_eq!(verdict(url), Verdict::Block, "{url} should be blocked");
}
}
#[test]
fn verdict_blocks_private_ip_literals_and_localhost() {
for url in [
"http://127.0.0.1/",
"http://10.0.0.1/",
"http://192.168.1.1:5432/",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://localhost/",
"https://[fd00::1]/",
] {
assert_eq!(verdict(url), Verdict::Block, "{url} should be blocked");
}
}
#[test]
fn verdict_allows_public_ip_literals() {
for url in ["http://93.184.216.34/", "https://[2606:4700:4700::1111]/"] {
assert_eq!(verdict(url), Verdict::Allow, "{url} should be allowed");
}
}
#[test]
fn verdict_defers_hostnames_to_resolution() {
assert_eq!(
verdict("https://cdn.example.com/img.jpg"),
Verdict::ResolveHost {
host: "cdn.example.com".to_string(),
port: 443
}
);
assert_eq!(
verdict("http://catalog.test:8080/list"),
Verdict::ResolveHost {
host: "catalog.test".to_string(),
port: 8080
}
);
}
#[tokio::test]
async fn is_blocked_true_for_private_literal_no_dns() {
assert!(is_blocked("http://169.254.169.254/").await);
assert!(!is_blocked("http://93.184.216.34/").await);
// Non-network scheme is always allowed through.
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.
let prev = is_enabled();
set_enabled(true);
assert!(is_enabled());
set_enabled(false);
assert!(!is_enabled());
set_enabled(prev);
}
}

View File

@@ -15,11 +15,18 @@ use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum JobPayload {
/// Fetch one manga's detail page, upsert metadata, enqueue
/// `SyncChapterList`.
/// Fetch one manga's detail page, upsert metadata, sync its chapter
/// list. `url` and `title` are carried from the list ref so the worker
/// can reconstruct a `SourceMangaRef` without re-walking, and so the
/// dead-jobs/history UI can render a label even before any manga row
/// exists. `#[serde(default)]` keeps older/hand-inserted rows decodable.
SyncManga {
source_id: String,
source_manga_key: String,
#[serde(default)]
url: String,
#[serde(default)]
title: String,
},
/// Diff the chapter list, enqueue `SyncChapterContent` for new
/// chapters, soft-drop vanished ones.
@@ -34,6 +41,15 @@ pub enum JobPayload {
chapter_id: Uuid,
source_chapter_key: String,
},
/// Run AI content-analysis (OCR, auto-tags, scene description, NSFW
/// moderation) on a single page image via the local vision model.
/// `force` re-analyzes a page that is already `done` (manual admin
/// re-trigger); otherwise the worker skips it.
AnalyzePage {
page_id: Uuid,
#[serde(default)]
force: bool,
},
}
#[derive(Clone, Copy, Debug, sqlx::Type, Serialize, Deserialize)]
@@ -52,6 +68,15 @@ pub enum JobState {
/// without re-spelling the literal.
pub const KIND_SYNC_CHAPTER_CONTENT: &str = "sync_chapter_content";
/// Kind discriminator for manga-detail sync jobs (used by the reconcile
/// pass to enqueue missing mangas). The crawl worker leases this alongside
/// `KIND_SYNC_CHAPTER_CONTENT`; both serialize on the single browser.
pub const KIND_SYNC_MANGA: &str = "sync_manga";
/// Kind discriminator for AI page-analysis jobs. The analysis daemon
/// leases with this filter so it never contends with crawl jobs.
pub const KIND_ANALYZE_PAGE: &str = "analyze_page";
#[derive(Debug)]
pub enum EnqueueResult {
Inserted(Uuid),
@@ -64,32 +89,60 @@ pub struct Lease {
pub payload: JobPayload,
pub attempts: i32,
pub max_attempts: i32,
/// Strictly-increasing token bumped by every `lease`, `release` /
/// `release_unowned`, and `reclaim_orphaned`. `ack_done` /
/// `ack_failed` / `renew` match on `(id, lease_generation)` so a
/// late ack from a dead-but-still-dispatching lease cannot clobber
/// a successor that has re-leased the same row. See migration
/// 0032 for the column and the `ack_done_from_dead_lease_*` tests
/// in `crawler_jobs.rs` for the race the token closes.
pub lease_generation: i64,
}
/// Exponential backoff for `ack_failed` retries. `attempts` is the
/// post-increment value reported by `lease()` (so the first failure has
/// `attempts == 1` and waits 60s, the second 120s, etc.). Capped at 1h to
/// avoid runaway long sleeps that would outlive the daemon process.
fn backoff_for(attempts: i32) -> Duration {
/// Deterministic exponential backoff base for `ack_failed` retries.
/// `attempts` is the post-increment value reported by `lease()` (so the
/// first failure has `attempts == 1` and waits 60s, the second 120s,
/// etc.). Capped at 1h to avoid runaway long sleeps that would outlive
/// the daemon process. Jitter is applied separately by [`apply_jitter`].
fn backoff_base(attempts: i32) -> Duration {
let shift = attempts.saturating_sub(1).clamp(0, 20) as u32;
let secs = 60u64.saturating_mul(1u64 << shift);
Duration::from_secs(secs.min(3600))
}
/// Apply ±20% jitter to a backoff duration. `jitter` is a fraction in
/// `[0.0, 1.0)` (e.g. `rand::random::<f64>()`), mapped to a multiplier in
/// `[0.8, 1.2)`. Pure so the bounds stay unit-testable. Spreading retries
/// avoids a thundering herd when a source outage fails many jobs at once.
fn apply_jitter(base: Duration, jitter: f64) -> Duration {
let frac = jitter.clamp(0.0, 1.0);
let mult = 0.8 + 0.4 * frac; // [0.8, 1.2)
Duration::from_secs((base.as_secs_f64() * mult).round() as u64)
}
/// Jittered exponential backoff for `ack_failed`. Wraps [`backoff_base`]
/// with a random ±20% spread.
fn backoff_for(attempts: i32) -> Duration {
apply_jitter(backoff_base(attempts), rand::random::<f64>())
}
/// Insert a new pending job. For `SyncChapterContent` payloads the
/// partial unique index `crawler_jobs_chapter_content_dedup_idx` blocks
/// a second `(pending|running)` insert per chapter_id, returning
/// `Skipped`. The slot frees again once the previous job leaves the
/// in-flight states (done/failed/dead), so a re-enqueue after a force
/// refetch succeeds.
pub async fn enqueue(pool: &PgPool, payload: &JobPayload) -> sqlx::Result<EnqueueResult> {
pub async fn enqueue<'e, E>(executor: E, payload: &JobPayload) -> sqlx::Result<EnqueueResult>
where
E: sqlx::PgExecutor<'e>,
{
let json = serde_json::to_value(payload).expect("JobPayload is always serializable");
let id: Option<Uuid> = sqlx::query_scalar(
"INSERT INTO crawler_jobs (payload) VALUES ($1) \
ON CONFLICT DO NOTHING RETURNING id",
)
.bind(json)
.fetch_optional(pool)
.fetch_optional(executor)
.await?;
Ok(match id {
Some(id) => EnqueueResult::Inserted(id),
@@ -104,6 +157,12 @@ pub async fn enqueue(pool: &PgPool, payload: &JobPayload) -> sqlx::Result<Enqueu
///
/// `kind_filter` matches against `payload->>'kind'`; `None` means
/// any kind.
///
/// Ties on `scheduled_at` (the common case: a cron batch enqueues
/// everything with the same default `now()`) break by `created_at`, so
/// jobs come off the queue in insertion order. The enqueue paths insert
/// chapter-content jobs in ascending `chapters.number` order, so this
/// tiebreaker is what propagates that intent through to dequeue.
pub async fn lease(
pool: &PgPool,
kind_filter: Option<&str>,
@@ -111,25 +170,26 @@ pub async fn lease(
lease_duration: Duration,
) -> sqlx::Result<Vec<Lease>> {
let lease_ms: i64 = lease_duration.as_millis().min(i64::MAX as u128) as i64;
let rows: Vec<(Uuid, serde_json::Value, i32, i32)> = sqlx::query_as(
let rows: Vec<(Uuid, serde_json::Value, i32, i32, i64)> = sqlx::query_as(
r#"
WITH leased AS (
SELECT id FROM crawler_jobs
WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
AND scheduled_at <= now()
AND ($1::text IS NULL OR payload->>'kind' = $1)
ORDER BY scheduled_at
ORDER BY scheduled_at, created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
)
UPDATE crawler_jobs j
SET state = 'running',
attempts = j.attempts + 1,
lease_generation = j.lease_generation + 1,
leased_until = now() + ($3::bigint || ' milliseconds')::interval,
updated_at = now()
FROM leased l
WHERE j.id = l.id
RETURNING j.id, j.payload, j.attempts, j.max_attempts
RETURNING j.id, j.payload, j.attempts, j.max_attempts, j.lease_generation
"#,
)
.bind(kind_filter)
@@ -138,8 +198,56 @@ pub async fn lease(
.fetch_all(pool)
.await?;
decode_leases(rows)
}
/// Like [`lease`] but matches any of several `payload->>'kind'` values. The
/// crawl worker uses this to drain both `sync_chapter_content` and
/// `sync_manga` from one loop (both serialize on the single browser); the
/// analysis daemon keeps using single-kind [`lease`] so it never contends.
pub async fn lease_kinds(
pool: &PgPool,
kinds: &[&str],
max: i64,
lease_duration: Duration,
) -> sqlx::Result<Vec<Lease>> {
let lease_ms: i64 = lease_duration.as_millis().min(i64::MAX as u128) as i64;
let kinds_vec: Vec<String> = kinds.iter().map(|s| s.to_string()).collect();
let rows: Vec<(Uuid, serde_json::Value, i32, i32, i64)> = sqlx::query_as(
r#"
WITH leased AS (
SELECT id FROM crawler_jobs
WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
AND scheduled_at <= now()
AND payload->>'kind' = ANY($1)
ORDER BY scheduled_at, created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
)
UPDATE crawler_jobs j
SET state = 'running',
attempts = j.attempts + 1,
lease_generation = j.lease_generation + 1,
leased_until = now() + ($3::bigint || ' milliseconds')::interval,
updated_at = now()
FROM leased l
WHERE j.id = l.id
RETURNING j.id, j.payload, j.attempts, j.max_attempts, j.lease_generation
"#,
)
.bind(&kinds_vec)
.bind(max)
.bind(lease_ms)
.fetch_all(pool)
.await?;
decode_leases(rows)
}
fn decode_leases(
rows: Vec<(Uuid, serde_json::Value, i32, i32, i64)>,
) -> sqlx::Result<Vec<Lease>> {
let mut leases = Vec::with_capacity(rows.len());
for (id, payload_json, attempts, max_attempts) in rows {
for (id, payload_json, attempts, max_attempts, lease_generation) in rows {
let payload: JobPayload = serde_json::from_value(payload_json).map_err(|e| {
sqlx::Error::Decode(format!("invalid JobPayload JSON for job {id}: {e}").into())
})?;
@@ -148,31 +256,71 @@ pub async fn lease(
payload,
attempts,
max_attempts,
lease_generation,
});
}
Ok(leases)
}
/// Mark a leased job as successfully completed. The `state = 'running'`
/// predicate guards against a late ack from a worker whose lease expired
/// and was already re-leased by another worker: without it, the late ack
/// would clobber the new lease's `state` and `leased_until`. `rows_affected
/// == 0` means we lost the lease — surfaced as a warn rather than an
/// error because the new lease holder is doing real work; the late ack
/// just has to step aside.
pub async fn ack_done(pool: &PgPool, lease_id: Uuid) -> sqlx::Result<()> {
/// Extend the lease on a still-owned `running` job. Returns `true` if the
/// row was updated (we still hold the lease), `false` if the job is no
/// longer `running` (re-leased after a missed heartbeat, or already
/// acked) — the caller's heartbeat loop should stop. The `state =
/// 'running'` guard mirrors [`ack_done`]'s rationale.
///
/// This is the heartbeat primitive: a worker renews periodically while a
/// long-but-healthy job runs so `leased_until` never lapses, which would
/// otherwise let another worker steal the in-flight job and spuriously
/// inflate `attempts` toward `max_attempts`.
pub async fn renew(
pool: &PgPool,
lease_id: Uuid,
lease_generation: i64,
lease_duration: Duration,
) -> sqlx::Result<bool> {
let lease_ms: i64 = lease_duration.as_millis().min(i64::MAX as u128) as i64;
let res = sqlx::query(
"UPDATE crawler_jobs \
SET leased_until = now() + ($3::bigint || ' milliseconds')::interval, \
updated_at = now() \
WHERE id = $1 AND lease_generation = $2 AND state = 'running'",
)
.bind(lease_id)
.bind(lease_generation)
.bind(lease_ms)
.execute(pool)
.await?;
Ok(res.rows_affected() > 0)
}
/// Mark a leased job as successfully completed. The
/// `(lease_generation, state = 'running')` predicate guards against a
/// late ack from a *specific* dead lease — the case where the worker's
/// lease was released or expired and a successor re-leased the same id.
/// Before migration 0032 the guard was state-only, so a late ack from
/// the original could still match (state was running again, owned by the
/// successor) and clobber. Scoping to the original's `lease_generation`
/// makes the match fail cleanly. `rows_affected == 0` is surfaced as a
/// warn — the successor is doing real work; the late ack steps aside.
pub async fn ack_done(
pool: &PgPool,
lease_id: Uuid,
lease_generation: i64,
) -> sqlx::Result<()> {
let res = sqlx::query(
"UPDATE crawler_jobs \
SET state = 'done', leased_until = NULL, updated_at = now() \
WHERE id = $1 AND state = 'running'",
WHERE id = $1 AND lease_generation = $2 AND state = 'running'",
)
.bind(lease_id)
.bind(lease_generation)
.execute(pool)
.await?;
if res.rows_affected() == 0 {
tracing::warn!(
%lease_id,
"ack_done: lease no longer running — likely re-leased by another worker; skipping update"
lease_generation,
"ack_done: lease no longer current — generation mismatch (force-analyzed, reclaimed, or re-leased); skipping update"
);
}
Ok(())
@@ -189,15 +337,17 @@ pub async fn ack_failed(
error: &str,
attempts: i32,
max_attempts: i32,
lease_generation: i64,
) -> sqlx::Result<()> {
let res = if attempts >= max_attempts {
sqlx::query(
"UPDATE crawler_jobs \
SET state = 'dead', last_error = $2, leased_until = NULL, updated_at = now() \
WHERE id = $1 AND state = 'running'",
WHERE id = $1 AND lease_generation = $3 AND state = 'running'",
)
.bind(lease_id)
.bind(error)
.bind(lease_generation)
.execute(pool)
.await?
} else {
@@ -207,84 +357,297 @@ pub async fn ack_failed(
SET state = 'pending', last_error = $2, leased_until = NULL, \
scheduled_at = now() + ($3::bigint || ' milliseconds')::interval, \
updated_at = now() \
WHERE id = $1 AND state = 'running'",
WHERE id = $1 AND lease_generation = $4 AND state = 'running'",
)
.bind(lease_id)
.bind(error)
.bind(backoff_ms)
.bind(lease_generation)
.execute(pool)
.await?
};
if res.rows_affected() == 0 {
tracing::warn!(
%lease_id,
"ack_failed: lease no longer running — likely re-leased by another worker; skipping update"
lease_generation,
"ack_failed: lease no longer current — generation mismatch (force-analyzed, reclaimed, or re-leased); skipping update"
);
}
Ok(())
}
/// Return a leased job to `pending` without burning a retry attempt.
/// Used on graceful shutdown and on session-expired aborts where the
/// failure isn't the job's fault. See [`ack_done`] for the
/// `state = 'running'` guard rationale — important here because
/// `attempts - 1` would otherwise spuriously decrement the new lease's
/// attempt count.
pub async fn release(pool: &PgPool, lease_id: Uuid) -> sqlx::Result<()> {
/// Used by workers on graceful shutdown and on session-expired aborts
/// where the failure isn't the job's fault. The
/// `(lease_generation, state = 'running')` guard scopes the release to
/// the *specific* lease the caller was holding — otherwise a late
/// release could clobber a successor's lease (drop the row back to
/// pending mid-dispatch). Also bumps `lease_generation` so any
/// subsequent ack from this same lease finds nothing.
///
/// For force-analyze and other external paths that release a
/// running lease without holding a [`Lease`] struct, use
/// [`release_unowned`] instead.
pub async fn release(
pool: &PgPool,
lease_id: Uuid,
lease_generation: i64,
) -> sqlx::Result<()> {
let res = sqlx::query(
"UPDATE crawler_jobs \
SET state = 'pending', leased_until = NULL, \
attempts = GREATEST(0, attempts - 1), updated_at = now() \
WHERE id = $1 AND state = 'running'",
attempts = GREATEST(0, attempts - 1), \
lease_generation = lease_generation + 1, \
updated_at = now() \
WHERE id = $1 AND lease_generation = $2 AND state = 'running'",
)
.bind(lease_id)
.bind(lease_generation)
.execute(pool)
.await?;
if res.rows_affected() == 0 {
tracing::warn!(
%lease_id,
"release: lease no longer running — likely re-leased by another worker; skipping update"
lease_generation,
"release: lease no longer current — generation mismatch (force-analyzed, reclaimed, or re-leased); skipping update"
);
}
Ok(())
}
/// Delete `done` jobs whose `updated_at` is older than `retention_days`
/// days. `0` disables the reaper without touching the table. Returns the
/// number of rows removed.
pub async fn reap_done(pool: &PgPool, retention_days: u32) -> sqlx::Result<u64> {
if retention_days == 0 {
return Ok(0);
}
let result = sqlx::query(
"DELETE FROM crawler_jobs \
WHERE state = 'done' \
AND updated_at < now() - ($1::bigint || ' days')::interval",
/// Release a `running` lease without owning it: caller has only a
/// job id, not a [`Lease`] struct. Used by force-analyze (admin
/// click that drops an in-flight lease so a refreshed payload is
/// picked up) and ops tools.
///
/// Unconditionally matches the row by id and bumps
/// `lease_generation` so the dropped lease's pending ack from
/// the original worker becomes a no-op. Returns the attempt to
/// `pending` and refunds the retry attempt (the operator click
/// isn't a job-level failure).
pub async fn release_unowned<'e, E>(executor: E, lease_id: Uuid) -> sqlx::Result<()>
where
E: sqlx::PgExecutor<'e>,
{
let res = sqlx::query(
"UPDATE crawler_jobs \
SET state = 'pending', leased_until = NULL, \
attempts = GREATEST(0, attempts - 1), \
lease_generation = lease_generation + 1, \
updated_at = now() \
WHERE id = $1 AND state = 'running'",
)
.bind(lease_id)
.execute(executor)
.await?;
if res.rows_affected() == 0 {
tracing::warn!(
%lease_id,
"release_unowned: row not running — likely already completed or never leased; skipping"
);
}
Ok(())
}
/// Reclaim jobs orphaned by a crashed/killed worker: those still `running`
/// whose `leased_until` has already lapsed. Each is returned to `pending`
/// with its lease cleared and the lease's `attempts` increment refunded
/// (`GREATEST(0, attempts - 1)`, mirroring [`release`]) — a crash that
/// interrupted an attempt mid-flight shouldn't count against `max_attempts`.
/// Returns the number reclaimed.
///
/// Intended to run once at daemon startup so crash recovery is immediate
/// rather than waiting up to a full lease window for `lease`'s expiry clause
/// to re-pick the row. It is safe under multi-replica deployment precisely
/// because it only touches **already-expired** leases: a healthy peer
/// heartbeats (`renew`) every ~20s, keeping its in-flight jobs' `leased_until`
/// in the future, so this never steals live work — it only does eagerly what
/// `lease` would do lazily, minus the attempt burn.
///
/// Trade-off: a job whose dispatch reliably hard-kills the process (e.g. OOM
/// on a pathological payload) is refunded each boot and could loop without
/// dead-lettering. That window is bounded in practice by the per-image size
/// cap and the worker's `job_timeout` (a hang is acked-failed normally, which
/// *does* burn an attempt); only a true process-killer evades it, which is an
/// infrastructure signal worth surfacing rather than silently dead-lettering
/// everyone's chapters during a crash loop.
pub async fn reclaim_orphaned(pool: &PgPool) -> sqlx::Result<u64> {
let result = sqlx::query(
"UPDATE crawler_jobs \
SET state = 'pending', leased_until = NULL, \
attempts = GREATEST(0, attempts - 1), \
lease_generation = lease_generation + 1, \
updated_at = now() \
WHERE state = 'running' AND leased_until < now()",
)
.bind(retention_days as i64)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Delete **terminal** jobs (`done` or `dead`) whose `updated_at` is older
/// than `retention_days` days. Both states are end-of-life — `done`
/// succeeded, `dead` exhausted its retries — and neither is ever leased
/// again, so reaping only `done` left `dead` rows to accumulate forever.
/// `pending` / `running` are active and never touched. `0` disables the
/// reaper without touching the table. Returns the number of rows removed.
pub async fn reap_terminal(pool: &PgPool, retention_days: u32) -> sqlx::Result<u64> {
if retention_days == 0 {
return Ok(0);
}
// Delete in bounded batches rather than one statement. A single unbatched
// DELETE over an unbounded backlog takes a long-held lock and one huge
// transaction; batching keeps each delete short (index-backed by
// crawler_jobs_terminal_reap_idx, 0040) so the reaper never pins the table
// (audit M5). Loop until a batch comes back short.
const BATCH: i64 = 5_000;
let mut total: u64 = 0;
loop {
let result = sqlx::query(
"DELETE FROM crawler_jobs \
WHERE ctid IN ( \
SELECT ctid FROM crawler_jobs \
WHERE state IN ('done', 'dead') \
AND updated_at < now() - ($1::bigint || ' days')::interval \
LIMIT $2 )",
)
.bind(retention_days as i64)
.bind(BATCH)
.execute(pool)
.await?;
let n = result.rows_affected();
total += n;
if n < BATCH as u64 {
break;
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_exponentially_and_caps_at_one_hour() {
fn analyze_page_payload_round_trips_through_tagged_json() {
let page_id = Uuid::new_v4();
let payload = JobPayload::AnalyzePage {
page_id,
force: true,
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["kind"], "analyze_page");
assert_eq!(json["page_id"], page_id.to_string());
assert_eq!(json["force"], true);
// `force` defaults to false when an older enqueue omitted it.
let legacy = serde_json::json!({ "kind": "analyze_page", "page_id": page_id });
match serde_json::from_value::<JobPayload>(legacy).unwrap() {
JobPayload::AnalyzePage { page_id: pid, force } => {
assert_eq!(pid, page_id);
assert!(!force);
}
other => panic!("expected AnalyzePage, got {other:?}"),
}
}
#[test]
fn sync_manga_payload_round_trips_with_url_and_title() {
let payload = JobPayload::SyncManga {
source_id: "target".into(),
source_manga_key: "foo".into(),
url: "https://target.example/manga/foo".into(),
title: "Foo Title".into(),
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["kind"], KIND_SYNC_MANGA);
assert_eq!(json["source_id"], "target");
assert_eq!(json["source_manga_key"], "foo");
assert_eq!(json["url"], "https://target.example/manga/foo");
assert_eq!(json["title"], "Foo Title");
match serde_json::from_value::<JobPayload>(json).unwrap() {
JobPayload::SyncManga {
source_id,
source_manga_key,
url,
title,
} => {
assert_eq!(source_id, "target");
assert_eq!(source_manga_key, "foo");
assert_eq!(url, "https://target.example/manga/foo");
assert_eq!(title, "Foo Title");
}
other => panic!("expected SyncManga, got {other:?}"),
}
}
#[test]
fn sync_manga_payload_defaults_missing_url_and_title() {
// A row that predates the url/title fields must still decode.
let legacy = serde_json::json!({
"kind": "sync_manga",
"source_id": "target",
"source_manga_key": "foo",
});
match serde_json::from_value::<JobPayload>(legacy).unwrap() {
JobPayload::SyncManga {
url,
title,
source_manga_key,
..
} => {
assert_eq!(source_manga_key, "foo");
assert_eq!(url, "");
assert_eq!(title, "");
}
other => panic!("expected SyncManga, got {other:?}"),
}
}
#[test]
fn backoff_base_grows_exponentially_and_caps_at_one_hour() {
// attempts == 1 → 60s, doubling each step.
assert_eq!(backoff_for(1), Duration::from_secs(60));
assert_eq!(backoff_for(2), Duration::from_secs(120));
assert_eq!(backoff_for(3), Duration::from_secs(240));
assert_eq!(backoff_for(4), Duration::from_secs(480));
assert_eq!(backoff_for(5), Duration::from_secs(960));
assert_eq!(backoff_for(6), Duration::from_secs(1920));
assert_eq!(backoff_base(1), Duration::from_secs(60));
assert_eq!(backoff_base(2), Duration::from_secs(120));
assert_eq!(backoff_base(3), Duration::from_secs(240));
assert_eq!(backoff_base(4), Duration::from_secs(480));
assert_eq!(backoff_base(5), Duration::from_secs(960));
assert_eq!(backoff_base(6), Duration::from_secs(1920));
// 7th: 60 * 64 = 3840 → capped to 3600.
assert_eq!(backoff_for(7), Duration::from_secs(3600));
assert_eq!(backoff_for(20), Duration::from_secs(3600));
assert_eq!(backoff_base(7), Duration::from_secs(3600));
assert_eq!(backoff_base(20), Duration::from_secs(3600));
// Garbage / zero / negatives stay sane.
assert_eq!(backoff_for(0), Duration::from_secs(60));
assert_eq!(backoff_for(-5), Duration::from_secs(60));
assert_eq!(backoff_base(0), Duration::from_secs(60));
assert_eq!(backoff_base(-5), Duration::from_secs(60));
}
#[test]
fn apply_jitter_stays_within_plus_minus_twenty_percent() {
let base = Duration::from_secs(100);
// Lower bound (jitter = 0.0) → 0.8x.
assert_eq!(apply_jitter(base, 0.0), Duration::from_secs(80));
// Midpoint (jitter = 0.5) → 1.0x.
assert_eq!(apply_jitter(base, 0.5), Duration::from_secs(100));
// Upper end (jitter → 1.0) → ~1.2x.
assert_eq!(apply_jitter(base, 1.0), Duration::from_secs(120));
// Out-of-range inputs are clamped, never panic.
assert_eq!(apply_jitter(base, -3.0), Duration::from_secs(80));
assert_eq!(apply_jitter(base, 9.0), Duration::from_secs(120));
}
#[test]
fn backoff_for_random_jitter_stays_in_band() {
// The production wrapper draws its own randomness; assert the
// result for a mid-range attempt always lands within the jitter
// band of the base, across many draws.
let base = backoff_base(3).as_secs_f64(); // 240s
for _ in 0..1000 {
let v = backoff_for(3).as_secs_f64();
assert!(
v >= base * 0.8 - 1.0 && v <= base * 1.2 + 1.0,
"jittered backoff {v} outside band of base {base}"
);
}
}
}

View File

@@ -19,11 +19,17 @@ pub mod content;
pub mod daemon;
pub mod detect;
pub mod diff;
pub mod intercept;
pub mod jobs;
pub mod nav;
pub mod pipeline;
pub mod rate_limit;
pub mod reconcile;
pub mod resync;
pub mod safety;
pub mod session;
pub mod session_control;
pub mod source;
pub mod status;
pub mod tor;
pub mod url_utils;

View File

@@ -85,6 +85,28 @@ pub async fn wait_for_selector(
/// under a second.
pub const SELECTOR_TIMEOUT: Duration = Duration::from_secs(10);
/// Run `body` to completion, then run `close` — on *every* exit path,
/// including an early `?` / `return` inside `body`. chromiumoxide's `Page`
/// does **not** close its CDP target on drop, so a fetch helper that opens a
/// page with `browser.new_page(...)` and then bails via `?` on a nav /
/// content-read error would leak a browser tab for the process's lifetime.
/// Wrapping the fallible work in `close_after` — with `close` built from a
/// clone of the page — guarantees the tab is closed regardless of how `body`
/// returns.
///
/// Both arguments are pre-built futures, so this stays generic over the
/// body's return type (the anyhow and `PageError` fetch paths both use it)
/// and references no browser types — which also lets it be unit-tested
/// without standing up a real `Page`.
pub(crate) async fn close_after<R>(
close: impl std::future::Future<Output = ()>,
body: impl std::future::Future<Output = R>,
) -> R {
let result = body.await;
close.await;
result
}
impl NavError {
/// Does this navigation error indicate the underlying Chromium
/// process has died or its CDP connection has dropped? Used by the
@@ -150,6 +172,41 @@ mod tests {
assert!(result.is_err(), "expected Elapsed on a hung future");
}
#[tokio::test]
async fn close_after_runs_close_and_returns_value_on_ok() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let closed = Arc::new(AtomicBool::new(false));
let c = closed.clone();
let out: anyhow::Result<i32> = close_after(
async move { c.store(true, Ordering::SeqCst) },
async { Ok(7) },
)
.await;
assert_eq!(out.unwrap(), 7);
assert!(closed.load(Ordering::SeqCst), "close must run on the happy path");
}
#[tokio::test]
async fn close_after_runs_close_even_when_body_errs() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let closed = Arc::new(AtomicBool::new(false));
let c = closed.clone();
// This is the leak the fix targets: the body bails before it could
// close the page itself, and `close_after` must still close it.
let out: anyhow::Result<()> =
close_after(async move { c.store(true, Ordering::SeqCst) }, async {
anyhow::bail!("nav failed")
})
.await;
assert!(out.is_err());
assert!(
closed.load(Ordering::SeqCst),
"the page must be closed even when the body returns Err"
);
}
#[test]
fn nav_error_timeout_message_includes_duration() {
let e = NavError::Timeout(Duration::from_secs(30));
@@ -164,8 +221,7 @@ mod tests {
#[test]
fn anyhow_with_nav_timeout_in_chain_is_flagged() {
let inner: Result<(), NavError> = Err(NavError::Timeout(NAV_TIMEOUT));
let outer = inner.unwrap_err();
let outer = NavError::Timeout(NAV_TIMEOUT);
let wrapped: anyhow::Error =
anyhow::Error::new(outer).context("wait for chapter nav");
assert!(anyhow_looks_browser_dead(&wrapped));

View File

@@ -13,7 +13,7 @@ use crate::crawler::jobs::{self, EnqueueResult, JobPayload};
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::safety::{fetch_bytes_capped, looks_like_image, DownloadAllowlist};
use crate::crawler::source::target::TargetSource;
use crate::crawler::source::{FetchContext, Source};
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
use crate::repo;
use crate::repo::crawler::UpsertStatus;
use crate::storage::Storage;
@@ -65,6 +65,224 @@ pub(crate) fn should_mark_clean_exit(
walked_to_completion || hit_stop_condition
}
/// Circuit-breaker: abort the walk once `consecutive` `fetch_manga`
/// failures reach `threshold`. A `threshold` of 0 disables the breaker
/// (unbounded — the legacy behaviour). When it fires the caller must NOT
/// mark a clean exit, so the next tick does a recovery sweep over the
/// catalog tail the aborted pass never reached.
///
/// Pure so the rule is unit-testable without the walker.
pub(crate) fn should_abort_pass(consecutive: u32, threshold: u32) -> bool {
threshold > 0 && consecutive >= threshold
}
/// Success outcome of [`process_manga_ref`].
pub(crate) struct RefProcessed {
pub manga_id: Uuid,
pub status: UpsertStatus,
pub chapters_new: Option<usize>,
pub cover_fetched: bool,
}
/// Why [`process_manga_ref`] produced no upsert.
pub(crate) enum RefError {
/// `fetch_manga` itself failed. Counts toward the consecutive-failure
/// breaker in the metadata pass; the `SyncManga` worker treats it as a
/// retryable job failure.
Fetch(anyhow::Error),
/// The detail fetched but the ref was skipped — partial-render guard or
/// an upsert error. The fetch *succeeded*, so the breaker resets, but no
/// manga was upserted.
Skip(anyhow::Error),
}
/// Run fetch → partial-render guard → upsert → cover → chapter-sync for a
/// single discovered ref. Extracted from [`run_metadata_pass`] so the
/// `SyncManga` worker drives the identical per-manga work and the two paths
/// can't drift. Records the detail/cover crawl metrics internally; the
/// caller owns the discovered/seen/stop/breaker bookkeeping.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn process_manga_ref(
ctx: &FetchContext<'_>,
source: &TargetSource,
db: &PgPool,
storage: &dyn Storage,
http: &reqwest::Client,
rate: &HostRateLimiters,
r: &SourceMangaRef,
skip_chapters: bool,
allowlist: &DownloadAllowlist,
max_image_bytes: usize,
status: Option<&crate::crawler::status::StatusHandle>,
) -> Result<RefProcessed, RefError> {
let source_id = source.id();
let detail_started = std::time::Instant::now();
let manga = match source.fetch_manga(ctx, r).await {
Ok(m) => m,
Err(e) => {
// Detail-fetch timing (failed). manga_id is unknown — the manga
// was never upserted.
let _ = repo::crawl_metrics::record(
db,
repo::crawl_metrics::OP_MANGA_DETAIL,
None,
None,
"failed",
detail_started.elapsed().as_millis() as i64,
None,
Some(&format!("{e:#}")),
)
.await;
return Err(RefError::Fetch(e));
}
};
// Detail fetch succeeded; stash its duration to record once the
// manga_id is known (after upsert).
let detail_ms = detail_started.elapsed().as_millis() as i64;
// Partial-render guard: an empty chapter list paired with a prior count
// > 0 is overwhelmingly a chromium snapshot taken between the wrapper
// render and its rows render. Treat as a transient skip (no upsert) so
// a later attempt retries. Skipped in `skip_chapters` mode because the
// parser returns an empty Vec there by design.
if !skip_chapters && manga.chapters.is_empty() {
match repo::crawler::live_chapter_count_for_source_manga(db, source_id, &r.source_manga_key)
.await
{
Ok(prior) if prior > 0 => {
return Err(RefError::Skip(anyhow::anyhow!(
"fetch_manga returned empty chapters but prior count {prior} > 0; \
treating as partial-render transient"
)));
}
Ok(_) => {}
Err(e) => {
// DB lookup failed — fail safe: skip rather than risk a
// soft-drop on a manga whose prior count we couldn't confirm.
return Err(RefError::Skip(
anyhow::Error::new(e).context("live_chapter_count_for_source_manga"),
));
}
}
}
let upsert = match repo::crawler::upsert_manga_from_source(db, source_id, &r.url, &manga).await {
Ok(u) => u,
Err(e) => {
return Err(RefError::Skip(
anyhow::Error::new(e).context("upsert_manga_from_source"),
));
}
};
// Detail-fetch timing (ok), now that we have the manga_id.
let _ = repo::crawl_metrics::record(
db,
repo::crawl_metrics::OP_MANGA_DETAIL,
Some(upsert.manga_id),
None,
"ok",
detail_ms,
None,
None,
)
.await;
tracing::info!(
key = %manga.source_manga_key,
manga_id = %upsert.manga_id,
status = ?upsert.status,
title = %manga.title,
"manga upserted"
);
// Cover image: download when missing in storage or when metadata
// signaled an update (cover URL is part of metadata_hash, so Updated
// implies the URL may have moved). Failures are non-fatal.
let mut cover_fetched = false;
let needs_cover =
upsert.cover_image_path.is_none() || matches!(upsert.status, UpsertStatus::Updated);
if needs_cover {
if let Some(cover_url) = manga.cover_url.as_deref() {
// RAII: the guard clears `current_cover` on every exit path.
let _cover_guard = status.map(|s| {
s.begin_cover(crate::crawler::status::CoverTarget {
manga_id: upsert.manga_id,
manga_title: manga.title.clone(),
})
});
let cover_started = std::time::Instant::now();
let cover_result = download_and_store_cover(
db,
storage,
http,
rate,
&r.url,
upsert.manga_id,
cover_url,
allowlist,
max_image_bytes,
)
.await;
let cover_ms = cover_started.elapsed().as_millis() as i64;
let _ = repo::crawl_metrics::record(
db,
repo::crawl_metrics::OP_MANGA_COVER,
Some(upsert.manga_id),
None,
if cover_result.is_ok() { "ok" } else { "failed" },
cover_ms,
None,
cover_result.as_ref().err().map(|e| format!("{e:#}")).as_deref(),
)
.await;
match cover_result {
Ok(()) => cover_fetched = true,
Err(e) => tracing::warn!(
manga_id = %upsert.manga_id,
error = ?e,
"cover download failed"
),
}
}
}
// Chapter sync. `None` (skip_chapters mode, or a logged-and-swallowed
// sync error) refuses to stop on this manga because we can't confirm
// "no new chapters."
let chapters_new: Option<usize> = if skip_chapters {
None
} else {
match repo::crawler::sync_manga_chapters(db, source_id, upsert.manga_id, &manga.chapters)
.await
{
Ok(diff) => {
tracing::info!(
manga_id = %upsert.manga_id,
new = diff.new,
refreshed = diff.refreshed,
dropped = diff.dropped,
"chapters synced"
);
Some(diff.new)
}
Err(e) => {
tracing::warn!(
manga_id = %upsert.manga_id,
error = ?e,
"chapter sync failed"
);
None
}
}
};
Ok(RefProcessed {
manga_id: upsert.manga_id,
status: upsert.status,
chapters_new,
cover_fetched,
})
}
/// Runs the discover → fetch → upsert → cover → chapter-list-diff pipeline
/// for the target source. Pure metadata; chapter content is enqueued as
/// separate `SyncChapterContent` jobs by the caller after this returns.
@@ -103,12 +321,19 @@ pub async fn run_metadata_pass(
skip_chapters: bool,
allowlist: &DownloadAllowlist,
max_image_bytes: usize,
max_consecutive_failures: u32,
status: Option<&crate::crawler::status::StatusHandle>,
tor: Option<&crate::crawler::tor::TorController>,
) -> anyhow::Result<MetadataStats> {
let pass_started = std::time::Instant::now();
let lease = browser_manager
.acquire()
.await
.context("acquire browser lease for metadata pass")?;
let browser_ref: &chromiumoxide::Browser = &lease;
if let Some(s) = status {
s.set_phase(crate::crawler::status::Phase::WalkingList).await;
}
let source = {
let s = TargetSource::new(start_url.to_string());
@@ -121,6 +346,7 @@ pub async fn run_metadata_pass(
let ctx = FetchContext {
browser: browser_ref,
rate,
tor,
};
let source_id = source.id();
@@ -163,6 +389,11 @@ pub async fn run_metadata_pass(
let mut walked_to_completion = false;
let mut hit_limit = false;
let mut hit_stop_condition = false;
// Circuit-breaker state: consecutive fetch_manga failures. A sustained
// run abort (source outage) leaves the pass un-clean → recovery sweep
// next tick.
let mut consecutive_failures = 0u32;
let mut hit_failure_breaker = false;
'outer: loop {
let batch = match walker.next_batch(&ctx).await? {
@@ -173,6 +404,17 @@ pub async fn run_metadata_pass(
}
};
for r in batch {
// Cooperative checkpoint: if a coordinated browser restart is
// pending, yield our (long-lived) lease so the drain can
// proceed instead of stalling for the rest of the walk. The
// pass exits un-clean, so the next tick recovery-sweeps the
// tail we didn't reach.
if browser_manager.is_restart_pending() {
tracing::info!(
"metadata pass: browser restart pending — yielding (recovery sweep next tick)"
);
break 'outer;
}
if max_refs.map(|m| stats.discovered >= m).unwrap_or(false) {
hit_limit = true;
tracing::info!(cap = ?max_refs, "max_results reached; halting walk");
@@ -196,14 +438,55 @@ pub async fn run_metadata_pass(
continue;
}
stats.discovered += 1;
if let Some(s) = status {
s.set_phase(crate::crawler::status::Phase::FetchingMetadata {
index: stats.discovered,
total: max_refs,
title: r.title.clone(),
})
.await;
}
tracing::info!(
idx = stats.discovered,
key = %r.source_manga_key,
"fetching metadata"
);
let manga = match source.fetch_manga(&ctx, &r).await {
Ok(m) => m,
Err(e) => {
match process_manga_ref(
&ctx,
&source,
db,
storage,
http,
rate,
&r,
skip_chapters,
allowlist,
max_image_bytes,
status,
)
.await
{
Ok(p) => {
consecutive_failures = 0;
stats.upserted += 1;
if p.cover_fetched {
stats.covers_fetched += 1;
}
// Record success in the dedup set. Cover and chapter-sync
// failures inside process_manga_ref are non-fatal and
// don't roll this back — metadata is the durable source
// of truth for the dedup.
seen.insert(r.source_manga_key.clone());
if should_stop(was_clean, p.status, p.chapters_new) {
hit_stop_condition = true;
tracing::info!(
key = %r.source_manga_key,
"stop condition met (Unchanged metadata + 0 new chapters); halting walk"
);
break 'outer;
}
}
Err(RefError::Fetch(e)) => {
tracing::warn!(
key = %r.source_manga_key,
url = %r.url,
@@ -211,154 +494,30 @@ pub async fn run_metadata_pass(
"fetch_manga failed"
);
stats.mangas_failed += 1;
continue;
}
};
// Partial-render guard: an empty chapter list paired with a
// prior count > 0 is overwhelmingly a chromium snapshot
// taken between the #chapter_table wrapper render and its
// rows render. The wait_for_selector wait in `navigate`
// narrows this window but cannot close it for slow renders
// beyond the selector budget. Treat as a transient failure
// here — skip upsert, skip seen.insert — so the next batch
// (or the next tick) retries. Skipped in `skip_chapters`
// mode because the parser is configured to return an empty
// Vec by design there.
if !skip_chapters && manga.chapters.is_empty() {
match repo::crawler::live_chapter_count_for_source_manga(
db, source_id, &r.source_manga_key,
)
.await
{
Ok(prior) if prior > 0 => {
tracing::warn!(
key = %r.source_manga_key,
url = %r.url,
prior_chapter_count = prior,
"fetch_manga returned empty chapters but prior count > 0; treating as partial-render transient and skipping"
consecutive_failures += 1;
if should_abort_pass(consecutive_failures, max_consecutive_failures) {
hit_failure_breaker = true;
tracing::error!(
consecutive_failures,
threshold = max_consecutive_failures,
"metadata pass: too many consecutive fetch_manga failures; \
aborting (recovery sweep on next tick)"
);
stats.mangas_failed += 1;
continue;
}
Ok(_) => {}
Err(e) => {
// DB lookup failed — fail safe: skip rather
// than risk a soft-drop on a manga whose prior
// count we couldn't confirm.
tracing::warn!(
key = %r.source_manga_key,
error = ?e,
"live_chapter_count_for_source_manga failed; skipping cautiously"
);
stats.mangas_failed += 1;
continue;
break 'outer;
}
}
}
let upsert = match repo::crawler::upsert_manga_from_source(
db, source_id, &r.url, &manga,
)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!(
Err(RefError::Skip(e)) => {
// Fetch succeeded (so the breaker resets) but the ref was
// skipped — partial render or upsert error. Left out of
// `seen` so a reappearance in a later batch retries.
tracing::warn!(
key = %r.source_manga_key,
error = ?e,
"upsert_manga_from_source failed"
"manga ref skipped (partial-render or upsert error)"
);
consecutive_failures = 0;
stats.mangas_failed += 1;
continue;
}
};
stats.upserted += 1;
// Record success in the dedup set. Cover and chapter-sync
// failures below are non-fatal and don't roll this back —
// metadata is the durable source of truth for the dedup.
seen.insert(r.source_manga_key.clone());
tracing::info!(
key = %manga.source_manga_key,
manga_id = %upsert.manga_id,
status = ?upsert.status,
title = %manga.title,
"manga upserted"
);
// Cover image: download when missing in storage or when metadata
// signaled an update (cover URL is part of metadata_hash, so
// Updated implies the URL may have moved). Failures are non-fatal.
let needs_cover = upsert.cover_image_path.is_none()
|| matches!(upsert.status, repo::crawler::UpsertStatus::Updated);
if needs_cover {
if let Some(cover_url) = manga.cover_url.as_deref() {
match download_and_store_cover(
db,
storage,
http,
rate,
&r.url,
upsert.manga_id,
cover_url,
allowlist,
max_image_bytes,
)
.await
{
Ok(()) => stats.covers_fetched += 1,
Err(e) => tracing::warn!(
manga_id = %upsert.manga_id,
error = ?e,
"cover download failed"
),
}
}
}
// Chapter sync. `chapters_new` feeds the stop check below:
// `None` (skip_chapters mode, or a logged-and-swallowed sync
// error) refuses to stop on this manga because we can't
// confirm "no new chapters."
let chapters_new: Option<usize> = if skip_chapters {
None
} else {
match repo::crawler::sync_manga_chapters(
db,
source_id,
upsert.manga_id,
&manga.chapters,
)
.await
{
Ok(diff) => {
tracing::info!(
manga_id = %upsert.manga_id,
new = diff.new,
refreshed = diff.refreshed,
dropped = diff.dropped,
"chapters synced"
);
Some(diff.new)
}
Err(e) => {
tracing::warn!(
manga_id = %upsert.manga_id,
error = ?e,
"chapter sync failed"
);
None
}
}
};
if should_stop(was_clean, upsert.status, chapters_new) {
hit_stop_condition = true;
tracing::info!(
key = %manga.source_manga_key,
"stop condition met (Unchanged metadata + 0 new chapters); halting walk"
);
break 'outer;
}
}
}
@@ -388,10 +547,26 @@ pub async fn run_metadata_pass(
walked_to_completion,
hit_limit,
hit_stop_condition,
hit_failure_breaker,
exited_cleanly,
"metadata pass complete"
);
// Record the whole-list-walk timing (best-effort). `items` = mangas
// discovered this pass; outcome is `failed` only when the failure breaker
// tripped (a degraded walk), else `ok`.
let _ = repo::crawl_metrics::record(
db,
repo::crawl_metrics::OP_MANGA_LIST,
None,
None,
if hit_failure_breaker { "failed" } else { "ok" },
pass_started.elapsed().as_millis() as i64,
Some(stats.discovered as i32),
None,
)
.await;
drop(lease);
Ok(stats)
}
@@ -427,8 +602,8 @@ pub async fn enqueue_bookmarked_pending(pool: &PgPool) -> anyhow::Result<Enqueue
AND cj.state = 'dead'
AND cj.updated_at > now() - ($1::bigint || ' days')::interval
)
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.created_at
ORDER BY c.manga_id, c.created_at ASC
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.number, c.created_at
ORDER BY c.manga_id, c.number ASC, c.created_at ASC
"#,
)
.bind(CHAPTER_DEAD_QUARANTINE_DAYS)
@@ -469,7 +644,7 @@ pub async fn enqueue_pending_for_manga(
) -> anyhow::Result<EnqueueSummary> {
let rows: Vec<(String, Uuid, String)> = sqlx::query_as(
r#"
SELECT DISTINCT cs.source_id, c.id AS chapter_id, cs.source_chapter_key
SELECT cs.source_id, c.id AS chapter_id, cs.source_chapter_key
FROM chapters c
JOIN chapter_sources cs ON cs.chapter_id = c.id
WHERE c.manga_id = $1
@@ -482,7 +657,8 @@ pub async fn enqueue_pending_for_manga(
AND cj.state = 'dead'
AND cj.updated_at > now() - ($2::bigint || ' days')::interval
)
ORDER BY cs.source_id, c.id
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.number, c.created_at
ORDER BY c.number ASC, c.created_at ASC, cs.source_id
"#,
)
.bind(manga_id)
@@ -521,12 +697,160 @@ pub struct EnqueueSummary {
pub failed: usize,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CoverBackfillStats {
pub considered: usize,
pub fetched: usize,
pub failed: usize,
}
/// Default per-tick cap for [`backfill_missing_covers`]. The metadata pass
/// already retries covers when its walk reaches the affected manga; this
/// backfill exists to catch the residual case where the early-stop
/// optimisation prevents the walk from reaching mangas whose cover failed
/// on first attempt. A small cap is enough because the backlog only grows
/// from sporadic download failures, not from systematic misses.
pub const COVER_BACKFILL_DEFAULT_MAX: usize = 10;
/// Re-attempt cover downloads for mangas where `cover_image_path IS NULL`
/// but a live `manga_sources` row exists. Refetches the source detail
/// page (which is where the cover URL lives) and downloads the cover.
///
/// Bounded by `max_mangas` per call so a steady stream of failing covers
/// — e.g. a CDN host that's persistently 502 — can't monopolise a cron
/// tick. Orders by `manga_sources.last_seen_at DESC` so the freshest
/// missing-cover mangas are addressed first.
///
/// Failures are logged and counted, not raised: a single bad cover URL
/// must not stall every other backfill behind it.
#[allow(clippy::too_many_arguments)]
pub async fn backfill_missing_covers(
browser_manager: &BrowserManager,
db: &PgPool,
storage: &dyn Storage,
http: &reqwest::Client,
rate: &HostRateLimiters,
max_mangas: usize,
allowlist: &DownloadAllowlist,
max_image_bytes: usize,
status: Option<&crate::crawler::status::StatusHandle>,
tor: Option<&crate::crawler::tor::TorController>,
) -> anyhow::Result<CoverBackfillStats> {
let mut stats = CoverBackfillStats::default();
if max_mangas == 0 {
return Ok(stats);
}
let entries = repo::crawler::list_missing_covers(db, max_mangas as i64)
.await
.context("list_missing_covers")?;
if entries.is_empty() {
return Ok(stats);
}
let lease = browser_manager
.acquire()
.await
.context("acquire browser lease for cover backfill")?;
let browser_ref: &chromiumoxide::Browser = &lease;
let ctx = FetchContext { browser: browser_ref, rate, tor };
let total = entries.len();
for (index, entry) in entries.into_iter().enumerate() {
stats.considered += 1;
if let Some(s) = status {
s.set_phase(crate::crawler::status::Phase::CoverBackfill { index, total })
.await;
}
// Metadata-only TargetSource: skip chapter-list parsing so a
// missing-cover refetch doesn't soft-drop chapters on a partial
// render. Cover URL alone is what we need.
let source = TargetSource::new(entry.source_url.clone()).without_chapter_parsing();
let r = SourceMangaRef {
source_manga_key: entry.source_manga_key.clone(),
title: String::new(),
url: entry.source_url.clone(),
};
let manga = match source.fetch_manga(&ctx, &r).await {
Ok(manga) => manga,
Err(e) => {
tracing::warn!(
manga_id = %entry.manga_id,
url = %entry.source_url,
error = ?e,
"cover backfill: fetch_manga failed"
);
stats.failed += 1;
continue;
}
};
let Some(cover_url) = manga.cover_url.clone() else {
tracing::warn!(
manga_id = %entry.manga_id,
url = %entry.source_url,
"cover backfill: source returned no cover_url"
);
stats.failed += 1;
continue;
};
// RAII guard: clears the live current_cover on every exit path,
// including a panic inside download_and_store_cover. Mirrors the
// chapter-side ChapterGuard.
let _cover_guard = status.map(|s| {
s.begin_cover(crate::crawler::status::CoverTarget {
manga_id: entry.manga_id,
manga_title: manga.title.clone(),
})
});
let cover_started = std::time::Instant::now();
let cover_result = download_and_store_cover(
db,
storage,
http,
rate,
&entry.source_url,
entry.manga_id,
&cover_url,
allowlist,
max_image_bytes,
)
.await;
let _ = repo::crawl_metrics::record(
db,
repo::crawl_metrics::OP_MANGA_COVER,
Some(entry.manga_id),
None,
if cover_result.is_ok() { "ok" } else { "failed" },
cover_started.elapsed().as_millis() as i64,
None,
cover_result.as_ref().err().map(|e| format!("{e:#}")).as_deref(),
)
.await;
match cover_result {
Ok(()) => stats.fetched += 1,
Err(e) => {
tracing::warn!(
manga_id = %entry.manga_id,
url = %entry.source_url,
error = ?e,
"cover backfill: download failed"
);
stats.failed += 1;
}
}
}
drop(lease);
Ok(stats)
}
/// Download a cover image and persist its storage path. Local to the
/// pipeline because the CLI still calls it from its inline chapter-content
/// loop; once the worker pool fully replaces that path we can fold this
/// into `pipeline` proper.
#[allow(clippy::too_many_arguments)]
async fn download_and_store_cover(
pub(crate) async fn download_and_store_cover(
db: &PgPool,
storage: &dyn Storage,
http: &reqwest::Client,
@@ -565,7 +889,7 @@ async fn download_and_store_cover(
.put(&key, &bytes)
.await
.with_context(|| format!("store cover at {key}"))?;
repo::manga::set_cover_image_path(db, manga_id, &key)
repo::manga::set_cover_image_path(db, manga_id, &key, bytes.len() as i64)
.await
.with_context(|| format!("update cover_image_path for {manga_id}"))?;
tracing::info!(
@@ -632,6 +956,18 @@ mod tests {
assert!(!should_stop(false, UpsertStatus::New, None));
}
#[test]
fn abort_pass_fires_at_threshold_and_respects_disable() {
// Disabled (0) never fires, no matter how many failures.
assert!(!should_abort_pass(0, 0));
assert!(!should_abort_pass(100, 0));
// Below threshold: keep going.
assert!(!should_abort_pass(9, 10));
// At/above threshold: abort.
assert!(should_abort_pass(10, 10));
assert!(should_abort_pass(11, 10));
}
#[test]
fn clean_exit_when_walked_to_completion() {
// End-of-walk reached the catalog tail — the recovery flag may

View File

@@ -44,6 +44,25 @@ impl RateLimiter {
}
}
/// Once the per-host map grows past this many entries, the next `wait_for`
/// sweeps out hosts idle longer than [`IDLE_EVICT_AFTER`]. A long crawl that
/// touches thousands of distinct CDN shards would otherwise retain one bucket
/// per host for the daemon's whole lifetime. Far above any single source's
/// real host count, so the sweep is rare.
const MAX_TRACKED_HOSTS: usize = 1024;
/// A host bucket untouched for at least this long is evicted on the next
/// over-cap sweep. Generous: a host still being crawled is touched every
/// `interval`, so only genuinely-finished hosts age out.
const IDLE_EVICT_AFTER: Duration = Duration::from_secs(3600);
/// A host's bucket plus when it was last used, so idle entries can be evicted.
#[derive(Debug)]
struct HostEntry {
limiter: Arc<Mutex<RateLimiter>>,
last_used: Instant,
}
/// Per-host rate limiter map. The outer `Mutex<HashMap>` is held only
/// during the entry-or-insert + Arc clone; the per-host `Mutex<RateLimiter>`
/// is held during the actual `wait().await`. So N workers calling
@@ -54,7 +73,7 @@ impl RateLimiter {
pub struct HostRateLimiters {
default_interval: Duration,
overrides: HashMap<String, Duration>,
map: Mutex<HashMap<String, Arc<Mutex<RateLimiter>>>>,
map: Mutex<HashMap<String, HostEntry>>,
}
impl HostRateLimiters {
@@ -82,20 +101,37 @@ impl HostRateLimiters {
.ok_or_else(|| anyhow::anyhow!("no host in url: {url}"))?;
let limiter = {
let mut map = self.map.lock().await;
map.entry(host.clone())
.or_insert_with(|| {
let interval = self
.overrides
.get(&host)
.copied()
.unwrap_or(self.default_interval);
Arc::new(Mutex::new(RateLimiter::new(interval)))
})
.clone()
let now = Instant::now();
// Bound the map: when it grows past the soft cap, drop hosts that
// have been idle past the TTL. Only fires over-cap, so the common
// path is a plain lookup. A host still being crawled is touched
// every `interval` and so never ages out.
if map.len() >= MAX_TRACKED_HOSTS {
map.retain(|_, e| now.duration_since(e.last_used) < IDLE_EVICT_AFTER);
}
let entry = map.entry(host.clone()).or_insert_with(|| {
let interval = self
.overrides
.get(&host)
.copied()
.unwrap_or(self.default_interval);
HostEntry {
limiter: Arc::new(Mutex::new(RateLimiter::new(interval))),
last_used: now,
}
});
entry.last_used = now;
entry.limiter.clone()
};
limiter.lock().await.wait().await;
Ok(())
}
/// Number of host buckets currently tracked. Test/observability hook.
#[cfg(test)]
pub(crate) async fn tracked_hosts(&self) -> usize {
self.map.lock().await.len()
}
}
// `host_of` was duplicated across session/rate_limit/pipeline; the
@@ -165,6 +201,46 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
async fn host_rate_limiters_evict_idle_hosts_over_cap() {
// Fill the map to the soft cap with distinct hosts (first call to a
// fresh host never sleeps, so this is fast even under real time).
let rl = HostRateLimiters::new(Duration::from_millis(1));
for i in 0..MAX_TRACKED_HOSTS {
rl.wait_for(&format!("https://host{i}.example/x")).await.unwrap();
}
assert_eq!(rl.tracked_hosts().await, MAX_TRACKED_HOSTS);
// Let every tracked host age past the idle TTL, then touch one new
// host: the over-cap sweep should evict all the idle ones, leaving
// only the freshly-inserted entry.
tokio::time::sleep(IDLE_EVICT_AFTER + Duration::from_secs(1)).await;
rl.wait_for("https://newcomer.example/y").await.unwrap();
assert_eq!(
rl.tracked_hosts().await,
1,
"idle hosts should be swept once the map is over the cap"
);
}
#[tokio::test(start_paused = true)]
async fn host_rate_limiters_keep_recently_used_hosts() {
// A host touched within the TTL must survive a sweep so active crawls
// aren't reset. Fill to the cap, then re-touch one host right before
// adding a newcomer that triggers the sweep.
let rl = HostRateLimiters::new(Duration::from_millis(1));
for i in 0..MAX_TRACKED_HOSTS {
rl.wait_for(&format!("https://host{i}.example/x")).await.unwrap();
}
tokio::time::sleep(IDLE_EVICT_AFTER + Duration::from_secs(1)).await;
// Re-touch host0 so it's recent again.
rl.wait_for("https://host0.example/x").await.unwrap();
// Newcomer triggers the sweep (map is at cap+1 conceptually).
rl.wait_for("https://newcomer.example/y").await.unwrap();
// host0 (recent) + newcomer survive; the rest aged out.
assert_eq!(rl.tracked_hosts().await, 2);
}
#[tokio::test(start_paused = true)]
async fn host_rate_limiters_honor_overrides() {
let rl = HostRateLimiters::new(Duration::from_millis(1000))

View File

@@ -0,0 +1,260 @@
//! Reconcile pass — find mangas the metadata pass missed.
//!
//! The interleaved metadata pass ([`crate::crawler::pipeline`]) walks the
//! source list newest-first and visits each manga inline, which makes it
//! vulnerable to list drift: a manga can slip a pagination slot while the
//! walk spends minutes on detail work, and never get upserted.
//!
//! Reconcile fixes that with a cheap, full, unconditional list-only walk
//! (refs only — no `fetch_manga`, no early stop), set-diffs the walked keys
//! against `manga_sources`, and enqueues the strictly-missing ones as
//! `SyncManga` jobs for the crawl worker to visit. A set-diff is immune to
//! drift: a manga only has to appear *somewhere* in the list, not survive a
//! specific slot during a slow walk.
//!
//! Identity matches the DB exactly because both sides derive the key the
//! same way: the walker's refs carry `source_manga_key =
//! derive_key_from_url(list_href)`, which is precisely what
//! `manga_sources.source_manga_key` stores.
use std::collections::HashSet;
use anyhow::Context;
use sqlx::PgPool;
use crate::crawler::browser_manager::BrowserManager;
use crate::crawler::jobs::{self, EnqueueResult, JobPayload};
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::source::target::TargetSource;
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
use crate::crawler::status::Phase;
use crate::repo;
use crate::repo::crawler::ensure_source;
/// Counters surfaced when a reconcile pass finishes.
#[derive(Debug, Default, Clone, Copy)]
pub struct ReconcileStats {
/// Distinct refs collected from the full list walk.
pub walked: usize,
/// Walked keys with no `manga_sources` row (strict `NOT EXISTS`).
pub missing: usize,
/// Missing mangas newly enqueued as `SyncManga` jobs this pass.
pub enqueued: usize,
/// Missing mangas skipped because a blocking (pending/running/dead)
/// `SyncManga` job already exists, or the enqueue lost an insert race.
pub skipped: usize,
}
/// Pure set-diff: which walked refs should be enqueued. A ref is selected
/// when its key is neither already in the DB (`existing`) nor already has a
/// blocking job (`blocked`). Repeated keys in `walked` (source index drift
/// can surface the same manga twice) are de-duplicated — the first ref wins.
pub(crate) fn select_missing<'a>(
walked: &'a [SourceMangaRef],
existing: &HashSet<String>,
blocked: &HashSet<String>,
) -> Vec<&'a SourceMangaRef> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for r in walked {
if existing.contains(&r.source_manga_key) || blocked.contains(&r.source_manga_key) {
continue;
}
if seen.insert(r.source_manga_key.clone()) {
out.push(r);
}
}
out
}
/// Run a full list-only walk, diff against the DB, and enqueue missing
/// mangas as `SyncManga` jobs. Holds the (exclusive) browser lease only for
/// the cheap walk; the enqueued jobs are drained later by the crawl worker.
#[allow(clippy::too_many_arguments)]
pub async fn reconcile_missing(
browser_manager: &BrowserManager,
db: &PgPool,
rate: &HostRateLimiters,
start_url: &str,
status: Option<&crate::crawler::status::StatusHandle>,
tor: Option<&crate::crawler::tor::TorController>,
) -> anyhow::Result<ReconcileStats> {
let lease = browser_manager
.acquire()
.await
.context("acquire browser lease for reconcile pass")?;
let browser_ref: &chromiumoxide::Browser = &lease;
if let Some(s) = status {
s.set_phase(Phase::Reconciling {
walked: 0,
enqueued: 0,
})
.await;
}
// Chapter parsing is irrelevant here (we never call fetch_manga); the
// metadata-only constructor is intent-revealing and harmless.
let source = TargetSource::new(start_url.to_string()).without_chapter_parsing();
let ctx = FetchContext {
browser: browser_ref,
rate,
tor,
};
let source_id = source.id();
ensure_source(
db,
source_id,
"Target Site",
&crate::crawler::url_utils::origin_of(start_url).unwrap_or_else(|| start_url.to_string()),
)
.await
.context("ensure_source")?;
tracing::info!("starting reconcile pass (full list-only walk)");
let mut walker = source.discover(&ctx).await.context("discover failed")?;
// Full unconditional walk: collect every ref, no early stop. Yield the
// lease if a coordinated browser restart is pending so the drain isn't
// stalled — the missing set we've gathered so far is still enqueued.
let mut walked: Vec<SourceMangaRef> = Vec::new();
'outer: loop {
if browser_manager.is_restart_pending() {
tracing::info!("reconcile pass: browser restart pending — yielding partial walk");
break;
}
match walker.next_batch(&ctx).await? {
Some(batch) => {
for r in batch {
walked.push(r);
}
if let Some(s) = status {
s.set_phase(Phase::Reconciling {
walked: walked.len(),
enqueued: 0,
})
.await;
}
if browser_manager.is_restart_pending() {
tracing::info!(
"reconcile pass: browser restart pending mid-batch — yielding partial walk"
);
break 'outer;
}
}
None => break,
}
}
// Browser work is done — release before the (browser-free) diff+enqueue.
drop(lease);
let existing = repo::crawler::existing_source_keys(db, source_id)
.await
.context("existing_source_keys")?;
let blocked = repo::crawler::sync_manga_keys_with_blocking_job(db, source_id)
.await
.context("sync_manga_keys_with_blocking_job")?;
let missing = select_missing(&walked, &existing, &blocked);
let mut stats = ReconcileStats {
walked: walked.len(),
missing: missing.len(),
..Default::default()
};
for r in missing {
let payload = JobPayload::SyncManga {
source_id: source_id.to_string(),
source_manga_key: r.source_manga_key.clone(),
url: r.url.clone(),
title: r.title.clone(),
};
match jobs::enqueue(db, &payload).await {
Ok(EnqueueResult::Inserted(_)) => stats.enqueued += 1,
Ok(EnqueueResult::Skipped) => stats.skipped += 1,
Err(e) => {
tracing::warn!(key = %r.source_manga_key, error = ?e, "enqueue SyncManga failed");
stats.skipped += 1;
}
}
if let Some(s) = status {
s.set_phase(Phase::Reconciling {
walked: stats.walked,
enqueued: stats.enqueued,
})
.await;
}
}
tracing::info!(
walked = stats.walked,
missing = stats.missing,
enqueued = stats.enqueued,
skipped = stats.skipped,
"reconcile pass complete"
);
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
fn r(key: &str) -> SourceMangaRef {
SourceMangaRef {
source_manga_key: key.to_string(),
title: format!("Title {key}"),
url: format!("https://target.example/manga/{key}"),
}
}
fn set(keys: &[&str]) -> HashSet<String> {
keys.iter().map(|s| s.to_string()).collect()
}
#[test]
fn select_missing_returns_keys_not_in_manga_sources() {
let walked = vec![r("a"), r("b"), r("c")];
let existing = set(&["b"]);
let blocked = set(&[]);
let got: Vec<&str> = select_missing(&walked, &existing, &blocked)
.iter()
.map(|r| r.source_manga_key.as_str())
.collect();
assert_eq!(got, vec!["a", "c"]);
}
#[test]
fn select_missing_treats_dropped_rows_as_present() {
// The caller passes dropped keys in `existing` (existing_source_keys
// does not filter dropped_at), so a dropped manga is NOT re-enqueued.
let walked = vec![r("a"), r("dropped")];
let existing = set(&["dropped"]);
let got: Vec<&str> = select_missing(&walked, &existing, &set(&[]))
.iter()
.map(|r| r.source_manga_key.as_str())
.collect();
assert_eq!(got, vec!["a"]);
}
#[test]
fn select_missing_skips_keys_with_blocking_job() {
let walked = vec![r("a"), r("queued"), r("dead")];
let blocked = set(&["queued", "dead"]);
let got: Vec<&str> = select_missing(&walked, &set(&[]), &blocked)
.iter()
.map(|r| r.source_manga_key.as_str())
.collect();
assert_eq!(got, vec!["a"]);
}
#[test]
fn select_missing_dedups_repeated_walked_keys() {
// Index drift can surface the same manga twice in one walk.
let walked = vec![r("a"), r("a"), r("b")];
let got: Vec<&str> = select_missing(&walked, &set(&[]), &set(&[]))
.iter()
.map(|r| r.source_manga_key.as_str())
.collect();
assert_eq!(got, vec!["a", "b"]);
}
}

View File

@@ -0,0 +1,281 @@
//! Admin-triggered resync of a single manga's metadata + cover, or a
//! single chapter's content.
//!
//! The cron tick already retries covers and chapter content on its own
//! schedule. This module exists for the operator-controlled path:
//! "this manga's metadata is stale / its cover never landed / this
//! chapter is broken — pull from source now, not at the next daily
//! tick." Wired into the admin API, never into the queue, so the work
//! happens synchronously with the HTTP request and the admin sees the
//! refreshed row in the response.
//!
//! Shares the daemon's [`BrowserManager`], rate limiter, HTTP client,
//! and TOR controller so a force resync respects the same per-host
//! pacing and recircuit budget the daily crawl uses — admin actions
//! must not let an operator accidentally hammer the source.
use std::sync::Arc;
use anyhow::Context;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::crawler::browser_manager::BrowserManager;
use crate::crawler::content::{self, SyncOutcome};
use crate::crawler::pipeline;
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::safety::DownloadAllowlist;
use crate::crawler::source::target::TargetSource;
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
use crate::crawler::tor::TorController;
use crate::repo;
use crate::repo::crawler::UpsertStatus;
use crate::storage::Storage;
/// Outcome of [`ResyncService::resync_manga`]. Mirrors the bits the
/// admin UI cares about — was the row actually re-upserted, did the
/// cover land — so the response can show "metadata refreshed, cover
/// re-downloaded" or "metadata unchanged" without a second round-trip.
#[derive(Debug, Clone, Copy)]
pub struct MangaResyncOutcome {
pub manga_id: Uuid,
pub metadata_status: UpsertStatus,
pub cover_fetched: bool,
}
/// Outcome of [`ResyncService::resync_chapter`]. `Fetched(pages)` is the
/// success case; `Skipped` means the source row was already gone or the
/// chapter had no live source.
#[derive(Debug, Clone)]
pub enum ChapterResyncOutcome {
Fetched { chapter_id: Uuid, pages: usize },
Skipped { chapter_id: Uuid, reason: String },
}
/// Service exposed by the daemon to the admin API. Optional on
/// [`AppState`] — `None` when the crawler daemon is disabled
/// (`CRAWLER_DAEMON=false`), in which case admin handlers return 503.
#[async_trait]
pub trait ResyncService: Send + Sync {
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome>;
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome>;
}
/// Errors with a stable shape so the API layer can map them to the
/// right HTTP status (404 vs 422 vs 5xx). Anything else surfaces as a
/// generic 500.
#[derive(Debug, thiserror::Error)]
pub enum ResyncError {
#[error("manga has no source to resync from")]
NoMangaSource,
#[error("chapter has no source to resync from")]
NoChapterSource,
}
pub struct RealResyncService {
pub browser_manager: Arc<BrowserManager>,
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub http: reqwest::Client,
pub rate: Arc<HostRateLimiters>,
pub download_allowlist: DownloadAllowlist,
pub max_image_bytes: usize,
/// Per-chapter image-count cap (see `CrawlerConfig::max_images_per_chapter`).
pub max_images_per_chapter: usize,
pub tor: Option<Arc<TorController>>,
}
#[async_trait]
impl ResyncService for RealResyncService {
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
// Pick the freshest live source row. Multi-source mangas
// (theoretical — only one Source impl today) get the row whose
// `last_seen_at` is newest; soft-dropped rows are skipped.
let row: Option<(String, String, String)> = sqlx::query_as(
"SELECT source_id, source_manga_key, source_url \
FROM manga_sources \
WHERE manga_id = $1 AND dropped_at IS NULL \
ORDER BY last_seen_at DESC \
LIMIT 1",
)
.bind(manga_id)
.fetch_optional(&self.db)
.await
.context("look up manga_sources for resync")?;
let Some((_source_id, source_manga_key, source_url)) = row else {
return Err(ResyncError::NoMangaSource.into());
};
let lease = self
.browser_manager
.acquire()
.await
.context("acquire browser lease for manga resync")?;
let browser_ref: &chromiumoxide::Browser = &lease;
let ctx = FetchContext {
browser: browser_ref,
rate: &self.rate,
tor: self.tor.as_deref(),
};
// Parse chapters too — a force resync is "make this manga fully
// current," not just metadata. The full pipeline handles the
// partial-render guard for us; we replicate the same caution
// here by skipping the chapter sync when the parser returned
// empty but the manga previously had chapters.
let source = TargetSource::new(source_url.clone());
let r = SourceMangaRef {
source_manga_key: source_manga_key.clone(),
title: String::new(),
url: source_url.clone(),
};
let manga = source
.fetch_manga(&ctx, &r)
.await
.with_context(|| format!("fetch_manga during resync of {manga_id}"))?;
let source_id = source.id();
let upsert = repo::crawler::upsert_manga_from_source(
&self.db,
source_id,
&source_url,
&manga,
)
.await
.with_context(|| format!("upsert_manga_from_source during resync of {manga_id}"))?;
// Cover refetch: force-download regardless of UpsertStatus.
// Admin clicked "resync" because they want the cover too.
let mut cover_fetched = false;
if let Some(cover_url) = manga.cover_url.as_deref() {
match pipeline::download_and_store_cover(
&self.db,
self.storage.as_ref(),
&self.http,
&self.rate,
&source_url,
upsert.manga_id,
cover_url,
&self.download_allowlist,
self.max_image_bytes,
)
.await
{
Ok(()) => cover_fetched = true,
Err(e) => tracing::warn!(
%manga_id,
error = ?e,
"resync_manga: cover download failed"
),
}
}
// Chapter sync — only when the partial-render guard above
// didn't bail.
let prior_chapter_count = repo::crawler::live_chapter_count_for_source_manga(
&self.db,
source_id,
&source_manga_key,
)
.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,
source_id,
upsert.manga_id,
&manga.chapters,
)
.await
{
Ok(diff) => tracing::info!(
%manga_id,
new = diff.new,
refreshed = diff.refreshed,
dropped = diff.dropped,
"resync_manga: chapters synced"
),
Err(e) => tracing::warn!(
%manga_id,
error = ?e,
"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);
Ok(MangaResyncOutcome {
manga_id: upsert.manga_id,
metadata_status: upsert.status,
cover_fetched,
})
}
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
let row = repo::chapter::dispatch_target(&self.db, chapter_id)
.await
.context("look up chapter_sources for resync")?;
let Some((manga_id, source_url, _title, _number)) = row else {
return Err(ResyncError::NoChapterSource.into());
};
let lease = self
.browser_manager
.acquire()
.await
.context("acquire browser lease for chapter resync")?;
let result = content::sync_chapter_content(
&lease,
&self.db,
self.storage.as_ref(),
&self.http,
&self.rate,
chapter_id,
manga_id,
&source_url,
true,
&self.download_allowlist,
self.max_image_bytes,
self.max_images_per_chapter,
self.tor.as_deref(),
// Admin resync isn't a daemon worker slot — no live status.
None,
// Resync re-fetches existing pages (same ids); analysis isn't
// re-enqueued here — use the admin force re-analyze endpoint.
false,
)
.await;
drop(lease);
match result? {
SyncOutcome::Fetched { pages } => {
Ok(ChapterResyncOutcome::Fetched { chapter_id, pages })
}
SyncOutcome::Skipped => Ok(ChapterResyncOutcome::Skipped {
chapter_id,
reason: "chapter already had pages on disk".to_string(),
}),
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

@@ -31,11 +31,13 @@
//! URL string, and the byte accumulator is keyed off a generic stream.
//! Easy to unit-test without a live network or browser.
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use anyhow::{bail, Context};
use bytes::BytesMut;
use futures_util::StreamExt;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::Url;
/// Default per-image download cap. A page image is generally <2 MiB;
@@ -91,6 +93,16 @@ impl DownloadAllowlist {
self.hosts.is_empty()
}
/// The explicitly-allowed hosts (lowercased). Empty when `allow_any`.
pub fn hosts(&self) -> &[String] {
&self.hosts
}
/// Whether the host check is bypassed entirely (`CRAWLER_ALLOW_ANY_HOST`).
pub fn is_allow_any(&self) -> bool {
self.allow_any
}
pub fn contains(&self, host: &str) -> bool {
if self.allow_any {
return true;
@@ -114,6 +126,34 @@ impl DownloadAllowlist {
/// An empty allowlist rejects everything (the conservative default —
/// callers must explicitly allow the catalog and CDN hosts).
pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSafetyError> {
let url = ensure_public_target_inner(raw_url)?;
let lower_host = url
.host_str()
.expect("host validated by ensure_public_target_inner")
.to_ascii_lowercase();
if !allow.contains(&lower_host) {
return Err(UrlSafetyError::HostNotAllowed(lower_host));
}
Ok(())
}
/// Validate that an admin-supplied URL points at a publicly-routable target:
/// scheme is http or https, host is present, host isn't `localhost`, and (if
/// the host is an IP literal) it isn't loopback/private/link-local/CGNAT/etc.
///
/// Used to validate `endpoint`-style admin settings (crawler `start_url`,
/// analysis `endpoint`) where there is no per-deployment allowlist to consult,
/// but where a hostile or careless admin value (e.g. `http://169.254.169.254/`,
/// `http://127.0.0.1:5432/`) would let the worker pivot inside the deployment.
///
/// Note: DNS hostnames (`mangalord-vision`, `vision.internal.corp`) pass — the
/// check is on *literal* IP private-range strings only, so the documented
/// docker-internal vision endpoint keeps working.
pub fn ensure_public_target(raw_url: &str) -> Result<(), UrlSafetyError> {
ensure_public_target_inner(raw_url).map(|_| ())
}
fn ensure_public_target_inner(raw_url: &str) -> Result<Url, UrlSafetyError> {
let url = Url::parse(raw_url).map_err(|_| UrlSafetyError::Unparseable)?;
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
@@ -138,13 +178,10 @@ pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSa
return Err(UrlSafetyError::PrivateIp(ip));
}
}
if !allow.contains(&lower_host) {
return Err(UrlSafetyError::HostNotAllowed(lower_host));
}
Ok(())
Ok(url)
}
fn is_private_ip(ip: &IpAddr) -> bool {
pub(crate) fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
@@ -158,11 +195,14 @@ fn is_private_ip(ip: &IpAddr) -> bool {
|| v4.octets()[0] == 0
}
IpAddr::V6(v6) => {
// IPv4-mapped IPv6 (::ffff:0:0/96): unwrap to the embedded
// IPv4 and recurse so `::ffff:127.0.0.1` is caught by the
// IPv4 loopback check rather than passing through.
// `Ipv6Addr::is_loopback()` only matches `::1` exactly.
if let Some(v4) = v6.to_ipv4_mapped() {
// Any IPv6 form that *embeds* an IPv4 address (mapped
// `::ffff:0:0/96`, compatible `::/96`, NAT64 `64:ff9b::/96`,
// 6to4 `2002::/16`) is unwrapped and re-checked as its IPv4 —
// otherwise `::127.0.0.1` / `2002:7f00:1::` / `64:ff9b::7f00:1`
// would smuggle an internal IPv4 past the check (the audit's
// IPv6-embedding gap). `Ipv6Addr::is_loopback()` only matches
// `::1` exactly, so these embeddings need explicit handling.
if let Some(v4) = embedded_ipv4(v6) {
return is_private_ip(&IpAddr::V4(v4));
}
v6.is_loopback()
@@ -175,6 +215,114 @@ fn is_private_ip(ip: &IpAddr) -> bool {
}
}
/// Extract the IPv4 address embedded in an IPv6 literal, for every
/// transitional encoding that can carry one: IPv4-mapped (`::ffff:0:0/96`),
/// IPv4-compatible (`::/96`, deprecated but still routable via some stacks),
/// NAT64 (`64:ff9b::/96`), and 6to4 (`2002::/16`). Returns `None` for a
/// native IPv6 address. Callers recurse into [`is_private_ip`] on the result
/// so a private IPv4 can't hide inside an IPv6 literal.
fn embedded_ipv4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
let seg = v6.segments();
let low32 = |a: u16, b: u16| Ipv4Addr::new((a >> 8) as u8, (a & 0xff) as u8, (b >> 8) as u8, (b & 0xff) as u8);
// ::ffff:0:0/96 (mapped) and ::/96 (compatible) — top 96 bits zero,
// except mapped which has 0xffff at seg[5]. to_ipv4() covers both.
if seg[0..5] == [0, 0, 0, 0, 0] && (seg[5] == 0 || seg[5] == 0xffff) {
return Some(low32(seg[6], seg[7]));
}
// NAT64 64:ff9b::/96
if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0] {
return Some(low32(seg[6], seg[7]));
}
// 6to4 2002::/16 — embedded IPv4 is bits 16..48 (seg[1], seg[2]).
if seg[0] == 0x2002 {
return Some(low32(seg[1], seg[2]));
}
None
}
/// A `reqwest::dns::Resolve` that performs normal system resolution, then
/// drops any resolved address in a private / loopback / link-local / metadata
/// range. Installed on every crawler + analysis reqwest client so a hostname
/// that resolves to an internal IP (DNS rebinding: an attacker-owned domain
/// with an `A` record for `169.254.169.254` or `10.x`) can never be connected
/// to — closing the TOCTOU gap that the string-only `ensure_public_target`
/// check leaves open. Fires per connection, so it also guards redirect hops.
#[derive(Debug, Default)]
pub struct SafeResolver;
/// Partition resolved addresses into the public ones, rejecting when nothing
/// survives. Split out from the async `resolve` so the security-critical
/// filter is unit-testable without real DNS.
fn retain_public_addrs(
host: &str,
addrs: impl Iterator<Item = SocketAddr>,
) -> Result<Vec<SocketAddr>, BlockedResolution> {
let public: Vec<SocketAddr> = addrs.filter(|a| !is_private_ip(&a.ip())).collect();
if public.is_empty() {
return Err(BlockedResolution(host.to_string()));
}
Ok(public)
}
#[derive(Debug, thiserror::Error)]
#[error("host {0} resolved only to private/blocked addresses")]
struct BlockedResolution(String);
impl Resolve for SafeResolver {
fn resolve(&self, name: Name) -> Resolving {
Box::pin(async move {
let host = name.as_str().to_string();
// Port 0: reqwest overrides it with the URL's port after
// resolution (same convention as reqwest's default GaiResolver).
let resolved = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
let public = retain_public_addrs(&host, resolved)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
Ok(Box::new(public.into_iter()) as Addrs)
})
}
}
/// Shared [`SafeResolver`] for wiring into `ClientBuilder::dns_resolver`.
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")]
@@ -191,6 +339,73 @@ pub enum UrlSafetyError {
HostNotAllowed(String),
}
/// Maximum number of redirects the crawler will follow before giving up.
/// Matches reqwest's historical default; every hop is re-validated by
/// [`check_redirect_hop`], so the cap is a belt-and-braces stop against a
/// redirect loop rather than the primary SSRF defence.
pub const MAX_REDIRECTS: usize = 10;
/// Why a redirect hop was refused.
#[derive(Debug, thiserror::Error)]
pub enum RedirectError {
#[error("redirect chain exceeded {0} hops")]
TooManyHops(usize),
#[error("redirect target rejected: {0}")]
Unsafe(#[from] UrlSafetyError),
}
/// Decide whether a single redirect hop is safe to follow.
///
/// `is_safe_url` only inspects the *initial* URL a caller hands to reqwest;
/// without re-validation an allowlisted CDN that answers `302 ->
/// http://169.254.169.254/...` or `-> http://127.0.0.1:5432/` would be
/// followed transparently (reqwest's default policy follows up to 10
/// redirects). This re-runs the full allowlist + private-IP + scheme check on
/// each hop and enforces [`MAX_REDIRECTS`]. `completed_hops` is the number of
/// URLs already visited in the chain (reqwest's `attempt.previous().len()`).
pub fn check_redirect_hop(
next_url: &str,
completed_hops: usize,
allow: &DownloadAllowlist,
) -> Result<(), RedirectError> {
if completed_hops >= MAX_REDIRECTS {
return Err(RedirectError::TooManyHops(completed_hops));
}
is_safe_url(next_url, allow)?;
Ok(())
}
/// Build a reqwest redirect policy that re-validates every hop against the
/// download allowlist (see [`check_redirect_hop`]). Use for the crawler image
/// clients, which fetch attacker-influenced URLs.
pub fn safe_redirect_policy(allow: DownloadAllowlist) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
let hops = attempt.previous().len();
match check_redirect_hop(attempt.url().as_str(), hops, &allow) {
Ok(()) => attempt.follow(),
Err(e) => attempt.error(e),
}
})
}
/// Build a reqwest redirect policy that re-validates every hop with
/// [`ensure_public_target`] (scheme + private-IP, no allowlist). Use for
/// single-endpoint clients (the analysis vision endpoint / its probe) where
/// there is no per-deployment allowlist but a redirect into the deployment's
/// internal network must still be refused.
pub fn public_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
let hops = attempt.previous().len();
if hops >= MAX_REDIRECTS {
return attempt.error(RedirectError::TooManyHops(hops));
}
match ensure_public_target(attempt.url().as_str()) {
Ok(()) => attempt.follow(),
Err(e) => attempt.error(RedirectError::Unsafe(e)),
}
})
}
/// Drain a byte stream into a single buffer, bailing out as soon as
/// the running total exceeds `max_bytes`. Generic over the stream so
/// it's testable without a live HTTP response.
@@ -241,6 +456,31 @@ pub async fn fetch_bytes_capped(
.with_context(|| format!("download body for {url}"))
}
/// Send `req` and return the response body as a stream after the
/// safety check + 2xx status check. Caller owns chunking, capping, and
/// piping to storage. Used by `download_and_store_page` so peak memory
/// stays at one chunk per concurrent dispatch instead of one full
/// image.
pub async fn fetch_stream(
http: &reqwest::Client,
url: &str,
referer: Option<&str>,
allow: &DownloadAllowlist,
) -> anyhow::Result<reqwest::Response> {
is_safe_url(url, allow).with_context(|| format!("reject unsafe URL {url}"))?;
let mut req = http.get(url);
if let Some(r) = referer {
req = req.header(reqwest::header::REFERER, r);
}
let resp = req
.send()
.await
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("non-2xx for {url}"))?;
Ok(resp)
}
/// True when `bytes` sniffs as one of the *renderable* image formats
/// the `/files/*key` endpoint can serve with a correct Content-Type:
/// JPEG, PNG, WebP, GIF, AVIF. Matches the upload pipeline's
@@ -267,6 +507,26 @@ mod tests {
DownloadAllowlist::new().allow(host)
}
#[test]
fn rejects_alternate_ipv4_encodings_of_loopback() {
// The `url` crate normalizes special-scheme IPv4 hosts per WHATWG, so
// decimal/hex/octal/short forms all collapse to 127.0.0.1 and must be
// rejected. This normalization is load-bearing (it's what stops these
// encodings smuggling past the literal-IP check) but was untested — pin
// it so a future URL-parser swap can't silently reopen the hole.
for url in [
"http://2130706433/", // decimal 127.0.0.1
"http://0x7f000001/", // hex
"http://0177.0.0.1/", // octal first octet
"http://127.1/", // short form
] {
assert!(
ensure_public_target(url).is_err(),
"{url} normalizes to loopback and must be rejected"
);
}
}
#[test]
fn allow_any_admits_arbitrary_public_host() {
// Operators who can't pre-enumerate a numbered-CDN fleet
@@ -348,6 +608,71 @@ mod tests {
));
}
// --- ensure_public_target (allowlist-free admin URL validation) ---
#[test]
fn public_target_allows_dns_hostnames() {
// The whole point of this helper is to validate admin-supplied
// endpoint URLs (analysis vision endpoint, crawler start_url)
// WITHOUT consulting an allowlist. Docker-internal DNS names
// (which resolve at runtime to private IPs) MUST pass.
assert!(ensure_public_target("http://mangalord-vision:8000/v1/chat/completions").is_ok());
assert!(ensure_public_target("https://api.openai.com/v1/chat/completions").is_ok());
assert!(ensure_public_target("https://example.com/").is_ok());
}
#[test]
fn public_target_blocks_ip_literal_attacks() {
// Literal IPs in private/loopback/link-local ranges — the routes
// an attacker would actually use (AWS IMDS, postgres on
// 127.0.0.1, RFC1918 inside a corp network).
for url in [
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:5432/",
"http://10.0.0.1/",
"http://192.168.1.1/",
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://0.0.0.0/",
] {
assert!(
matches!(
ensure_public_target(url).unwrap_err(),
UrlSafetyError::PrivateIp(_)
),
"must reject {url}"
);
}
}
#[test]
fn public_target_blocks_localhost_hostname() {
assert!(matches!(
ensure_public_target("http://localhost:5432/").unwrap_err(),
UrlSafetyError::Loopback
));
}
#[test]
fn public_target_blocks_non_http_schemes() {
for url in ["file:///etc/passwd", "gopher://x.example/", "ftp://x/"] {
assert!(matches!(
ensure_public_target(url).unwrap_err(),
UrlSafetyError::BadScheme(_)
));
}
}
#[test]
fn public_target_rejects_unparseable() {
assert!(matches!(
ensure_public_target("not a url").unwrap_err(),
UrlSafetyError::Unparseable
));
}
// --- back to is_safe_url ---
#[test]
fn safe_url_blocks_rfc1918() {
let allow = allow_just("10.0.0.1");
@@ -417,6 +742,79 @@ mod tests {
assert!(matches!(err, UrlSafetyError::PrivateIp(_)));
}
#[test]
fn is_private_ip_unwraps_embedded_ipv4_encodings() {
// Every IPv6 encoding that can smuggle an internal IPv4 must be
// caught. The audit flagged compatible ::/96, NAT64, and 6to4 as
// gaps past the original mapped-only handling.
for s in [
"::ffff:127.0.0.1", // IPv4-mapped loopback
"::127.0.0.1", // IPv4-compatible loopback (was a gap)
"::ffff:10.1.2.3", // mapped RFC1918
"::10.1.2.3", // compatible RFC1918 (was a gap)
"64:ff9b::7f00:1", // NAT64 of 127.0.0.1 (was a gap)
"64:ff9b::a01:203", // NAT64 of 10.1.2.3
"2002:7f00:1::", // 6to4 of 127.0.0.1 (was a gap)
"2002:a01:203::", // 6to4 of 10.1.2.3
"2002:a9fe:a9fe::", // 6to4 of 169.254.169.254 (metadata)
] {
let ip: IpAddr = s.parse().unwrap();
assert!(is_private_ip(&ip), "{s} must be flagged private");
}
}
#[test]
fn is_private_ip_allows_public_embedded_and_native_ipv6() {
// A public IPv4 embedded in IPv6, and a native public IPv6, must
// NOT be flagged — the unwrap only blocks when the embedded v4 is
// itself private.
for s in [
"::ffff:8.8.8.8", // mapped public
"2002:808:808::", // 6to4 of 8.8.8.8 (public)
"2606:4700:4700::1111", // native public (Cloudflare)
] {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_private_ip(&ip), "{s} must be allowed");
}
}
#[test]
fn retain_public_addrs_drops_private_and_errors_when_all_private() {
use std::net::{Ipv4Addr, SocketAddr};
let pub_addr = SocketAddr::from((Ipv4Addr::new(93, 184, 216, 34), 0));
let loopback = SocketAddr::from((Ipv4Addr::new(127, 0, 0, 1), 0));
let metadata = SocketAddr::from((Ipv4Addr::new(169, 254, 169, 254), 0));
// Mixed result keeps only the public address.
let kept =
retain_public_addrs("mixed.example", [pub_addr, loopback, metadata].into_iter())
.expect("public address survives");
assert_eq!(kept, vec![pub_addr]);
// All-private (DNS rebinding to internal) is rejected outright.
let err =
retain_public_addrs("rebind.attacker", [loopback, metadata].into_iter()).unwrap_err();
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");
@@ -453,6 +851,54 @@ mod tests {
assert!(is_safe_url("https://CDN.EXAMPLE.com/x.jpg", &allow).is_ok());
}
// --- redirect-hop re-validation (SSRF via 3xx) ---
#[test]
fn redirect_hop_allows_listed_public_target() {
let allow = allow_just("cdn.example.com");
assert!(check_redirect_hop("https://cdn.example.com/next.jpg", 1, &allow).is_ok());
}
#[test]
fn redirect_hop_blocks_private_ip_target() {
// The core SSRF case: an allowlisted CDN 302s to the cloud metadata
// service / an intra-compose port. Must be refused mid-chain.
let allow = allow_just("cdn.example.com");
for url in ["http://169.254.169.254/", "http://127.0.0.1:5432/", "http://10.0.0.1/"] {
let err = check_redirect_hop(url, 1, &allow).unwrap_err();
assert!(
matches!(err, RedirectError::Unsafe(UrlSafetyError::PrivateIp(_))),
"expected PrivateIp for {url}, got {err:?}"
);
}
}
#[test]
fn redirect_hop_blocks_off_allowlist_public_host() {
// Per the strict policy: a redirect to an unlisted *public* host is
// also refused (allow_any covers the numbered-CDN case instead).
let allow = allow_just("cdn.example.com");
let err = check_redirect_hop("https://evil.example.org/x", 1, &allow).unwrap_err();
assert!(matches!(err, RedirectError::Unsafe(UrlSafetyError::HostNotAllowed(_))));
}
#[test]
fn redirect_hop_blocks_bad_scheme_target() {
let allow = DownloadAllowlist::allow_any();
let err = check_redirect_hop("file:///etc/passwd", 1, &allow).unwrap_err();
assert!(matches!(err, RedirectError::Unsafe(UrlSafetyError::BadScheme(_))));
}
#[test]
fn redirect_hop_caps_chain_length() {
let allow = allow_just("cdn.example.com");
// A safe target is still refused once the hop cap is reached, so a
// redirect loop can't spin forever.
let err = check_redirect_hop("https://cdn.example.com/x", MAX_REDIRECTS, &allow)
.unwrap_err();
assert!(matches!(err, RedirectError::TooManyHops(n) if n == MAX_REDIRECTS));
}
#[tokio::test]
async fn accumulate_capped_returns_full_body_under_cap() {
let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = vec![

View File

@@ -162,62 +162,162 @@ const PROBE_RETRY_DELAY: Duration = Duration::from_secs(2);
/// limiter. The trade is worth it — failing here costs ~1s; failing 30
/// minutes into a backfill costs 30 minutes.
pub async fn verify_session(browser: &Browser, probe_url: &str) -> anyhow::Result<()> {
let mut attempt = 0u32;
verify_session_with_recircuit(browser, probe_url, None, 0).await
}
/// Like [`verify_session`] but, when `tor` is `Some`, signals
/// `SIGNAL NEWNYM` between retries on transient pages AND treats
/// `Unauthenticated` as recoverable (up to `tor_max_attempts` total
/// probes, calling NEWNYM between each).
///
/// `verify_session` is `verify_session_with_recircuit(..., None, _)`,
/// which collapses the `Unauthenticated` budget to 1 attempt — i.e.
/// fail-fast, exactly the pre-TOR behavior.
pub async fn verify_session_with_recircuit(
browser: &Browser,
probe_url: &str,
tor: Option<&crate::crawler::tor::TorController>,
tor_max_attempts: u32,
) -> anyhow::Result<()> {
let unauth_max_attempts = if tor.is_some() { tor_max_attempts.max(1) } else { 1 };
run_session_probe_loop(
|| fetch_probe_html(browser, probe_url),
|| async {
if let Some(t) = tor {
if let Err(e) = t.new_identity().await {
tracing::warn!(error = %e, "TOR NEWNYM failed; continuing with same circuit");
}
}
},
PROBE_MAX_ATTEMPTS,
unauth_max_attempts,
PROBE_RETRY_DELAY,
probe_url,
)
.await
}
/// Pure-over-IO loop body for the session probe. Generic over the
/// fetch and recircuit closures so it can be unit-tested without a
/// real browser or TOR daemon.
///
/// Both budgets count **total attempts**, including the first — so
/// `transient_max_attempts = 3` allows 3 fetches and 2 recircuits
/// between them, and `unauth_max_attempts = 1` means "fail-fast, no
/// retry". This matches [`crate::crawler::detect::retry_on_transient`]
/// and the content-path recircuit loop.
///
/// Outcomes:
/// - `SessionProbe::Ok` → return `Ok(())`.
/// - `SessionProbe::Unauthenticated` → recircuit + retry while
/// under the unauth budget. After the cap, bail with the
/// "PHPSESSID expired" diagnostic, mentioning the attempt count so
/// a TOR-misconfig diagnosis is easier.
/// - `SessionProbe::Transient` → same shape against the transient
/// budget; bails with "site down or rate-limiting" after the cap.
async fn run_session_probe_loop<F, Fut, R, RFut>(
mut fetch_html: F,
mut recircuit: R,
transient_max_attempts: u32,
unauth_max_attempts: u32,
retry_delay: Duration,
probe_url_for_msg: &str,
) -> anyhow::Result<()>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = anyhow::Result<String>>,
R: FnMut() -> RFut,
RFut: std::future::Future<Output = ()>,
{
debug_assert!(transient_max_attempts >= 1);
debug_assert!(unauth_max_attempts >= 1);
let mut transient_attempts = 0u32;
let mut unauth_attempts = 0u32;
loop {
attempt += 1;
let html = fetch_probe_html(browser, probe_url).await?;
let html = fetch_html().await?;
match classify_probe(&html) {
SessionProbe::Ok => {
tracing::info!(attempt, "session probe ok — #logo + #avatar_menu present");
tracing::info!(
transient_attempts,
unauth_attempts,
"session probe ok — #logo + #avatar_menu present"
);
return Ok(());
}
SessionProbe::Unauthenticated => {
return Err(anyhow!(
"session probe failed — #avatar_menu not present at {probe_url} \
(page rendered the normal layout); PHPSESSID is missing, expired, \
or revoked. Refresh CRAWLER_PHPSESSID and re-run."
));
}
SessionProbe::Transient if attempt < PROBE_MAX_ATTEMPTS => {
unauth_attempts += 1;
if unauth_attempts >= unauth_max_attempts {
return Err(anyhow!(
"session probe failed — #avatar_menu not present at {probe_url_for_msg} \
after {unauth_attempts} attempt(s); PHPSESSID is missing, \
expired, or revoked. Refresh CRAWLER_PHPSESSID and re-run."
));
}
tracing::warn!(
attempt,
max_attempts = PROBE_MAX_ATTEMPTS,
"session probe got a transient page; retrying"
attempt = unauth_attempts,
max_attempts = unauth_max_attempts,
"session probe Unauthenticated despite PHPSESSID; signaling TOR \
NEWNYM and retrying"
);
tokio::time::sleep(PROBE_RETRY_DELAY).await;
recircuit().await;
tokio::time::sleep(retry_delay).await;
}
SessionProbe::Transient => {
return Err(anyhow!(
"session probe failed — probe page at {probe_url} returned a \
broken-page response after {PROBE_MAX_ATTEMPTS} attempts. \
The site appears to be down or rate-limiting us; try again \
later before refreshing CRAWLER_PHPSESSID."
));
transient_attempts += 1;
if transient_attempts >= transient_max_attempts {
return Err(anyhow!(
"session probe failed — probe page at {probe_url_for_msg} returned \
a broken-page response after {transient_max_attempts} attempts. \
The site appears to be down or rate-limiting us; try again \
later before refreshing CRAWLER_PHPSESSID."
));
}
tracing::warn!(
attempt = transient_attempts,
max_attempts = transient_max_attempts,
"session probe got a transient page; recircuit + retry"
);
recircuit().await;
tokio::time::sleep(retry_delay).await;
}
}
}
}
async fn fetch_probe_html(browser: &Browser, probe_url: &str) -> anyhow::Result<String> {
let page = browser
.new_page(probe_url)
// Guard the probe navigation for parity with the list/detail and
// chapter-content paths — the probe URL is operator-controlled, but
// keeping every `new_page` behind the same SSRF check avoids a gap if
// the URL ever becomes attacker-influenced.
crate::crawler::safety::ensure_public_target(probe_url)
.with_context(|| format!("refuse to navigate unsafe probe URL {probe_url}"))?;
let page = crate::crawler::intercept::open_page(browser, probe_url)
.await
.with_context(|| format!("open probe page {probe_url}"))?;
crate::crawler::nav::wait_for_nav(&page)
.await
.context("wait for nav on probe")?;
// Best-effort wait for the layout marker. Timeout is fine — the
// probe classifier handles a missing `#logo` as Transient anyway,
// and the verify loop retries on Transient.
let _ = crate::crawler::nav::wait_for_selector(
&page,
"#logo",
crate::crawler::nav::SELECTOR_TIMEOUT,
// Close the tab on every exit path — a `?` on wait_for_nav / content()
// would otherwise leak it (chromiumoxide doesn't close on drop).
let closer = page.clone();
crate::crawler::nav::close_after(
async move {
closer.close().await.ok();
},
async {
crate::crawler::nav::wait_for_nav(&page)
.await
.context("wait for nav on probe")?;
// Best-effort wait for the layout marker. Timeout is fine — the
// probe classifier handles a missing `#logo` as Transient anyway,
// and the verify loop retries on Transient.
let _ = crate::crawler::nav::wait_for_selector(
&page,
"#logo",
crate::crawler::nav::SELECTOR_TIMEOUT,
)
.await;
page.content().await.context("read probe html")
},
)
.await;
let html = page.content().await.context("read probe html")?;
page.close().await.ok();
Ok(html)
.await
}
#[cfg(test)]
@@ -336,6 +436,204 @@ mod tests {
assert_eq!(classify_chapter_probe(html), ChapterProbe::Ok);
}
// --- run_session_probe_loop -----------------------------------------
//
// These tests exercise the recircuit-aware loop without a real
// browser. The fetch and recircuit closures are mocked over Vecs of
// canned outcomes / counters.
const OK_HTML: &str = r#"<html><body><div id="logo"></div><div id="avatar_menu"></div></body></html>"#;
const UNAUTH_HTML: &str = r#"<html><body><div id="logo"></div></body></html>"#;
const TRANSIENT_HTML: &str = "<html><body><p>we're sorry, the request file are not found.</p></body></html>";
#[tokio::test]
async fn probe_loop_ok_on_first_attempt_does_not_recircuit() {
let mut recircuits = 0u32;
let mut fetched = 0u32;
run_session_probe_loop(
|| {
fetched += 1;
async { Ok(OK_HTML.to_string()) }
},
|| {
recircuits += 1;
async {}
},
3,
3,
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect("ok on first attempt");
assert_eq!(fetched, 1);
assert_eq!(recircuits, 0);
}
#[tokio::test]
async fn probe_loop_unauth_then_ok_when_attempt_budget_available() {
// Budget = 3 total attempts. Unauth on call 1, ok on call 2.
let mut recircuits = 0u32;
let mut call = 0u32;
run_session_probe_loop(
|| {
call += 1;
let n = call;
async move {
if n == 1 {
Ok(UNAUTH_HTML.to_string())
} else {
Ok(OK_HTML.to_string())
}
}
},
|| {
recircuits += 1;
async {}
},
3,
3,
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect("recovers after one recircuit");
assert_eq!(call, 2);
assert_eq!(recircuits, 1);
}
#[tokio::test]
async fn probe_loop_unauth_with_single_attempt_budget_fails_fast() {
// Budget = 1 total attempt = no retry (matches no-TOR behavior).
let mut recircuits = 0u32;
let mut call = 0u32;
let err = run_session_probe_loop(
|| {
call += 1;
async { Ok(UNAUTH_HTML.to_string()) }
},
|| {
recircuits += 1;
async {}
},
3,
1,
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect_err("budget=1 → fail-fast");
assert_eq!(call, 1, "no retry when budget is 1");
assert_eq!(recircuits, 0);
let msg = format!("{err:#}");
assert!(msg.contains("Refresh CRAWLER_PHPSESSID"), "msg: {msg}");
assert!(msg.contains("after 1 attempt"), "expected attempt count in msg: {msg}");
}
#[tokio::test]
async fn probe_loop_unauth_after_exhausting_budget_emits_attempt_count() {
let mut recircuits = 0u32;
let mut call = 0u32;
let err = run_session_probe_loop(
|| {
call += 1;
async { Ok(UNAUTH_HTML.to_string()) }
},
|| {
recircuits += 1;
async {}
},
10, // transient budget irrelevant here
3, // 3 attempts total, 2 recircuits between
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect_err("exhausts unauth budget");
assert_eq!(call, 3);
assert_eq!(recircuits, 2);
let msg = format!("{err:#}");
assert!(msg.contains("after 3 attempt"), "expected attempt count in error, got: {msg}");
}
#[tokio::test]
async fn probe_loop_transient_repeats_until_max_then_errors() {
let mut recircuits = 0u32;
let mut call = 0u32;
let err = run_session_probe_loop(
|| {
call += 1;
async { Ok(TRANSIENT_HTML.to_string()) }
},
|| {
recircuits += 1;
async {}
},
3,
1,
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect_err("transient until max → fail");
assert_eq!(call, 3);
// Recircuit fires between attempts: 3 attempts → 2 recircuits.
assert_eq!(recircuits, 2);
let msg = format!("{err:#}");
assert!(msg.contains("broken-page response after 3 attempts"), "msg: {msg}");
}
#[tokio::test]
async fn probe_loop_transient_then_ok_returns_ok_after_one_recircuit() {
let mut recircuits = 0u32;
let mut call = 0u32;
run_session_probe_loop(
|| {
call += 1;
let n = call;
async move {
if n == 1 {
Ok(TRANSIENT_HTML.to_string())
} else {
Ok(OK_HTML.to_string())
}
}
},
|| {
recircuits += 1;
async {}
},
3,
1,
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect("ok on second try");
assert_eq!(call, 2);
assert_eq!(recircuits, 1);
}
#[tokio::test]
async fn probe_loop_propagates_fetch_errors_immediately() {
let mut call = 0u32;
let err = run_session_probe_loop(
|| {
call += 1;
async { Err(anyhow!("nav timeout")) }
},
|| async {},
5,
5,
Duration::from_millis(0),
"https://example/probe",
)
.await
.expect_err("fetch error bubbles");
assert_eq!(call, 1);
assert!(format!("{err:#}").contains("nav timeout"));
}
#[test]
fn classify_probe_trusts_broken_body_over_stray_avatar_match() {
// Defensive: if a broken-page body somehow contains an

View File

@@ -0,0 +1,184 @@
//! Runtime-updatable crawler session (PHPSESSID).
//!
//! At startup the session comes from `CRAWLER_PHPSESSID`, but it expires
//! and previously needed a container restart to refresh. This controller
//! lets an admin push a fresh cookie at runtime: it rewrites the reqwest
//! cookie jar (CDN image fetches), updates the in-memory value the browser
//! `on_launch` hook reads, persists it to `crawler_state` (so it survives
//! a restart), and clears the sticky `session_expired` flag. A subsequent
//! coordinated browser restart re-runs `on_launch`, re-injecting the new
//! cookie into Chromium and re-probing.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::Context;
use sqlx::PgPool;
use tokio::sync::RwLock;
use crate::repo;
pub struct SessionController {
/// Current PHPSESSID — what `on_launch` injects into a fresh browser.
phpsessid: RwLock<Option<String>>,
/// The same `Arc<Jar>` handed to the reqwest client; updating it here
/// updates the client's cookies (the jar is internally mutable).
cookie_jar: Arc<reqwest::cookie::Jar>,
cookie_domain: Option<String>,
start_url: Option<String>,
db: PgPool,
session_expired: Arc<AtomicBool>,
}
impl SessionController {
pub fn new(
initial: Option<String>,
cookie_jar: Arc<reqwest::cookie::Jar>,
cookie_domain: Option<String>,
start_url: Option<String>,
db: PgPool,
session_expired: Arc<AtomicBool>,
) -> Arc<Self> {
Arc::new(Self {
phpsessid: RwLock::new(initial),
cookie_jar,
cookie_domain,
start_url,
db,
session_expired,
})
}
/// The PHPSESSID a fresh browser should inject (None when unset).
pub async fn current(&self) -> Option<String> {
self.phpsessid.read().await.clone()
}
/// Whether the sticky session-expired flag is set (chapter workers
/// idle while true).
pub fn is_expired(&self) -> bool {
self.session_expired.load(Ordering::Acquire)
}
/// Clear the session-expired flag without changing the cookie — used
/// when the operator knows the session is fine and wants workers to
/// resume immediately.
pub fn clear_expired(&self) {
self.session_expired.store(false, Ordering::Release);
}
/// Update the session everywhere: reqwest jar, in-memory value, and
/// persisted `crawler_state`. Clears the session-expired flag. Does
/// NOT relaunch the browser — the caller triggers a coordinated
/// restart so `on_launch` re-injects + re-probes.
pub async fn update(&self, sid: &str) -> anyhow::Result<()> {
let sid = sid.trim().to_string();
anyhow::ensure!(!sid.is_empty(), "PHPSESSID must not be empty");
// The value is spliced into a cookie string and a CDP CookieParam.
// PHPSESSID values produced by PHP are URL-safe base64 alphanumerics
// plus a small set of punctuation depending on session.sid_bits_per_
// character. An allow-list (rather than a blocklist of control chars
// + `;,`) makes the check robust against future cookie syntax
// extensions and forces a paste that includes whitespace, quotes,
// backslashes, etc. — typical signs of a botched copy-paste — to
// be rejected early.
anyhow::ensure!(
sid.chars().all(is_phpsessid_char),
"PHPSESSID contains invalid characters"
);
if let (Some(domain), Some(start_url)) = (&self.cookie_domain, &self.start_url) {
let cookie_str = format!("PHPSESSID={sid}; Domain={domain}; Path=/");
let seed_url =
reqwest::Url::parse(start_url).context("parse start_url for cookie seed")?;
self.cookie_jar.add_cookie_str(&cookie_str, &seed_url);
}
*self.phpsessid.write().await = Some(sid.clone());
repo::crawler::runtime_session_persist(&self.db, &sid)
.await
.context("persist runtime session")?;
self.session_expired.store(false, Ordering::Release);
tracing::info!("crawler session updated at runtime");
Ok(())
}
/// Read a persisted runtime session (if any) from `crawler_state`.
/// Called at startup so a mid-day refresh survives a restart.
pub async fn load_persisted(db: &PgPool) -> Option<String> {
repo::crawler::runtime_session_load(db).await.ok().flatten()
}
}
/// Characters allowed in a PHPSESSID. PHP's session.sid_bits_per_character
/// produces alphanumerics plus `-` and `,` in the lowest-bit mode, but our
/// audit rejects `,` (cookie delimiter) — operators paste from a browser
/// devtools snapshot, which never embeds raw commas in the SID itself.
/// Underscore is allowed because some sources customise their session
/// alphabet.
fn is_phpsessid_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_')
}
#[cfg(test)]
mod tests {
use super::*;
fn controller(db: PgPool) -> Arc<SessionController> {
SessionController::new(
None,
Arc::new(reqwest::cookie::Jar::default()),
Some("example.com".into()),
Some("https://example.com/".into()),
db,
Arc::new(AtomicBool::new(true)),
)
}
#[sqlx::test(migrations = "./migrations")]
async fn update_rejects_empty_and_control_chars(pool: PgPool) {
let c = controller(pool);
assert!(c.update(" ").await.is_err(), "empty rejected");
assert!(c.update("abc\r\ndef").await.is_err(), "CRLF rejected");
assert!(c.update("ab;Domain=evil").await.is_err(), "semicolon rejected");
assert!(c.update("x,y").await.is_err(), "comma rejected");
}
#[sqlx::test(migrations = "./migrations")]
async fn update_rejects_non_alphanumeric_pastes(pool: PgPool) {
// Allow-list tightening (M6): pastes that include whitespace,
// quotes, slashes, backslashes, `=`, etc. are typical signs of a
// botched copy-paste and must be rejected outright.
let c = controller(pool);
for bad in ["ab cd", "ab\"cd", "ab=cd", "ab/cd", "ab\\cd", "ab+cd", "ab.cd"] {
assert!(c.update(bad).await.is_err(), "{bad:?} should be rejected");
}
// Allowed cases (sanity): plain alphanumerics, '-' and '_'.
assert!(c.update("abc_DEF-123").await.is_ok());
}
#[sqlx::test(migrations = "./migrations")]
async fn update_persists_and_clears_expired_then_round_trips(pool: PgPool) {
let c = controller(pool.clone());
c.update("good-sid-123").await.unwrap();
assert_eq!(c.current().await.as_deref(), Some("good-sid-123"));
assert!(!c.is_expired(), "update clears the expired flag");
// Persisted to crawler_state and readable by a fresh load.
assert_eq!(
SessionController::load_persisted(&pool).await.as_deref(),
Some("good-sid-123")
);
}
#[sqlx::test(migrations = "./migrations")]
async fn clear_expired_flips_sticky_flag_without_touching_session(pool: PgPool) {
// The flag starts `true` per `controller(pool)`'s test wiring.
let c = controller(pool);
assert!(c.is_expired(), "test fixture starts with the flag set");
c.clear_expired();
assert!(!c.is_expired(), "clear_expired flips the sticky flag to false");
assert!(
c.current().await.is_none(),
"clear_expired does not invent a session"
);
}
}

View File

@@ -67,6 +67,10 @@ pub struct SourceChapter {
pub struct FetchContext<'a> {
pub browser: &'a Browser,
pub rate: &'a crate::crawler::rate_limit::HostRateLimiters,
/// Optional TOR control-port client. When `Some`, retry helpers
/// signal `NEWNYM` between transient-page attempts so the next try
/// draws a fresh exit. `None` keeps pre-TOR behavior.
pub tor: Option<&'a crate::crawler::tor::TorController>,
}
/// Lazy iterator over discovered manga refs. The caller drives the

View File

@@ -18,9 +18,10 @@ use super::{
SourceMangaRef,
};
use crate::crawler::detect::{
has_logo_sentinel, is_broken_page_body, retry_on_transient, PageError,
has_logo_sentinel, is_broken_page_body, retry_on_transient_with_hook, PageError,
};
use crate::crawler::nav::{wait_for_nav, wait_for_selector, NavError, SELECTOR_TIMEOUT};
use crate::crawler::nav::{close_after, wait_for_nav, wait_for_selector, NavError, SELECTOR_TIMEOUT};
use crate::crawler::safety::ensure_public_target;
/// `sources.id` value for this Source impl. Exposed as a const so the
/// daemon can look up per-source state (e.g. the recovery flag) before
@@ -79,12 +80,13 @@ impl Source for TargetSource {
// and the HTML is handed straight to the first `next_batch` call
// so the walker doesn't re-fetch it. Page count is discovered
// incrementally — see `TargetSourceWalker::next_batch`.
let first_html = retry_on_transient(
let first_html = retry_on_transient_with_hook(
|| async {
navigate(ctx, self.base_url.as_str(), LIST_PAGE_MARKER).await
},
PAGE_TRANSIENT_RETRY_ATTEMPTS,
PAGE_TRANSIENT_RETRY_DELAY,
|| async { recircuit_if_configured(ctx.tor).await },
)
.await?;
@@ -169,7 +171,7 @@ impl DiscoverWalk for TargetSourceWalker {
parse_manga_list_from(&doc)?
}
None => {
retry_on_transient(
retry_on_transient_with_hook(
|| async {
let html = navigate(
ctx,
@@ -182,12 +184,13 @@ impl DiscoverWalk for TargetSourceWalker {
},
PAGE_TRANSIENT_RETRY_ATTEMPTS,
PAGE_TRANSIENT_RETRY_DELAY,
|| async { recircuit_if_configured(ctx.tor).await },
)
.await?
}
}
} else {
retry_on_transient(
retry_on_transient_with_hook(
|| async {
let url = page_url(&self.base_url, page_num);
let html = navigate(ctx, &url, LIST_PAGE_MARKER).await?;
@@ -196,6 +199,7 @@ impl DiscoverWalk for TargetSourceWalker {
},
PAGE_TRANSIENT_RETRY_ATTEMPTS,
PAGE_TRANSIENT_RETRY_DELAY,
|| async { recircuit_if_configured(ctx.tor).await },
)
.await?
};
@@ -217,6 +221,19 @@ const LIST_PAGE_MARKER: &str = "#left_side .pic_list .updatesli";
const DETAIL_PAGE_CHAPTERS_MARKER: &str = "#chapter_table td h4 a.chico";
const DETAIL_PAGE_LAYOUT_MARKER: &str = "#logo";
/// Refuse to point the headless browser at a private/internal target.
/// The list/detail URLs driven through [`navigate`] originate from
/// scraped hrefs (base URL, pagination, and detail links harvested from
/// listings), so a hostile or compromised source could otherwise steer
/// Chromium at `http://169.254.169.254/`, `http://postgres:5432/`, etc.
/// and read the response body as an SSRF oracle. Mirrors the
/// chapter-content guard in [`crate::crawler::content`].
fn guard_navigate_url(url: &str) -> Result<(), PageError> {
ensure_public_target(url).map_err(|e| {
PageError::Other(anyhow::anyhow!("refuse to navigate unsafe URL {url}: {e}"))
})
}
/// Single point of rate-limited navigation. Every Source request goes
/// through here, so the per-host limiter map is the only knob that
/// controls per-origin RPS. Also the choke point for transient-page
@@ -234,32 +251,39 @@ async fn navigate(
url: &str,
marker: &str,
) -> Result<String, PageError> {
guard_navigate_url(url)?;
ctx.rate.wait_for(url).await?;
let page = ctx
.browser
.new_page(url)
let page = crate::crawler::intercept::open_page(ctx.browser, url)
.await
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
match wait_for_nav(&page).await {
Ok(()) => {}
Err(NavError::Timeout(_)) => {
page.close().await.ok();
return Err(PageError::transient("nav timeout"));
}
Err(NavError::Cdp(e)) => {
page.close().await.ok();
return Err(PageError::Other(anyhow::Error::from(e)));
}
}
// Best-effort wait for the page-type marker. We deliberately
// discard a timeout here — see fn-level doc.
let _ = wait_for_selector(&page, marker, SELECTOR_TIMEOUT).await;
let html = page
.content()
.await
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
page.close().await.ok();
classify_navigate_html(html)
// Close the tab on every exit path — the previous code closed on the two
// nav-error branches but leaked it on a content() read error.
let closer = page.clone();
close_after(
async move {
closer.close().await.ok();
},
async {
match wait_for_nav(&page).await {
Ok(()) => {}
Err(NavError::Timeout(_)) => {
return Err(PageError::transient("nav timeout"));
}
Err(NavError::Cdp(e)) => {
return Err(PageError::Other(anyhow::Error::from(e)));
}
}
// Best-effort wait for the page-type marker. We deliberately
// discard a timeout here — see fn-level doc.
let _ = wait_for_selector(&page, marker, SELECTOR_TIMEOUT).await;
let html = page
.content()
.await
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
classify_navigate_html(html)
},
)
.await
}
/// Classify a fetched body. The broken-page template is universal across
@@ -274,6 +298,20 @@ fn classify_navigate_html(html: String) -> Result<String, PageError> {
Ok(html)
}
/// Hook for [`retry_on_transient_with_hook`]: when TOR is configured,
/// signal `NEWNYM` so the next navigation draws a fresh exit. Errors
/// from the controller are logged and swallowed — failing to recircuit
/// shouldn't take down the crawl, the next attempt just runs on the
/// same circuit as before.
async fn recircuit_if_configured(tor: Option<&crate::crawler::tor::TorController>) {
if let Some(t) = tor {
if let Err(e) = t.new_identity().await {
tracing::warn!(error = %e, "TOR NEWNYM failed; retrying on same circuit");
}
}
}
/// Substitutes the first `/N/` path segment with the target page
/// number. Source impls that paginate via a different URL shape can
/// override this — for the modeled site the segment is always present.
@@ -315,25 +353,54 @@ fn parse_manga_list_from(doc: &scraper::Html) -> Result<Vec<SourceMangaRef>, Pag
if !has_logo_sentinel(doc) {
return Err(PageError::transient("manga list: #logo sentinel missing"));
}
let (refs, dropped) = parse_manga_list_anchors(doc);
// A non-zero drop count means the listing selector matched anchors but
// some lacked a usable href/title. In steady state this is ~always zero;
// a sustained non-zero count is the early signal of a source markup
// drift silently eroding coverage (GAP-2), so surface it rather than
// swallowing it in the filter. `#logo` was already confirmed present,
// so this is genuine per-item loss, not a broken-page response.
if dropped > 0 {
tracing::warn!(
dropped,
kept = refs.len(),
"manga list: dropped listing anchors with missing/empty href or title \
(possible source markup drift)"
);
}
Ok(refs)
}
/// Extract `SourceMangaRef`s from a listing document, returning the kept
/// refs alongside the count of anchors dropped for a missing/empty href or
/// title. Splitting the count out of [`parse_manga_list_from`] keeps the
/// drop accounting unit-testable and lets the caller log coverage erosion.
fn parse_manga_list_anchors(doc: &scraper::Html) -> (Vec<SourceMangaRef>, usize) {
let sel = scraper::Selector::parse("#left_side .pic_list .updatesli span a").unwrap();
Ok(doc
.select(&sel)
.filter_map(|a| {
let url = a.value().attr("href")?.trim().to_string();
if url.is_empty() {
return None;
}
let title = collapse_whitespace(&a.text().collect::<String>());
if title.is_empty() {
return None;
}
Some(SourceMangaRef {
source_manga_key: derive_key_from_url(&url),
title,
url,
})
})
.collect())
let mut refs = Vec::new();
let mut dropped = 0usize;
for a in doc.select(&sel) {
let url = a
.value()
.attr("href")
.map(|h| h.trim().to_string())
.unwrap_or_default();
if url.is_empty() {
dropped += 1;
continue;
}
let title = collapse_whitespace(&a.text().collect::<String>());
if title.is_empty() {
dropped += 1;
continue;
}
refs.push(SourceMangaRef {
source_manga_key: derive_key_from_url(&url),
title,
url,
});
}
(refs, dropped)
}
fn parse_manga_detail(
@@ -682,6 +749,35 @@ mod tests {
assert_eq!(refs[1].source_manga_key, "bar-baz");
}
#[test]
fn parse_manga_list_anchors_reports_dropped_count() {
// The fixture's third `.updatesli` anchor has an empty href. The
// kept list must exclude it AND the drop count must be 1 so the
// caller can warn on markup drift (GAP-2).
let doc = scraper::Html::parse_document(LISTING_HTML);
let (refs, dropped) = parse_manga_list_anchors(&doc);
assert_eq!(refs.len(), 2, "two well-formed anchors kept");
assert_eq!(dropped, 1, "the empty-href anchor is counted as dropped");
// Also cover the *missing*-href branch (no `href` attr at all),
// which is the path the refactor reroutes through
// `.map(..).unwrap_or_default()` — distinct from the empty-string
// href above. Plus a missing-title anchor.
let html = r#"<html><body>
<header><div id="logo">Target</div></header>
<div id="left_side"><div class="pic_list">
<div class="updatesli"><span><a href="/manga/keep">Keep</a></span></div>
<div class="updatesli"><span><a>no href at all</a></span></div>
<div class="updatesli"><span><a href="/manga/blank"> </a></span></div>
</div></div>
</body></html>"#;
let doc = scraper::Html::parse_document(html);
let (refs, dropped) = parse_manga_list_anchors(&doc);
assert_eq!(refs.len(), 1, "only the well-formed anchor is kept");
assert_eq!(refs[0].title, "Keep");
assert_eq!(dropped, 2, "missing-href and missing-title anchors both counted");
}
#[test]
fn parse_manga_list_returns_transient_when_logo_missing() {
// Broken-page response: no #logo, no listing. Empty Vec would
@@ -1032,4 +1128,37 @@ mod tests {
.expect("metadata-only parse must not require chapter table");
assert!(manga.chapters.is_empty());
}
#[test]
fn navigate_guard_rejects_private_and_internal_targets() {
// The SSRF guard `navigate` runs before opening any headless page.
// A scraped listing/detail href pointing at cloud metadata, an
// internal service, or the loopback interface must be refused.
for bad in [
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:5432/",
"http://postgres:5432/", // resolves to a private range name, but…
"http://[::1]/",
"http://10.0.0.5/",
"file:///etc/passwd",
] {
// `postgres` is a bare hostname, not an IP literal, so the
// literal-IP guard alone passes it — assert only the cases the
// guard is designed to catch (IP literals + bad schemes).
if bad.contains("postgres") {
assert!(guard_navigate_url(bad).is_ok(), "bare hostname passes literal check");
continue;
}
assert!(
guard_navigate_url(bad).is_err(),
"expected {bad} to be refused before navigation"
);
}
}
#[test]
fn navigate_guard_allows_public_targets() {
assert!(guard_navigate_url("https://target.example/manga/foo").is_ok());
assert!(guard_navigate_url("https://8.8.8.8/").is_ok());
}
}

View File

@@ -0,0 +1,460 @@
//! Live, in-process crawler status.
//!
//! The metadata pass runs inline in the cron tick (it is not a
//! `crawler_jobs` row), so without this surface "what is the crawler doing
//! right now" is unanswerable from the dashboard. The daemon publishes its
//! current [`Phase`], the chapters being crawled right now (with a live
//! page count), and the cover being fetched into a shared [`StatusHandle`];
//! the admin endpoint reads a [`CrawlerStatus`] snapshot and composes it
//! with DB-derived counts + the session/browser flags.
//!
//! NOTE: this is per-process state. The deployment is a single server
//! (see CLAUDE.md), so an in-memory handle is sufficient; durable signals
//! (last-pass summary, runtime session) are persisted in `crawler_state`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use serde::Serialize;
use tokio::sync::{watch, RwLock};
use uuid::Uuid;
use crate::crawler::pipeline::MetadataStats;
/// What the daemon's metadata pass is doing right now. Serialised with an
/// internal `state` tag so the frontend can switch on it.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Phase {
/// Sleeping until the next scheduled metadata pass.
Idle { next_fire: Option<DateTime<Utc>> },
/// Walking the source catalog list pages.
WalkingList,
/// Fetching one manga's metadata. `index`/`total` drive a progress bar
/// (`total` is `None` when the source size is unknown / uncapped).
FetchingMetadata {
index: usize,
total: Option<usize>,
title: String,
},
/// Backfilling covers that failed on first attempt. `index`/`total`
/// track progress through this tick's batch.
CoverBackfill { index: usize, total: usize },
/// Reconcile pass: walking the full source list (refs only, no detail
/// visit) to find mangas missing from the DB. `walked` is refs seen so
/// far this walk; `enqueued` is missing mangas queued as `SyncManga`.
Reconciling { walked: usize, enqueued: usize },
}
/// A chapter being downloaded right now, with a live page count. Keyed in
/// the status by `chapter_id`; inserted by the dispatcher when a job starts
/// and removed (via an RAII guard) when it finishes, panics, or times out.
#[derive(Clone, Debug, Serialize)]
pub struct ActiveChapter {
pub manga_id: Uuid,
pub manga_title: String,
pub chapter_id: Uuid,
pub chapter_number: i32,
pub pages_done: usize,
/// `None` until the chapter page list has been parsed.
pub pages_total: Option<usize>,
}
/// The manga whose cover is being downloaded right now.
#[derive(Clone, Debug, Serialize)]
pub struct CoverTarget {
pub manga_id: Uuid,
pub manga_title: String,
}
/// Summary of the most recent metadata pass (persisted across restarts in
/// `crawler_state` by the cron; mirrored here for the live read).
#[derive(Clone, Debug, Serialize, Default)]
pub struct LastPass {
pub at: Option<DateTime<Utc>>,
pub discovered: usize,
pub upserted: usize,
pub covers_fetched: usize,
pub mangas_failed: usize,
}
/// A point-in-time snapshot returned by [`StatusHandle::snapshot`]. The
/// session/browser/queue fields are composed at read time by the endpoint
/// (they live elsewhere), so they are not stored here.
#[derive(Clone, Debug, Serialize)]
pub struct CrawlerStatus {
pub phase: Phase,
/// Number of configured chapter workers (for "N busy / M workers").
pub worker_count: usize,
/// Chapters being downloaded right now, with live page counts.
pub active_chapters: Vec<ActiveChapter>,
pub last_pass: LastPass,
/// The cover being downloaded right now, if any.
pub current_cover: Option<CoverTarget>,
}
/// Scalar status state held under the async `RwLock`. Active chapters and
/// the current cover live in separate sync maps so per-page updates and
/// RAII removal don't need to `.await` (removal happens in `Drop`).
#[derive(Clone, Debug)]
struct Scalar {
phase: Phase,
worker_count: usize,
last_pass: LastPass,
}
/// Cloneable handle the daemon tasks use to publish status. Cheap to clone
/// (`Arc`). All writers funnel through the helper methods so locking stays
/// localised. Every mutation bumps a `watch` version so SSE subscribers
/// get pushed an update instead of polling.
#[derive(Clone)]
pub struct StatusHandle {
scalar: Arc<RwLock<Scalar>>,
/// Currently-downloading chapters keyed by `chapter_id`. A sync mutex so
/// the RAII [`ChapterGuard`]'s `Drop` can remove without `.await`.
active: Arc<Mutex<HashMap<Uuid, ActiveChapter>>>,
/// The cover being downloaded right now (if any). Sync mutex so the
/// RAII [`CoverGuard`]'s `Drop` can clear without `.await`, which is
/// what makes the cleared-on-panic guarantee hold.
current_cover: Arc<Mutex<Option<CoverTarget>>>,
/// Monotonic version bumped on every change. SSE handlers `subscribe()`
/// and `await .changed()` for instant pushes; `watch` has no
/// lost-wakeup so a change between snapshots is never missed.
version: Arc<watch::Sender<u64>>,
}
/// Lock the active map, recovering from a poisoned mutex. The map values
/// are plain structs and we never hold the lock across a panic-prone
/// section, so resuming on poison is safe — but log it so a real poison
/// (which signals a panic-in-critical-section bug somewhere) doesn't pass
/// in silence.
fn lock_active(
m: &Mutex<HashMap<Uuid, ActiveChapter>>,
) -> std::sync::MutexGuard<'_, HashMap<Uuid, ActiveChapter>> {
m.lock().unwrap_or_else(|e| {
tracing::warn!(
"status::lock_active recovered from a poisoned mutex — \
this implies a panic somewhere holding the lock"
);
e.into_inner()
})
}
/// Same shape as [`lock_active`] but for the single-slot cover mutex.
fn lock_cover(
m: &Mutex<Option<CoverTarget>>,
) -> std::sync::MutexGuard<'_, Option<CoverTarget>> {
m.lock().unwrap_or_else(|e| {
tracing::warn!(
"status::lock_cover recovered from a poisoned mutex — \
this implies a panic somewhere holding the lock"
);
e.into_inner()
})
}
impl StatusHandle {
pub fn new(num_workers: usize) -> Self {
let (version, _rx) = watch::channel(0u64);
Self {
scalar: Arc::new(RwLock::new(Scalar {
phase: Phase::Idle { next_fire: None },
worker_count: num_workers.max(1),
last_pass: LastPass::default(),
})),
active: Arc::new(Mutex::new(HashMap::new())),
current_cover: Arc::new(Mutex::new(None)),
version: Arc::new(version),
}
}
fn bump(&self) {
self.version.send_modify(|v| *v = v.wrapping_add(1));
}
/// A receiver whose `.changed()` resolves on the next status change.
pub fn subscribe(&self) -> watch::Receiver<u64> {
self.version.subscribe()
}
/// Signal a change without mutating in-memory state — used when an
/// *external* signal the live snapshot reflects (browser phase,
/// session-expired flag, queue counts) has changed, so subscribers
/// recompose promptly.
pub fn poke(&self) {
self.bump();
}
pub async fn set_phase(&self, phase: Phase) {
self.scalar.write().await.phase = phase;
self.bump();
}
/// Register a cover-fetch as in flight; returns a guard that clears
/// the current cover when dropped (on completion, panic-unwind, or
/// any future early-return). Last-writer-wins: a guard only clears
/// the slot when it still holds the cover it set (so overlapping
/// guards — not used today, but defensive — don't clobber each
/// other).
pub fn begin_cover(&self, target: CoverTarget) -> CoverGuard {
let manga_id = target.manga_id;
*lock_cover(&self.current_cover) = Some(target);
self.bump();
CoverGuard {
current_cover: Arc::clone(&self.current_cover),
version: Arc::clone(&self.version),
manga_id,
}
}
/// Register a chapter as crawling now; returns a guard that removes it
/// when dropped (on completion, panic-unwind, or timeout-drop).
pub fn begin_chapter(&self, chapter: ActiveChapter) -> ChapterGuard {
let id = chapter.chapter_id;
lock_active(&self.active).insert(id, chapter);
self.bump();
ChapterGuard {
active: Arc::clone(&self.active),
version: Arc::clone(&self.version),
chapter_id: id,
}
}
/// Update the live page count of an in-flight chapter. Sync (no
/// `.await`) so it's cheap to call once per stored page.
pub fn set_chapter_pages(&self, chapter_id: Uuid, done: usize, total: Option<usize>) {
{
let mut map = lock_active(&self.active);
if let Some(c) = map.get_mut(&chapter_id) {
c.pages_done = done;
c.pages_total = total;
}
}
self.bump();
}
/// Record a finished metadata pass. Stamps `at` with `now`.
pub async fn record_pass(&self, stats: &MetadataStats, at: DateTime<Utc>) {
self.scalar.write().await.last_pass = LastPass {
at: Some(at),
discovered: stats.discovered,
upserted: stats.upserted,
covers_fetched: stats.covers_fetched,
mangas_failed: stats.mangas_failed,
};
self.bump();
}
/// Seed the last-pass summary from a persisted `crawler_state` value on
/// startup so the dashboard isn't blank until the first tick.
pub async fn set_last_pass(&self, last: LastPass) {
self.scalar.write().await.last_pass = last;
self.bump();
}
pub async fn snapshot(&self) -> CrawlerStatus {
let scalar = self.scalar.read().await.clone();
let mut active_chapters: Vec<ActiveChapter> =
lock_active(&self.active).values().cloned().collect();
// Stable, readable order: by chapter number then id.
active_chapters.sort_by(|a, b| {
a.chapter_number
.cmp(&b.chapter_number)
.then(a.chapter_id.cmp(&b.chapter_id))
});
let current_cover = lock_cover(&self.current_cover).clone();
CrawlerStatus {
phase: scalar.phase,
worker_count: scalar.worker_count,
active_chapters,
last_pass: scalar.last_pass,
current_cover,
}
}
}
/// RAII handle clearing the [`CoverTarget`] from the live status when the
/// cover-fetch finishes, panics, or is dropped on any early-return.
pub struct CoverGuard {
current_cover: Arc<Mutex<Option<CoverTarget>>>,
version: Arc<watch::Sender<u64>>,
/// Manga id whose cover this guard registered. The drop only clears
/// the slot when the stored value still matches — defends against a
/// hypothetical newer guard clobbering this one's clear.
manga_id: Uuid,
}
impl Drop for CoverGuard {
fn drop(&mut self) {
let mut slot = lock_cover(&self.current_cover);
if slot.as_ref().map(|c| c.manga_id) == Some(self.manga_id) {
*slot = None;
}
self.version.send_modify(|v| *v = v.wrapping_add(1));
}
}
/// RAII handle removing an [`ActiveChapter`] from the live status when the
/// chapter dispatch finishes, panics, or is dropped on timeout.
pub struct ChapterGuard {
active: Arc<Mutex<HashMap<Uuid, ActiveChapter>>>,
version: Arc<watch::Sender<u64>>,
chapter_id: Uuid,
}
impl Drop for ChapterGuard {
fn drop(&mut self) {
lock_active(&self.active).remove(&self.chapter_id);
self.version.send_modify(|v| *v = v.wrapping_add(1));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_chapter(n: i32) -> ActiveChapter {
ActiveChapter {
manga_id: Uuid::new_v4(),
manga_title: "M".into(),
chapter_id: Uuid::new_v4(),
chapter_number: n,
pages_done: 0,
pages_total: None,
}
}
#[tokio::test]
async fn begin_chapter_shows_in_snapshot_and_guard_removes_on_drop() {
let h = StatusHandle::new(2);
let chap = sample_chapter(7);
let cid = chap.chapter_id;
{
let _guard = h.begin_chapter(chap);
let snap = h.snapshot().await;
assert_eq!(snap.active_chapters.len(), 1);
assert_eq!(snap.active_chapters[0].chapter_id, cid);
assert_eq!(snap.worker_count, 2);
}
// Guard dropped → entry removed.
let snap = h.snapshot().await;
assert!(snap.active_chapters.is_empty());
}
#[tokio::test]
async fn set_chapter_pages_updates_live_count() {
let h = StatusHandle::new(1);
let chap = sample_chapter(1);
let cid = chap.chapter_id;
let _guard = h.begin_chapter(chap);
h.set_chapter_pages(cid, 3, Some(20));
let snap = h.snapshot().await;
assert_eq!(snap.active_chapters[0].pages_done, 3);
assert_eq!(snap.active_chapters[0].pages_total, Some(20));
// Updating an unknown chapter is a no-op, not a panic.
h.set_chapter_pages(Uuid::new_v4(), 9, Some(9));
}
#[tokio::test]
async fn snapshot_sorts_active_chapters_by_number() {
let h = StatusHandle::new(2);
let _g1 = h.begin_chapter(sample_chapter(5));
let _g2 = h.begin_chapter(sample_chapter(2));
let snap = h.snapshot().await;
assert_eq!(snap.active_chapters[0].chapter_number, 2);
assert_eq!(snap.active_chapters[1].chapter_number, 5);
}
#[tokio::test]
async fn cover_guard_sets_then_clears_on_drop() {
let h = StatusHandle::new(1);
let mid = Uuid::new_v4();
{
let _g = h.begin_cover(CoverTarget {
manga_id: mid,
manga_title: "One Piece".into(),
});
assert_eq!(
h.snapshot().await.current_cover.map(|c| c.manga_id),
Some(mid)
);
}
assert!(h.snapshot().await.current_cover.is_none());
}
#[tokio::test]
async fn cover_guard_clears_on_panic_drop() {
// Simulate a download_and_store_cover panic: the guard is on the
// stack, the panic unwinds, and the slot must still be cleared.
let h = StatusHandle::new(1);
let mid = Uuid::new_v4();
let h2 = h.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = h2.begin_cover(CoverTarget {
manga_id: mid,
manga_title: "K-On!".into(),
});
panic!("simulated cover-download panic");
}));
assert!(result.is_err());
assert!(h.snapshot().await.current_cover.is_none());
}
#[tokio::test]
async fn cover_guard_does_not_clobber_a_newer_target() {
// Defensive: if a *newer* begin_cover ran before the older
// guard's drop fires, the drop must not clear the newer
// target. (No code today produces overlapping guards, but the
// invariant prevents a future caller from quietly breaking the
// live-cover surface.)
let h = StatusHandle::new(1);
let older = Uuid::new_v4();
let newer = Uuid::new_v4();
let g_old = h.begin_cover(CoverTarget {
manga_id: older,
manga_title: "old".into(),
});
let _g_new = h.begin_cover(CoverTarget {
manga_id: newer,
manga_title: "new".into(),
});
drop(g_old);
assert_eq!(
h.snapshot().await.current_cover.map(|c| c.manga_id),
Some(newer)
);
}
#[tokio::test]
async fn record_pass_captures_stats_and_timestamp() {
let h = StatusHandle::new(1);
let stats = MetadataStats {
discovered: 5,
upserted: 3,
covers_fetched: 2,
mangas_failed: 1,
};
let at = Utc::now();
h.record_pass(&stats, at).await;
let snap = h.snapshot().await;
assert_eq!(snap.last_pass.discovered, 5);
assert_eq!(snap.last_pass.upserted, 3);
assert_eq!(snap.last_pass.at, Some(at));
}
#[tokio::test]
async fn subscribe_resolves_on_mutation_poke_and_chapter_change() {
let h = StatusHandle::new(1);
let mut rx = h.subscribe();
h.set_phase(Phase::WalkingList).await;
rx.changed().await.unwrap();
h.poke();
rx.changed().await.unwrap();
// begin_chapter + guard drop each bump the version.
let g = h.begin_chapter(sample_chapter(1));
rx.changed().await.unwrap();
drop(g);
rx.changed().await.unwrap();
}
}

446
backend/src/crawler/tor.rs Normal file
View File

@@ -0,0 +1,446 @@
//! TOR control-port client for `SIGNAL NEWNYM` ("recircuit").
//!
//! The crawler can be proxied through TOR (`CRAWLER_PROXY=socks5h://tor:9050`)
//! to randomize the exit IP seen by the target site. When the target
//! returns a "bad page" (its broken-template body, missing layout
//! sentinel, or unauthenticated probe despite a valid PHPSESSID), it
//! is often the current exit being rate-limited or fingerprinted rather
//! than a real failure. Asking the local TOR daemon for a new identity
//! over its control port (port 9051 by default) makes subsequent
//! connections draw a fresh circuit; combined with `IsolateDestAddr`
//! in torrc this is usually enough to clear the failure.
//!
//! Scope is deliberately tiny — `AUTHENTICATE` + `SIGNAL NEWNYM` over
//! a one-shot TCP connection. No `torut` dep, no hidden-service
//! plumbing, no event streaming.
//!
//! **Caveat for in-flight connections:** Chromium reuses sockets, so a
//! `NEWNYM` only affects *new* connections (in TOR terms, new circuits).
//! That's fine for our retry path — the next navigation opens a fresh
//! connection. We do not try to forcibly close existing streams.
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Context};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::timeout;
/// Default control-port (`tor --defaults-torrc` ships 9051).
const DEFAULT_CONTROL_PORT: u16 = 9051;
/// Connect timeout — generous enough for a slow compose start, short
/// enough that a misconfigured controller doesn't stall a crawl.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Per-command read timeout. `SIGNAL NEWNYM` returns instantly on the
/// happy path; bound it so a half-broken control port can't hang us.
const READ_TIMEOUT: Duration = Duration::from_secs(5);
/// How the controller authenticates to the control port.
///
/// `Cookie` is preferred for compose deploys where the auth cookie file
/// is shared between the `tor` and `backend` containers via a named
/// volume. `Password` is the fallback when the cookie file isn't
/// reachable (different gid, no shared volume, etc.). `None` matches a
/// torrc with no `CookieAuthentication 1` and no `HashedControlPassword`
/// — useful for local experimentation, not for production.
///
/// `Debug` is implemented manually to redact the password (and the
/// cookie path, which is non-sensitive but uninteresting in logs).
/// Don't add `#[derive(Debug)]` — the controller is `?`-logged at
/// startup and a derive would expand the password into the trace.
#[derive(Clone)]
pub enum TorAuth {
None,
Password(String),
Cookie(PathBuf),
}
impl std::fmt::Debug for TorAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TorAuth::None => f.write_str("None"),
TorAuth::Password(_) => f.write_str("Password(<redacted>)"),
TorAuth::Cookie(_) => f.write_str("Cookie(<path>)"),
}
}
}
#[derive(Debug, Clone)]
pub struct TorController {
/// `host:port` string. Kept as a string (not a `SocketAddr`) so
/// docker-compose hostnames like `tor:9051` resolve at connect time.
addr: String,
auth: TorAuth,
}
impl TorController {
pub fn new(addr: impl Into<String>, auth: TorAuth) -> Self {
Self { addr: addr.into(), auth }
}
/// Build a controller from the env-config shape:
/// `url` (e.g. `tcp://tor:9051`, `127.0.0.1:9051`, or `tor`),
/// optional password, optional cookie path. Returns `Ok(None)` when
/// `url` is absent — that's the "TOR feature disabled" signal.
/// Cookie wins over password when both are set (rotates with TOR;
/// no secret to manage).
pub fn from_parts(
url: Option<&str>,
password: Option<&str>,
cookie_path: Option<&Path>,
) -> anyhow::Result<Option<Self>> {
let Some(url) = url else { return Ok(None) };
let addr = parse_control_url(url)?;
let auth = match (cookie_path, password) {
(Some(p), _) => TorAuth::Cookie(p.to_path_buf()),
(None, Some(p)) => TorAuth::Password(p.to_string()),
(None, None) => TorAuth::None,
};
Ok(Some(Self { addr, auth }))
}
/// Open the control port, `AUTHENTICATE`, `SIGNAL NEWNYM`, `QUIT`.
/// Each invocation is a fresh connection; the controller is cheap
/// to clone and stateless across calls.
pub async fn new_identity(&self) -> anyhow::Result<()> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&self.addr))
.await
.with_context(|| {
format!("timed out connecting to TOR control port {}", self.addr)
})?
.with_context(|| format!("connect to TOR control port {}", self.addr))?;
let (read, mut write) = stream.into_split();
let mut read = BufReader::new(read);
let auth_line = self.build_auth_line().await?;
write_line(&mut write, &auth_line).await?;
timeout(READ_TIMEOUT, expect_250(&mut read))
.await
.map_err(|_| anyhow!("TOR control AUTHENTICATE timed out"))?
.context("AUTHENTICATE")?;
write_line(&mut write, "SIGNAL NEWNYM").await?;
timeout(READ_TIMEOUT, expect_250(&mut read))
.await
.map_err(|_| anyhow!("TOR control SIGNAL NEWNYM timed out"))?
.context("SIGNAL NEWNYM")?;
// QUIT is courtesy; ignore errors — the daemon may close the
// socket before our QUIT lands and that's perfectly fine.
let _ = write_line(&mut write, "QUIT").await;
// Debug-level: a busy crawl can rotate circuits many times per
// minute, INFO is too chatty. Failures still log at WARN.
tracing::debug!(addr = %self.addr, "TOR NEWNYM signaled");
Ok(())
}
async fn build_auth_line(&self) -> anyhow::Result<String> {
match &self.auth {
TorAuth::None => Ok("AUTHENTICATE".to_string()),
TorAuth::Password(p) => Ok(format!("AUTHENTICATE \"{}\"", escape_quoted(p))),
TorAuth::Cookie(path) => {
let bytes = tokio::fs::read(path)
.await
.with_context(|| format!("read TOR cookie file {}", path.display()))?;
Ok(format!("AUTHENTICATE {}", hex_encode(&bytes)))
}
}
}
}
/// Parse `tcp://host:port`, `host:port`, or bare `host` into a
/// connect-time string. Default port is [`DEFAULT_CONTROL_PORT`].
fn parse_control_url(url: &str) -> anyhow::Result<String> {
let stripped = url.strip_prefix("tcp://").unwrap_or(url);
if stripped.is_empty() {
bail!("TOR control url is empty");
}
if stripped.contains(':') {
Ok(stripped.to_string())
} else {
Ok(format!("{stripped}:{DEFAULT_CONTROL_PORT}"))
}
}
fn escape_quoted(s: &str) -> String {
s.replace('\\', r"\\").replace('"', r#"\""#)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
async fn write_line<W: tokio::io::AsyncWrite + Unpin>(
w: &mut W,
line: &str,
) -> anyhow::Result<()> {
w.write_all(line.as_bytes()).await?;
w.write_all(b"\r\n").await?;
w.flush().await?;
Ok(())
}
/// Drain a TOR control reply, accepting only status `250`. Handles
/// the protocol's three line forms: `XYZ ...` (single/end), `XYZ-...`
/// (continuation), `XYZ+...` (data block ended by a lone `.`). Our
/// commands only ever produce single-line `250 OK`, but we honor the
/// continuation forms so a future torrc that adds events / banners
/// doesn't confuse the parser.
async fn expect_250<R: AsyncBufReadExt + Unpin>(r: &mut R) -> anyhow::Result<()> {
loop {
let mut line = String::new();
let n = r.read_line(&mut line).await?;
if n == 0 {
bail!("TOR control port closed connection mid-reply");
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.len() < 4 {
bail!("malformed TOR control reply: {trimmed:?}");
}
let (code, rest) = trimmed.split_at(3);
if code != "250" {
bail!("TOR control replied {trimmed:?}");
}
let sep = rest.as_bytes()[0];
match sep {
b' ' => return Ok(()),
b'-' => continue,
b'+' => {
// Data block — read until a line consisting of only ".".
loop {
let mut data = String::new();
let n = r.read_line(&mut data).await?;
if n == 0 {
bail!("TOR control port closed mid-data-block");
}
if data.trim_end_matches(['\r', '\n']) == "." {
break;
}
}
}
_ => bail!("malformed TOR control reply separator: {trimmed:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
/// Spawn a mock control port that responds to each \r\n-terminated
/// inbound line with the next entry from `replies`. Each reply has
/// its own `\r\n` appended. Records received lines into `recorder`.
/// After `replies.len()` exchanges the task drops the socket — this
/// matches the real TOR behavior for QUIT (close after acking).
async fn spawn_mock(
replies: Vec<&'static str>,
recorder: Arc<Mutex<Vec<String>>>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (sock, _) = listener.accept().await.unwrap();
let (r, mut w) = sock.into_split();
let mut r = BufReader::new(r);
for reply in replies {
let mut line = String::new();
let n = r.read_line(&mut line).await.unwrap_or(0);
if n == 0 {
return;
}
recorder
.lock()
.unwrap()
.push(line.trim_end_matches(['\r', '\n']).to_string());
w.write_all(reply.as_bytes()).await.unwrap();
w.write_all(b"\r\n").await.unwrap();
w.flush().await.unwrap();
}
});
addr
}
#[tokio::test]
async fn password_auth_then_newnym_writes_expected_sequence() {
let recorder = Arc::new(Mutex::new(Vec::new()));
// Two replies: AUTHENTICATE then SIGNAL NEWNYM. QUIT is
// fire-and-forget; the mock dropping the socket is the
// expected real-world behavior.
let addr =
spawn_mock(vec!["250 OK", "250 OK"], Arc::clone(&recorder)).await;
let controller = TorController::new(addr, TorAuth::Password("secret".into()));
controller.new_identity().await.expect("new_identity ok");
let recorded = recorder.lock().unwrap().clone();
assert_eq!(recorded.first().map(String::as_str), Some("AUTHENTICATE \"secret\""));
assert_eq!(recorded.get(1).map(String::as_str), Some("SIGNAL NEWNYM"));
}
#[tokio::test]
async fn cookie_auth_hex_encodes_file_bytes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let cookie: Vec<u8> = (0u8..32).collect();
std::fs::write(tmp.path(), &cookie).unwrap();
let recorder = Arc::new(Mutex::new(Vec::new()));
let addr =
spawn_mock(vec!["250 OK", "250 OK"], Arc::clone(&recorder)).await;
let controller =
TorController::new(addr, TorAuth::Cookie(tmp.path().to_path_buf()));
controller.new_identity().await.expect("new_identity ok");
let recorded = recorder.lock().unwrap().clone();
let expected_hex: String = cookie.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
recorded.first().map(String::as_str),
Some(format!("AUTHENTICATE {expected_hex}").as_str())
);
}
#[tokio::test]
async fn no_auth_sends_bare_authenticate() {
let recorder = Arc::new(Mutex::new(Vec::new()));
let addr =
spawn_mock(vec!["250 OK", "250 OK"], Arc::clone(&recorder)).await;
let controller = TorController::new(addr, TorAuth::None);
controller.new_identity().await.expect("new_identity ok");
let recorded = recorder.lock().unwrap().clone();
assert_eq!(recorded.first().map(String::as_str), Some("AUTHENTICATE"));
}
#[tokio::test]
async fn non_250_reply_returns_err_with_reply_text() {
let recorder = Arc::new(Mutex::new(Vec::new()));
let addr = spawn_mock(
vec!["515 Bad authentication"],
Arc::clone(&recorder),
)
.await;
let controller =
TorController::new(addr, TorAuth::Password("wrong".into()));
let err = controller.new_identity().await.expect_err("should fail");
let msg = format!("{err:#}");
assert!(msg.contains("515"), "expected 515 in error, got: {msg}");
}
#[tokio::test]
async fn closed_connection_mid_reply_is_an_error() {
// Listener accepts the AUTH line then drops without replying —
// this exercises the EOF-mid-reply path in expect_250 (rather
// than tor's own error replies which are covered elsewhere).
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
let (r, _w) = sock.into_split();
let mut r = BufReader::new(r);
let mut line = String::new();
let _ = r.read_line(&mut line).await; // read AUTH, ignore
// Drop _w (and the read half via scope exit) so the
// peer sees an immediate EOF on the next read.
}
});
let controller = TorController::new(addr, TorAuth::None);
let err = controller.new_identity().await.expect_err("should fail");
let msg = format!("{err:#}");
assert!(
msg.contains("closed connection"),
"expected EOF-mid-reply error, got: {msg}"
);
}
#[tokio::test]
async fn multi_line_250_continuation_is_accepted() {
let recorder = Arc::new(Mutex::new(Vec::new()));
// AUTHENTICATE reply uses the `250-...\r\n250 OK\r\n` form.
// Single reply string contains the whole multi-line response.
let addr = spawn_mock(
vec!["250-banner=foo\r\n250 OK", "250 OK"],
Arc::clone(&recorder),
)
.await;
let controller = TorController::new(addr, TorAuth::None);
controller.new_identity().await.expect("new_identity ok");
}
#[test]
fn from_parts_returns_none_when_url_unset() {
let c = TorController::from_parts(None, None, None).unwrap();
assert!(c.is_none());
}
#[test]
fn from_parts_prefers_cookie_over_password() {
let c = TorController::from_parts(
Some("tor:9051"),
Some("pw"),
Some(Path::new("/var/lib/tor/control_auth_cookie")),
)
.unwrap()
.expect("controller built");
assert!(matches!(c.auth, TorAuth::Cookie(_)));
}
#[test]
fn from_parts_falls_back_to_password_without_cookie() {
let c = TorController::from_parts(Some("tor:9051"), Some("pw"), None)
.unwrap()
.expect("controller built");
assert!(matches!(c.auth, TorAuth::Password(p) if p == "pw"));
}
#[test]
fn parse_control_url_accepts_tcp_scheme() {
assert_eq!(parse_control_url("tcp://127.0.0.1:9051").unwrap(), "127.0.0.1:9051");
}
#[test]
fn parse_control_url_defaults_port_when_omitted() {
assert_eq!(parse_control_url("tor").unwrap(), "tor:9051");
}
#[test]
fn parse_control_url_passes_through_host_port() {
assert_eq!(parse_control_url("tor:9999").unwrap(), "tor:9999");
}
#[test]
fn parse_control_url_rejects_empty() {
assert!(parse_control_url("").is_err());
assert!(parse_control_url("tcp://").is_err());
}
#[test]
fn escape_quoted_handles_quotes_and_backslashes() {
assert_eq!(escape_quoted(r#"a"b\c"#), r#"a\"b\\c"#);
}
#[test]
fn debug_format_redacts_password_and_cookie_path() {
// Regression: app.rs / bin/crawler.rs log the controller at
// startup via `tracing::info!(?t, ...)`. A derived Debug on
// TorAuth would expand TorAuth::Password(p) and leak the
// plaintext into logs.
let c = TorController::new("tor:9051", TorAuth::Password("super-secret".into()));
let dbg = format!("{c:?}");
assert!(!dbg.contains("super-secret"), "password leaked: {dbg}");
assert!(dbg.contains("<redacted>"), "expected <redacted>, got: {dbg}");
let c = TorController::new(
"tor:9051",
TorAuth::Cookie("/var/lib/tor/control_auth_cookie".into()),
);
let dbg = format!("{c:?}");
assert!(!dbg.contains("control_auth_cookie"), "cookie path leaked: {dbg}");
}
#[test]
fn hex_encode_zero_pads_low_bytes() {
assert_eq!(hex_encode(&[0x00, 0x0f, 0xff]), "000fff");
}
}

View File

@@ -91,6 +91,26 @@ pub fn registrable_domain(url: &str) -> Option<String> {
Some(format!(".{}", registrable.join(".")))
}
/// Normalise a SOCKS proxy URL for Chromium's `--proxy-server=` flag.
///
/// reqwest accepts both `socks5://` (resolve locally) and
/// `socks5h://` (resolve via the SOCKS server — important when the
/// proxy is TOR and we don't want the host's resolver to see the
/// target hostname). Chromium does **not** know the `socks5h` scheme
/// and refuses navigations with `ERR_NO_SUPPORTED_PROXIES`. It
/// already sends destination hostnames over SOCKS5 by default
/// regardless, so stripping the `h` is a pure scheme rename — the
/// remote-DNS behaviour is preserved.
///
/// Non-SOCKS schemes pass through unchanged.
pub fn chromium_proxy_arg(proxy: &str) -> String {
if let Some(rest) = proxy.strip_prefix("socks5h://") {
format!("socks5://{rest}")
} else {
proxy.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -191,4 +211,34 @@ mod tests {
Some("[2001:db8::1]")
);
}
#[test]
fn chromium_proxy_arg_strips_socks5h_to_socks5() {
// Regression: passing socks5h:// to Chromium yields
// ERR_NO_SUPPORTED_PROXIES at navigation time.
assert_eq!(
chromium_proxy_arg("socks5h://127.0.0.1:9050"),
"socks5://127.0.0.1:9050"
);
assert_eq!(
chromium_proxy_arg("socks5h://tor:9050"),
"socks5://tor:9050"
);
}
#[test]
fn chromium_proxy_arg_passes_socks5_unchanged() {
assert_eq!(
chromium_proxy_arg("socks5://127.0.0.1:9050"),
"socks5://127.0.0.1:9050"
);
}
#[test]
fn chromium_proxy_arg_passes_non_socks_unchanged() {
assert_eq!(
chromium_proxy_arg("http://proxy.example:8080"),
"http://proxy.example:8080"
);
}
}

View File

@@ -12,4 +12,6 @@ pub struct ApiToken {
pub token_hash: Vec<u8>,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
/// When the token stops authenticating. `None` = never expires.
pub expires_at: Option<DateTime<Utc>>,
}

View File

@@ -11,6 +11,13 @@ pub struct Chapter {
pub title: Option<String>,
pub page_count: i32,
pub created_at: DateTime<Utc>,
/// Total bytes of this chapter's stored page images. `Some(0)` for an
/// uncrawled chapter (no page rows). `None` when the size is unknown —
/// at least one page predates the size backfill — so the frontend can
/// show an em-dash instead of a misleading "0 B". `Some(n)` once every
/// page in the chapter has a measured size.
#[serde(default)]
pub size_bytes: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]

View File

@@ -33,6 +33,22 @@ pub struct CollectionSummary {
pub sample_covers: Vec<String>,
}
/// Row returned by `GET /collections/:id/pages`. Joins through
/// `chapters` and `mangas` so the collection detail view can render a
/// thumbnail + breadcrumb without per-row follow-up fetches.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct CollectionPageItem {
pub page_id: Uuid,
pub chapter_id: Uuid,
pub manga_id: Uuid,
pub page_number: i32,
pub chapter_number: i32,
pub chapter_title: Option<String>,
pub manga_title: String,
pub storage_key: String,
pub added_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewCollection {
pub name: String,

View File

@@ -0,0 +1,57 @@
//! Timing metrics for crawler operations (see `crawl_metrics` table, 0028).
//!
//! One [`OpRow`] per completed operation (manga list walk, manga detail,
//! cover, whole chapter); [`OpSummary`] is the per-type average roll-up that
//! drives the admin Metrics tab. Per-page crawl timing is derived on the
//! client from the `chapter` summary's `avg_items` (pages/chapter), not
//! stored per image. Analysis duration lives on `page_analysis`, not here.
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;
/// One time bucket of a metrics series — count, ok/failed split, and mean
/// duration over the bucket. Shared by the crawl-ops and analysis trend
/// charts (both are `{t, n, ok, failed, avg_ms}` over a `date_trunc` window).
/// Empty intervals are simply absent; the client fills the continuous axis.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MetricsBucket {
/// Bucket start (`date_trunc(unit, finished_at|analyzed_at)`).
pub t: DateTime<Utc>,
pub n: i64,
pub ok: i64,
pub failed: i64,
pub avg_ms: Option<f64>,
}
/// Per-operation-type average roll-up over a time window.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct OpSummary {
/// `manga_list` | `manga_detail` | `manga_cover` | `chapter`.
pub op: String,
/// Mean duration in milliseconds (`NULL`→absent only when `n = 0`).
pub avg_ms: Option<f64>,
/// Total operations in the window.
pub n: i64,
pub ok: i64,
pub failed: i64,
/// Mean `items` (pages/chapter, mangas/list-walk); `None` when unused.
pub avg_items: Option<f64>,
}
/// One timed operation, resolved to manga/chapter labels for the recent-ops log.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct OpRow {
pub id: Uuid,
pub op: String,
pub manga_id: Option<Uuid>,
pub manga_title: Option<String>,
pub chapter_id: Option<Uuid>,
pub chapter_number: Option<i32>,
pub outcome: String,
pub duration_ms: i64,
pub items: Option<i32>,
pub error: Option<String>,
pub finished_at: DateTime<Utc>,
}

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use super::author::AuthorRef;
use super::genre::GenreRef;
use super::page_analysis::ContentWarning;
use super::patch::Patch;
use super::tag::TagRef;
@@ -32,7 +33,8 @@ pub struct MangaCard {
}
/// Shape returned by `GET /mangas/:id`. Adds user-added tags on top of
/// the card fields.
/// the card fields, plus the deduped content warnings derived by the
/// analysis worker across all the manga's pages.
#[derive(Debug, Clone, Serialize)]
pub struct MangaDetail {
#[serde(flatten)]
@@ -40,6 +42,13 @@ pub struct MangaDetail {
pub authors: Vec<AuthorRef>,
pub genres: Vec<GenreRef>,
pub tags: Vec<TagRef>,
pub content_warnings: Vec<ContentWarning>,
/// Total bytes of all this manga's stored chapter pages (cover
/// excluded), over every chapter. `None` when any page is unmeasured
/// (frontend shows an em-dash); `Some(0)` when there are no pages.
/// Computed in the DB rather than summed from the chapter list, which
/// is paginated and would undercount mangas with many chapters.
pub chapter_storage_bytes: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, Default)]

View File

@@ -4,12 +4,17 @@ pub mod author;
pub mod bookmark;
pub mod chapter;
pub mod collection;
pub mod crawl_metrics;
pub mod genre;
pub mod manga;
pub mod page;
pub mod page_analysis;
pub mod page_tag;
pub mod patch;
pub mod reaction;
pub mod read_progress;
pub mod session;
pub mod storage_stats;
pub mod sync_state;
pub mod tag;
pub mod upload_entry;
@@ -21,13 +26,23 @@ pub use api_token::ApiToken;
pub use author::{Author, AuthorRef, AuthorWithCount};
pub use bookmark::{Bookmark, BookmarkSummary};
pub use chapter::Chapter;
pub use collection::{Collection, CollectionSummary};
pub use collection::{Collection, CollectionPageItem, CollectionSummary};
pub use crawl_metrics::{OpRow, OpSummary};
pub use genre::{Genre, GenreRef};
pub use manga::{Manga, MangaCard, MangaDetail};
pub use page::Page;
pub use page_analysis::{
AnalysisStatus, ContentWarning, OcrKind, OcrResult, PageAnalysis, PageSearchItem,
SafetyFlag, VisionAnalysis,
};
pub use page_tag::{
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
TaggedPageItem,
};
pub use patch::Patch;
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
pub use session::Session;
pub use storage_stats::{StorageStats, TopChapter, TopManga};
pub use sync_state::{ChapterSyncState, MangaSyncState};
pub use tag::{Tag, TagRef};
pub use upload_entry::UploadEntry;

View File

@@ -0,0 +1,333 @@
//! AI page-analysis domain types.
//!
//! Two distinct shapes live here:
//!
//! * The **persisted** row types (`PageAnalysis`) and the closed
//! vocabularies (`OcrKind`, `ContentWarning`, `AnalysisStatus`) that the
//! `page_analysis` / `page_ocr_text` / `page_content_warnings` tables
//! constrain via CHECKs.
//! * The **vision-response** DTOs (`VisionAnalysis` and friends) that the
//! worker deserializes from the local model's JSON. These are
//! deliberately lenient — `kind` and `content_type` arrive as free
//! strings because a small local model can emit anything; mapping to the
//! closed enums (and dropping the unmappable) happens at persist time via
//! [`OcrKind::from_model_str`] / [`ContentWarning::from_model_str`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
/// Lifecycle of a page's analysis row.
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum AnalysisStatus {
Pending,
Done,
Failed,
}
/// Kind of an OCR'd text piece. Drives the search-ranking weight:
/// `Speech`/`Title` → A, `Narration`/`Thought`/`Caption` → B, `Sfx` → D
/// (the scene description is weighted C separately). See
/// [`OcrKind::weight`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum OcrKind {
Speech,
Thought,
Narration,
Sfx,
Title,
Caption,
}
impl OcrKind {
/// Postgres `tsvector` weight label for this kind. The default
/// `ts_rank` weights `{D,C,B,A} = {0.1,0.2,0.4,1.0}` then realize the
/// intended relevance ordering: speech/title most important, sfx least.
pub fn weight(self) -> char {
match self {
OcrKind::Speech | OcrKind::Title => 'A',
OcrKind::Narration | OcrKind::Thought | OcrKind::Caption => 'B',
OcrKind::Sfx => 'D',
}
}
/// Map a model-supplied kind string onto the closed vocabulary. Unknown
/// or unparseable kinds fall back to `Narration` (the neutral
/// mid-weight bucket) rather than dropping the text — losing the
/// transcription entirely is worse than mis-weighting it.
pub fn from_model_str(raw: &str) -> OcrKind {
match raw.trim().to_lowercase().as_str() {
"speech" => OcrKind::Speech,
"thought" => OcrKind::Thought,
"narration" => OcrKind::Narration,
"sfx" => OcrKind::Sfx,
"title" => OcrKind::Title,
"caption" => OcrKind::Caption,
_ => OcrKind::Narration,
}
}
}
/// A content-warning category from the closed moderation vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ContentWarning {
Sexual,
Nudity,
Gore,
Violence,
Disturbing,
}
impl ContentWarning {
/// Map a model-supplied content-type string onto the closed
/// vocabulary, or `None` if it isn't one we recognize. Unlike OCR
/// kinds, an unknown warning is *dropped* — flagging a page with a
/// category we can't filter on is meaningless.
pub fn from_model_str(raw: &str) -> Option<ContentWarning> {
match raw.trim().to_lowercase().as_str() {
"sexual" => Some(ContentWarning::Sexual),
"nudity" => Some(ContentWarning::Nudity),
"gore" => Some(ContentWarning::Gore),
"violence" => Some(ContentWarning::Violence),
"disturbing" => Some(ContentWarning::Disturbing),
_ => None,
}
}
/// Parse a wire/query-param value strictly (no fallback). Used by the
/// API layer to validate `cw_include` / `cw_exclude` filters.
pub fn parse_strict(raw: &str) -> Option<ContentWarning> {
ContentWarning::from_model_str(raw)
}
}
/// One result row from the page content-search (`GET /v1/me/page-search`).
/// One row per matching page, carrying the breadcrumb plus the moderation
/// flags and the text-search rank so the UI can badge and order results.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageSearchItem {
pub page_id: Uuid,
pub chapter_id: Uuid,
pub manga_id: Uuid,
pub page_number: i32,
pub chapter_number: i32,
pub chapter_title: Option<String>,
pub manga_title: String,
pub storage_key: String,
pub is_nsfw: bool,
/// Deduped content warnings on this page (canonical lowercase names).
pub content_warnings: Vec<String>,
/// `ts_rank` against the text query; `0` for tag/warning-only searches.
pub rank: f32,
}
/// Analysis coverage for one manga (admin overview): how many of its pages
/// have a completed analysis vs. how many exist.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MangaCoverage {
pub manga_id: Uuid,
pub title: String,
pub total_pages: i64,
pub analyzed_pages: i64,
}
/// Analysis coverage for one chapter.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ChapterCoverage {
pub chapter_id: Uuid,
pub number: i32,
pub title: Option<String>,
pub total_pages: i64,
pub analyzed_pages: i64,
}
/// Per-page analysis status for a chapter's page grid. `status` is one of
/// `done` | `failed` | `queued` (a pending/running job) | `none`.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageStatusItem {
pub page_id: Uuid,
pub page_number: i32,
pub status: String,
}
/// One row in the admin analysis-history table: a completed or failed
/// analysis pass resolved to its page/chapter/manga context. Sourced from
/// the persistent `page_analysis` table (not the reaped job queue), so it
/// survives indefinitely. `status` is `done` | `failed`.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct AnalysisHistoryRow {
pub page_id: Uuid,
pub page_number: i32,
pub chapter_id: Uuid,
pub chapter_number: i32,
pub manga_id: Uuid,
pub manga_title: String,
pub status: String,
pub is_nsfw: bool,
pub model: Option<String>,
pub error: Option<String>,
pub analyzed_at: Option<DateTime<Utc>>,
/// Wall-clock the worker spent on this page; `None` for rows analyzed
/// before duration tracking landed.
pub duration_ms: Option<i64>,
}
/// Per-model average analysis duration (admin Metrics tab).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ModelDuration {
pub model: Option<String>,
pub avg_ms: Option<f64>,
pub n: i64,
}
/// Aggregate analysis timing/outcome roll-up over a time window.
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisMetrics {
pub n: i64,
pub ok: i64,
pub failed: i64,
pub avg_ms: Option<f64>,
pub by_model: Vec<ModelDuration>,
}
/// One OCR line in the page-detail view.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct OcrLine {
pub kind: OcrKind,
pub text: String,
}
/// Full analysis result for one page, for the admin detail modal. `status`
/// is `done` | `failed` | `none` (no analysis row yet).
#[derive(Debug, Clone, Serialize)]
pub struct PageAnalysisDetail {
pub page_id: Uuid,
pub page_number: i32,
pub chapter_id: Uuid,
pub manga_id: Uuid,
pub status: String,
pub is_nsfw: bool,
pub scene_description: Option<String>,
pub model: Option<String>,
pub error: Option<String>,
pub analyzed_at: Option<DateTime<Utc>>,
pub ocr: Vec<OcrLine>,
pub tags: Vec<String>,
pub content_warnings: Vec<ContentWarning>,
}
/// One persisted `page_analysis` row.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageAnalysis {
pub page_id: Uuid,
pub status: AnalysisStatus,
pub scene_description: Option<String>,
pub is_nsfw: bool,
pub model: Option<String>,
pub error: Option<String>,
pub analyzed_at: Option<DateTime<Utc>>,
}
// --- Vision-response DTOs (deserialized from the local model) ---------
/// The full JSON object the vision model returns for one page. Lenient by
/// design: see the module docs.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct VisionAnalysis {
#[serde(default)]
pub ocr_results: Vec<OcrResult>,
#[serde(default)]
pub tagging_results: Vec<String>,
#[serde(default)]
pub scene_description: String,
#[serde(default)]
pub safety_flag: SafetyFlag,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct OcrResult {
#[serde(default)]
pub text: String,
/// Free string from the model; mapped via [`OcrKind::from_model_str`].
#[serde(default)]
pub kind: String,
/// Optional vertical center of the text as a fraction (0.0 top … 1.0
/// bottom) of the slice image, requested in the OCR pass to dedup
/// duplicates across slice seams by position. `None` for the combined
/// single-call path. Internal-only — not persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct SafetyFlag {
#[serde(default)]
pub is_nsfw: bool,
/// Free strings; mapped via [`ContentWarning::from_model_str`].
#[serde(default)]
pub content_type: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ocr_kind_weights_match_spec() {
assert_eq!(OcrKind::Speech.weight(), 'A');
assert_eq!(OcrKind::Title.weight(), 'A');
assert_eq!(OcrKind::Narration.weight(), 'B');
assert_eq!(OcrKind::Thought.weight(), 'B');
assert_eq!(OcrKind::Caption.weight(), 'B');
assert_eq!(OcrKind::Sfx.weight(), 'D');
}
#[test]
fn ocr_kind_from_model_str_falls_back_to_narration() {
assert_eq!(OcrKind::from_model_str("SPEECH"), OcrKind::Speech);
assert_eq!(OcrKind::from_model_str(" sfx "), OcrKind::Sfx);
assert_eq!(OcrKind::from_model_str("dialogue"), OcrKind::Narration);
assert_eq!(OcrKind::from_model_str(""), OcrKind::Narration);
}
#[test]
fn content_warning_from_model_str_drops_unknown() {
assert_eq!(
ContentWarning::from_model_str("Sexual"),
Some(ContentWarning::Sexual)
);
assert_eq!(
ContentWarning::from_model_str(" gore"),
Some(ContentWarning::Gore)
);
assert_eq!(ContentWarning::from_model_str("spicy"), None);
assert_eq!(ContentWarning::from_model_str(""), None);
}
#[test]
fn vision_analysis_deserializes_sample_and_tolerates_missing_fields() {
let json = r#"{
"ocr_results": [{"text":"Hi","kind":"speech"}],
"tagging_results": ["action","city"],
"scene_description": "A street.",
"safety_flag": {"is_nsfw": false, "content_type": []}
}"#;
let v: VisionAnalysis = serde_json::from_str(json).unwrap();
assert_eq!(v.ocr_results.len(), 1);
assert_eq!(v.tagging_results, vec!["action", "city"]);
assert!(!v.safety_flag.is_nsfw);
// Missing optional fields default rather than failing the parse.
let sparse: VisionAnalysis = serde_json::from_str("{}").unwrap();
assert!(sparse.ocr_results.is_empty());
assert_eq!(sparse.scene_description, "");
assert!(!sparse.safety_flag.is_nsfw);
}
}

Some files were not shown because too many files have changed in this diff Show More