Compare commits
238 Commits
e3cff9d874
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cc41bacd | ||
|
|
08a9819d76 | ||
|
|
70a6598924 | ||
|
|
e6aefaa804 | ||
|
|
6354a7a8a3 | ||
|
|
60d5a2efea | ||
|
|
90f2398e56 | ||
|
|
e960aae163 | ||
|
|
adce950049 | ||
|
|
bde72e47f4 | ||
|
|
cb757e7b69 | ||
|
|
042e7e9047 | ||
|
|
cc8ae2566f | ||
|
|
4f085f228a | ||
|
|
ac5641bcd3 | ||
|
|
cd5358dc9b | ||
|
|
c31830468c | ||
|
|
75f4481bbd | ||
|
|
958d5bd713 | ||
|
|
f2c9cfe162 | ||
|
|
b5f7467c47 | ||
|
|
9148e23da8 | ||
|
|
f39307232c | ||
|
|
1b7b8a3038 | ||
|
|
253d46c7e5 | ||
|
|
c570e0cc37 | ||
|
|
5784483a57 | ||
|
|
a44511983d | ||
|
|
f8e53809d5 | ||
|
|
0afd202164 | ||
|
|
ba3e5b481b | ||
|
|
3b783c1d9b | ||
|
|
a47b6895c2 | ||
|
|
3ca05dcb58 | ||
|
|
c308eb3eac | ||
|
|
69a9309c54 | ||
|
|
1ed1a134ea | ||
|
|
32d0a7e13b | ||
|
|
d779fa2b97 | ||
|
|
755417730f | ||
|
|
5b1ce581f3 | ||
|
|
c11df3182c | ||
|
|
d749b56d58 | ||
|
|
c24e296f07 | ||
|
|
9508fb8e86 | ||
|
|
ca55712622 | ||
|
|
4fe435cc76 | ||
|
|
f5cb460aec | ||
|
|
f83d49b83e | ||
|
|
cef41ce76a | ||
|
|
7570524e5b | ||
|
|
5b46eab73a | ||
|
|
91d6a50dba | ||
| 134ab54b34 | |||
|
|
f879ce1866 | ||
|
|
bf425cf8e6 | ||
|
|
ff4ca964f5 | ||
|
|
61669aac3f | ||
|
|
795eb76f9a | ||
|
|
ebf0b8289b | ||
|
|
ce9a727c73 | ||
|
|
46134c8760 | ||
|
|
b7d8faadf7 | ||
|
|
b0500e8e48 | ||
|
|
379224bee4 | ||
|
|
1c955458d6 | ||
|
|
987d1ba235 | ||
|
|
c69ff502f7 | ||
|
|
51ea254dde | ||
|
|
fee68dd9ac | ||
|
|
3c8e264f48 | ||
|
|
16cdec051a | ||
|
|
5b51bcf056 | ||
|
|
f1e66141f4 | ||
|
|
cee1e73f98 | ||
|
|
ec73c6e001 | ||
|
|
bd6ae86a85 | ||
|
|
3622dcc02f | ||
|
|
9cb2f152d3 | ||
|
|
7c3c9cf699 | ||
|
|
0d9505ce9f | ||
|
|
7bdbe3ce5b | ||
|
|
3364ea52c9 | ||
|
|
def97d3087 | ||
|
|
09f12c8959 | ||
|
|
34c1122e4e | ||
|
|
200ab1c0a0 | ||
|
|
ef8d226ba6 | ||
|
|
19db66a845 | ||
|
|
d6a109df2d | ||
|
|
bb833c7e71 | ||
|
|
258a536254 | ||
|
|
9c4a93c058 | ||
|
|
bfaa166e0a | ||
|
|
3824eaafb2 | ||
|
|
f1349100d7 | ||
|
|
3f1a5e9c41 | ||
|
|
c212deb7b0 | ||
|
|
f5692ea109 | ||
|
|
ad1689b818 | ||
|
|
01d18e7ba2 | ||
|
|
2267a83f6c | ||
|
|
f318c3bf51 | ||
|
|
cf8971faae | ||
|
|
38146b4d03 | ||
|
|
8acc0e6cc2 | ||
|
|
4e154434a1 | ||
|
|
141cd52f7e | ||
|
|
83a9ab40cd | ||
|
|
a141d65db1 | ||
|
|
3a9e7ca2da | ||
|
|
5dc93bfb84 | ||
|
|
592747f1e0 | ||
|
|
a0d63ac9fd | ||
|
|
35c02066fe | ||
|
|
ded77fe4ba | ||
|
|
4d02b56b77 | ||
|
|
0865659ab3 | ||
|
|
5596e1920d | ||
|
|
3cba9ecf95 | ||
|
|
ed18d95bb0 | ||
|
|
2ed42f7b9e | ||
|
|
cbbc626768 | ||
|
|
a3e53f303b | ||
|
|
2af421893f | ||
|
|
48f0439273 | ||
|
|
de7aefef69 | ||
|
|
3ba30f4ba9 | ||
|
|
4306d1c96a | ||
|
|
886caaecfa | ||
|
|
de510cc19a | ||
|
|
5b76e0cc37 | ||
|
|
66ae4b221b | ||
|
|
83c2899373 | ||
|
|
4fb98e4a1e | ||
|
|
5130c9933e | ||
|
|
e9331747d0 | ||
|
|
af6a07bd1f | ||
|
|
5e8323d7fa | ||
|
|
2715275065 | ||
|
|
198a1d46b0 | ||
|
|
3a36796768 | ||
|
|
bd7c3fc28c | ||
|
|
4456deb146 | ||
|
|
94591dfda4 | ||
|
|
194adf1339 | ||
|
|
dee53fa212 | ||
|
|
93b7e451bf | ||
|
|
ae708a72d0 | ||
|
|
579e6ade0f | ||
|
|
4e14ed09ef | ||
|
|
2ee77bc867 | ||
|
|
a62a5f155b | ||
|
|
e3e86843d6 | ||
|
|
0f9254b054 | ||
|
|
d6a4fd668c | ||
|
|
e3d16e49b7 | ||
|
|
54cbbfc440 | ||
|
|
27fc1a52f6 | ||
|
|
95de02f583 | ||
|
|
651fb27522 | ||
|
|
95b98eebf1 | ||
|
|
34d6d570eb | ||
|
|
93bb156fba | ||
|
|
747bdeda46 | ||
|
|
c0281f7e9b | ||
|
|
6444ddee29 | ||
|
|
b9dd75684e | ||
|
|
80f4819fad | ||
|
|
660184a048 | ||
|
|
ee9f5c1a4d | ||
|
|
dd25f073cd | ||
|
|
b14ed02670 | ||
|
|
9f7dfe4d4e | ||
|
|
c6a6d1690d | ||
|
|
314fc8738b | ||
|
|
31013cc893 | ||
|
|
dd300a150c | ||
| 35664bccc7 | |||
| cde4aca98b | |||
| 8445f338f6 | |||
| 32fcebd47a | |||
| d85fba7056 | |||
| cf62dae2c9 | |||
| d51ab2a049 | |||
|
|
790549636f | ||
| 4b6c19979a | |||
| 91d058c426 | |||
| 4262efeff7 | |||
| 39fcfc813a | |||
|
|
ce6d96c5e1 | ||
| 54530d67ef | |||
| d92acb17e7 | |||
| e6fcff5eea | |||
|
|
f441425519 | ||
|
|
64a9dceb67 | ||
|
|
b86aa80c87 | ||
|
|
2b7a11b480 | ||
|
|
d3b827421f | ||
|
|
25aba3ac58 | ||
|
|
85d65f5eda | ||
|
|
ab9b8fb172 | ||
|
|
8b5bd99446 | ||
|
|
d0cd31c9a7 | ||
|
|
6bb72b8775 | ||
|
|
824f5acf22 | ||
|
|
268e8cc6c2 | ||
|
|
7e675b72cc | ||
|
|
8b7ea2e1b2 | ||
|
|
9607488278 | ||
|
|
d36f24e9af | ||
|
|
bd39476ac7 | ||
|
|
af870bd157 | ||
|
|
7d80a437bf | ||
|
|
dbde6e02c4 | ||
|
|
2bbf1595ff | ||
|
|
63e1aa5484 | ||
|
|
f30600162e | ||
|
|
33f684887d | ||
|
|
6c901e64c9 | ||
|
|
9910a0a995 | ||
|
|
00577071fd | ||
|
|
1e3fd27308 | ||
|
|
f5842510b7 | ||
|
|
780632bee3 | ||
|
|
be6b974150 | ||
|
|
6dcb720a0f | ||
|
|
d6ac648ac9 | ||
|
|
679abae736 | ||
|
|
b812c6d16c | ||
|
|
e93eec89e5 | ||
|
|
8818c890c5 | ||
|
|
c134bdbbde | ||
|
|
5c22dfdb41 | ||
|
|
e50fc093c3 | ||
|
|
72756cfef2 | ||
|
|
4e20350645 | ||
|
|
713ca139c4 |
306
.env.example
306
.env.example
@@ -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
|
||||
@@ -106,6 +238,11 @@ CRAWLER_PROXY=socks5h://tor:9050
|
||||
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
|
||||
@@ -132,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
|
||||
|
||||
@@ -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
10
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
136
HYBRID-OCR-VISION.md
Normal 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) ~240–261) —
|
||||
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).
|
||||
@@ -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
138
VISION-AUTOSCALE.md
Normal 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. 5–10 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** (5–10 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
230
VISION-MEMORY-YIELD.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Vision-manager — memory-pressure yield (design outline)
|
||||
|
||||
**Status:** implemented in `vision-manager/manager.sh` (feat/vision-mem-yield).
|
||||
Verifications V1–V4 (§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 V1–V3 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
4
backend/.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
/target
|
||||
/.sqlx
|
||||
.env
|
||||
|
||||
# Local OCR models for native dev (downloaded, not source)
|
||||
models/
|
||||
*.rten
|
||||
|
||||
355
backend/Cargo.lock
generated
355
backend/Cargo.lock
generated
@@ -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.46.0"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.46.0"
|
||||
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 ~50–70% off that.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
@@ -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
|
||||
|
||||
18
backend/migrations/0021_chapter_source_index.sql
Normal file
18
backend/migrations/0021_chapter_source_index.sql
Normal 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;
|
||||
19
backend/migrations/0022_crawler_observability_indexes.sql
Normal file
19
backend/migrations/0022_crawler_observability_indexes.sql
Normal 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';
|
||||
48
backend/migrations/0023_page_collections_and_tags.sql
Normal file
48
backend/migrations/0023_page_collections_and_tags.sql
Normal 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);
|
||||
48
backend/migrations/0024_normalize_page_tags.sql
Normal file
48
backend/migrations/0024_normalize_page_tags.sql
Normal 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;
|
||||
81
backend/migrations/0025_page_analysis.sql
Normal file
81
backend/migrations/0025_page_analysis.sql
Normal 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);
|
||||
15
backend/migrations/0026_app_settings.sql
Normal file
15
backend/migrations/0026_app_settings.sql
Normal 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()
|
||||
);
|
||||
20
backend/migrations/0027_storage_sizes.sql
Normal file
20
backend/migrations/0027_storage_sizes.sql
Normal 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);
|
||||
39
backend/migrations/0028_operation_metrics.sql
Normal file
39
backend/migrations/0028_operation_metrics.sql
Normal 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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
50
backend/migrations/0031_crawler_jobs_analyze_page_dedup.sql
Normal file
50
backend/migrations/0031_crawler_jobs_analyze_page_dedup.sql
Normal 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';
|
||||
23
backend/migrations/0032_crawler_jobs_lease_generation.sql
Normal file
23
backend/migrations/0032_crawler_jobs_lease_generation.sql
Normal 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;
|
||||
16
backend/migrations/0033_mangas_updated_at_index.sql
Normal file
16
backend/migrations/0033_mangas_updated_at_index.sql
Normal 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);
|
||||
9
backend/migrations/0034_api_tokens_expires_at.sql
Normal file
9
backend/migrations/0034_api_tokens_expires_at.sql
Normal 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;
|
||||
15
backend/migrations/0035_manga_reactions.sql
Normal file
15
backend/migrations/0035_manga_reactions.sql
Normal 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);
|
||||
103
backend/migrations/0036_manga_content_warnings.sql
Normal file
103
backend/migrations/0036_manga_content_warnings.sql
Normal file
@@ -0,0 +1,103 @@
|
||||
-- Denormalized manga -> content-warning set.
|
||||
--
|
||||
-- The list filter previously tested each candidate manga with a correlated
|
||||
-- `page_content_warnings -> pages -> chapters` join, i.e. O(mangas * pages) on
|
||||
-- every filtered list AND its count. This table holds the DISTINCT union of a
|
||||
-- manga's page warnings so the filter is a single indexed lookup.
|
||||
--
|
||||
-- Kept in sync by triggers that recompute an affected manga's set from current
|
||||
-- data (the set is tiny — at most the five moderation labels — so a
|
||||
-- delete-and-reinsert per change is cheap and always correct, sidestepping the
|
||||
-- cascade-ordering hazards of incremental maintenance).
|
||||
|
||||
CREATE TABLE manga_content_warnings (
|
||||
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
|
||||
warning text NOT NULL
|
||||
CHECK (warning IN ('sexual', 'nudity', 'gore', 'violence', 'disturbing')),
|
||||
PRIMARY KEY (manga_id, warning)
|
||||
);
|
||||
|
||||
-- warning -> mangas, for the include/exclude list filters.
|
||||
CREATE INDEX manga_content_warnings_warning_idx ON manga_content_warnings (warning);
|
||||
|
||||
-- Recompute a single manga's warning set from the live per-page rows.
|
||||
CREATE OR REPLACE FUNCTION mcw_refresh_for_manga(mid uuid) RETURNS void AS $$
|
||||
BEGIN
|
||||
-- Skip when the manga is gone (e.g. mid-cascade of a manga delete) so we
|
||||
-- never re-insert a row that would violate the FK / resurrect a deleted set.
|
||||
IF NOT EXISTS (SELECT 1 FROM mangas WHERE id = mid) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
DELETE FROM manga_content_warnings WHERE manga_id = mid;
|
||||
INSERT INTO manga_content_warnings (manga_id, warning)
|
||||
SELECT DISTINCT mid, pw.warning
|
||||
FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE c.manga_id = mid;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- page_content_warnings changed for a page: refresh that page's manga.
|
||||
CREATE OR REPLACE FUNCTION mcw_on_pcw_change() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
mid uuid;
|
||||
pid uuid := COALESCE(NEW.page_id, OLD.page_id);
|
||||
BEGIN
|
||||
-- The page (and thus chapter) may already be gone when this fires as part
|
||||
-- of a pages/chapters cascade; in that case the pages/chapters triggers do
|
||||
-- the refresh instead, so a missing join here is harmless.
|
||||
SELECT c.manga_id INTO mid
|
||||
FROM pages p JOIN chapters c ON c.id = p.chapter_id
|
||||
WHERE p.id = pid;
|
||||
IF mid IS NOT NULL THEN
|
||||
PERFORM mcw_refresh_for_manga(mid);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER mcw_pcw_ins AFTER INSERT ON page_content_warnings
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change();
|
||||
CREATE TRIGGER mcw_pcw_del AFTER DELETE ON page_content_warnings
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change();
|
||||
|
||||
-- A page was deleted (directly, or via a chapter cascade): its
|
||||
-- page_content_warnings rows are already cascade-gone, so recompute from the
|
||||
-- chapter's manga. If the chapter is gone too, the chapters trigger covers it.
|
||||
CREATE OR REPLACE FUNCTION mcw_on_page_delete() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
mid uuid;
|
||||
BEGIN
|
||||
SELECT c.manga_id INTO mid FROM chapters c WHERE c.id = OLD.chapter_id;
|
||||
IF mid IS NOT NULL THEN
|
||||
PERFORM mcw_refresh_for_manga(mid);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER mcw_page_del AFTER DELETE ON pages
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_page_delete();
|
||||
|
||||
-- A chapter was deleted (directly, or via a manga cascade): recompute from the
|
||||
-- chapter's manga. `chapters.manga_id` is on the row, so it is always available
|
||||
-- even after the child pages have cascaded; the refresh no-ops when the manga
|
||||
-- itself is being deleted.
|
||||
CREATE OR REPLACE FUNCTION mcw_on_chapter_delete() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM mcw_refresh_for_manga(OLD.manga_id);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER mcw_chapter_del AFTER DELETE ON chapters
|
||||
FOR EACH ROW EXECUTE FUNCTION mcw_on_chapter_delete();
|
||||
|
||||
-- Backfill from existing per-page rows.
|
||||
INSERT INTO manga_content_warnings (manga_id, warning)
|
||||
SELECT DISTINCT c.manga_id, pw.warning
|
||||
FROM page_content_warnings pw
|
||||
JOIN pages p ON p.id = pw.page_id
|
||||
JOIN chapters c ON c.id = p.chapter_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
51
backend/migrations/0037_mangas_sort_author.sql
Normal file
51
backend/migrations/0037_mangas_sort_author.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Precomputed author sort key for `?sort=author`.
|
||||
--
|
||||
-- The author sort used a correlated `min(lower(a.name))` subquery as the ORDER
|
||||
-- BY key, evaluated per filter-matching row before LIMIT — it scaled worse than
|
||||
-- the indexed date/title sorts. Materialize the same value on `mangas` so the
|
||||
-- sort is a plain indexed column read.
|
||||
--
|
||||
-- `sort_author` = the alphabetically-first attached author's lowercased name,
|
||||
-- or NULL when the manga has no authors (kept last via NULLS LAST in the query).
|
||||
-- Author names are immutable (authors are upserted by unique lowercased name and
|
||||
-- never renamed), so the value only changes when the manga_authors join changes
|
||||
-- — maintained by the triggers below.
|
||||
|
||||
ALTER TABLE mangas ADD COLUMN sort_author text;
|
||||
|
||||
CREATE INDEX mangas_sort_author_idx ON mangas (sort_author, id);
|
||||
|
||||
-- Backfill from existing links.
|
||||
UPDATE mangas m
|
||||
SET sort_author = (
|
||||
SELECT min(lower(a.name))
|
||||
FROM manga_authors ma
|
||||
JOIN authors a ON a.id = ma.author_id
|
||||
WHERE ma.manga_id = m.id
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION refresh_manga_sort_author(mid uuid) RETURNS void AS $$
|
||||
BEGIN
|
||||
UPDATE mangas
|
||||
SET sort_author = (
|
||||
SELECT min(lower(a.name))
|
||||
FROM manga_authors ma
|
||||
JOIN authors a ON a.id = ma.author_id
|
||||
WHERE ma.manga_id = mid
|
||||
)
|
||||
WHERE id = mid;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mangas_sort_author_on_ma_change() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- On a manga cascade-delete the UPDATE simply no-ops (row already gone).
|
||||
PERFORM refresh_manga_sort_author(COALESCE(NEW.manga_id, OLD.manga_id));
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER manga_authors_sort_author_ins AFTER INSERT ON manga_authors
|
||||
FOR EACH ROW EXECUTE FUNCTION mangas_sort_author_on_ma_change();
|
||||
CREATE TRIGGER manga_authors_sort_author_del AFTER DELETE ON manga_authors
|
||||
FOR EACH ROW EXECUTE FUNCTION mangas_sort_author_on_ma_change();
|
||||
44
backend/migrations/0038_genres_name_lower_unique.sql
Normal file
44
backend/migrations/0038_genres_name_lower_unique.sql
Normal 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));
|
||||
18
backend/migrations/0039_crawler_jobs_running_lease_idx.sql
Normal file
18
backend/migrations/0039_crawler_jobs_running_lease_idx.sql
Normal 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';
|
||||
@@ -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');
|
||||
592
backend/src/analysis/daemon.rs
Normal file
592
backend/src/analysis/daemon.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
123
backend/src/analysis/events.rs
Normal file
123
backend/src/analysis/events.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend/src/analysis/mod.rs
Normal file
14
backend/src/analysis/mod.rs
Normal 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
388
backend/src/analysis/ocr.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
274
backend/src/analysis/prompt.rs
Normal file
274
backend/src/analysis/prompt.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1361
backend/src/analysis/vision.rs
Normal file
1361
backend/src/analysis/vision.rs
Normal file
File diff suppressed because it is too large
Load Diff
466
backend/src/api/admin/analysis.rs
Normal file
466
backend/src/api/admin/analysis.rs
Normal 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 }))
|
||||
}
|
||||
70
backend/src/api/admin/audit.rs
Normal file
70
backend/src/api/admin/audit.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
67
backend/src/api/admin/crawler/backlog.rs
Normal file
67
backend/src/api/admin/crawler/backlog.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
246
backend/src/api/admin/crawler/control.rs
Normal file
246
backend/src/api/admin/crawler/control.rs
Normal 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
|
||||
}
|
||||
121
backend/src/api/admin/crawler/dead_jobs.rs
Normal file
121
backend/src/api/admin/crawler/dead_jobs.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
63
backend/src/api/admin/crawler/history.rs
Normal file
63
backend/src/api/admin/crawler/history.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
99
backend/src/api/admin/crawler/metrics.rs
Normal file
99
backend/src/api/admin/crawler/metrics.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
54
backend/src/api/admin/crawler/mod.rs
Normal file
54
backend/src/api/admin/crawler/mod.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
245
backend/src/api/admin/crawler/status.rs
Normal file
245
backend/src/api/admin/crawler/status.rs
Normal 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())
|
||||
}
|
||||
444
backend/src/api/admin/health.rs
Normal file
444
backend/src/api/admin/health.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
113
backend/src/api/admin/overview.rs
Normal file
113
backend/src/api/admin/overview.rs
Normal 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))
|
||||
}
|
||||
174
backend/src/api/admin/resync.rs
Normal file
174
backend/src/api/admin/resync.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
239
backend/src/api/admin/settings.rs
Normal file
239
backend/src/api/admin/settings.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
253
backend/src/api/admin/storage.rs
Normal file
253
backend/src/api/admin/storage.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
//! The handler uses `Storage::get_stream` so a multi-MB page is piped to
|
||||
//! the client a chunk at a time instead of buffered server-side.
|
||||
//!
|
||||
//! **Thumbnails.** `?w=<px>` serves a width-bounded variant (grids ask for a
|
||||
//! small width so they download ~KB instead of the 1–5 MB original). The width
|
||||
//! snaps to a small allow-list so the number of cached derivatives stays
|
||||
//! bounded; the resized image is cached in storage under a `thumbs/w{W}/` prefix
|
||||
//! and regenerated on demand. Only JPEG/PNG sources are thumbnailed (encoders we
|
||||
//! ship); other formats fall back to the original.
|
||||
//!
|
||||
//! **Auth model — capability URLs by design.** This endpoint is
|
||||
//! deliberately unauthenticated: reads stay public per the project
|
||||
//! brief, and per-page authorisation would require either a per-request
|
||||
@@ -16,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
532
backend/src/api/page_tags.rs
Normal file
532
backend/src/api/page_tags.rs
Normal 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(¶ms.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(¶ms.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());
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
backend/src/api/reactions.rs
Normal file
69
backend/src/api/reactions.rs
Normal 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 }))
|
||||
}
|
||||
1244
backend/src/app.rs
1244
backend/src/app.rs
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,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);
|
||||
@@ -123,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,
|
||||
@@ -215,6 +244,7 @@ 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,
|
||||
@@ -245,6 +275,7 @@ 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,
|
||||
@@ -258,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(),
|
||||
@@ -302,6 +313,11 @@ 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?;
|
||||
@@ -319,6 +335,7 @@ async fn run(
|
||||
force_refetch_chapters,
|
||||
Arc::clone(&allowlist),
|
||||
max_image_bytes,
|
||||
max_images_per_chapter,
|
||||
tor.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -345,6 +362,7 @@ 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(
|
||||
@@ -410,7 +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);
|
||||
@@ -421,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,
|
||||
@@ -487,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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,9 +18,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::crawler::detect::PageError;
|
||||
use crate::crawler::rate_limit::HostRateLimiters;
|
||||
use crate::crawler::safety::{fetch_bytes_capped, looks_like_image, DownloadAllowlist};
|
||||
use crate::crawler::safety::{ensure_public_target, fetch_stream, looks_like_image, DownloadAllowlist};
|
||||
use crate::crawler::session::{self, ChapterProbe};
|
||||
use crate::storage::Storage;
|
||||
use crate::storage::{Storage, StorageError};
|
||||
|
||||
/// Parse the chapter page DOM and return the page images in `pageN`
|
||||
/// order. Filters out the loader `<img class="loading">` and any
|
||||
@@ -71,6 +71,13 @@ pub enum SyncOutcome {
|
||||
/// Session probe failed mid-sync (avatar selector missing on the
|
||||
/// chapter page). Caller should abort the whole crawler run.
|
||||
SessionExpired,
|
||||
/// The headless browser could not be acquired (down or mid-restart).
|
||||
/// Produced only by the queue dispatcher (which calls `acquire()`); it is
|
||||
/// an *infrastructure* outage, not a job failure, so the daemon returns the
|
||||
/// job to `pending` WITHOUT burning a retry attempt. `sync_chapter_content`
|
||||
/// itself never returns this — callers that already hold a lease can treat
|
||||
/// it as unreachable.
|
||||
BrowserUnavailable,
|
||||
}
|
||||
|
||||
/// Per-chapter max fetch attempts when TOR is configured. `N = 3` means
|
||||
@@ -95,6 +102,19 @@ enum ChapterFetchOutcome {
|
||||
PersistentTransient,
|
||||
}
|
||||
|
||||
/// Refuse to navigate Chromium at a chapter URL that points inside the
|
||||
/// deployment. The image-download path goes through `is_safe_url`, but
|
||||
/// `new_page` is a second network surface: a `chapter_sources` row whose
|
||||
/// host resolves to (or was crafted to be) a private IP would otherwise let
|
||||
/// the headless browser probe `postgres:5432`, the cloud metadata service,
|
||||
/// etc. Reuses the allowlist-free `ensure_public_target` (scheme +
|
||||
/// private-IP literal check) since there is no per-host allowlist for the
|
||||
/// scraped catalog itself.
|
||||
fn guard_nav_url(source_url: &str) -> anyhow::Result<()> {
|
||||
ensure_public_target(source_url)
|
||||
.map_err(|e| anyhow::anyhow!("refuse to navigate unsafe chapter URL {source_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Single rate-limited Chromium navigation to the chapter URL,
|
||||
/// returning the page HTML. Extracted from `sync_chapter_content` so
|
||||
/// the recircuit loop can call it once per attempt.
|
||||
@@ -103,27 +123,36 @@ async fn fetch_chapter_html_once(
|
||||
rate: &HostRateLimiters,
|
||||
source_url: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
guard_nav_url(source_url)?;
|
||||
rate.wait_for(source_url).await?;
|
||||
let page = browser
|
||||
.new_page(source_url)
|
||||
let page = crate::crawler::intercept::open_page(browser, source_url)
|
||||
.await
|
||||
.with_context(|| format!("open chapter page {source_url}"))?;
|
||||
crate::crawler::nav::wait_for_nav(&page)
|
||||
.await
|
||||
.context("wait for chapter nav")?;
|
||||
// Best-effort wait for the reader marker — same partial-render
|
||||
// race that bit the chapter-list parser can hit here. Timeout is
|
||||
// not an error; the chapter probe + parser sentinels still catch
|
||||
// real failures.
|
||||
let _ = crate::crawler::nav::wait_for_selector(
|
||||
&page,
|
||||
"a#pic_container",
|
||||
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 chapter nav")?;
|
||||
// Best-effort wait for the reader marker — same partial-render
|
||||
// race that bit the chapter-list parser can hit here. Timeout is
|
||||
// not an error; the chapter probe + parser sentinels still catch
|
||||
// real failures.
|
||||
let _ = crate::crawler::nav::wait_for_selector(
|
||||
&page,
|
||||
"a#pic_container",
|
||||
crate::crawler::nav::SELECTOR_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
page.content().await.context("read chapter html")
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let html = page.content().await.context("read chapter html")?;
|
||||
page.close().await.ok();
|
||||
Ok(html)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pure-over-IO loop: fetch + classify, up to `max_attempts` total
|
||||
@@ -186,11 +215,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all images for one chapter and persist them atomically. On
|
||||
/// any error after the first storage put, the DB transaction rolls
|
||||
/// back so the chapter stays at `page_count = 0` and is retried on the
|
||||
/// next run. Bytes already written to storage become orphans; a future
|
||||
/// reaper sweeps them.
|
||||
/// Fetch one chapter's images and persist them. Each image is
|
||||
/// streamed straight to storage via `Storage::put_stream` after a
|
||||
/// short prefix is peeked off the body for content-type sniffing —
|
||||
/// peak memory per concurrent dispatch is one HTTP chunk plus the
|
||||
/// sniff prefix, not a full multi-MB image. The per-image size cap
|
||||
/// (`CRAWLER_MAX_IMAGE_BYTES`) is enforced inside the stream so a
|
||||
/// server that omits Content-Length still can't exhaust memory. The
|
||||
/// page rows + `page_count` are then written in one short transaction.
|
||||
/// On any failure the chapter stays at `page_count = 0` (no partial
|
||||
/// rows) and the blobs already written are deleted best-effort by
|
||||
/// [`cleanup_orphans`], so a retry starts clean.
|
||||
/// Timing wrapper over [`sync_chapter_content_inner`]: records one `chapter`
|
||||
/// row in `crawl_metrics` with the wall-clock and outcome. Best-effort — a
|
||||
/// metrics-write failure never fails the chapter sync. `Skipped` /
|
||||
/// `SessionExpired` perform no fetch, so they aren't timed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sync_chapter_content(
|
||||
browser: &chromiumoxide::Browser,
|
||||
@@ -204,7 +243,75 @@ pub async fn sync_chapter_content(
|
||||
force_refetch: bool,
|
||||
allowlist: &DownloadAllowlist,
|
||||
max_image_bytes: usize,
|
||||
max_images_per_chapter: usize,
|
||||
tor: Option<&crate::crawler::tor::TorController>,
|
||||
progress: Option<&crate::crawler::status::StatusHandle>,
|
||||
enqueue_analysis: bool,
|
||||
) -> anyhow::Result<SyncOutcome> {
|
||||
let started = std::time::Instant::now();
|
||||
let result = sync_chapter_content_inner(
|
||||
browser, db, storage, http, rate, chapter_id, manga_id, source_url,
|
||||
force_refetch, allowlist, max_image_bytes, max_images_per_chapter, tor, progress,
|
||||
enqueue_analysis,
|
||||
)
|
||||
.await;
|
||||
let duration_ms = started.elapsed().as_millis() as i64;
|
||||
match &result {
|
||||
Ok(SyncOutcome::Fetched { pages }) => {
|
||||
let _ = crate::repo::crawl_metrics::record(
|
||||
db,
|
||||
crate::repo::crawl_metrics::OP_CHAPTER,
|
||||
Some(manga_id),
|
||||
Some(chapter_id),
|
||||
"ok",
|
||||
duration_ms,
|
||||
Some(*pages as i32),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = crate::repo::crawl_metrics::record(
|
||||
db,
|
||||
crate::repo::crawl_metrics::OP_CHAPTER,
|
||||
Some(manga_id),
|
||||
Some(chapter_id),
|
||||
"failed",
|
||||
duration_ms,
|
||||
None,
|
||||
Some(&format!("{e:#}")),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Skipped / SessionExpired: no fetch performed → not a timed op.
|
||||
Ok(_) => {}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn sync_chapter_content_inner(
|
||||
browser: &chromiumoxide::Browser,
|
||||
db: &PgPool,
|
||||
storage: &dyn Storage,
|
||||
http: &reqwest::Client,
|
||||
rate: &HostRateLimiters,
|
||||
chapter_id: Uuid,
|
||||
manga_id: Uuid,
|
||||
source_url: &str,
|
||||
force_refetch: bool,
|
||||
allowlist: &DownloadAllowlist,
|
||||
max_image_bytes: usize,
|
||||
max_images_per_chapter: usize,
|
||||
tor: Option<&crate::crawler::tor::TorController>,
|
||||
// Optional live-status sink for the realtime page counter. The daemon
|
||||
// dispatcher passes the shared handle (the chapter has already been
|
||||
// registered via `begin_chapter`); the CLI / admin resync pass `None`.
|
||||
progress: Option<&crate::crawler::status::StatusHandle>,
|
||||
// When `true`, enqueue an `analyze_page` job per persisted page. The
|
||||
// daemon dispatcher passes the configured flag; CLI / resync pass
|
||||
// `false`.
|
||||
enqueue_analysis: bool,
|
||||
) -> anyhow::Result<SyncOutcome> {
|
||||
// Skip if already fetched, unless caller explicitly forces.
|
||||
if !force_refetch {
|
||||
@@ -256,84 +363,341 @@ pub async fn sync_chapter_content(
|
||||
if images.is_empty() {
|
||||
anyhow::bail!("no page images parsed from {source_url}");
|
||||
}
|
||||
// Bound total disk per chapter: the per-image byte cap doesn't stop a
|
||||
// hostile reader page from listing thousands of <img> tags. Ack failed
|
||||
// (the caller records it and backs off) rather than downloading them.
|
||||
if let Some(over) = image_count_over_cap(images.len(), max_images_per_chapter) {
|
||||
anyhow::bail!(
|
||||
"chapter at {source_url} lists {} page images, over the {} cap",
|
||||
over.count,
|
||||
over.cap
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve image URLs against the chapter URL (they may be relative).
|
||||
let base = reqwest::Url::parse(source_url).context("parse chapter URL")?;
|
||||
|
||||
// Fetch every image bytes-first into memory before writing
|
||||
// anything. Lets us bail the whole chapter cleanly if any image
|
||||
// fails — DB stays at page_count=0, no partial rows persisted.
|
||||
let mut fetched: Vec<(i32, Vec<u8>, &'static str)> = Vec::with_capacity(images.len());
|
||||
// Stream each image straight to storage as it's fetched, capping peak
|
||||
// memory at a single image rather than the whole chapter. Track the
|
||||
// keys written so they can be rolled back if a later page (or the
|
||||
// final DB commit) fails — preserving the all-or-nothing guarantee
|
||||
// without holding a DB transaction open across the network puts
|
||||
// (which matters once `Storage` is backed by S3).
|
||||
let total = images.len();
|
||||
// Publish the now-known page total so the dashboard shows "0/N".
|
||||
if let Some(p) = progress {
|
||||
p.set_chapter_pages(chapter_id, 0, Some(total));
|
||||
}
|
||||
let mut written_keys: Vec<String> = Vec::with_capacity(total);
|
||||
let mut stored: Vec<StoredPage> = Vec::with_capacity(total);
|
||||
for img in &images {
|
||||
let url = base.join(&img.url).with_context(|| {
|
||||
format!("join image URL {} onto {source_url}", img.url)
|
||||
})?;
|
||||
rate.wait_for(url.as_str()).await?;
|
||||
let bytes = fetch_bytes_capped(
|
||||
match download_and_store_page(
|
||||
storage,
|
||||
http,
|
||||
url.as_str(),
|
||||
Some(source_url),
|
||||
rate,
|
||||
&base,
|
||||
source_url,
|
||||
manga_id,
|
||||
chapter_id,
|
||||
img,
|
||||
allowlist,
|
||||
max_image_bytes,
|
||||
)
|
||||
.await?
|
||||
.to_vec();
|
||||
// Reject any non-image response: the only valid output of an
|
||||
// image URL is an image. `infer` returns None on truncated
|
||||
// bytes too, which also wants to be a failure not a silent
|
||||
// `.bin` extension.
|
||||
if !looks_like_image(&bytes) {
|
||||
anyhow::bail!(
|
||||
"image URL {url} returned non-image bytes \
|
||||
(first 16: {:?}); refusing to store as binary blob",
|
||||
&bytes.get(..16.min(bytes.len()))
|
||||
);
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
written_keys.push(page.storage_key.clone());
|
||||
stored.push(page);
|
||||
// Live page counter: push the climbing count to subscribers.
|
||||
if let Some(p) = progress {
|
||||
p.set_chapter_pages(chapter_id, stored.len(), Some(total));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
cleanup_orphans(storage, &written_keys).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
let ext = infer::get(&bytes)
|
||||
.map(|k| k.extension())
|
||||
.expect("looks_like_image asserted infer succeeded");
|
||||
fetched.push((img.page_number, bytes, ext));
|
||||
}
|
||||
|
||||
// Atomic write: storage puts + page row inserts + page_count
|
||||
// update, all in one transaction. If anything fails, rollback +
|
||||
// the chapter is retried next run. Storage orphans the bytes; a
|
||||
// reaper sweeps them later.
|
||||
let mut tx = db.begin().await.context("open chapter sync tx")?;
|
||||
for (page_number, bytes, ext) in &fetched {
|
||||
let key = format!(
|
||||
"mangas/{manga_id}/chapters/{chapter_id}/pages/{:04}.{ext}",
|
||||
page_number
|
||||
// Short transaction: page rows + page_count only, no network I/O. On
|
||||
// failure, roll back the stored bytes so the chapter stays at
|
||||
// page_count=0 and is retried cleanly next run.
|
||||
if let Err(e) = persist_pages(db, chapter_id, &stored, enqueue_analysis).await {
|
||||
cleanup_orphans(storage, &written_keys).await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(SyncOutcome::Fetched { pages: stored.len() })
|
||||
}
|
||||
|
||||
/// A page image that has been written to storage and is awaiting its DB
|
||||
/// row. Carries everything `persist_pages` needs.
|
||||
pub(crate) struct StoredPage {
|
||||
page_number: i32,
|
||||
storage_key: String,
|
||||
content_type: String,
|
||||
/// Bytes written to storage, captured from `put_stream`'s return so
|
||||
/// storage-usage stats are a pure DB SUM.
|
||||
size_bytes: i64,
|
||||
}
|
||||
|
||||
/// Bytes accumulated for content-type sniffing. `infer` only needs the
|
||||
/// first few bytes for image formats (the longest signature in our
|
||||
/// allow-list is AVIF at 12 bytes), but we read up to this many so a
|
||||
/// fragmented TCP frame still produces a confident sniff and the
|
||||
/// "first 16 bytes" diagnostic in the error path is useful.
|
||||
const SNIFF_PREFIX_BYTES: usize = 64;
|
||||
|
||||
/// Bytes still admissible for the streaming tail after the sniff prefix
|
||||
/// has been drained. The prefix already counts against the per-image cap,
|
||||
/// so the tail budget is `max_image_bytes - prefix_len` using the
|
||||
/// **actual** drained length — never a constant — so `prefix_len + tail`
|
||||
/// can never exceed the cap.
|
||||
fn remaining_after_prefix(max_image_bytes: usize, prefix_len: usize) -> usize {
|
||||
max_image_bytes.saturating_sub(prefix_len)
|
||||
}
|
||||
|
||||
/// A rejected over-cap image count, carrying the numbers for the error.
|
||||
struct ImageCountOverCap {
|
||||
count: usize,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
/// `Some(..)` when `count` exceeds the per-chapter image cap. A `cap` of
|
||||
/// `0` disables the check (unbounded), matching the config's "0 = no cap"
|
||||
/// contract.
|
||||
fn image_count_over_cap(count: usize, cap: usize) -> Option<ImageCountOverCap> {
|
||||
(cap != 0 && count > cap).then_some(ImageCountOverCap { count, cap })
|
||||
}
|
||||
|
||||
/// Download a single page image, validate it's really an image, and
|
||||
/// stream it to storage. Returns the storage key + content type. Does
|
||||
/// not touch the DB — persistence is batched into one short transaction
|
||||
/// afterward.
|
||||
///
|
||||
/// Streaming path: we peek the first [`SNIFF_PREFIX_BYTES`] from the
|
||||
/// HTTP body to determine the file extension (and thus the storage
|
||||
/// key), then re-emit those bytes followed by the rest of the response
|
||||
/// stream via `Storage::put_stream`. Peak memory per concurrent
|
||||
/// dispatch is one HTTP chunk (~16 KiB) plus the sniff prefix, not a
|
||||
/// full multi-MB image. The per-image cap is enforced as bytes flow.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_and_store_page(
|
||||
storage: &dyn Storage,
|
||||
http: &reqwest::Client,
|
||||
rate: &HostRateLimiters,
|
||||
base: &reqwest::Url,
|
||||
source_url: &str,
|
||||
manga_id: Uuid,
|
||||
chapter_id: Uuid,
|
||||
img: &ChapterImage,
|
||||
allowlist: &DownloadAllowlist,
|
||||
max_image_bytes: usize,
|
||||
) -> anyhow::Result<StoredPage> {
|
||||
use futures_util::StreamExt as _;
|
||||
let url = base
|
||||
.join(&img.url)
|
||||
.with_context(|| format!("join image URL {} onto {source_url}", img.url))?;
|
||||
rate.wait_for(url.as_str()).await?;
|
||||
let resp = fetch_stream(http, url.as_str(), Some(source_url), allowlist).await?;
|
||||
let mut body = resp.bytes_stream();
|
||||
|
||||
// Drain chunks until we have enough bytes to sniff confidently
|
||||
// (or the body is shorter than the prefix). Enforces the per-image
|
||||
// cap on the prefix accumulation too.
|
||||
let mut prefix = bytes::BytesMut::new();
|
||||
while prefix.len() < SNIFF_PREFIX_BYTES {
|
||||
let Some(chunk) = body.next().await else { break };
|
||||
let chunk = chunk
|
||||
.with_context(|| format!("stream chunk for {url}"))?;
|
||||
if prefix.len().saturating_add(chunk.len()) > max_image_bytes {
|
||||
anyhow::bail!(
|
||||
"image {url} exceeds {max_image_bytes}-byte cap (received >{}+{})",
|
||||
prefix.len(),
|
||||
chunk.len()
|
||||
);
|
||||
}
|
||||
prefix.extend_from_slice(&chunk);
|
||||
}
|
||||
let prefix = prefix.freeze();
|
||||
|
||||
// Reject any non-image response: the only valid output of an image
|
||||
// URL is an image. `infer` returns None on truncated bytes too,
|
||||
// which is also a failure not a silent `.bin` extension.
|
||||
if !looks_like_image(&prefix) {
|
||||
anyhow::bail!(
|
||||
"image URL {url} returned non-image bytes \
|
||||
(first 16: {:?}); refusing to store as binary blob",
|
||||
&prefix.get(..16.min(prefix.len()))
|
||||
);
|
||||
storage
|
||||
.put(&key, bytes)
|
||||
.await
|
||||
.with_context(|| format!("put {key}"))?;
|
||||
// (chapter_id, page_number) is unique — re-runs idempotent.
|
||||
sqlx::query(
|
||||
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
}
|
||||
let ext = infer::get(&prefix)
|
||||
.map(|k| k.extension())
|
||||
.expect("looks_like_image asserted infer succeeded");
|
||||
let key = format!(
|
||||
"mangas/{manga_id}/chapters/{chapter_id}/pages/{:04}.{ext}",
|
||||
img.page_number
|
||||
);
|
||||
|
||||
// Build a single stream of (prefix + remaining body) and pipe it
|
||||
// straight to storage. The cap is enforced via a running total in
|
||||
// the stream adapter so a server that omits Content-Length still
|
||||
// can't exhaust memory.
|
||||
// Budget the streaming tail against the bytes *actually* drained into
|
||||
// the prefix, not the constant `SNIFF_PREFIX_BYTES`. The prefix loop
|
||||
// appends whole chunks, so a single 16 KiB first chunk fills the
|
||||
// 64-byte sniff window in one drain — charging only 64 bytes would
|
||||
// then let the tail add another full `max_image_bytes`, storing up to
|
||||
// ~2× the cap. Capture the length before `prefix` is moved into the
|
||||
// stream below.
|
||||
let prefix_len = prefix.len();
|
||||
let prefix_stream = futures_util::stream::once(async move {
|
||||
Ok::<bytes::Bytes, StorageError>(prefix)
|
||||
});
|
||||
let mut remaining = remaining_after_prefix(max_image_bytes, prefix_len);
|
||||
let url_for_err = url.clone();
|
||||
let rest_stream = body.map(move |frame| match frame {
|
||||
Ok(chunk) => {
|
||||
if chunk.len() > remaining {
|
||||
return Err(StorageError::Io(std::io::Error::other(format!(
|
||||
"image {url_for_err} exceeds {max_image_bytes}-byte cap"
|
||||
))));
|
||||
}
|
||||
remaining -= chunk.len();
|
||||
Ok(chunk)
|
||||
}
|
||||
Err(e) => Err(StorageError::Io(std::io::Error::other(format!(
|
||||
"stream chunk for {url_for_err}: {e}"
|
||||
)))),
|
||||
});
|
||||
let combined = prefix_stream.chain(rest_stream);
|
||||
let size_bytes = storage
|
||||
.put_stream(&key, Box::pin(combined))
|
||||
.await
|
||||
.with_context(|| format!("put_stream {key}"))?;
|
||||
Ok(StoredPage {
|
||||
page_number: img.page_number,
|
||||
storage_key: key,
|
||||
content_type: format!("image/{ext}"),
|
||||
size_bytes: size_bytes as i64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist the page rows + chapter `page_count` in one short transaction.
|
||||
/// `(chapter_id, page_number)` is unique so re-runs are idempotent.
|
||||
pub(crate) async fn persist_pages(
|
||||
db: &PgPool,
|
||||
chapter_id: Uuid,
|
||||
stored: &[StoredPage],
|
||||
enqueue_analysis: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut tx = db.begin().await.context("open chapter sync tx")?;
|
||||
let mut page_ids: Vec<Uuid> = Vec::with_capacity(stored.len());
|
||||
let mut page_numbers: Vec<i32> = Vec::with_capacity(stored.len());
|
||||
// Pages whose row already existed and was overwritten by this re-crawl.
|
||||
// Their image changed but pages.id (and any analysis keyed to it) survived,
|
||||
// so the derived analysis is now stale and must be invalidated below.
|
||||
let mut updated_page_ids: Vec<Uuid> = Vec::new();
|
||||
for page in stored {
|
||||
// `xmax <> 0` on the RETURNING row distinguishes a conflict UPDATE
|
||||
// (existing row overwritten) from a fresh INSERT — the standard upsert
|
||||
// idiom. Cast through text/bigint since there is no direct xid<>int op.
|
||||
let (id, was_update): (Uuid, bool) = sqlx::query_as(
|
||||
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (chapter_id, page_number) DO UPDATE
|
||||
SET storage_key = EXCLUDED.storage_key,
|
||||
content_type = EXCLUDED.content_type",
|
||||
content_type = EXCLUDED.content_type,
|
||||
size_bytes = EXCLUDED.size_bytes
|
||||
RETURNING id, (xmax::text::bigint <> 0)",
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.bind(page_number)
|
||||
.bind(&key)
|
||||
.bind(format!("image/{ext}"))
|
||||
.bind(page.page_number)
|
||||
.bind(&page.storage_key)
|
||||
.bind(&page.content_type)
|
||||
.bind(page.size_bytes)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.with_context(|| format!("insert page row {}", page.page_number))?;
|
||||
page_ids.push(id);
|
||||
page_numbers.push(page.page_number);
|
||||
if was_update {
|
||||
updated_page_ids.push(id);
|
||||
}
|
||||
}
|
||||
// Invalidate stale analysis for re-crawled (overwritten) pages. Storage keys
|
||||
// are deterministic per page number, so the row survives the upsert and its
|
||||
// page_analysis / OCR / auto-tags / content-warnings would otherwise reflect
|
||||
// the OLD image — poisoning search results and the derived
|
||||
// manga_content_warnings. Clearing them (the DELETE on page_content_warnings
|
||||
// fires the mcw_* triggers so manga_content_warnings recomputes) drops the
|
||||
// pages back to "unanalyzed" so the enqueue below — and the admin
|
||||
// only-unanalyzed backfill — re-derive them. Newly inserted pages have no
|
||||
// prior analysis, so they're untouched.
|
||||
if !updated_page_ids.is_empty() {
|
||||
for table in [
|
||||
"page_ocr_text",
|
||||
"page_auto_tags",
|
||||
"page_content_warnings",
|
||||
"page_analysis",
|
||||
] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE page_id = ANY($1::uuid[])"))
|
||||
.bind(&updated_page_ids)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.with_context(|| format!("invalidate {table} for re-crawled pages"))?;
|
||||
}
|
||||
}
|
||||
// Drop any rows left over from a prior, larger crawl of this chapter
|
||||
// (re-crawl that now yields fewer pages). Without this the stale rows —
|
||||
// and their stale size_bytes — would inflate the storage aggregates
|
||||
// and the page_count would disagree with the row count. Cascades to
|
||||
// collection_pages / page_tags by design (re-upload drops saved-page
|
||||
// references).
|
||||
sqlx::query("DELETE FROM pages WHERE chapter_id = $1 AND page_number <> ALL($2::int[])")
|
||||
.bind(chapter_id)
|
||||
.bind(&page_numbers)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.with_context(|| format!("insert page row {page_number}"))?;
|
||||
}
|
||||
.context("prune stale page rows")?;
|
||||
sqlx::query("UPDATE chapters SET page_count = $1 WHERE id = $2")
|
||||
.bind(fetched.len() as i32)
|
||||
.bind(stored.len() as i32)
|
||||
.bind(chapter_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("update page_count")?;
|
||||
tx.commit().await.context("commit chapter sync")?;
|
||||
|
||||
Ok(SyncOutcome::Fetched { pages: fetched.len() })
|
||||
// Enqueue AI content-analysis for the crawled pages once their rows
|
||||
// are committed. Best-effort: a failed enqueue is logged, never fatal
|
||||
// (the admin re-enqueue endpoint can backfill). Gated by the caller so
|
||||
// jobs don't pile up when the analysis worker is disabled.
|
||||
if enqueue_analysis {
|
||||
for page_id in page_ids {
|
||||
if let Err(e) =
|
||||
crate::repo::page_analysis::enqueue_for_page(db, page_id, false).await
|
||||
{
|
||||
tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis after crawl");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort delete of partially-written page blobs after a chapter sync
|
||||
/// fails, so a retry doesn't accumulate orphans. Errors are logged, not
|
||||
/// raised — a leftover blob is harmless and a future reaper can sweep it.
|
||||
pub(crate) async fn cleanup_orphans(storage: &dyn Storage, keys: &[String]) {
|
||||
for key in keys {
|
||||
if let Err(e) = storage.delete(key).await {
|
||||
tracing::warn!(
|
||||
%key,
|
||||
error = ?e,
|
||||
"failed to delete orphaned page blob after chapter sync failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused-import warning for `session::registrable_domain`
|
||||
@@ -347,6 +711,257 @@ fn _keep_session_in_scope() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::LocalStorage;
|
||||
|
||||
#[test]
|
||||
fn guard_nav_url_rejects_private_and_loopback_targets() {
|
||||
// A chapter_sources row that resolves to / was crafted as an
|
||||
// internal target must be refused before Chromium navigates.
|
||||
for url in [
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://10.0.0.1/chapter/1",
|
||||
"http://localhost:8080/",
|
||||
"file:///etc/passwd",
|
||||
] {
|
||||
assert!(guard_nav_url(url).is_err(), "must reject {url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_nav_url_allows_public_chapter_urls() {
|
||||
assert!(guard_nav_url("https://reader.example.com/chapter/42").is_ok());
|
||||
assert!(guard_nav_url("http://manga-host.test/c/1/p/2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_budget_uses_actual_prefix_length_not_constant() {
|
||||
// A single 16 KiB first chunk fills the 64-byte sniff window in one
|
||||
// drain, so the streaming tail budget must be `cap - 16KiB`, not
|
||||
// `cap - 64`. The constant-64 math previously admitted a further
|
||||
// ~full cap on top of the already-drained prefix (~2× overshoot).
|
||||
let cap = 20 * 1024;
|
||||
let prefix_len = 16 * 1024; // one real chunk, well over SNIFF_PREFIX_BYTES
|
||||
let remaining = remaining_after_prefix(cap, prefix_len);
|
||||
assert_eq!(remaining, cap - prefix_len);
|
||||
// Invariant: prefix + admitted tail never exceeds the cap.
|
||||
assert!(prefix_len + remaining <= cap);
|
||||
// And it's strictly tighter than the old constant-64 budget.
|
||||
assert!(remaining < cap - SNIFF_PREFIX_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_count_cap_rejects_only_over_cap_and_respects_disable() {
|
||||
// Under / at the cap: accepted.
|
||||
assert!(image_count_over_cap(0, 2000).is_none());
|
||||
assert!(image_count_over_cap(2000, 2000).is_none());
|
||||
// Over the cap: rejected, carrying the numbers for the error.
|
||||
let over = image_count_over_cap(2001, 2000).expect("over cap");
|
||||
assert_eq!(over.count, 2001);
|
||||
assert_eq!(over.cap, 2000);
|
||||
// `0` disables the cap entirely (unbounded), matching config contract.
|
||||
assert!(image_count_over_cap(1_000_000, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_budget_saturates_when_prefix_hits_cap() {
|
||||
// Body shorter than the sniff window: prefix drained == cap, tail
|
||||
// budget is zero (no negative underflow).
|
||||
assert_eq!(remaining_after_prefix(50, 50), 0);
|
||||
assert_eq!(remaining_after_prefix(50, 64), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_orphans_deletes_written_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = LocalStorage::new(dir.path());
|
||||
let keys = vec![
|
||||
"mangas/m/chapters/c/pages/0001.jpg".to_string(),
|
||||
"mangas/m/chapters/c/pages/0002.jpg".to_string(),
|
||||
];
|
||||
for k in &keys {
|
||||
storage.put(k, b"\xff\xd8\xff\xe0 jpeg-ish").await.unwrap();
|
||||
assert!(storage.exists(k).await.unwrap());
|
||||
}
|
||||
cleanup_orphans(&storage, &keys).await;
|
||||
for k in &keys {
|
||||
assert!(!storage.exists(k).await.unwrap(), "{k} should be deleted");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_orphans_tolerates_missing_keys() {
|
||||
// A key that was never written (e.g. the put itself failed) must
|
||||
// not make cleanup error — it's best-effort.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = LocalStorage::new(dir.path());
|
||||
cleanup_orphans(&storage, &["never/written.jpg".to_string()]).await;
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn persist_pages_inserts_rows_and_sets_page_count(pool: PgPool) {
|
||||
let manga_id = Uuid::new_v4();
|
||||
let chapter_id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'T')")
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)")
|
||||
.bind(chapter_id)
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = vec![
|
||||
StoredPage {
|
||||
page_number: 1,
|
||||
storage_key: "k/0001.jpg".into(),
|
||||
content_type: "image/jpeg".into(),
|
||||
size_bytes: 111,
|
||||
},
|
||||
StoredPage {
|
||||
page_number: 2,
|
||||
storage_key: "k/0002.jpg".into(),
|
||||
content_type: "image/jpeg".into(),
|
||||
size_bytes: 222,
|
||||
},
|
||||
];
|
||||
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
||||
|
||||
let page_count: i32 =
|
||||
sqlx::query_scalar("SELECT page_count FROM chapters WHERE id = $1")
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page_count, 2);
|
||||
let rows: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM pages WHERE chapter_id = $1")
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 2);
|
||||
// Sizes are persisted from the StoredPage so SUM(size_bytes) is
|
||||
// the chapter's storage usage.
|
||||
let total: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(SUM(size_bytes),0)::bigint FROM pages WHERE chapter_id = $1")
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 333);
|
||||
|
||||
// Idempotent re-run (force refetch path): same rows, page_count stable.
|
||||
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
||||
let rows2: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM pages WHERE chapter_id = $1")
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows2, 2, "re-run is idempotent via ON CONFLICT");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn persist_pages_prunes_stale_rows_on_smaller_recrawl(pool: PgPool) {
|
||||
// A re-crawl that yields fewer pages must drop the leftover
|
||||
// high-numbered rows so page_count, the row count, and the storage
|
||||
// aggregates stay consistent.
|
||||
let manga_id = Uuid::new_v4();
|
||||
let chapter_id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'T')")
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)")
|
||||
.bind(chapter_id)
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let three = vec![
|
||||
StoredPage { page_number: 1, storage_key: "k/1.jpg".into(), content_type: "image/jpeg".into(), size_bytes: 10 },
|
||||
StoredPage { page_number: 2, storage_key: "k/2.jpg".into(), content_type: "image/jpeg".into(), size_bytes: 20 },
|
||||
StoredPage { page_number: 3, storage_key: "k/3.jpg".into(), content_type: "image/jpeg".into(), size_bytes: 30 },
|
||||
];
|
||||
persist_pages(&pool, chapter_id, &three, false).await.unwrap();
|
||||
|
||||
// Re-crawl now yields only 2 pages.
|
||||
let two = vec![
|
||||
StoredPage { page_number: 1, storage_key: "k/1.jpg".into(), content_type: "image/jpeg".into(), size_bytes: 11 },
|
||||
StoredPage { page_number: 2, storage_key: "k/2.jpg".into(), content_type: "image/jpeg".into(), size_bytes: 22 },
|
||||
];
|
||||
persist_pages(&pool, chapter_id, &two, false).await.unwrap();
|
||||
|
||||
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pages WHERE chapter_id = $1")
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 2, "stale page 3 should be pruned");
|
||||
let page_count: i32 = sqlx::query_scalar("SELECT page_count FROM chapters WHERE id = $1")
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page_count, 2);
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(size_bytes),0)::bigint FROM pages WHERE chapter_id = $1",
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 33, "size reflects only the 2 re-crawled pages");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn persist_pages_enqueues_analysis_only_when_flag_set(pool: PgPool) {
|
||||
let manga_id = Uuid::new_v4();
|
||||
let chapter_id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'T')")
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)")
|
||||
.bind(chapter_id)
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = vec![StoredPage {
|
||||
page_number: 1,
|
||||
storage_key: "k/0001.jpg".into(),
|
||||
content_type: "image/jpeg".into(),
|
||||
size_bytes: 100,
|
||||
}];
|
||||
|
||||
// Flag off: no analyze_page jobs.
|
||||
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
||||
let jobs_off: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(jobs_off, 0, "flag off must not enqueue analysis");
|
||||
|
||||
// Flag on: one analyze_page job for the upserted page.
|
||||
persist_pages(&pool, chapter_id, &stored, true).await.unwrap();
|
||||
let jobs_on: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(jobs_on, 1, "flag on enqueues one job per persisted page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chapter_pages_skips_loader_and_sorts_by_id() {
|
||||
@@ -618,4 +1233,91 @@ mod tests {
|
||||
assert_eq!(fetch_n, 1);
|
||||
assert!(format!("{err:#}").contains("nav timeout"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn persist_pages_invalidates_stale_analysis_on_recrawl_update(pool: PgPool) {
|
||||
// Page storage keys are deterministic per page number, so a re-crawl
|
||||
// overwrites the image but keeps pages.id. Analysis keyed to page_id
|
||||
// would otherwise stay stale (old OCR/search_doc/warnings). persist_pages
|
||||
// must clear the derived analysis for updated pages so it re-derives.
|
||||
let manga_id = Uuid::new_v4();
|
||||
let chapter_id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'T')")
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)")
|
||||
.bind(chapter_id)
|
||||
.bind(manga_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v1 = vec![StoredPage {
|
||||
page_number: 1,
|
||||
storage_key: "k/0001.jpg".into(),
|
||||
content_type: "image/jpeg".into(),
|
||||
size_bytes: 10,
|
||||
}];
|
||||
persist_pages(&pool, chapter_id, &v1, false).await.unwrap();
|
||||
let page_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT id FROM pages WHERE chapter_id = $1 AND page_number = 1",
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate a completed prior analysis pass for that page.
|
||||
sqlx::query(
|
||||
"INSERT INTO page_analysis (page_id, status, is_nsfw, analyzed_at) \
|
||||
VALUES ($1, 'done', true, now())",
|
||||
)
|
||||
.bind(page_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO page_ocr_text (page_id, kind, text) VALUES ($1, 'speech', 'stale')")
|
||||
.bind(page_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO page_content_warnings (page_id, warning) VALUES ($1, 'gore')")
|
||||
.bind(page_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Re-crawl page 1 with a new image (same deterministic key, new size).
|
||||
let v2 = vec![StoredPage {
|
||||
page_number: 1,
|
||||
storage_key: "k/0001.jpg".into(),
|
||||
content_type: "image/jpeg".into(),
|
||||
size_bytes: 999,
|
||||
}];
|
||||
persist_pages(&pool, chapter_id, &v2, false).await.unwrap();
|
||||
|
||||
// The page row survived (deterministic key).
|
||||
let same_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT id FROM pages WHERE chapter_id = $1 AND page_number = 1",
|
||||
)
|
||||
.bind(chapter_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(same_id, page_id, "re-crawl keeps the page id");
|
||||
|
||||
// ...but its stale analysis is cleared so it gets re-derived.
|
||||
for table in ["page_analysis", "page_ocr_text", "page_content_warnings"] {
|
||||
let n: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM {table} WHERE page_id = $1"
|
||||
))
|
||||
.bind(page_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "{table} must be invalidated when the re-crawl updates the page");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
301
backend/src/crawler/intercept.rs
Normal file
301
backend/src/crawler/intercept.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,13 +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());
|
||||
@@ -165,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? {
|
||||
@@ -175,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");
|
||||
@@ -198,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,
|
||||
@@ -213,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,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)
|
||||
}
|
||||
@@ -429,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)
|
||||
@@ -471,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
|
||||
@@ -484,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)
|
||||
@@ -523,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,
|
||||
@@ -567,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!(
|
||||
@@ -634,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
|
||||
|
||||
@@ -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))
|
||||
|
||||
260
backend/src/crawler/reconcile.rs
Normal file
260
backend/src/crawler/reconcile.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
281
backend/src/crawler/resync.rs
Normal file
281
backend/src/crawler/resync.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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![
|
||||
|
||||
@@ -285,25 +285,39 @@ where
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
184
backend/src/crawler/session_control.rs
Normal file
184
backend/src/crawler/session_control.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ use super::{
|
||||
use crate::crawler::detect::{
|
||||
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
|
||||
@@ -220,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
|
||||
@@ -237,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
|
||||
@@ -332,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(
|
||||
@@ -699,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
|
||||
@@ -1049,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());
|
||||
}
|
||||
}
|
||||
|
||||
460
backend/src/crawler/status.rs
Normal file
460
backend/src/crawler/status.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
57
backend/src/domain/crawl_metrics.rs
Normal file
57
backend/src/domain/crawl_metrics.rs
Normal 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>,
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
333
backend/src/domain/page_analysis.rs
Normal file
333
backend/src/domain/page_analysis.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
63
backend/src/domain/page_tag.rs
Normal file
63
backend/src/domain/page_tag.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NewPageTag {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// Returned by `GET /v1/me/page-tags`. Joins through chapters and
|
||||
/// mangas so the library Page-tags tab can render the breadcrumb
|
||||
/// without follow-up requests.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TaggedPageItem {
|
||||
pub tag: String,
|
||||
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 tagged_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Distinct-tags histogram row. Used both for autocomplete in the
|
||||
/// "Add tag" sheet and for the chip cloud in the library Page-tags
|
||||
/// tab.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct PageTagSummary {
|
||||
pub tag: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// One chapter (with breadcrumb) ranked by how many of its pages the
|
||||
/// caller has tagged with a given tag. Returned by the `/search`
|
||||
/// page's Chapters tab via `GET /v1/me/page-tags/chapters`.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TaggedChapterAggregate {
|
||||
pub chapter_id: Uuid,
|
||||
pub manga_id: Uuid,
|
||||
pub manga_title: String,
|
||||
pub chapter_number: i32,
|
||||
pub chapter_title: Option<String>,
|
||||
pub match_count: i64,
|
||||
/// Up to 3 storage keys of matching pages in this chapter,
|
||||
/// page-number ascending. Powers the thumbnail strip in the row.
|
||||
pub sample_storage_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// One manga ranked by how many of its pages (across all chapters)
|
||||
/// the caller has tagged with a given tag. Returned by the `/search`
|
||||
/// page's Mangas tab.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TaggedMangaAggregate {
|
||||
pub manga_id: Uuid,
|
||||
pub manga_title: String,
|
||||
pub manga_cover_image_path: Option<String>,
|
||||
pub match_count: i64,
|
||||
pub sample_storage_keys: Vec<String>,
|
||||
}
|
||||
40
backend/src/domain/reaction.rs
Normal file
40
backend/src/domain/reaction.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A user's private taste signal on a manga. Stored as text in
|
||||
/// `manga_reactions.reaction` (CHECK-constrained), so we map to/from a
|
||||
/// `&str` at the repo layer rather than deriving a Postgres enum type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Reaction {
|
||||
Like,
|
||||
Dislike,
|
||||
}
|
||||
|
||||
impl Reaction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Reaction::Like => "like",
|
||||
Reaction::Dislike => "dislike",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored/inbound value. Returns `None` for anything outside the
|
||||
/// closed vocabulary (the DB CHECK guarantees stored rows are valid; this
|
||||
/// also guards the inbound API body).
|
||||
pub fn parse(s: &str) -> Option<Reaction> {
|
||||
match s {
|
||||
"like" => Some(Reaction::Like),
|
||||
"dislike" => Some(Reaction::Dislike),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response shape for `GET /me/reactions/:manga_id` — the current user's
|
||||
/// reaction on one manga, or `null` when they haven't reacted.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MangaReaction {
|
||||
pub manga_id: Uuid,
|
||||
pub reaction: Option<Reaction>,
|
||||
}
|
||||
@@ -24,8 +24,19 @@ pub struct ReadProgressSummary {
|
||||
/// `None` when the chapter was deleted after this row was written
|
||||
/// (FK ON DELETE SET NULL on `chapter_id`).
|
||||
pub chapter_number: Option<i32>,
|
||||
/// Page count of the last-read chapter (`None` when unknown / deleted).
|
||||
/// Lets a client distinguish a finished series (read to the last page,
|
||||
/// nothing new) from one still in progress — e.g. to keep finished
|
||||
/// series off a "Continue reading" shelf.
|
||||
pub chapter_page_count: Option<i32>,
|
||||
pub page: i32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// How many chapters sit past the reader's last-read chapter (by
|
||||
/// chapter number) — a personal "new since you last read" count.
|
||||
/// `0` when the reader is caught up or when `chapter_number` is
|
||||
/// unknown (manga-level progress or a deleted chapter), so we never
|
||||
/// claim chapters are new when we can't place the reader.
|
||||
pub new_chapters_count: i64,
|
||||
}
|
||||
|
||||
/// Returned by `GET /me/read-progress/:manga_id`. Same shape as
|
||||
@@ -40,6 +51,11 @@ pub struct ReadProgressForManga {
|
||||
pub chapter_number: Option<i32>,
|
||||
pub page: i32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Distinct chapter numbers past the last-read chapter — the detail
|
||||
/// page's authoritative "new since last read" count (computed over all
|
||||
/// chapters, not the client's paginated list). `0` when caught up or
|
||||
/// the position is unknown. See [`ReadProgressSummary::new_chapters_count`].
|
||||
pub new_chapters_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user