Compare commits
194 Commits
f441425519
...
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 |
230
.env.example
230
.env.example
@@ -25,6 +25,12 @@ DATABASE_URL=postgres://mangalord:mangalord@postgres:5432/mangalord
|
|||||||
BIND_ADDRESS=0.0.0.0:8080
|
BIND_ADDRESS=0.0.0.0:8080
|
||||||
STORAGE_DIR=/var/lib/mangalord/storage
|
STORAGE_DIR=/var/lib/mangalord/storage
|
||||||
RUST_LOG=info,mangalord=debug,chromiumoxide::conn=off,chromiumoxide::handler=off
|
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 -----
|
# ----- Auth / cookies -----
|
||||||
# COOKIE_SECURE controls whether the `Secure` flag is set on the session
|
# 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).
|
# rate-limiting reverse proxy that already enforces a budget).
|
||||||
AUTH_RATE_PER_SEC=5
|
AUTH_RATE_PER_SEC=5
|
||||||
AUTH_RATE_BURST=10
|
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 -----
|
# ----- CORS -----
|
||||||
# Comma-separated origins allowed to call the API with credentials.
|
# Comma-separated origins allowed to call the API with credentials.
|
||||||
@@ -62,13 +75,39 @@ CORS_ALLOWED_ORIGINS=
|
|||||||
# neither Origin nor Referer (curl, server-to-server callers) are
|
# neither Origin nor Referer (curl, server-to-server callers) are
|
||||||
# always allowed.
|
# always allowed.
|
||||||
#
|
#
|
||||||
# Default is empty: CSRF check disabled (operator opt-out). For a
|
# Empty does NOT mean "off" for browsers: cookie-authenticated admin
|
||||||
# browser-exposed deployment this should be set to the SvelteKit
|
# mutations FAIL CLOSED (403) when this is empty, since there's no
|
||||||
# origin, e.g. https://app.example.com. For a same-origin
|
# allowlist to check the Origin against. Non-cookie callers (curl,
|
||||||
# docker-compose deploy where only one origin exists, set the same
|
# bots with a Bearer token, no Origin/Referer) are still allowed.
|
||||||
# value the browser uses.
|
# 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_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 -----
|
# ----- Upload limits -----
|
||||||
# Per-request body cap. axum rejects oversized requests with 413 before
|
# Per-request body cap. axum rejects oversized requests with 413 before
|
||||||
# our handlers run. Default 200 MiB.
|
# our handlers run. Default 200 MiB.
|
||||||
@@ -77,6 +116,13 @@ MAX_REQUEST_BYTES=209715200
|
|||||||
# oversized image is rejected even when the total request fits.
|
# oversized image is rejected even when the total request fits.
|
||||||
# Default 20 MiB.
|
# Default 20 MiB.
|
||||||
MAX_FILE_BYTES=20971520
|
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 -----
|
# ----- Crawler download safety -----
|
||||||
# Hosts the crawler is allowed to fetch images/covers from, in addition
|
# Hosts the crawler is allowed to fetch images/covers from, in addition
|
||||||
@@ -91,6 +137,11 @@ CRAWLER_DOWNLOAD_ALLOWLIST=
|
|||||||
CRAWLER_ALLOW_ANY_HOST=false
|
CRAWLER_ALLOW_ANY_HOST=false
|
||||||
# Hard cap on a single image body. Default 32 MiB.
|
# Hard cap on a single image body. Default 32 MiB.
|
||||||
CRAWLER_MAX_IMAGE_BYTES=33554432
|
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
|
# 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
|
# 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.
|
# to completion. Useful for capped test runs against a new source.
|
||||||
@@ -101,6 +152,39 @@ CRAWLER_LIMIT=0
|
|||||||
# A job that exceeds the budget is acked failed (with exponential
|
# A job that exceeds the budget is acked failed (with exponential
|
||||||
# backoff) instead of wedging a worker. Default 600s.
|
# backoff) instead of wedging a worker. Default 600s.
|
||||||
CRAWLER_JOB_TIMEOUT_SECS=600
|
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
|
# Consecutive metadata-pass `fetch_manga` failures that abort the pass
|
||||||
# (circuit breaker for a source outage). The pass does NOT mark a clean
|
# (circuit breaker for a source outage). The pass does NOT mark a clean
|
||||||
# exit, so the next tick does a recovery sweep. Default 10.
|
# exit, so the next tick does a recovery sweep. Default 10.
|
||||||
@@ -109,6 +193,19 @@ CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES=10
|
|||||||
# exhausted) that trigger an automatic coordinated browser restart.
|
# exhausted) that trigger an automatic coordinated browser restart.
|
||||||
# Default 3.
|
# Default 3.
|
||||||
CRAWLER_BROWSER_RESTART_THRESHOLD=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
|
# Path to a system Chromium binary. When set, the crawler skips the
|
||||||
# bundled-fetcher download. Required on platforms without a usable
|
# bundled-fetcher download. Required on platforms without a usable
|
||||||
# upstream Chromium build (notably Linux_arm64 / Raspberry Pi). On
|
# upstream Chromium build (notably Linux_arm64 / Raspberry Pi). On
|
||||||
@@ -141,6 +238,11 @@ CRAWLER_PROXY=socks5h://tor:9050
|
|||||||
CRAWLER_TOR_CONTROL_URL=tcp://tor:9051
|
CRAWLER_TOR_CONTROL_URL=tcp://tor:9051
|
||||||
# Max NEWNYM-and-retry cycles per recircuit-eligible failure. Default 3.
|
# Max NEWNYM-and-retry cycles per recircuit-eligible failure. Default 3.
|
||||||
CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS=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 -----
|
# ----- TOR control-port password -----
|
||||||
# Shared between the bundled dockurr/tor service (which hashes it into
|
# Shared between the bundled dockurr/tor service (which hashes it into
|
||||||
@@ -182,6 +284,25 @@ BACKEND_PROXY_TIMEOUT_MS=300000
|
|||||||
# analysis ANALYSIS_API_KEY. The important site levers (PRIVATE_MODE,
|
# analysis ANALYSIS_API_KEY. The important site levers (PRIVATE_MODE,
|
||||||
# ALLOW_SELF_REGISTER) also remain env-only by design.
|
# 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):
|
# New analysis knobs (all optional, all seed defaults):
|
||||||
# ANALYSIS_TEMPERATURE Sampling temperature. Default 0 (deterministic).
|
# ANALYSIS_TEMPERATURE Sampling temperature. Default 0 (deterministic).
|
||||||
# ANALYSIS_SYSTEM_PROMPT Override the single-call vision system prompt.
|
# ANALYSIS_SYSTEM_PROMPT Override the single-call vision system prompt.
|
||||||
@@ -190,6 +311,82 @@ BACKEND_PROXY_TIMEOUT_MS=300000
|
|||||||
# Leave the prompt vars unset to use the built-in defaults (also editable,
|
# Leave the prompt vars unset to use the built-in defaults (also editable,
|
||||||
# with a per-prompt "reset to default", in the dashboard).
|
# 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) -----
|
# ----- Vision autoscaling (the `ai` compose profile) -----
|
||||||
# The mangalord-vision (llama.cpp) container pins ~4.4 GiB and has no
|
# 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-unload, so the `vision-manager` sidecar starts it on demand and
|
||||||
@@ -218,3 +415,26 @@ VISION_MANAGER_DATABASE_URL=
|
|||||||
# VISION_START_HEALTH_TIMEOUT Max seconds to wait for /health 200 after start. Default 300 (cold model load).
|
# 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_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).
|
# 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
|
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
for svc in backend frontend; do
|
for svc in backend frontend; do
|
||||||
img="$REGISTRY_URL/mangalord-$svc"
|
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
|
for tag in "$IMAGE_TAG" latest "$VERSION"; do docker push "$img:$tag"; done
|
||||||
done
|
done
|
||||||
docker logout "$REGISTRY_URL"
|
docker logout "$REGISTRY_URL"
|
||||||
@@ -149,5 +155,12 @@ jobs:
|
|||||||
export MANGALORD_TAG="$IMAGE_TAG"
|
export MANGALORD_TAG="$IMAGE_TAG"
|
||||||
docker compose pull mangalord-backend mangalord-frontend
|
docker compose pull mangalord-backend mangalord-frontend
|
||||||
docker compose up -d 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 image prune -f
|
||||||
docker logout "$REGISTRY_URL"
|
docker logout "$REGISTRY_URL"
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,10 @@
|
|||||||
/frontend/build
|
/frontend/build
|
||||||
/frontend/test-results
|
/frontend/test-results
|
||||||
/frontend/playwright-report
|
/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
|
# Local storage volume (manga files). `/data` is the root path the
|
||||||
# compose volume mounts at; `/backend/data` is where the dev backend
|
# compose volume mounts at; `/backend/data` is where the dev backend
|
||||||
|
|||||||
@@ -136,8 +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.
|
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.
|
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
||||||
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user page tags live in `page_tags`, which references the **shared** `tags` table by `tag_id` (migration 0024) — the same lookup table `manga_tags` uses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag *names*; `repo::page_tag` resolves name↔id via `repo::tag::upsert_by_name` and applies the stricter page-tag `normalize_tag` (lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Both `collection_pages` and `page_tags` cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design.
|
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user page tags live in `page_tags`, which references the **shared** `tags` table by `tag_id` (migration 0024) — the same lookup table `manga_tags` uses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag *names*; `repo::page_tag` resolves name↔id via `repo::tag::upsert_by_name` and applies the stricter page-tag `normalize_tag` (lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Both `collection_pages` and `page_tags` cascade-delete with `pages` and `chapters`, so a saved-page reference is dropped only when its `pages` row is genuinely deleted — i.e. when the chapter is deleted (cascade), or when a user deletes and re-creates a chapter (the user upload path in `api::chapters::finalize_chapter` inserts a *new* chapter row with fresh page ids). A crawler **re-fetch** does **not** drop saves: `content::persist_pages` upserts pages by `(chapter_id, page_number)` (`ON CONFLICT (…) DO UPDATE … RETURNING id`), preserving each `pages.id`, so collections and page tags keyed on that id survive the re-fetch. (Migration 0023's header comment predates this and describes the cascade as an unconditional "re-upload drops saves"; the checked-in migration text is intentionally left as-is because sqlx checksums applied migrations.)
|
||||||
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints is **reserved** for the planned OCR text-search input. Both aggregation handlers accept `text=` on the wire but reject non-empty values with 501 `text_search_not_yet_supported` so adding OCR later doesn't break the API shape. Adding OCR is then: a background worker writes `page_ocr_text` rows, a JOIN on the existing aggregation queries adds the new filter, the `text=` param starts validating instead of rejecting.
|
- **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.
|
- **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.
|
- **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.
|
- **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 |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------------------------------------- | ------------------------------------------ |
|
| ------ | ------------------------------------------------------- | ------------------------------------------ |
|
||||||
| GET | `/api/v1/health` | Liveness probe. |
|
| 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}` | Single manga. |
|
||||||
| GET | `/api/v1/mangas/{id}/chapters` | Paginated chapter list, ordered by number. |
|
| GET | `/api/v1/mangas/{id}/chapters` | Paginated chapter list, ordered by number. |
|
||||||
| GET | `/api/v1/mangas/{id}/chapters/{n}` | Single chapter. |
|
| GET | `/api/v1/mangas/{id}/chapters/{n}` | Single chapter. |
|
||||||
|
|||||||
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
|
/target
|
||||||
/.sqlx
|
/.sqlx
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local OCR models for native dev (downloaded, not source)
|
||||||
|
models/
|
||||||
|
*.rten
|
||||||
|
|||||||
259
backend/Cargo.lock
generated
259
backend/Cargo.lock
generated
@@ -214,6 +214,12 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -514,6 +520,25 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
@@ -638,7 +663,7 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -772,6 +797,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -1059,6 +1094,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -1448,7 +1489,7 @@ version = "0.1.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.7.5",
|
"redox_syscall 0.7.5",
|
||||||
@@ -1517,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.81.0"
|
version = "0.128.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1537,14 +1578,15 @@ dependencies = [
|
|||||||
"infer",
|
"infer",
|
||||||
"mime",
|
"mime",
|
||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
|
"ocrs",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rten",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"subtle",
|
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1669,7 +1711,7 @@ version = "0.29.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1681,7 +1723,7 @@ version = "0.31.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1757,6 +1799,16 @@ dependencies = [
|
|||||||
"libm",
|
"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]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -1772,7 +1824,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -1793,7 +1845,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
@@ -1804,7 +1856,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -1837,7 +1889,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -1855,7 +1907,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -1868,7 +1920,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -1879,7 +1931,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
@@ -1891,7 +1943,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
@@ -1916,6 +1968,21 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -2142,7 +2209,7 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"fdeflate",
|
"fdeflate",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2361,13 +2428,33 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2376,7 +2463,7 @@ version = "0.7.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
|
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2496,19 +2583,135 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
@@ -2521,7 +2724,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
@@ -2603,7 +2806,7 @@ version = "0.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
|
checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"fxhash",
|
"fxhash",
|
||||||
@@ -2911,7 +3114,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2955,7 +3158,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -3317,7 +3520,7 @@ version = "0.6.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -3428,6 +3631,12 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@@ -3668,7 +3877,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4096,7 +4305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.81.0"
|
version = "0.128.25"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ dotenvy = "0.15"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
subtle = "2"
|
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
# Image decode + downscale for the analysis worker (keep the page image
|
# Image decode + downscale for the analysis worker (keep the page image
|
||||||
# under the local vision model's token budget). Only the manga page formats.
|
# under the local vision model's token budget). Only the manga page formats.
|
||||||
@@ -48,10 +47,12 @@ futures-core = "0.3"
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
chromiumoxide = { version = "0.7", features = ["tokio-runtime", "_fetcher-rusttls-tokio"], default-features = false }
|
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"] }
|
nix = { version = "0.29", features = ["fs"] }
|
||||||
scraper = "0.20"
|
scraper = "0.20"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream", "json"] }
|
||||||
|
ocrs = "0.12"
|
||||||
|
rten = "0.24"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -60,6 +61,7 @@ http-body-util = "0.1"
|
|||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tokio = { version = "1", features = ["test-util"] }
|
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
|
# Trim debug builds: keep line numbers in panics / backtraces but drop the
|
||||||
# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx +
|
# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx +
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ WORKDIR /app
|
|||||||
COPY --from=builder /app/target/release/mangalord /usr/local/bin/mangalord
|
COPY --from=builder /app/target/release/mangalord /usr/local/bin/mangalord
|
||||||
COPY --from=builder /app/migrations /app/migrations
|
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
|
ENV STORAGE_DIR=/var/lib/mangalord/storage
|
||||||
# Pre-create the storage dir so the entrypoint doesn't need to
|
# Pre-create the storage dir so the entrypoint doesn't need to
|
||||||
# mkdir-as-root and so the named volume mount inherits the right
|
# mkdir-as-root and so the named volume mount inherits the right
|
||||||
|
|||||||
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');
|
||||||
@@ -37,6 +37,22 @@ const LEASE_HEARTBEAT: Duration = Duration::from_secs(20);
|
|||||||
/// long enough not to hammer `/health` while vision is down.
|
/// long enough not to hammer `/health` while vision is down.
|
||||||
const READINESS_POLL: Duration = Duration::from_secs(2);
|
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
|
/// The unit of work: analyze one page. Implemented by
|
||||||
/// [`RealAnalyzeDispatcher`] in production and stubbed in tests.
|
/// [`RealAnalyzeDispatcher`] in production and stubbed in tests.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -135,6 +151,9 @@ impl WorkerContext {
|
|||||||
// Last observed readiness, so we log only on transitions (not every
|
// Last observed readiness, so we log only on transitions (not every
|
||||||
// poll). `None` until the first probe.
|
// poll). `None` until the first probe.
|
||||||
let mut was_ready: Option<bool> = None;
|
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 {
|
loop {
|
||||||
if self.cancel.is_cancelled() {
|
if self.cancel.is_cancelled() {
|
||||||
tracing::info!(worker = self.id, "analysis worker: shutdown");
|
tracing::info!(worker = self.id, "analysis worker: shutdown");
|
||||||
@@ -178,11 +197,15 @@ impl WorkerContext {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(lease) = leases.into_iter().next() else {
|
let Some(lease) = leases.into_iter().next() else {
|
||||||
if self.sleep_or_cancel(Duration::from_secs(1)).await {
|
let wait = idle_backoff(consecutive_empty);
|
||||||
|
consecutive_empty = consecutive_empty.saturating_add(1);
|
||||||
|
if self.sleep_or_cancel(wait).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// Leased work — drop back to a tight poll cadence.
|
||||||
|
consecutive_empty = 0;
|
||||||
self.process_lease(lease).await;
|
self.process_lease(lease).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +223,7 @@ impl WorkerContext {
|
|||||||
// Shouldn't happen — we lease only analyze_page — but ack done
|
// Shouldn't happen — we lease only analyze_page — but ack done
|
||||||
// so a misrouted job doesn't loop forever.
|
// so a misrouted job doesn't loop forever.
|
||||||
tracing::warn!(worker = self.id, "analysis worker: non-analyze payload leased");
|
tracing::warn!(worker = self.id, "analysis worker: non-analyze payload leased");
|
||||||
let _ = jobs::ack_done(&self.pool, lease.id).await;
|
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,22 +233,29 @@ impl WorkerContext {
|
|||||||
if !force {
|
if !force {
|
||||||
if let Ok(Some(row)) = repo::page_analysis::load(&self.pool, page_id).await {
|
if let Ok(Some(row)) = repo::page_analysis::load(&self.pool, page_id).await {
|
||||||
if row.status == crate::domain::page_analysis::AnalysisStatus::Done {
|
if row.status == crate::domain::page_analysis::AnalysisStatus::Done {
|
||||||
let _ = jobs::ack_done(&self.pool, lease.id).await;
|
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the page breadcrumb once so live events carry the
|
// Resolve the labeled page breadcrumb once so live events carry the
|
||||||
// manga/chapter/number the dashboard keys on. A missing breadcrumb
|
// manga/chapter/number AND human labels the dashboard's "now
|
||||||
// (page deleted) just suppresses events — the dispatch still runs
|
// analyzing" banner names. A missing breadcrumb (page deleted) just
|
||||||
// and acks normally.
|
// suppresses events — the dispatch still runs and acks normally.
|
||||||
let breadcrumb = repo::page::locate(&self.pool, page_id).await.ok().flatten();
|
let breadcrumb = repo::page::locate_labeled(&self.pool, page_id)
|
||||||
if let Some((manga_id, chapter_id, page_number)) = breadcrumb {
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
if let Some((manga_id, manga_title, chapter_id, chapter_number, page_number)) =
|
||||||
|
breadcrumb.clone()
|
||||||
|
{
|
||||||
self.events.publish(AnalysisEvent::Started {
|
self.events.publish(AnalysisEvent::Started {
|
||||||
page_id,
|
page_id,
|
||||||
manga_id,
|
manga_id,
|
||||||
|
manga_title,
|
||||||
chapter_id,
|
chapter_id,
|
||||||
|
chapter_number,
|
||||||
page_number,
|
page_number,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -234,10 +264,11 @@ impl WorkerContext {
|
|||||||
let heartbeat = {
|
let heartbeat = {
|
||||||
let hb_pool = self.pool.clone();
|
let hb_pool = self.pool.clone();
|
||||||
let hb_id = lease.id;
|
let hb_id = lease.id;
|
||||||
|
let hb_gen = lease.lease_generation;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(LEASE_HEARTBEAT).await;
|
tokio::time::sleep(LEASE_HEARTBEAT).await;
|
||||||
match jobs::renew(&hb_pool, hb_id, LEASE_DURATION).await {
|
match jobs::renew(&hb_pool, hb_id, hb_gen, LEASE_DURATION).await {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => break,
|
Ok(false) => break,
|
||||||
Err(e) => tracing::warn!(lease_id = %hb_id, ?e, "heartbeat renew failed"),
|
Err(e) => tracing::warn!(lease_id = %hb_id, ?e, "heartbeat renew failed"),
|
||||||
@@ -246,9 +277,48 @@ impl WorkerContext {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
let dispatch = AssertUnwindSafe(self.dispatcher.dispatch(page_id)).catch_unwind();
|
let dispatch = AssertUnwindSafe(self.dispatcher.dispatch(page_id)).catch_unwind();
|
||||||
let outcome = tokio::time::timeout(self.job_timeout, dispatch).await;
|
// 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();
|
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.
|
// Flatten timeout / panic / dispatch error into one Result + message.
|
||||||
let result: Result<(), String> = match outcome {
|
let result: Result<(), String> = match outcome {
|
||||||
@@ -258,18 +328,45 @@ impl WorkerContext {
|
|||||||
Ok(Ok(Ok(()))) => Ok(()),
|
Ok(Ok(Ok(()))) => Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((manga_id, chapter_id, page_number)) = breadcrumb {
|
if let Some((manga_id, manga_title, chapter_id, chapter_number, page_number)) =
|
||||||
|
breadcrumb
|
||||||
|
{
|
||||||
let event = if result.is_ok() {
|
let event = if result.is_ok() {
|
||||||
AnalysisEvent::Completed { page_id, manga_id, chapter_id, page_number }
|
AnalysisEvent::Completed {
|
||||||
|
page_id,
|
||||||
|
manga_id,
|
||||||
|
manga_title,
|
||||||
|
chapter_id,
|
||||||
|
chapter_number,
|
||||||
|
page_number,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
AnalysisEvent::Failed { page_id, manga_id, chapter_id, page_number }
|
AnalysisEvent::Failed {
|
||||||
|
page_id,
|
||||||
|
manga_id,
|
||||||
|
manga_title,
|
||||||
|
chapter_id,
|
||||||
|
chapter_number,
|
||||||
|
page_number,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
self.events.publish(event);
|
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 {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = jobs::ack_done(&self.pool, lease.id).await;
|
let _ = jobs::ack_done(&self.pool, lease.id, lease.lease_generation).await;
|
||||||
}
|
}
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -284,6 +381,7 @@ impl WorkerContext {
|
|||||||
&msg,
|
&msg,
|
||||||
lease.attempts,
|
lease.attempts,
|
||||||
lease.max_attempts,
|
lease.max_attempts,
|
||||||
|
lease.lease_generation,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Terminal failure (retries exhausted) → record a `failed`
|
// Terminal failure (retries exhausted) → record a `failed`
|
||||||
@@ -294,6 +392,10 @@ impl WorkerContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if wrote_row {
|
||||||
|
let _ = repo::page_analysis::record_duration(&self.pool, page_id, elapsed_ms).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,19 +416,20 @@ impl AnalyzeDispatcher for RealAnalyzeDispatcher {
|
|||||||
// Page was deleted between enqueue and dispatch — nothing to do.
|
// Page was deleted between enqueue and dispatch — nothing to do.
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let bytes = self
|
// 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
|
.storage
|
||||||
.get(&page.storage_key)
|
.get_stream(&page.storage_key)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
|
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
|
||||||
if bytes.len() > self.max_image_bytes {
|
let bytes =
|
||||||
anyhow::bail!(
|
crate::crawler::safety::accumulate_capped(file.stream, self.max_image_bytes)
|
||||||
"page image {} is {} bytes, over the {} cap",
|
.await
|
||||||
page.storage_key,
|
.map_err(|e| {
|
||||||
bytes.len(),
|
anyhow::anyhow!("page image {} over the byte cap: {e}", page.storage_key)
|
||||||
self.max_image_bytes
|
})?;
|
||||||
);
|
|
||||||
}
|
|
||||||
let analysis = self.vision.analyze(&bytes, &page.content_type).await?;
|
let analysis = self.vision.analyze(&bytes, &page.content_type).await?;
|
||||||
repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, &self.model).await?;
|
repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, &self.model).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -383,6 +486,32 @@ pub mod test_support {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[async_trait]
|
||||||
impl AnalyzeDispatcher for CountingDispatcher {
|
impl AnalyzeDispatcher for CountingDispatcher {
|
||||||
async fn dispatch(&self, _page_id: Uuid) -> anyhow::Result<()> {
|
async fn dispatch(&self, _page_id: Uuid) -> anyhow::Result<()> {
|
||||||
@@ -404,6 +533,17 @@ mod tests {
|
|||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpListener;
|
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`
|
/// Bind an ephemeral port that answers every request with `status_line`
|
||||||
/// (e.g. `"200 OK"`), and return its `/health` URL. The probe under test
|
/// (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.
|
/// only inspects the status code, so a zero-length body is enough.
|
||||||
|
|||||||
@@ -25,25 +25,48 @@ pub enum AnalysisEvent {
|
|||||||
manga_id: Option<Uuid>,
|
manga_id: Option<Uuid>,
|
||||||
chapter_id: Option<Uuid>,
|
chapter_id: Option<Uuid>,
|
||||||
},
|
},
|
||||||
/// The worker began analyzing a page.
|
/// 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 {
|
Started {
|
||||||
page_id: Uuid,
|
page_id: Uuid,
|
||||||
manga_id: Uuid,
|
manga_id: Uuid,
|
||||||
|
manga_title: String,
|
||||||
chapter_id: Uuid,
|
chapter_id: Uuid,
|
||||||
|
chapter_number: i32,
|
||||||
page_number: i32,
|
page_number: i32,
|
||||||
},
|
},
|
||||||
/// The worker finished a page successfully.
|
/// The worker finished a page successfully.
|
||||||
Completed {
|
Completed {
|
||||||
page_id: Uuid,
|
page_id: Uuid,
|
||||||
manga_id: Uuid,
|
manga_id: Uuid,
|
||||||
|
manga_title: String,
|
||||||
chapter_id: Uuid,
|
chapter_id: Uuid,
|
||||||
|
chapter_number: i32,
|
||||||
page_number: i32,
|
page_number: i32,
|
||||||
},
|
},
|
||||||
/// The worker failed a page (this attempt).
|
/// The worker failed a page (this attempt).
|
||||||
Failed {
|
Failed {
|
||||||
page_id: Uuid,
|
page_id: Uuid,
|
||||||
manga_id: Uuid,
|
manga_id: Uuid,
|
||||||
|
manga_title: String,
|
||||||
chapter_id: Uuid,
|
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,
|
page_number: i32,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -76,3 +99,25 @@ impl Default for AnalysisEvents {
|
|||||||
Self::new()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,5 +9,6 @@
|
|||||||
|
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod ocr;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod vision;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,116 @@ struct SliceParams {
|
|||||||
overlap: f64,
|
overlap: f64,
|
||||||
tall_threshold: f64,
|
tall_threshold: f64,
|
||||||
max_slices: usize,
|
max_slices: usize,
|
||||||
|
/// Hard cap on the **decoded** pixel count, enforced as an allocation
|
||||||
|
/// limit on the decoder itself. `max_pixels` only downscales *after* a
|
||||||
|
/// full decode, so without this a tiny WebP/JPEG/PNG declaring huge
|
||||||
|
/// dimensions would OOM the blocking worker (decompression bomb).
|
||||||
|
/// Shared with the OCR backend's cap (`ANALYSIS_OCR_MAX_DECODE_PIXELS`).
|
||||||
|
max_decode_pixels: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the (blocking-pool) prep pass: a self-contained set of byte
|
||||||
|
/// payloads the async HTTP loop can dispatch without further CPU work.
|
||||||
|
/// The enum mirrors [`Plan`] but carries actual JPEGs instead of geometry.
|
||||||
|
enum PreparedAnalysis {
|
||||||
|
/// `image::load_from_memory` failed — fall back to sending the raw bytes.
|
||||||
|
Undecodable,
|
||||||
|
/// One vision call against `jpeg` (the encoded whole page).
|
||||||
|
Single { jpeg: Vec<u8> },
|
||||||
|
/// Per-band OCR calls plus a final whole-image grounding call. Each
|
||||||
|
/// slice tuple is `(y0, y1, encoded_jpeg)`.
|
||||||
|
Sliced {
|
||||||
|
slices: Vec<(u32, u32, Vec<u8>)>,
|
||||||
|
whole: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU-only image work: decode → plan → optional width-reduce → slice →
|
||||||
|
/// JPEG encode. Returns `Ok(Undecodable)` (never `Err`) when the bytes
|
||||||
|
/// don't parse, so the caller can fall through to the raw-pass-through
|
||||||
|
/// behaviour.
|
||||||
|
///
|
||||||
|
/// **Diagnosability.** The four fallback paths below (decode failure,
|
||||||
|
/// `render_whole` on `Single`, `render_slice` mid-loop, `render_whole`
|
||||||
|
/// on `Sliced`) all return `Undecodable`, which the caller treats as
|
||||||
|
/// "send raw bytes". That's correct semantically — a server can
|
||||||
|
/// sometimes salvage a broken page — but it also makes a silent
|
||||||
|
/// JPEG-encoder regression indistinguishable from "the page was just
|
||||||
|
/// garbage." Emit a `warn` on each fallback so an operator can grep
|
||||||
|
/// for "vision prep fell through" and tell the two apart.
|
||||||
|
/// Decode an encoded page image with a hard allocation cap so a
|
||||||
|
/// decompression bomb — a tiny WebP/JPEG/PNG header declaring enormous
|
||||||
|
/// dimensions — can't OOM the blocking worker before we get a chance to
|
||||||
|
/// downscale. The `image` crate's default reader applies no such bound;
|
||||||
|
/// format-specific self-limits (strongest for PNG) don't cover WebP/JPEG,
|
||||||
|
/// which manga pages commonly use. `4 bytes/px` (RGBA) leaves headroom
|
||||||
|
/// over any single intermediate the decoder allocates per pixel. Mirrors
|
||||||
|
/// `ocr::decode_rgb8_within`.
|
||||||
|
fn decode_within(image: &[u8], max_decode_pixels: u64) -> Option<DynamicImage> {
|
||||||
|
use std::io::Cursor;
|
||||||
|
let mut reader = image::ImageReader::new(Cursor::new(image))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?;
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_alloc = Some(max_decode_pixels.saturating_mul(4));
|
||||||
|
reader.limits(limits);
|
||||||
|
reader.decode().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_analysis(image: &[u8], params: SliceParams) -> PreparedAnalysis {
|
||||||
|
let Some(img) = decode_within(image, params.max_decode_pixels) else {
|
||||||
|
tracing::warn!(
|
||||||
|
bytes = image.len(),
|
||||||
|
"vision prep fell through to Undecodable: decode failed or exceeded pixel cap"
|
||||||
|
);
|
||||||
|
return PreparedAnalysis::Undecodable;
|
||||||
|
};
|
||||||
|
match plan_slices(img.width(), img.height(), ¶ms) {
|
||||||
|
Plan::Single { .. } => match render_whole(&img, params.max_pixels) {
|
||||||
|
Some(jpeg) => PreparedAnalysis::Single { jpeg },
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
width = img.width(),
|
||||||
|
height = img.height(),
|
||||||
|
"vision prep fell through to Undecodable: render_whole on Single plan failed"
|
||||||
|
);
|
||||||
|
PreparedAnalysis::Undecodable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Plan::Sliced { width, height, bands } => {
|
||||||
|
let work = if width == img.width() && height == img.height() {
|
||||||
|
img.clone()
|
||||||
|
} else {
|
||||||
|
img.resize_exact(width, height, FilterType::Triangle)
|
||||||
|
};
|
||||||
|
let mut slices = Vec::with_capacity(bands.len());
|
||||||
|
for (y0, y1) in &bands {
|
||||||
|
let Some(jpeg) = render_slice(&work, *y0, *y1, params.max_pixels) else {
|
||||||
|
// A failed slice falls back to the undecodable path —
|
||||||
|
// the server then gets one combined call instead of N
|
||||||
|
// broken slice calls.
|
||||||
|
tracing::warn!(
|
||||||
|
y0 = *y0,
|
||||||
|
y1 = *y1,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
"vision prep fell through to Undecodable: render_slice failed"
|
||||||
|
);
|
||||||
|
return PreparedAnalysis::Undecodable;
|
||||||
|
};
|
||||||
|
slices.push((*y0, *y1, jpeg));
|
||||||
|
}
|
||||||
|
let Some(whole) = render_whole(&img, params.max_pixels) else {
|
||||||
|
tracing::warn!(
|
||||||
|
width = img.width(),
|
||||||
|
height = img.height(),
|
||||||
|
"vision prep fell through to Undecodable: render_whole on Sliced plan failed"
|
||||||
|
);
|
||||||
|
return PreparedAnalysis::Undecodable;
|
||||||
|
};
|
||||||
|
PreparedAnalysis::Sliced { slices, whole }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VisionClient {
|
impl VisionClient {
|
||||||
@@ -77,6 +187,7 @@ impl VisionClient {
|
|||||||
overlap: cfg.slice_overlap,
|
overlap: cfg.slice_overlap,
|
||||||
tall_threshold: cfg.tall_aspect_threshold,
|
tall_threshold: cfg.tall_aspect_threshold,
|
||||||
max_slices: cfg.max_slices,
|
max_slices: cfg.max_slices,
|
||||||
|
max_decode_pixels: cfg.ocr_max_decode_pixels,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,26 +195,39 @@ impl VisionClient {
|
|||||||
/// Analyze one page image. `mime` is the stored content type (used only
|
/// Analyze one page image. `mime` is the stored content type (used only
|
||||||
/// for the fallback when the image can't be decoded locally).
|
/// for the fallback when the image can't be decoded locally).
|
||||||
pub async fn analyze(&self, image: &[u8], mime: &str) -> anyhow::Result<VisionAnalysis> {
|
pub async fn analyze(&self, image: &[u8], mime: &str) -> anyhow::Result<VisionAnalysis> {
|
||||||
// Undecodable locally → send the raw bytes in a single combined call
|
// All the CPU-heavy work — JPEG/PNG decode, optional width reduce,
|
||||||
// and let the server cope (preserves the prior behavior).
|
// per-band slice, JPEG re-encode — runs on the blocking pool so it
|
||||||
let Some(img) = image::load_from_memory(image).ok() else {
|
// doesn't starve the tokio runtime (axum handlers, SSE streams,
|
||||||
let url = format!("data:{mime};base64,{}", b64(image));
|
// other daemons share the same threads). A single hop pays the
|
||||||
let body = build_request_body(
|
// overhead once per page; the per-call work stays on the blocking
|
||||||
&self.model,
|
// worker until the HTTP loop below picks back up.
|
||||||
self.max_tokens,
|
let prepared = {
|
||||||
&url,
|
let bytes = image.to_vec();
|
||||||
self.response_format,
|
let params = self.slice;
|
||||||
self.frequency_penalty,
|
tokio::task::spawn_blocking(move || prepare_analysis(&bytes, params))
|
||||||
self.temperature,
|
.await
|
||||||
&self.system_prompt,
|
.map_err(|e| anyhow!("vision prep join: {e}"))?
|
||||||
);
|
|
||||||
return parse_chat_completion(&self.post_chat(body).await?);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match plan_slices(img.width(), img.height(), &self.slice) {
|
match prepared {
|
||||||
Plan::Single { .. } => {
|
PreparedAnalysis::Undecodable => {
|
||||||
let jpeg = render_whole(&img, self.slice.max_pixels)
|
// Send raw bytes in a single combined call and let the server
|
||||||
.ok_or_else(|| anyhow!("failed to encode page image"))?;
|
// cope (preserves prior behavior). Base64 encoding still runs
|
||||||
|
// on the runtime; for an undecodable page this is the
|
||||||
|
// happy path's exit and stays brief.
|
||||||
|
let url = format!("data:{mime};base64,{}", b64(image));
|
||||||
|
let body = build_request_body(
|
||||||
|
&self.model,
|
||||||
|
self.max_tokens,
|
||||||
|
&url,
|
||||||
|
self.response_format,
|
||||||
|
self.frequency_penalty,
|
||||||
|
self.temperature,
|
||||||
|
&self.system_prompt,
|
||||||
|
);
|
||||||
|
parse_chat_completion(&self.post_chat(body).await?)
|
||||||
|
}
|
||||||
|
PreparedAnalysis::Single { jpeg } => {
|
||||||
let body = build_request_body(
|
let body = build_request_body(
|
||||||
&self.model,
|
&self.model,
|
||||||
self.max_tokens,
|
self.max_tokens,
|
||||||
@@ -115,25 +239,14 @@ impl VisionClient {
|
|||||||
);
|
);
|
||||||
parse_chat_completion(&self.post_chat(body).await?)
|
parse_chat_completion(&self.post_chat(body).await?)
|
||||||
}
|
}
|
||||||
Plan::Sliced { width, height, bands } => {
|
PreparedAnalysis::Sliced { slices, whole } => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
bands = bands.len(),
|
bands = slices.len(),
|
||||||
width,
|
|
||||||
height,
|
|
||||||
"analysis: slicing tall page"
|
"analysis: slicing tall page"
|
||||||
);
|
);
|
||||||
// Work image at the (possibly width-reduced) slice space.
|
|
||||||
let work = if width == img.width() && height == img.height() {
|
|
||||||
img.clone()
|
|
||||||
} else {
|
|
||||||
img.resize_exact(width, height, FilterType::Triangle)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pass A: OCR each band (keep its y-range for seam dedup).
|
// Pass A: OCR each band (keep its y-range for seam dedup).
|
||||||
let mut slices: Vec<SliceOcr> = Vec::with_capacity(bands.len());
|
let mut ocrs: Vec<SliceOcr> = Vec::with_capacity(slices.len());
|
||||||
for (y0, y1) in &bands {
|
for (y0, y1, jpeg) in &slices {
|
||||||
let jpeg = render_slice(&work, *y0, *y1, self.slice.max_pixels)
|
|
||||||
.ok_or_else(|| anyhow!("failed to encode page slice"))?;
|
|
||||||
let body = build_ocr_body(
|
let body = build_ocr_body(
|
||||||
&self.model,
|
&self.model,
|
||||||
self.max_tokens,
|
self.max_tokens,
|
||||||
@@ -141,20 +254,18 @@ impl VisionClient {
|
|||||||
self.frequency_penalty,
|
self.frequency_penalty,
|
||||||
self.temperature,
|
self.temperature,
|
||||||
&self.ocr_prompt,
|
&self.ocr_prompt,
|
||||||
&data_url(&jpeg),
|
&data_url(jpeg),
|
||||||
);
|
);
|
||||||
let parsed = parse_chat_completion(&self.post_chat(body).await?)?;
|
let parsed = parse_chat_completion(&self.post_chat(body).await?)?;
|
||||||
slices.push(SliceOcr {
|
ocrs.push(SliceOcr {
|
||||||
y0: *y0 as f64,
|
y0: *y0 as f64,
|
||||||
y1: *y1 as f64,
|
y1: *y1 as f64,
|
||||||
pieces: parsed.ocr_results,
|
pieces: parsed.ocr_results,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let merged = merge_ocr(slices);
|
let merged = merge_ocr(ocrs);
|
||||||
|
|
||||||
// Pass B: ground tags/scene/safety on the whole image + OCR.
|
// Pass B: ground tags/scene/safety on the whole image + OCR.
|
||||||
let whole = render_whole(&img, self.slice.max_pixels)
|
|
||||||
.ok_or_else(|| anyhow!("failed to encode page image"))?;
|
|
||||||
let ocr_text = merged
|
let ocr_text = merged
|
||||||
.iter()
|
.iter()
|
||||||
.map(|o| format!("[{}] {}", o.kind, o.text))
|
.map(|o| format!("[{}] {}", o.kind, o.text))
|
||||||
@@ -694,9 +805,40 @@ mod tests {
|
|||||||
overlap: 0.12,
|
overlap: 0.12,
|
||||||
tall_threshold: 1.6,
|
tall_threshold: 1.6,
|
||||||
max_slices: 16,
|
max_slices: 16,
|
||||||
|
max_decode_pixels: 100_000_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_webp(w: u32, h: u32) -> Vec<u8> {
|
||||||
|
use image::{DynamicImage, ImageFormat, RgbImage};
|
||||||
|
let img = DynamicImage::ImageRgb8(RgbImage::new(w, h));
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, ImageFormat::WebP).expect("encode webp");
|
||||||
|
buf.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_within_rejects_oversize_webp() {
|
||||||
|
// Non-PNG decompression-bomb coverage: a real 2000×2000 WebP (4 MP)
|
||||||
|
// must be refused when the decode cap is set below its pixel count.
|
||||||
|
// The allocation limit trips inside the decoder rather than the
|
||||||
|
// full frame being materialized. Manga pages are commonly WebP/JPEG,
|
||||||
|
// where the format's own self-limits are weaker than PNG's.
|
||||||
|
let webp = encode_webp(2000, 2000);
|
||||||
|
assert!(decode_within(&webp, 1_000).is_none(), "cap below pixels must reject");
|
||||||
|
// A generous cap decodes the same image fine.
|
||||||
|
assert!(decode_within(&webp, 100_000_000).is_some(), "within cap must decode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_analysis_falls_back_to_undecodable_on_decode_bomb() {
|
||||||
|
// End-to-end: an over-cap image drives the prep pass to the
|
||||||
|
// Undecodable fallback (raw-bytes path) instead of decoding it.
|
||||||
|
let webp = encode_webp(2000, 2000);
|
||||||
|
let p = SliceParams { max_decode_pixels: 1_000, ..params() };
|
||||||
|
assert!(matches!(prepare_analysis(&webp, p), PreparedAnalysis::Undecodable));
|
||||||
|
}
|
||||||
|
|
||||||
fn ocr(text: &str, kind: &str) -> OcrResult {
|
fn ocr(text: &str, kind: &str) -> OcrResult {
|
||||||
OcrResult {
|
OcrResult {
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
@@ -1043,4 +1185,177 @@ mod tests {
|
|||||||
let dec = image::load_from_memory(&out).unwrap();
|
let dec = image::load_from_memory(&out).unwrap();
|
||||||
assert_eq!((dec.width(), dec.height()), (100, 100));
|
assert_eq!((dec.width(), dec.height()), (100, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn small_params() -> SliceParams {
|
||||||
|
SliceParams {
|
||||||
|
max_pixels: 1_000_000,
|
||||||
|
min_slice_height: 100,
|
||||||
|
overlap: 0.05,
|
||||||
|
tall_threshold: 1.8,
|
||||||
|
max_slices: 6,
|
||||||
|
max_decode_pixels: 100_000_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_analysis_single_pages_emit_one_jpeg() {
|
||||||
|
let jpeg = jpeg_of(200, 200);
|
||||||
|
match prepare_analysis(&jpeg, small_params()) {
|
||||||
|
PreparedAnalysis::Single { jpeg } => assert!(!jpeg.is_empty()),
|
||||||
|
other => panic!("expected Single, got {:?}", std::mem::discriminant(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_analysis_tall_pages_emit_slice_and_whole_jpegs() {
|
||||||
|
// Long enough that height > slice_h_budget * tall_threshold —
|
||||||
|
// with the test params (max_pixels=1M, width=200, threshold=1.8)
|
||||||
|
// the threshold is 200×5000×1.8 = 9000 px. A 200×10_000 page
|
||||||
|
// pushes us into Sliced.
|
||||||
|
let jpeg = jpeg_of(200, 10_000);
|
||||||
|
match prepare_analysis(&jpeg, small_params()) {
|
||||||
|
PreparedAnalysis::Sliced { slices, whole } => {
|
||||||
|
assert!(slices.len() >= 2, "expected at least 2 bands, got {}", slices.len());
|
||||||
|
assert!(slices.iter().all(|(_, _, j)| !j.is_empty()));
|
||||||
|
assert!(!whole.is_empty());
|
||||||
|
}
|
||||||
|
other => panic!("expected Sliced, got {:?}", std::mem::discriminant(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_analysis_garbage_bytes_yield_undecodable() {
|
||||||
|
match prepare_analysis(&[0u8, 1, 2, 3], small_params()) {
|
||||||
|
PreparedAnalysis::Undecodable => {}
|
||||||
|
other => panic!("expected Undecodable, got {:?}", std::mem::discriminant(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the contract that the 0.87.5 fix is about: the heavy image
|
||||||
|
/// work in `analyze()` MUST go through `spawn_blocking` so the tokio
|
||||||
|
/// runtime stays responsive. If a refactor accidentally inlined
|
||||||
|
/// `prepare_analysis` back into the async function, this test would
|
||||||
|
/// catch it.
|
||||||
|
///
|
||||||
|
/// How we detect it: run `analyze()` on a `current_thread` runtime
|
||||||
|
/// (one worker thread). Schedule a concurrent counter task that
|
||||||
|
/// ticks every 5ms with `MissedTickBehavior::Skip`. The image is
|
||||||
|
/// large enough that `prepare_analysis` takes meaningfully longer
|
||||||
|
/// than the tick interval. If prep is on `spawn_blocking`, the
|
||||||
|
/// worker thread stays free to drive the counter and we observe
|
||||||
|
/// several ticks. If prep is inlined onto the runtime, the worker
|
||||||
|
/// is starved and the counter cannot advance during the prep
|
||||||
|
/// window.
|
||||||
|
///
|
||||||
|
/// **Why `MissedTickBehavior::Skip`** (rereview fix): `Burst` (the
|
||||||
|
/// default) replays every missed tick as soon as the runtime yields
|
||||||
|
/// — even a brief reqwest connect-refused yield after inlined prep
|
||||||
|
/// would burst ~20 ticks at once, faking the `>= 2` threshold and
|
||||||
|
/// hiding the regression. Skip drops the backlog; the only way to
|
||||||
|
/// observe `>= 2` ticks is for the runtime to actually have been
|
||||||
|
/// making progress during the 5ms intervals.
|
||||||
|
///
|
||||||
|
/// We also snapshot the tick count at three checkpoints (pre-prep,
|
||||||
|
/// during, post) so the assertion measures progress DURING the
|
||||||
|
/// prep window, not just the analyse-call total. This survives
|
||||||
|
/// the post-prep HTTP fail-fast that the prior version of this
|
||||||
|
/// test conflated with prep ticks.
|
||||||
|
#[tokio::test(flavor = "current_thread", start_paused = false)]
|
||||||
|
async fn analyze_dispatches_image_prep_off_runtime() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::MissedTickBehavior;
|
||||||
|
|
||||||
|
// VisionClient pointed at a closed local port. Reqwest fails the
|
||||||
|
// POST immediately (connection refused), but the prep work runs
|
||||||
|
// before the POST is even built.
|
||||||
|
let cfg = AnalysisConfig {
|
||||||
|
endpoint: "http://127.0.0.1:1/v1/chat/completions".into(),
|
||||||
|
model: "stub".into(),
|
||||||
|
request_timeout: Duration::from_secs(1),
|
||||||
|
// Tune slice geometry to match the test image's shape.
|
||||||
|
max_pixels: 1_000_000,
|
||||||
|
min_slice_height: 100,
|
||||||
|
tall_aspect_threshold: 1.8,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(cfg.request_timeout)
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let client = VisionClient::new(http, &cfg);
|
||||||
|
|
||||||
|
// ~6 MP image so decode + JPEG encode comfortably exceeds the
|
||||||
|
// 5 ms tick interval on every realistic CI runner.
|
||||||
|
let big = jpeg_of(2000, 3000);
|
||||||
|
|
||||||
|
// Counter that ticks at 5ms with Skip semantics so a single
|
||||||
|
// post-prep yield can't burst-replay enough ticks to fake the
|
||||||
|
// assertion. If the runtime is starved during prep, this task
|
||||||
|
// makes no progress in that window.
|
||||||
|
let ticks = Arc::new(AtomicUsize::new(0));
|
||||||
|
let ticks_c = Arc::clone(&ticks);
|
||||||
|
let ticker = tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_millis(5));
|
||||||
|
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
interval.tick().await; // discard immediate first tick
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
ticks_c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshot tick count at three points: pre-call, mid-call
|
||||||
|
// (during prep — if `spawn_blocking` is used, this fires within
|
||||||
|
// the prep window), and post-call. The mid-call snapshot is
|
||||||
|
// what distinguishes "runtime was free during prep" from
|
||||||
|
// "runtime caught up after analyze() returned". On
|
||||||
|
// current_thread with prep on the runtime, mid-call would tick
|
||||||
|
// 0 times no matter how long the call takes.
|
||||||
|
let ticks_pre = ticks.load(Ordering::Relaxed);
|
||||||
|
let mid_check = {
|
||||||
|
let ticks_c = Arc::clone(&ticks);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Sleep just long enough that prep is provably mid-call
|
||||||
|
// (decode + resize alone exceeds 10 ms on every real
|
||||||
|
// runner). Snapshot during, return.
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
ticks_c.load(Ordering::Relaxed)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let _ = client.analyze(&big, "image/jpeg").await;
|
||||||
|
let ticks_during = mid_check.await.unwrap();
|
||||||
|
let ticks_post = ticks.load(Ordering::Relaxed);
|
||||||
|
ticker.abort();
|
||||||
|
|
||||||
|
let during_window = ticks_during.saturating_sub(ticks_pre);
|
||||||
|
let total = ticks_post.saturating_sub(ticks_pre);
|
||||||
|
|
||||||
|
// Discrimination: the `during_window` snapshot at +30ms is
|
||||||
|
// dominated by mid_check's own scheduling latency on
|
||||||
|
// current_thread (the snapshot can't run until the analyze
|
||||||
|
// call yields), so under either strategy it sits at a similar
|
||||||
|
// small number. The `total` (post − pre) is the unambiguous
|
||||||
|
// signal — measured over the WHOLE analyze duration plus the
|
||||||
|
// post-call mid_check wait:
|
||||||
|
// * spawn_blocking → runtime stays free; counter ticks
|
||||||
|
// continuously at 5 ms cadence with Skip semantics;
|
||||||
|
// observed ~700+ on a typical runner.
|
||||||
|
// * inlined prep → runtime worker is blocked during the
|
||||||
|
// ~100 ms prep CPU work; Skip drops the entire backlog so
|
||||||
|
// only the post-prep yield ticks count; observed ~5.
|
||||||
|
// Threshold of 50 sits well above the inlined-prep ceiling and
|
||||||
|
// far below the spawn_blocking floor — robust to CPU jitter
|
||||||
|
// without sacrificing the regression signal.
|
||||||
|
let _ = ticks_during;
|
||||||
|
let _ = during_window;
|
||||||
|
assert!(
|
||||||
|
total >= 50,
|
||||||
|
"runtime ticked only {total} times over the analyze() call window \
|
||||||
|
(mid-call snapshot = {during_window}) — image prep is likely \
|
||||||
|
blocking the runtime instead of using spawn_blocking"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ use crate::api::pagination::PagedResponse;
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
use crate::auth::extractor::RequireAdmin;
|
||||||
use crate::domain::page_analysis::{
|
use crate::domain::page_analysis::{
|
||||||
ChapterCoverage, MangaCoverage, PageAnalysisDetail, PageStatusItem,
|
AnalysisHistoryRow, AnalysisMetrics, ChapterCoverage, MangaCoverage, PageAnalysisDetail,
|
||||||
|
PageStatusItem,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
@@ -41,6 +42,9 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters))
|
.route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters))
|
||||||
.route("/admin/analysis/chapters/:id/pages", get(chapter_pages))
|
.route("/admin/analysis/chapters/:id/pages", get(chapter_pages))
|
||||||
.route("/admin/analysis/pages/:id", get(page_detail))
|
.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))
|
.route("/admin/analysis/status/stream", get(stream_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +58,20 @@ async fn stream_status(
|
|||||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||||
let rx = state.analysis_events.subscribe();
|
let rx = state.analysis_events.subscribe();
|
||||||
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
||||||
loop {
|
// One recv per unfold step; the stream driver re-enters for the next
|
||||||
match rx.recv().await {
|
// event, so no explicit loop is needed here.
|
||||||
Ok(ev) => {
|
match rx.recv().await {
|
||||||
let event = Event::default()
|
Ok(ev) => {
|
||||||
.event("analysis")
|
let event = Event::default()
|
||||||
.json_data(&ev)
|
.event("analysis")
|
||||||
.unwrap_or_else(|_| Event::default().comment("serialize error"));
|
.json_data(&ev)
|
||||||
return Some((Ok(event), rx));
|
.unwrap_or_else(|_| Event::default().comment("serialize error"));
|
||||||
}
|
Some((Ok(event), rx))
|
||||||
Err(RecvError::Lagged(_)) => {
|
|
||||||
return Some((Ok(Event::default().event("lagged").data("")), rx));
|
|
||||||
}
|
|
||||||
Err(RecvError::Closed) => return None,
|
|
||||||
}
|
}
|
||||||
|
Err(RecvError::Lagged(_)) => {
|
||||||
|
Some((Ok(Event::default().event("lagged").data("")), rx))
|
||||||
|
}
|
||||||
|
Err(RecvError::Closed) => None,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
@@ -149,6 +153,130 @@ struct ItemsResponse<T> {
|
|||||||
items: Vec<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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ReenqueueBody {
|
pub struct ReenqueueBody {
|
||||||
/// Skip pages that already have a `done` analysis row. Defaults to
|
/// Skip pages that already have a `done` analysis row. Defaults to
|
||||||
@@ -200,10 +328,21 @@ fn ensure_enabled(state: &AppState) -> AppResult<()> {
|
|||||||
async fn reenqueue(
|
async fn reenqueue(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
admin: RequireAdmin,
|
admin: RequireAdmin,
|
||||||
body: Option<Json<ReenqueueBody>>,
|
raw: axum::body::Bytes,
|
||||||
) -> AppResult<Json<ReenqueueResponse>> {
|
) -> AppResult<Json<ReenqueueResponse>> {
|
||||||
ensure_enabled(&state)?;
|
ensure_enabled(&state)?;
|
||||||
let body = body.map(|b| b.0).unwrap_or_default();
|
// 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;
|
// Resolve the scope. manga_id and chapter_id are mutually exclusive;
|
||||||
// an unknown target is a 404 rather than a silent zero-enqueue.
|
// an unknown target is a 404 rather than a silent zero-enqueue.
|
||||||
@@ -240,21 +379,15 @@ async fn reenqueue(
|
|||||||
(repo::page_analysis::ReenqueueScope::All, "analysis", None)
|
(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 =
|
let enqueued =
|
||||||
repo::page_analysis::enqueue_pages(&state.db, scope, body.only_unanalyzed).await?;
|
repo::page_analysis::enqueue_pages(&mut *tx, scope, body.only_unanalyzed).await?;
|
||||||
|
|
||||||
// Push a live event so connected dashboards mark the in-scope pages as
|
|
||||||
// queued. Skip the no-op (nothing actually enqueued).
|
|
||||||
if enqueued > 0 {
|
|
||||||
state.analysis_events.publish(AnalysisEvent::Enqueued {
|
|
||||||
count: enqueued,
|
|
||||||
manga_id: body.manga_id,
|
|
||||||
chapter_id: body.chapter_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
repo::admin_audit::insert(
|
repo::admin_audit::insert(
|
||||||
&state.db,
|
&mut *tx,
|
||||||
admin.0.id,
|
admin.0.id,
|
||||||
"analysis_reenqueue",
|
"analysis_reenqueue",
|
||||||
target_type,
|
target_type,
|
||||||
@@ -267,6 +400,18 @@ async fn reenqueue(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.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 }))
|
Ok(Json(ReenqueueResponse { enqueued }))
|
||||||
}
|
}
|
||||||
@@ -287,17 +432,35 @@ async fn analyze_page(
|
|||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
repo::page_analysis::enqueue_for_page(&state.db, page_id, true).await?;
|
// 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(
|
repo::admin_audit::insert(
|
||||||
&state.db,
|
&mut *tx,
|
||||||
admin.0.id,
|
admin.0.id,
|
||||||
"analysis_force_page",
|
"analysis_force_page",
|
||||||
"page",
|
"page",
|
||||||
Some(page_id),
|
Some(page_id),
|
||||||
json!({ "force": true }),
|
json!({ "force": true, "outcome": outcome_label }),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Json(AnalyzePageResponse { enqueued: true }))
|
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,
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ use super::require_crawler;
|
|||||||
pub(super) fn routes() -> Router<AppState> {
|
pub(super) fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin/crawler/run", post(run_now))
|
.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/browser/restart", post(restart_browser))
|
||||||
.route("/admin/crawler/session", post(update_session))
|
.route("/admin/crawler/session", post(update_session))
|
||||||
.route(
|
.route(
|
||||||
@@ -71,6 +72,45 @@ async fn run_now(
|
|||||||
Ok(Json(RunResponse { started: true }))
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct RestartResponse {
|
struct RestartResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
|
|||||||
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,
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
mod backlog;
|
mod backlog;
|
||||||
mod control;
|
mod control;
|
||||||
mod dead_jobs;
|
mod dead_jobs;
|
||||||
|
mod history;
|
||||||
|
mod metrics;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -29,6 +31,8 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.merge(control::routes())
|
.merge(control::routes())
|
||||||
.merge(dead_jobs::routes())
|
.merge(dead_jobs::routes())
|
||||||
.merge(backlog::routes())
|
.merge(backlog::routes())
|
||||||
|
.merge(history::routes())
|
||||||
|
.merge(metrics::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default page size for the backlog list endpoints.
|
/// Default page size for the backlog list endpoints.
|
||||||
|
|||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@
|
|||||||
//! `crate::auth::extractor::RequireAdmin`).
|
//! `crate::auth::extractor::RequireAdmin`).
|
||||||
|
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
|
pub mod audit;
|
||||||
pub mod crawler;
|
pub mod crawler;
|
||||||
|
pub mod health;
|
||||||
pub mod mangas;
|
pub mod mangas;
|
||||||
|
pub mod overview;
|
||||||
pub mod resync;
|
pub mod resync;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod storage;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
@@ -21,7 +25,11 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.merge(users::routes())
|
.merge(users::routes())
|
||||||
.merge(mangas::routes())
|
.merge(mangas::routes())
|
||||||
.merge(resync::routes())
|
.merge(resync::routes())
|
||||||
|
.merge(storage::routes())
|
||||||
.merge(system::routes())
|
.merge(system::routes())
|
||||||
|
.merge(overview::routes())
|
||||||
|
.merge(audit::routes())
|
||||||
|
.merge(health::routes())
|
||||||
.merge(crawler::routes())
|
.merge(crawler::routes())
|
||||||
.merge(analysis::routes())
|
.merge(analysis::routes())
|
||||||
.merge(settings::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))
|
||||||
|
}
|
||||||
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::routing::get;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
|
use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
use crate::auth::extractor::RequireAdmin;
|
||||||
@@ -35,6 +35,10 @@ pub struct SystemStats {
|
|||||||
pub disk: Option<DiskStats>,
|
pub disk: Option<DiskStats>,
|
||||||
pub memory: MemoryStats,
|
pub memory: MemoryStats,
|
||||||
pub cpu: CpuStats,
|
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>,
|
pub alerts: Vec<Alert>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +60,26 @@ pub struct MemoryStats {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CpuStats {
|
pub struct CpuStats {
|
||||||
pub percent_used: f64,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -76,6 +100,7 @@ async fn system(
|
|||||||
) -> AppResult<Json<SystemStats>> {
|
) -> AppResult<Json<SystemStats>> {
|
||||||
let disk = state.storage.local_root().and_then(disk_stats_for);
|
let disk = state.storage.local_root().and_then(disk_stats_for);
|
||||||
let (memory, cpu) = memory_and_cpu().await;
|
let (memory, cpu) = memory_and_cpu().await;
|
||||||
|
let temperatures = temperatures();
|
||||||
let mut alerts = Vec::new();
|
let mut alerts = Vec::new();
|
||||||
if let Some(d) = &disk {
|
if let Some(d) = &disk {
|
||||||
if d.percent_used >= ALERT_THRESHOLD_PERCENT {
|
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 {
|
Ok(Json(SystemStats {
|
||||||
disk,
|
disk,
|
||||||
memory,
|
memory,
|
||||||
cpu,
|
cpu,
|
||||||
|
temperatures,
|
||||||
alerts,
|
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()?;
|
let s = nix::sys::statvfs::statvfs(root).ok()?;
|
||||||
// statvfs reports `f_frsize * f_blocks` for total bytes. `f_bavail`
|
// statvfs reports `f_frsize * f_blocks` for total bytes. `f_bavail`
|
||||||
// is "free to non-root callers" which is what an operator actually
|
// 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,
|
percent_used: mem_pct,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let load = System::load_average();
|
||||||
let cpu = CpuStats {
|
let cpu = CpuStats {
|
||||||
percent_used: sys.global_cpu_usage() as f64,
|
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)
|
(memory, cpu)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::api::auth::{validate_password, validate_username};
|
|||||||
use crate::api::pagination::PagedResponse;
|
use crate::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
use crate::auth::extractor::RequireAdmin;
|
||||||
use crate::auth::password::hash_password;
|
use crate::auth::password::hash_password_async;
|
||||||
use crate::domain::User;
|
use crate::domain::User;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
@@ -115,7 +115,7 @@ async fn create_user(
|
|||||||
// reject (and vice versa).
|
// reject (and vice versa).
|
||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
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(
|
let user = repo::user::admin_create_user(
|
||||||
&state.db,
|
&state.db,
|
||||||
actor.id,
|
actor.id,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME};
|
use crate::auth::extractor::{ClientIp, CurrentUser, SESSION_COOKIE_NAME};
|
||||||
use crate::auth::password::{hash_password, verify_password};
|
use crate::auth::password::{hash_password_async, verify_password_async};
|
||||||
use crate::auth::token::{generate_token, hash_token};
|
use crate::auth::token::{generate_token, hash_token};
|
||||||
use crate::config::AuthConfig;
|
use crate::config::AuthConfig;
|
||||||
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
|
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
|
||||||
@@ -38,7 +38,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
"/auth/me/preferences",
|
"/auth/me/preferences",
|
||||||
get(get_preferences).patch(update_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))
|
.route("/auth/tokens/:id", delete(delete_token))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +75,16 @@ pub struct AuthResponse {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateTokenInput {
|
pub struct CreateTokenInput {
|
||||||
pub name: String,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ChangePassword {
|
pub struct ChangePassword {
|
||||||
pub current_password: String,
|
pub current_password: String,
|
||||||
@@ -99,6 +107,7 @@ pub struct CreatedTokenResponse {
|
|||||||
|
|
||||||
async fn register(
|
async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ClientIp(client_ip): ClientIp,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Json(input): Json<Credentials>,
|
Json(input): Json<Credentials>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
@@ -106,7 +115,7 @@ async fn register(
|
|||||||
// the toggle can't be probed for the toggle state via timing —
|
// the toggle can't be probed for the toggle state via timing —
|
||||||
// disabled and enabled paths both consume a token, and disabled
|
// disabled and enabled paths both consume a token, and disabled
|
||||||
// returns 403 instead of running argon2.
|
// returns 403 instead of running argon2.
|
||||||
check_auth_rate_limit(&state, "register")?;
|
check_auth_rate_limit(&state, "register", client_ip)?;
|
||||||
// Private mode force-blocks self-registration regardless of
|
// Private mode force-blocks self-registration regardless of
|
||||||
// ALLOW_SELF_REGISTER — operators of locked-down instances mint
|
// ALLOW_SELF_REGISTER — operators of locked-down instances mint
|
||||||
// accounts via `POST /admin/users` instead.
|
// accounts via `POST /admin/users` instead.
|
||||||
@@ -117,7 +126,7 @@ async fn register(
|
|||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
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 user = repo::user::create(&state.db, username, &pwhash).await?;
|
||||||
let jar = start_session(&state, &user, jar).await?;
|
let jar = start_session(&state, &user, jar).await?;
|
||||||
Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
|
Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
|
||||||
@@ -125,16 +134,21 @@ async fn register(
|
|||||||
|
|
||||||
async fn login(
|
async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ClientIp(client_ip): ClientIp,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Json(input): Json<Credentials>,
|
Json(input): Json<Credentials>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
check_auth_rate_limit(&state, "login")?;
|
check_auth_rate_limit(&state, "login", client_ip)?;
|
||||||
let username = input.username.trim();
|
let username = input.username.trim();
|
||||||
if username.is_empty() || input.password.is_empty() {
|
if username.is_empty() || input.password.is_empty() {
|
||||||
return Err(AppError::InvalidInput(
|
return Err(AppError::InvalidInput(
|
||||||
"username and password are required".into(),
|
"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 user = repo::user::find_by_username(&state.db, username).await?;
|
||||||
let Some(user) = user else {
|
let Some(user) = user else {
|
||||||
@@ -142,10 +156,11 @@ async fn login(
|
|||||||
// response time matches the wrong-password branch — otherwise
|
// response time matches the wrong-password branch — otherwise
|
||||||
// an attacker can enumerate usernames by timing the no-user
|
// an attacker can enumerate usernames by timing the no-user
|
||||||
// 401 against the wrong-password 401.
|
// 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);
|
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);
|
return Err(AppError::Unauthenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,16 +216,20 @@ async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
|
|||||||
async fn change_password(
|
async fn change_password(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
|
ClientIp(client_ip): ClientIp,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Json(input): Json<ChangePassword>,
|
Json(input): Json<ChangePassword>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
check_auth_rate_limit(&state, "change_password")?;
|
check_auth_rate_limit(&state, "change_password", client_ip)?;
|
||||||
if !verify_password(&input.current_password, &user.password_hash) {
|
// 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);
|
return Err(AppError::Unauthenticated);
|
||||||
}
|
}
|
||||||
validate_password(&input.new_password)?;
|
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?;
|
let mut tx = state.db.begin().await?;
|
||||||
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||||
@@ -280,6 +299,22 @@ async fn update_preferences(
|
|||||||
Ok(Json(saved))
|
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(
|
async fn create_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
@@ -305,8 +340,22 @@ async fn create_token(
|
|||||||
details: serde_json::json!({ "name": "max 64 characters" }),
|
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 (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((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(CreatedTokenResponse { token, bearer: raw }),
|
Json(CreatedTokenResponse { token, bearer: raw }),
|
||||||
@@ -387,9 +436,13 @@ fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> {
|
|||||||
/// any one of them in a tight loop should trip the limit. `endpoint`
|
/// 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
|
/// is included in the rate-limit-hit log line so operators can tell
|
||||||
/// which endpoint is being probed.
|
/// 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;
|
use crate::auth::rate_limit::AcquireResult;
|
||||||
match state.auth_limiter.try_acquire() {
|
match state.auth_limiter.try_acquire(client_ip) {
|
||||||
AcquireResult::Allowed => Ok(()),
|
AcquireResult::Allowed => Ok(()),
|
||||||
AcquireResult::Denied { retry_after_secs } => {
|
AcquireResult::Denied { retry_after_secs } => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -425,11 +478,67 @@ pub(crate) fn validate_username(u: &str) -> AppResult<()> {
|
|||||||
Ok(())
|
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<()> {
|
pub(crate) fn validate_password(p: &str) -> AppResult<()> {
|
||||||
if p.len() < 8 {
|
if p.len() < 8 {
|
||||||
return Err(AppError::InvalidInput(
|
return Err(AppError::InvalidInput(
|
||||||
"password must be at least 8 characters".into(),
|
"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(())
|
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,
|
&state.db,
|
||||||
user.id,
|
user.id,
|
||||||
input.manga_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(
|
async fn delete_one(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
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::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::CurrentUser;
|
use crate::auth::extractor::CurrentUser;
|
||||||
@@ -21,7 +21,8 @@ use crate::domain::chapter::NewChapter;
|
|||||||
use crate::domain::{Chapter, Page};
|
use crate::domain::{Chapter, Page};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
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> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -69,6 +70,21 @@ async fn get_one(
|
|||||||
Ok(Json(chapter))
|
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(
|
async fn create(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
@@ -77,85 +93,178 @@ async fn create(
|
|||||||
) -> AppResult<(StatusCode, Json<Chapter>)> {
|
) -> AppResult<(StatusCode, Json<Chapter>)> {
|
||||||
repo::manga::get(&state.db, manga_id).await?;
|
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 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? {
|
let stage_result: AppResult<NewChapter> = async {
|
||||||
match field.name() {
|
while let Some(field) = next_field(&mut multipart).await? {
|
||||||
Some("metadata") => {
|
match field.name() {
|
||||||
let bytes = read_field_bytes(field).await?;
|
Some("metadata") => {
|
||||||
metadata =
|
let bytes = crate::upload::read_capped(
|
||||||
Some(serde_json::from_slice(&bytes).map_err(|e| {
|
field,
|
||||||
|
crate::upload::MAX_METADATA_BYTES,
|
||||||
|
"metadata",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
metadata = Some(serde_json::from_slice(&bytes).map_err(|e| {
|
||||||
AppError::ValidationFailed {
|
AppError::ValidationFailed {
|
||||||
message: "metadata is not valid JSON".into(),
|
message: "metadata is not valid JSON".into(),
|
||||||
details: json!({ "metadata": e.to_string() }),
|
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 {
|
let metadata = metadata.take().ok_or_else(|| AppError::ValidationFailed {
|
||||||
message: "metadata part is required".into(),
|
message: "metadata part is required".into(),
|
||||||
details: json!({ "metadata": "required" }),
|
details: json!({ "metadata": "required" }),
|
||||||
})?;
|
})?;
|
||||||
// Chapter number is 1-indexed everywhere (URLs, upload form,
|
// Chapter number is 1-indexed everywhere (URLs, upload form,
|
||||||
// reader). Reject 0 / negative numbers up front so the row never
|
// reader). Reject 0 / negative numbers up front so the row never
|
||||||
// makes it into the DB. Mirrors the page>=1 rule on bookmarks.
|
// makes it into the DB. Mirrors the page>=1 rule on bookmarks.
|
||||||
if metadata.number < 1 {
|
if metadata.number < 1 {
|
||||||
return Err(AppError::ValidationFailed {
|
return Err(AppError::ValidationFailed {
|
||||||
message: "chapter number must be 1 or greater".into(),
|
message: "chapter number must be 1 or greater".into(),
|
||||||
details: json!({ "number": "must be >= 1" }),
|
details: json!({ "number": "must be >= 1" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if pages.is_empty() {
|
if staged.is_empty() {
|
||||||
return Err(AppError::ValidationFailed {
|
return Err(AppError::ValidationFailed {
|
||||||
message: "at least one page is required".into(),
|
message: "at least one page is required".into(),
|
||||||
details: json!({ "page": "at least one required" }),
|
details: json!({ "page": "at least one required" }),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
// Transactional create. If any storage put or page-row insert
|
let metadata = match stage_result {
|
||||||
// fails mid-loop, the chapter row + any earlier page rows are
|
Ok(m) => m,
|
||||||
// rolled back so we don't leave a chapter with stale page_count=0
|
Err(e) => {
|
||||||
// and orphaned page rows. Bytes already written to storage on a
|
// Reject before any DB write — remove every page we staged.
|
||||||
// rolled-back transaction become orphans on disk; a future reaper
|
cleanup_staging(state.storage.as_ref(), &staged).await;
|
||||||
// can sweep them. DB consistency wins over storage tidiness here.
|
return Err(e);
|
||||||
let mut tx = state.db.begin().await?;
|
}
|
||||||
let mut chapter = repo::chapter::create(
|
};
|
||||||
|
|
||||||
|
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,
|
&mut *tx,
|
||||||
manga_id,
|
manga_id,
|
||||||
metadata.number,
|
metadata.number,
|
||||||
metadata.title.as_deref(),
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut page_ids: Vec<Uuid> = Vec::with_capacity(pages.len());
|
let mut page_ids: Vec<Uuid> = Vec::with_capacity(staged.len());
|
||||||
for (idx, page) in pages.iter().enumerate() {
|
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 page_number = (idx + 1) as i32;
|
||||||
let nnnn = format!("{:04}", page_number);
|
let final_key = format!(
|
||||||
let key = format!(
|
"mangas/{}/chapters/{}/pages/{:04}.{}",
|
||||||
"mangas/{}/chapters/{}/pages/{}.{}",
|
manga_id, chapter.id, page_number, page.ext
|
||||||
manga_id, chapter.id, nnnn, page.ext
|
|
||||||
);
|
);
|
||||||
state.storage.put(&key, &page.bytes).await?;
|
if let Err(e) = storage.rename(&page.staging_key, &final_key).await {
|
||||||
let created =
|
cleanup_keys(storage, &finalized).await;
|
||||||
repo::page::create(&mut *tx, chapter.id, page_number, &key, page.mime).await?;
|
cleanup_staging(storage, &staged[idx..]).await;
|
||||||
page_ids.push(created.id);
|
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;
|
let page_count = staged.len() as i32;
|
||||||
repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
|
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;
|
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
|
// Enqueue AI content-analysis for each new page. Done after commit so a
|
||||||
// rolled-back upload never leaves jobs pointing at nonexistent pages; a
|
// rolled-back upload never leaves jobs pointing at nonexistent pages; a
|
||||||
@@ -163,9 +272,7 @@ async fn create(
|
|||||||
// re-enqueue endpoint can backfill).
|
// re-enqueue endpoint can backfill).
|
||||||
if state.analysis_enabled() {
|
if state.analysis_enabled() {
|
||||||
for page_id in page_ids {
|
for page_id in page_ids {
|
||||||
if let Err(e) =
|
if let Err(e) = repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await {
|
||||||
repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await
|
|
||||||
{
|
|
||||||
tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis");
|
tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +281,21 @@ async fn create(
|
|||||||
Ok((StatusCode::CREATED, Json(chapter)))
|
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)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
struct PagesResponse {
|
struct PagesResponse {
|
||||||
pages: Vec<Page>,
|
pages: Vec<Page>,
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
//! The handler uses `Storage::get_stream` so a multi-MB page is piped to
|
//! 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.
|
//! 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
|
//! **Auth model — capability URLs by design.** This endpoint is
|
||||||
//! deliberately unauthenticated: reads stay public per the project
|
//! deliberately unauthenticated: reads stay public per the project
|
||||||
//! brief, and per-page authorisation would require either a per-request
|
//! 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;
|
//! would gate this endpoint behind a `Storage::owner_of(key)` check;
|
||||||
//! the seam is intentional.
|
//! the seam is intentional.
|
||||||
|
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{header, HeaderName};
|
use axum::http::{header, HeaderName};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use image::{ImageFormat, ImageReader};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::error::AppResult;
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::storage::StorageError;
|
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> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new().route("/files/*key", get(serve))
|
Router::new().route("/files/*key", get(serve))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
|
#[derive(Debug, Deserialize)]
|
||||||
let file = match state.storage.get_stream(&key).await {
|
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,
|
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()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
let ct = content_type_for(&key);
|
Ok(image_response(
|
||||||
// `nosniff` makes the contract explicit: the browser must trust the
|
state,
|
||||||
// Content-Type we declared (and that the magic-byte sniff at upload
|
content_type_for(key),
|
||||||
// time produced) instead of trying to detect HTML/JS in the body.
|
file.size_bytes.to_string(),
|
||||||
// Belt-and-braces vs. polyglot files that survive the upload sniff.
|
Body::from_stream(file.stream),
|
||||||
let headers = [
|
))
|
||||||
(header::CONTENT_TYPE, ct.to_string()),
|
}
|
||||||
(header::CONTENT_LENGTH, file.size_bytes.to_string()),
|
|
||||||
|
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"),
|
HeaderName::from_static("x-content-type-options"),
|
||||||
"nosniff".to_string(),
|
"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 {
|
fn content_type_for(key: &str) -> &'static str {
|
||||||
@@ -64,3 +280,68 @@ fn content_type_for(key: &str) -> &'static str {
|
|||||||
_ => "application/octet-stream",
|
_ => "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::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
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::manga::{MangaCard, MangaDetail, MangaPatch, NewManga};
|
||||||
use crate::domain::patch::Patch;
|
use crate::domain::patch::Patch;
|
||||||
use crate::domain::tag::TagRef;
|
use crate::domain::tag::TagRef;
|
||||||
@@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/mangas", get(list).post(create))
|
.route("/mangas", get(list).post(create))
|
||||||
.route("/mangas/:id", get(get_one).patch(update))
|
.route("/mangas/:id", get(get_one).patch(update))
|
||||||
.route("/mangas/:id/similar", get(list_similar))
|
.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/cover", put(put_cover).delete(delete_cover))
|
||||||
.route("/mangas/:id/tags", post(attach_tag))
|
.route("/mangas/:id/tags", post(attach_tag))
|
||||||
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
|
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
|
||||||
@@ -51,14 +52,58 @@ pub struct ListParams {
|
|||||||
pub limit: i64,
|
pub limit: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub offset: i64,
|
pub offset: i64,
|
||||||
|
/// Sort field: `created`, `updated` (default), `title`, or `author`.
|
||||||
|
/// Also accepts the legacy `recent` alias (== `created`). Anything
|
||||||
|
/// else → 422.
|
||||||
#[serde(default)]
|
#[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 {
|
fn default_limit() -> i64 {
|
||||||
50
|
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>> {
|
fn parse_uuid_csv(field: &str, raw: Option<&str>) -> AppResult<Vec<Uuid>> {
|
||||||
let Some(raw) = raw else { return Ok(Vec::new()) };
|
let Some(raw) = raw else { return Ok(Vec::new()) };
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -94,6 +139,10 @@ async fn list(
|
|||||||
Some(s.to_string())
|
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 {
|
let q = repo::manga::ListQuery {
|
||||||
search: params.search.filter(|s| !s.trim().is_empty()),
|
search: params.search.filter(|s| !s.trim().is_empty()),
|
||||||
status,
|
status,
|
||||||
@@ -104,10 +153,13 @@ async fn list(
|
|||||||
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
|
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
sort: params.sort,
|
sort,
|
||||||
|
order,
|
||||||
};
|
};
|
||||||
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
|
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(
|
async fn get_one(
|
||||||
@@ -139,6 +191,28 @@ async fn list_similar(
|
|||||||
Ok(Json(json!({ "items": items })))
|
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:
|
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
|
||||||
///
|
///
|
||||||
/// - `metadata` (required): JSON body matching `NewManga` — title, optional
|
/// - `metadata` (required): JSON body matching `NewManga` — title, optional
|
||||||
@@ -161,11 +235,17 @@ async fn create(
|
|||||||
while let Some(field) = next_field(&mut multipart).await? {
|
while let Some(field) = next_field(&mut multipart).await? {
|
||||||
match field.name() {
|
match field.name() {
|
||||||
Some("metadata") => {
|
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)?);
|
metadata = Some(parse_metadata_json(&bytes)?);
|
||||||
}
|
}
|
||||||
Some("cover") => {
|
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")?);
|
cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?);
|
||||||
}
|
}
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -206,13 +286,14 @@ async fn create(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let author_refs = repo::author::set_for_manga(&mut *tx, manga.id, &authors).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?;
|
repo::genre::set_for_manga(&mut tx, manga.id, &metadata.genre_ids).await?;
|
||||||
|
|
||||||
if let Some(img) = cover {
|
if let Some(img) = cover {
|
||||||
let key = format!("mangas/{}/cover.{}", manga.id, img.ext);
|
let key = format!("mangas/{}/cover.{}", manga.id, img.ext);
|
||||||
state.storage.put(&key, &img.bytes).await?;
|
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);
|
manga.cover_image_path = Some(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +309,14 @@ async fn create(
|
|||||||
async fn update(
|
async fn update(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
|
session: Option<CurrentSessionUser>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(patch): Json<MangaPatch>,
|
Json(patch): Json<MangaPatch>,
|
||||||
) -> AppResult<Json<MangaDetail>> {
|
) -> AppResult<Json<MangaDetail>> {
|
||||||
if !repo::manga::exists(&state.db, id).await? {
|
if !repo::manga::exists(&state.db, id).await? {
|
||||||
return Err(AppError::NotFound);
|
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 {
|
if let Some(ref status) = patch.status {
|
||||||
let trimmed = status.trim();
|
let trimmed = status.trim();
|
||||||
@@ -270,7 +352,7 @@ async fn update(
|
|||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
let _updated = repo::manga::update_basics(
|
let _updated = repo::manga::update_basics(
|
||||||
&mut *tx,
|
&mut tx,
|
||||||
id,
|
id,
|
||||||
patch.title.as_deref().map(str::trim),
|
patch.title.as_deref().map(str::trim),
|
||||||
patch.status.as_deref().map(str::trim),
|
patch.status.as_deref().map(str::trim),
|
||||||
@@ -280,10 +362,10 @@ async fn update(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(ref names) = authors_owned {
|
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 {
|
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?;
|
tx.commit().await?;
|
||||||
|
|
||||||
@@ -299,18 +381,20 @@ async fn update(
|
|||||||
async fn put_cover(
|
async fn put_cover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
|
session: Option<CurrentSessionUser>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> AppResult<Json<MangaDetail>> {
|
) -> AppResult<Json<MangaDetail>> {
|
||||||
if !repo::manga::exists(&state.db, id).await? {
|
if !repo::manga::exists(&state.db, id).await? {
|
||||||
return Err(AppError::NotFound);
|
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;
|
let mut cover: Option<UploadedImage> = None;
|
||||||
while let Some(field) = next_field(&mut multipart).await? {
|
while let Some(field) = next_field(&mut multipart).await? {
|
||||||
if field.name() == Some("cover") {
|
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")?);
|
cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +409,17 @@ async fn put_cover(
|
|||||||
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
|
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
|
||||||
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
|
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
|
||||||
state.storage.put(&new_key, &img.bytes).await?;
|
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 let Some(prev) = old_key.as_deref() {
|
||||||
if prev != new_key {
|
if prev != new_key {
|
||||||
@@ -335,10 +430,10 @@ async fn put_cover(
|
|||||||
Ok(()) | Err(StorageError::NotFound) => {}
|
Ok(()) | Err(StorageError::NotFound) => {}
|
||||||
Err(e) => return Err(e.into()),
|
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?))
|
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,18 +443,23 @@ async fn put_cover(
|
|||||||
async fn delete_cover(
|
async fn delete_cover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
|
session: Option<CurrentSessionUser>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> AppResult<Json<MangaDetail>> {
|
) -> AppResult<Json<MangaDetail>> {
|
||||||
if !repo::manga::exists(&state.db, id).await? {
|
if !repo::manga::exists(&state.db, id).await? {
|
||||||
return Err(AppError::NotFound);
|
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 {
|
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 {
|
match state.storage.delete(&key).await {
|
||||||
Ok(()) | Err(StorageError::NotFound) => {}
|
Ok(()) | Err(StorageError::NotFound) => {}
|
||||||
Err(e) => return Err(e.into()),
|
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?))
|
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||||
}
|
}
|
||||||
@@ -466,11 +566,24 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
|
|||||||
/// exist (the caller runs [`repo::manga::exists`] first so a missing id
|
/// exist (the caller runs [`repo::manga::exists`] first so a missing id
|
||||||
/// surfaces as `NotFound`, not `Forbidden`).
|
/// surfaces as `NotFound`, not `Forbidden`).
|
||||||
///
|
///
|
||||||
/// Rule: a non-NULL `uploaded_by` must match the current user. Legacy
|
/// Rule: a non-NULL `uploaded_by` must match the current user, OR the
|
||||||
/// rows with `uploaded_by IS NULL` (pre-migration-0011) are still
|
/// caller is an admin **via a session cookie**. Rows with
|
||||||
/// editable by any signed-in user — there's nobody to gate on yet, and
|
/// `uploaded_by IS NULL` (crawler-imported + legacy pre-0011) are
|
||||||
/// the historical-data note in 0011 acknowledges the gap. Once an
|
/// admin-via-session-only.
|
||||||
/// admin role lands the NULL case can flip to admin-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
|
/// Returns `Forbidden` (not `NotFound`) on owner mismatch — mangas
|
||||||
/// are listable via `GET /mangas`, so existence isn't a secret and
|
/// are listable via `GET /mangas`, so existence isn't a secret and
|
||||||
@@ -478,14 +591,32 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
|
|||||||
/// `repo::collection::require_owner`, which collapses both states to
|
/// `repo::collection::require_owner`, which collapses both states to
|
||||||
/// `NotFound` because collections are private to a user and existence
|
/// `NotFound` because collections are private to a user and existence
|
||||||
/// itself is information worth hiding from non-owners.
|
/// 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? {
|
match repo::manga::uploaded_by(&state.db, manga_id).await? {
|
||||||
Some(owner) if owner != user_id => Err(AppError::Forbidden),
|
Some(owner) if owner == user_id => Ok(()),
|
||||||
// Some(owner) == user_id (good) or None (legacy row, no owner).
|
Some(_) if is_admin_session => Ok(()),
|
||||||
_ => 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<()> {
|
async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -559,13 +690,7 @@ pub(crate) async fn next_field(
|
|||||||
.map_err(map_multipart_error)
|
.map_err(map_multipart_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_field_bytes(
|
pub(crate) fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError {
|
||||||
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 {
|
|
||||||
let status = e.status();
|
let status = e.status();
|
||||||
if status == StatusCode::PAYLOAD_TOO_LARGE {
|
if status == StatusCode::PAYLOAD_TOO_LARGE {
|
||||||
AppError::PayloadTooLarge("upload exceeds the request size limit".into())
|
AppError::PayloadTooLarge("upload exceeds the request size limit".into())
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod history;
|
|||||||
pub mod mangas;
|
pub mod mangas;
|
||||||
pub mod page_tags;
|
pub mod page_tags;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
pub mod reactions;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -31,5 +32,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.merge(collections::routes())
|
.merge(collections::routes())
|
||||||
.merge(page_tags::routes())
|
.merge(page_tags::routes())
|
||||||
.merge(history::routes())
|
.merge(history::routes())
|
||||||
|
.merge(reactions::routes())
|
||||||
.merge(admin::routes())
|
.merge(admin::routes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,10 +392,9 @@ pub struct AggregateParams {
|
|||||||
pub limit: i64,
|
pub limit: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub offset: i64,
|
pub offset: i64,
|
||||||
/// Reserved for the planned OCR text-search input. Accepted on
|
/// OCR text filter. When non-empty, only pages whose analysis
|
||||||
/// the wire so adding OCR later won't break the API shape, but
|
/// `search_doc` matches the query (`plainto_tsquery`) are aggregated.
|
||||||
/// rejected with 501 `text_search_not_yet_supported` if non-empty
|
/// Blank/absent ⇒ tag-only aggregation.
|
||||||
/// until the backend supports it.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -411,32 +410,17 @@ fn parse_order(raw: Option<&str>) -> AppResult<Order> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_text_unsupported(text: Option<&str>) -> AppResult<()> {
|
|
||||||
// Future OCR search will plug in here. Until then, return a
|
|
||||||
// distinct code (`text_search_not_yet_supported`) so clients can
|
|
||||||
// detect "feature pending" vs. a generic 4xx — the code is the
|
|
||||||
// wire contract, not the message.
|
|
||||||
if text.is_some_and(|s| !s.trim().is_empty()) {
|
|
||||||
return Err(AppError::NotImplemented {
|
|
||||||
code: "text_search_not_yet_supported",
|
|
||||||
message: "text search is reserved for the planned OCR input but not yet supported",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_chapters_for_tag(
|
async fn list_chapters_for_tag(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Query(params): Query<AggregateParams>,
|
Query(params): Query<AggregateParams>,
|
||||||
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
|
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
|
||||||
ensure_text_unsupported(params.text.as_deref())?;
|
|
||||||
let tag = normalize_tag(¶ms.tag)?;
|
let tag = normalize_tag(¶ms.tag)?;
|
||||||
let order = parse_order(params.order.as_deref())?;
|
let order = parse_order(params.order.as_deref())?;
|
||||||
let limit = params.limit.clamp(1, 200);
|
let limit = params.limit.clamp(1, 200);
|
||||||
let offset = params.offset.max(0);
|
let offset = params.offset.max(0);
|
||||||
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
|
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
|
||||||
&state.db, user.id, &tag, order, limit, offset,
|
&state.db, user.id, &tag, order, limit, offset, params.text.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||||
@@ -447,13 +431,12 @@ async fn list_mangas_for_tag(
|
|||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Query(params): Query<AggregateParams>,
|
Query(params): Query<AggregateParams>,
|
||||||
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
|
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
|
||||||
ensure_text_unsupported(params.text.as_deref())?;
|
|
||||||
let tag = normalize_tag(¶ms.tag)?;
|
let tag = normalize_tag(¶ms.tag)?;
|
||||||
let order = parse_order(params.order.as_deref())?;
|
let order = parse_order(params.order.as_deref())?;
|
||||||
let limit = params.limit.clamp(1, 200);
|
let limit = params.limit.clamp(1, 200);
|
||||||
let offset = params.offset.max(0);
|
let offset = params.offset.max(0);
|
||||||
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
|
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
|
||||||
&state.db, user.id, &tag, order, limit, offset,
|
&state.db, user.id, &tag, order, limit, offset, params.text.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||||
|
|||||||
@@ -34,4 +34,18 @@ impl<T> PagedResponse<T> {
|
|||||||
page: PageInfo { limit, offset, total: Some(total) },
|
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 }))
|
||||||
|
}
|
||||||
@@ -170,6 +170,9 @@ pub struct CrawlerControl {
|
|||||||
/// Used by the "run metadata pass now" endpoint; `None` when no
|
/// Used by the "run metadata pass now" endpoint; `None` when no
|
||||||
/// `CRAWLER_START_URL` is configured (cron disabled).
|
/// `CRAWLER_START_URL` is configured (cron disabled).
|
||||||
pub metadata_pass: Option<Arc<dyn MetadataPass>>,
|
pub metadata_pass: Option<Arc<dyn MetadataPass>>,
|
||||||
|
/// Used by the "reconcile missing" endpoint; `None` when no
|
||||||
|
/// `CRAWLER_START_URL` is configured.
|
||||||
|
pub reconcile_pass: Option<Arc<dyn crate::crawler::daemon::ReconcilePass>>,
|
||||||
/// Drain budget for a manually-triggered coordinated browser restart.
|
/// Drain budget for a manually-triggered coordinated browser restart.
|
||||||
pub drain_deadline: std::time::Duration,
|
pub drain_deadline: std::time::Duration,
|
||||||
/// Held for the duration of a `/admin/crawler/run` pass so a second
|
/// Held for the duration of a `/admin/crawler/run` pass so a second
|
||||||
@@ -263,7 +266,8 @@ impl DaemonReloader for Supervisors {
|
|||||||
Arc::clone(&self.storage),
|
Arc::clone(&self.storage),
|
||||||
&cfg,
|
&cfg,
|
||||||
Arc::clone(&self.analysis_events),
|
Arc::clone(&self.analysis_events),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
*guard = Some(handle);
|
*guard = Some(handle);
|
||||||
tracing::info!(model = %cfg.model, "analysis daemon (re)started from settings");
|
tracing::info!(model = %cfg.model, "analysis daemon (re)started from settings");
|
||||||
} else {
|
} else {
|
||||||
@@ -273,9 +277,15 @@ impl DaemonReloader for Supervisors {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How often the background reaper sweeps expired sessions. Hourly is ample:
|
||||||
|
/// the sweep is a single indexed DELETE and expired rows are already invisible
|
||||||
|
/// to auth, so this is purely storage hygiene.
|
||||||
|
const SESSION_GC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
||||||
let db = PgPoolOptions::new()
|
let db = PgPoolOptions::new()
|
||||||
.max_connections(10)
|
.max_connections(config.db.max_connections)
|
||||||
|
.acquire_timeout(config.db.acquire_timeout)
|
||||||
.connect(&config.database_url)
|
.connect(&config.database_url)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::migrate!("./migrations").run(&db).await?;
|
sqlx::migrate!("./migrations").run(&db).await?;
|
||||||
@@ -332,6 +342,27 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
|||||||
tracing::info!("analysis worker disabled");
|
tracing::info!("analysis worker disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic reaper for lapsed sessions. `find_active` already ignores
|
||||||
|
// expired rows, so this only reclaims storage — without it the table grows
|
||||||
|
// unbounded as sessions lapse. Detached and best-effort: a failed sweep is
|
||||||
|
// logged and retried next tick. Runs regardless of crawler/analysis config
|
||||||
|
// since sessions exist in every deployment.
|
||||||
|
{
|
||||||
|
let db = db.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(SESSION_GC_INTERVAL);
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match repo::session::delete_expired(&db).await {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!(reaped = n, "session gc: removed expired sessions"),
|
||||||
|
Err(e) => tracing::warn!(?e, "session gc sweep failed; retrying next tick"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
|
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
@@ -402,42 +433,121 @@ async fn load_effective_analysis(
|
|||||||
/// Spawn the AI content-analysis worker daemon with the given config. Returns
|
/// Spawn the AI content-analysis worker daemon with the given config. Returns
|
||||||
/// its handle. Independent of the crawler daemon (works for uploads with the
|
/// its handle. Independent of the crawler daemon (works for uploads with the
|
||||||
/// crawler off). Uses a plain reqwest client — no cookie jar / proxy.
|
/// crawler off). Uses a plain reqwest client — no cookie jar / proxy.
|
||||||
fn spawn_analysis_daemon(
|
async fn spawn_analysis_daemon(
|
||||||
db: PgPool,
|
db: PgPool,
|
||||||
storage: Arc<dyn Storage>,
|
storage: Arc<dyn Storage>,
|
||||||
cfg: &AnalysisConfig,
|
cfg: &AnalysisConfig,
|
||||||
events: Arc<crate::analysis::events::AnalysisEvents>,
|
events: Arc<crate::analysis::events::AnalysisEvents>,
|
||||||
) -> anyhow::Result<crate::analysis::daemon::AnalysisDaemonHandle> {
|
) -> anyhow::Result<crate::analysis::daemon::AnalysisDaemonHandle> {
|
||||||
let http = reqwest::Client::builder()
|
// Reclaim jobs orphaned by a previous crash/kill. `crawler::jobs::reclaim_orphaned`
|
||||||
.timeout(cfg.request_timeout)
|
// is keyed only on `state='running' AND leased_until < now()`, so it
|
||||||
.build()
|
// covers any kind that uses the table — including this daemon's
|
||||||
.context("build analysis http client")?;
|
// `analyze_page` jobs. Previously this ran only inside
|
||||||
let vision = crate::analysis::vision::VisionClient::new(http, cfg);
|
// `spawn_crawler_daemon`, which meant a deploy with the crawler off
|
||||||
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
|
// (analysis-only) never refunded orphaned analysis leases until the
|
||||||
db: db.clone(),
|
// lease expiry path lazily picked them up. Safe under multi-replica
|
||||||
storage,
|
// (only expired leases are touched) and idempotent (the crawler-side
|
||||||
vision,
|
// call happens-before this if both are running).
|
||||||
model: cfg.model.clone(),
|
match crate::crawler::jobs::reclaim_orphaned(&db).await {
|
||||||
max_image_bytes: cfg.max_image_bytes,
|
Ok(0) => {}
|
||||||
});
|
Ok(n) => tracing::info!(
|
||||||
// When a readiness URL is configured, gate leasing on it so an
|
reclaimed = n,
|
||||||
// autoscaler that idle-stops the vision container never lets a job burn
|
"analysis: reclaimed orphaned in-flight jobs at startup"
|
||||||
// its retries. A dedicated short-timeout client keeps the probe snappy
|
),
|
||||||
// and independent of the (long) per-request analysis timeout.
|
Err(e) => tracing::warn!(?e, "analysis: reclaim_orphaned at startup failed"),
|
||||||
let readiness: Option<Arc<dyn crate::analysis::daemon::VisionReadiness>> =
|
}
|
||||||
match &cfg.vision_health_url {
|
// Pick the engine. The OCR backend runs in-process (no network, no
|
||||||
Some(url) if !url.is_empty() => {
|
// readiness gate); the vision backend talks to a local LLM server.
|
||||||
let probe = reqwest::Client::builder()
|
let (dispatcher, readiness): (
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
Arc<dyn crate::analysis::daemon::AnalyzeDispatcher>,
|
||||||
.build()
|
Option<Arc<dyn crate::analysis::daemon::VisionReadiness>>,
|
||||||
.context("build vision readiness http client")?;
|
) = match cfg.effective_backend() {
|
||||||
Some(Arc::new(crate::analysis::daemon::HttpVisionReadiness {
|
crate::config::AnalysisBackend::Ocr => {
|
||||||
http: probe,
|
// Load the `.rten` models once; a bad path is a loud boot error.
|
||||||
health_url: url.clone(),
|
let engine = crate::analysis::ocr::OcrsEngine::from_model_paths(
|
||||||
}))
|
&cfg.ocr_detection_model,
|
||||||
}
|
&cfg.ocr_recognition_model,
|
||||||
_ => None,
|
cfg.ocr_max_decode_pixels,
|
||||||
};
|
)
|
||||||
|
.context("build ocrs engine")?;
|
||||||
|
// Cap concurrent CPU-bound OCR runs across all workers so a high
|
||||||
|
// ANALYSIS_WORKERS can't oversubscribe the blocking pool.
|
||||||
|
let cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1);
|
||||||
|
let permits =
|
||||||
|
crate::analysis::ocr::ocr_concurrency_limit(cfg.workers, cores);
|
||||||
|
let dispatcher = Arc::new(crate::analysis::ocr::OcrAnalyzeDispatcher {
|
||||||
|
db: db.clone(),
|
||||||
|
storage,
|
||||||
|
engine: Arc::new(engine),
|
||||||
|
max_image_bytes: cfg.max_image_bytes,
|
||||||
|
ocr_permits: Arc::new(tokio::sync::Semaphore::new(permits)),
|
||||||
|
});
|
||||||
|
// In-process engine is always ready — no gate.
|
||||||
|
(dispatcher, None)
|
||||||
|
}
|
||||||
|
crate::config::AnalysisBackend::Vision => {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(cfg.request_timeout)
|
||||||
|
// Refuse to honour ambient HTTP_PROXY / HTTPS_PROXY container
|
||||||
|
// env. The vision call carries an env-managed bearer token +
|
||||||
|
// page image bytes; a stray upstream proxy would exfiltrate
|
||||||
|
// both. Mirrors the crawler client's `.no_proxy()` (see
|
||||||
|
// `spawn_crawler_daemon`).
|
||||||
|
.no_proxy()
|
||||||
|
// Re-validate redirect hops so a hostile/compromised vision
|
||||||
|
// endpoint can't 302 the bearer token + page bytes into the
|
||||||
|
// deployment's internal network. No allowlist here (the
|
||||||
|
// endpoint is a single admin-configured URL), so the policy
|
||||||
|
// enforces scheme + private-IP only.
|
||||||
|
.redirect(crate::crawler::safety::public_redirect_policy())
|
||||||
|
// NOTE: deliberately no `.dns_resolver(safe_dns_resolver())`.
|
||||||
|
// The vision endpoint is a single operator-configured internal
|
||||||
|
// service (e.g. `mangalord-vision`) that legitimately resolves
|
||||||
|
// to a private Docker IP; a private-IP-rejecting resolver drops
|
||||||
|
// every call. Redirect hops stay guarded by the policy above,
|
||||||
|
// and the URL is operator-set, not attacker-controlled input.
|
||||||
|
.build()
|
||||||
|
.context("build analysis http client")?;
|
||||||
|
let vision = crate::analysis::vision::VisionClient::new(http, cfg);
|
||||||
|
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
|
||||||
|
db: db.clone(),
|
||||||
|
storage,
|
||||||
|
vision,
|
||||||
|
model: cfg.model.clone(),
|
||||||
|
max_image_bytes: cfg.max_image_bytes,
|
||||||
|
});
|
||||||
|
// When a readiness URL is configured, gate leasing on it so an
|
||||||
|
// autoscaler that idle-stops the vision container never lets a job
|
||||||
|
// burn its retries. A dedicated short-timeout client keeps the
|
||||||
|
// probe snappy and independent of the (long) per-request timeout.
|
||||||
|
let readiness: Option<Arc<dyn crate::analysis::daemon::VisionReadiness>> =
|
||||||
|
match &cfg.vision_health_url {
|
||||||
|
Some(url) if !url.is_empty() => {
|
||||||
|
let probe = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
// Same reasoning as the main analysis client: do
|
||||||
|
// not honour ambient HTTP_PROXY env. The readiness
|
||||||
|
// probe is unauthenticated but a hostile upstream
|
||||||
|
// still gets a useful side-channel on backend
|
||||||
|
// uptime + vision health.
|
||||||
|
.no_proxy()
|
||||||
|
.redirect(crate::crawler::safety::public_redirect_policy())
|
||||||
|
// No private-IP resolver: same operator-configured
|
||||||
|
// internal vision host as the analysis client above.
|
||||||
|
.build()
|
||||||
|
.context("build vision readiness http client")?;
|
||||||
|
Some(Arc::new(crate::analysis::daemon::HttpVisionReadiness {
|
||||||
|
http: probe,
|
||||||
|
health_url: url.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
(dispatcher, readiness)
|
||||||
|
}
|
||||||
|
};
|
||||||
let handle = crate::analysis::daemon::spawn(
|
let handle = crate::analysis::daemon::spawn(
|
||||||
db,
|
db,
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
@@ -449,7 +559,21 @@ fn spawn_analysis_daemon(
|
|||||||
readiness,
|
readiness,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tracing::info!(workers = cfg.workers, model = %cfg.model, "analysis worker daemon started");
|
// Log the *effective* backend (what the worker actually dispatches
|
||||||
|
// through), not the raw `cfg.backend` — with vision dormant they diverge
|
||||||
|
// when an operator requests `vision`, and a misleading line here was a
|
||||||
|
// real observability footgun. The model label tracks the effective engine.
|
||||||
|
let effective_backend = cfg.effective_backend();
|
||||||
|
let effective_model: &str = match effective_backend {
|
||||||
|
crate::config::AnalysisBackend::Ocr => crate::analysis::ocr::OCR_MODEL_LABEL,
|
||||||
|
crate::config::AnalysisBackend::Vision => &cfg.model,
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
workers = cfg.workers,
|
||||||
|
backend = ?effective_backend,
|
||||||
|
model = %effective_model,
|
||||||
|
"analysis worker daemon started"
|
||||||
|
);
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +593,10 @@ async fn spawn_crawler_daemon(
|
|||||||
cfg: &CrawlerConfig,
|
cfg: &CrawlerConfig,
|
||||||
analysis_enabled: Arc<AtomicBool>,
|
analysis_enabled: Arc<AtomicBool>,
|
||||||
) -> anyhow::Result<SpawnedDaemon> {
|
) -> anyhow::Result<SpawnedDaemon> {
|
||||||
|
// Publish the opt-in browser SSRF-interception toggle so every headless
|
||||||
|
// navigation (via `intercept::open_page`) honours it. Off by default.
|
||||||
|
crate::crawler::intercept::set_enabled(cfg.ssrf_intercept);
|
||||||
|
|
||||||
// Reqwest client with a shared cookie jar so CDN image fetches include
|
// Reqwest client with a shared cookie jar so CDN image fetches include
|
||||||
// PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a
|
// PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a
|
||||||
// runtime session refresh rewrites it in place. Initial value: a
|
// runtime session refresh rewrites it in place. Initial value: a
|
||||||
@@ -489,6 +617,13 @@ async fn spawn_crawler_daemon(
|
|||||||
let mut http_builder = reqwest::Client::builder()
|
let mut http_builder = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
|
// Re-validate every redirect hop against the download allowlist:
|
||||||
|
// reqwest's default policy follows up to 10 redirects, and
|
||||||
|
// `is_safe_url` only guards the initial URL, so an allowlisted CDN
|
||||||
|
// 302ing to a private IP would otherwise be followed (SSRF).
|
||||||
|
.redirect(crate::crawler::safety::safe_redirect_policy(
|
||||||
|
cfg.download_allowlist.clone(),
|
||||||
|
))
|
||||||
.cookie_provider(Arc::clone(&cookie_jar));
|
.cookie_provider(Arc::clone(&cookie_jar));
|
||||||
if let Some(ua) = &cfg.user_agent {
|
if let Some(ua) = &cfg.user_agent {
|
||||||
http_builder = http_builder.user_agent(ua);
|
http_builder = http_builder.user_agent(ua);
|
||||||
@@ -497,6 +632,17 @@ async fn spawn_crawler_daemon(
|
|||||||
http_builder = http_builder
|
http_builder = http_builder
|
||||||
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy: {proxy}"))?);
|
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy: {proxy}"))?);
|
||||||
}
|
}
|
||||||
|
// DNS-rebinding guard: reject hosts that resolve to a private/internal IP,
|
||||||
|
// complementing the string-level allowlist check which can't see
|
||||||
|
// post-resolution addresses. Attached on the direct path AND on http(s)
|
||||||
|
// proxies (reqwest resolves the target itself there). Skipped only for SOCKS
|
||||||
|
// proxies, where the proxy — not reqwest — resolves the target, so the only
|
||||||
|
// name this resolver would see is the proxy's OWN host (legitimately on a
|
||||||
|
// private Docker IP, e.g. `tor` → 172.x); attaching it there rejected every
|
||||||
|
// fetch for zero gain. See `should_attach_safe_resolver`.
|
||||||
|
if crate::crawler::safety::should_attach_safe_resolver(cfg.proxy.as_deref()) {
|
||||||
|
http_builder = http_builder.dns_resolver(crate::crawler::safety::safe_dns_resolver());
|
||||||
|
}
|
||||||
let http = http_builder.build().context("build crawler reqwest")?;
|
let http = http_builder.build().context("build crawler reqwest")?;
|
||||||
|
|
||||||
let mut rate = HostRateLimiters::new(std::time::Duration::from_millis(cfg.rate_ms));
|
let mut rate = HostRateLimiters::new(std::time::Duration::from_millis(cfg.rate_ms));
|
||||||
@@ -599,6 +745,19 @@ async fn spawn_crawler_daemon(
|
|||||||
m
|
m
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let reconcile_pass: Option<Arc<dyn crate::crawler::daemon::ReconcilePass>> =
|
||||||
|
cfg.start_url.as_ref().map(|url| {
|
||||||
|
let m: Arc<dyn crate::crawler::daemon::ReconcilePass> = Arc::new(RealReconcilePass {
|
||||||
|
browser_manager: Arc::clone(&browser_manager),
|
||||||
|
db: db.clone(),
|
||||||
|
rate: Arc::clone(&rate),
|
||||||
|
start_url: url.clone(),
|
||||||
|
status: status.clone(),
|
||||||
|
tor: tor.as_ref().map(Arc::clone),
|
||||||
|
});
|
||||||
|
m
|
||||||
|
});
|
||||||
|
|
||||||
let dispatcher: Arc<dyn ChapterDispatcher> = Arc::new(RealChapterDispatcher {
|
let dispatcher: Arc<dyn ChapterDispatcher> = Arc::new(RealChapterDispatcher {
|
||||||
browser_manager: Arc::clone(&browser_manager),
|
browser_manager: Arc::clone(&browser_manager),
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
@@ -607,6 +766,7 @@ async fn spawn_crawler_daemon(
|
|||||||
rate: Arc::clone(&rate),
|
rate: Arc::clone(&rate),
|
||||||
download_allowlist: cfg.download_allowlist.clone(),
|
download_allowlist: cfg.download_allowlist.clone(),
|
||||||
max_image_bytes: cfg.max_image_bytes,
|
max_image_bytes: cfg.max_image_bytes,
|
||||||
|
max_images_per_chapter: cfg.max_images_per_chapter,
|
||||||
analysis_enabled,
|
analysis_enabled,
|
||||||
transient_failures: Arc::new(AtomicU32::new(0)),
|
transient_failures: Arc::new(AtomicU32::new(0)),
|
||||||
restart_threshold: cfg.browser_restart_threshold,
|
restart_threshold: cfg.browser_restart_threshold,
|
||||||
@@ -623,6 +783,7 @@ async fn spawn_crawler_daemon(
|
|||||||
rate: Arc::clone(&rate),
|
rate: Arc::clone(&rate),
|
||||||
download_allowlist: cfg.download_allowlist.clone(),
|
download_allowlist: cfg.download_allowlist.clone(),
|
||||||
max_image_bytes: cfg.max_image_bytes,
|
max_image_bytes: cfg.max_image_bytes,
|
||||||
|
max_images_per_chapter: cfg.max_images_per_chapter,
|
||||||
tor: tor.as_ref().map(Arc::clone),
|
tor: tor.as_ref().map(Arc::clone),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,6 +806,17 @@ async fn spawn_crawler_daemon(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reclaim jobs orphaned by a previous crash/kill (running with an
|
||||||
|
// expired lease) before workers start, so recovery is immediate instead
|
||||||
|
// of waiting a full lease window for `lease`'s expiry clause. Safe under
|
||||||
|
// multi-replica: only already-expired leases are touched. Best-effort —
|
||||||
|
// a failure here just defers recovery to the lazy lease path.
|
||||||
|
match crate::crawler::jobs::reclaim_orphaned(&db).await {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!(reclaimed = n, "crawler: reclaimed orphaned in-flight jobs at startup"),
|
||||||
|
Err(e) => tracing::warn!(?e, "crawler: reclaim_orphaned at startup failed"),
|
||||||
|
}
|
||||||
|
|
||||||
let daemon_handle = daemon::spawn(
|
let daemon_handle = daemon::spawn(
|
||||||
db,
|
db,
|
||||||
cancel,
|
cancel,
|
||||||
@@ -655,6 +827,7 @@ async fn spawn_crawler_daemon(
|
|||||||
daily_at: cfg.daily_at,
|
daily_at: cfg.daily_at,
|
||||||
tz: cfg.tz,
|
tz: cfg.tz,
|
||||||
retention_days: cfg.retention_days,
|
retention_days: cfg.retention_days,
|
||||||
|
metrics_retention_days: cfg.metrics_retention_days,
|
||||||
session_expired,
|
session_expired,
|
||||||
status: status.clone(),
|
status: status.clone(),
|
||||||
job_timeout: cfg.job_timeout,
|
job_timeout: cfg.job_timeout,
|
||||||
@@ -667,6 +840,7 @@ async fn spawn_crawler_daemon(
|
|||||||
session: session_controller,
|
session: session_controller,
|
||||||
status,
|
status,
|
||||||
metadata_pass,
|
metadata_pass,
|
||||||
|
reconcile_pass,
|
||||||
drain_deadline: cfg.job_timeout,
|
drain_deadline: cfg.job_timeout,
|
||||||
manual_pass_lock: Arc::new(tokio::sync::Mutex::new(())),
|
manual_pass_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
});
|
});
|
||||||
@@ -758,6 +932,36 @@ impl MetadataPass for RealMetadataPass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RealReconcilePass {
|
||||||
|
browser_manager: Arc<BrowserManager>,
|
||||||
|
db: PgPool,
|
||||||
|
rate: Arc<HostRateLimiters>,
|
||||||
|
start_url: String,
|
||||||
|
status: crate::crawler::status::StatusHandle,
|
||||||
|
tor: Option<Arc<crate::crawler::tor::TorController>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::crawler::daemon::ReconcilePass for RealReconcilePass {
|
||||||
|
async fn run(&self) -> anyhow::Result<crate::crawler::reconcile::ReconcileStats> {
|
||||||
|
let result = crate::crawler::reconcile::reconcile_missing(
|
||||||
|
&self.browser_manager,
|
||||||
|
&self.db,
|
||||||
|
&self.rate,
|
||||||
|
&self.start_url,
|
||||||
|
Some(&self.status),
|
||||||
|
self.tor.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = &result {
|
||||||
|
if crate::crawler::nav::anyhow_looks_browser_dead(e) {
|
||||||
|
self.browser_manager.invalidate().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct RealChapterDispatcher {
|
struct RealChapterDispatcher {
|
||||||
browser_manager: Arc<BrowserManager>,
|
browser_manager: Arc<BrowserManager>,
|
||||||
db: PgPool,
|
db: PgPool,
|
||||||
@@ -766,6 +970,8 @@ struct RealChapterDispatcher {
|
|||||||
rate: Arc<HostRateLimiters>,
|
rate: Arc<HostRateLimiters>,
|
||||||
download_allowlist: DownloadAllowlist,
|
download_allowlist: DownloadAllowlist,
|
||||||
max_image_bytes: usize,
|
max_image_bytes: usize,
|
||||||
|
/// Per-chapter image-count cap (see `CrawlerConfig::max_images_per_chapter`).
|
||||||
|
max_images_per_chapter: usize,
|
||||||
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Shared gate
|
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Shared gate
|
||||||
/// (read live) so toggling analysis at runtime takes effect without a
|
/// (read live) so toggling analysis at runtime takes effect without a
|
||||||
/// crawler respawn. Mirrors the analysis enable setting.
|
/// crawler respawn. Mirrors the analysis enable setting.
|
||||||
@@ -810,7 +1016,17 @@ impl ChapterDispatcher for RealChapterDispatcher {
|
|||||||
pages_done: 0,
|
pages_done: 0,
|
||||||
pages_total: None,
|
pages_total: None,
|
||||||
});
|
});
|
||||||
let lease = self.browser_manager.acquire().await?;
|
let lease = match self.browser_manager.acquire().await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
// Browser down / mid-restart: defer the job WITHOUT
|
||||||
|
// burning an attempt (the daemon releases it back to
|
||||||
|
// pending) rather than counting an infrastructure
|
||||||
|
// outage as a per-job failure.
|
||||||
|
tracing::warn!(error = ?e, "dispatch: browser unavailable — deferring job");
|
||||||
|
return Ok(SyncOutcome::BrowserUnavailable);
|
||||||
|
}
|
||||||
|
};
|
||||||
let result = content::sync_chapter_content(
|
let result = content::sync_chapter_content(
|
||||||
&lease,
|
&lease,
|
||||||
&self.db,
|
&self.db,
|
||||||
@@ -823,6 +1039,7 @@ impl ChapterDispatcher for RealChapterDispatcher {
|
|||||||
false,
|
false,
|
||||||
&self.download_allowlist,
|
&self.download_allowlist,
|
||||||
self.max_image_bytes,
|
self.max_image_bytes,
|
||||||
|
self.max_images_per_chapter,
|
||||||
self.tor.as_deref(),
|
self.tor.as_deref(),
|
||||||
Some(&self.status),
|
Some(&self.status),
|
||||||
self.analysis_enabled.load(Ordering::Relaxed),
|
self.analysis_enabled.load(Ordering::Relaxed),
|
||||||
@@ -861,9 +1078,88 @@ impl ChapterDispatcher for RealChapterDispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Other payload kinds aren't dispatched by this daemon yet —
|
// Reconcile-enqueued manga-detail sync: fetch the detail page,
|
||||||
// SyncManga / SyncChapterList are handled inline by the cron's
|
// upsert metadata, sync chapters — the identical per-ref work the
|
||||||
// metadata pass.
|
// cron metadata pass runs inline, via the shared
|
||||||
|
// `pipeline::process_manga_ref`.
|
||||||
|
JobPayload::SyncManga {
|
||||||
|
source_id: _,
|
||||||
|
source_manga_key,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
} => {
|
||||||
|
let source = crate::crawler::source::target::TargetSource::new(url.clone());
|
||||||
|
let r = crate::crawler::source::SourceMangaRef {
|
||||||
|
source_manga_key,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
// Scope the lease so it (and the borrowing FetchContext) drop
|
||||||
|
// before any browser-restart handling in the match below.
|
||||||
|
let result = {
|
||||||
|
let lease = match self.browser_manager.acquire().await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
// See the SyncChapterContent arm: defer without
|
||||||
|
// burning an attempt when the browser is unavailable.
|
||||||
|
tracing::warn!(error = ?e, "dispatch: browser unavailable — deferring job");
|
||||||
|
return Ok(SyncOutcome::BrowserUnavailable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ctx = crate::crawler::source::FetchContext {
|
||||||
|
browser: &lease,
|
||||||
|
rate: &self.rate,
|
||||||
|
tor: self.tor.as_deref(),
|
||||||
|
};
|
||||||
|
pipeline::process_manga_ref(
|
||||||
|
&ctx,
|
||||||
|
&source,
|
||||||
|
&self.db,
|
||||||
|
self.storage.as_ref(),
|
||||||
|
&self.http,
|
||||||
|
&self.rate,
|
||||||
|
&r,
|
||||||
|
false, // chapters ON — we want chapter rows synced
|
||||||
|
&self.download_allowlist,
|
||||||
|
self.max_image_bytes,
|
||||||
|
Some(&self.status),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(p) => {
|
||||||
|
self.transient_failures.store(0, Ordering::Release);
|
||||||
|
tracing::info!(
|
||||||
|
manga_id = %p.manga_id,
|
||||||
|
key = %r.source_manga_key,
|
||||||
|
"SyncManga: manga synced"
|
||||||
|
);
|
||||||
|
Ok(SyncOutcome::Fetched { pages: 0 })
|
||||||
|
}
|
||||||
|
Err(pipeline::RefError::Fetch(e)) | Err(pipeline::RefError::Skip(e)) => {
|
||||||
|
let streak = self.transient_failures.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
if crate::crawler::nav::anyhow_looks_browser_dead(&e) {
|
||||||
|
self.browser_manager.invalidate().await;
|
||||||
|
self.transient_failures.store(0, Ordering::Release);
|
||||||
|
} else if self.restart_threshold > 0 && streak >= self.restart_threshold {
|
||||||
|
tracing::warn!(
|
||||||
|
streak,
|
||||||
|
threshold = self.restart_threshold,
|
||||||
|
"auto browser restart: consecutive transient sync_manga failures"
|
||||||
|
);
|
||||||
|
let _ = self
|
||||||
|
.browser_manager
|
||||||
|
.coordinated_restart(self.drain_deadline)
|
||||||
|
.await;
|
||||||
|
self.transient_failures.store(0, Ordering::Release);
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Other payload kinds aren't dispatched by this daemon —
|
||||||
|
// SyncChapterList is handled inline by the cron's metadata pass;
|
||||||
|
// analyze_page is owned by the analysis daemon.
|
||||||
_ => Ok(SyncOutcome::Skipped),
|
_ => Ok(SyncOutcome::Skipped),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -898,10 +1194,36 @@ const ADMIN_PATH_PREFIX: &str = "/api/v1/admin/";
|
|||||||
/// from a malicious page — this middleware rejects such requests by
|
/// from a malicious page — this middleware rejects such requests by
|
||||||
/// comparing the request's `Origin` (with `Referer` as fallback) against
|
/// comparing the request's `Origin` (with `Referer` as fallback) against
|
||||||
/// the configured allowlist. Safe methods (`GET`/`HEAD`/`OPTIONS`) are
|
/// the configured allowlist. Safe methods (`GET`/`HEAD`/`OPTIONS`) are
|
||||||
/// always allowed. Requests with neither `Origin` nor `Referer` are
|
/// always allowed.
|
||||||
/// allowed (non-browser callers like curl can't be a CSRF vector). When
|
///
|
||||||
/// the allowlist is empty the check is skipped entirely (operator
|
/// **Bearer-token-only requests** (`Authorization: Bearer …` with NO
|
||||||
/// opt-out — documented in `.env.example`).
|
/// session cookie) are bot API callers — they can't be a CSRF vector
|
||||||
|
/// because the browser never attaches the Authorization header
|
||||||
|
/// automatically. Skip the check for them.
|
||||||
|
///
|
||||||
|
/// **Bearer + cookie (the "cookie-ride")** is treated as cookie-auth.
|
||||||
|
/// An attacker page can mint `Authorization: Bearer junk` on a
|
||||||
|
/// credentialed cross-site POST; the cookie carries the actual
|
||||||
|
/// authority. Closing this hole means cookie precedence: as soon as a
|
||||||
|
/// session cookie is present, the CSRF gate fires regardless of the
|
||||||
|
/// Authorization header. (0.87.10 closed a 0.87.2 regression here.)
|
||||||
|
///
|
||||||
|
/// **Cookie-auth requests** must:
|
||||||
|
/// * be from an allowed origin (`Origin` then `Referer`), AND
|
||||||
|
/// * have at least one of those headers present (a browser always
|
||||||
|
/// sends one on a cross-site POST; a missing pair on a cookie-auth
|
||||||
|
/// request is the exact niche an extension / no-referrer-policy
|
||||||
|
/// CSRF would try to exploit).
|
||||||
|
///
|
||||||
|
/// When the allowlist is empty we fail-closed for cookie-auth requests
|
||||||
|
/// — this used to silently let everything through, so an operator who
|
||||||
|
/// forgot to set `ADMIN_ALLOWED_ORIGINS` shipped an unguarded admin
|
||||||
|
/// surface. Operators on a pure-bot-token deploy aren't impacted (their
|
||||||
|
/// requests carry only `Authorization: Bearer …` with no session cookie
|
||||||
|
/// and skip the gate via the bearer-only branch above).
|
||||||
|
///
|
||||||
|
/// **No auth at all** (no cookie, no bearer): bypass the CSRF gate so
|
||||||
|
/// the auth extractor returns a clean 401 instead of a confusing 403.
|
||||||
async fn admin_csrf_guard(
|
async fn admin_csrf_guard(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -916,16 +1238,74 @@ async fn admin_csrf_guard(
|
|||||||
) {
|
) {
|
||||||
return Ok(next.run(req).await);
|
return Ok(next.run(req).await);
|
||||||
}
|
}
|
||||||
if state.admin_allowed_origins.is_empty() {
|
let headers = req.headers();
|
||||||
|
|
||||||
|
// Detect both auth modes BEFORE choosing a bypass. The previous
|
||||||
|
// version short-circuited on "is_bearer" regardless of cookie state,
|
||||||
|
// which let an attacker page do a credentialed cross-site POST with
|
||||||
|
// a forged `Authorization: Bearer junk` header: the header existed,
|
||||||
|
// CSRF bypassed, then the auth extractor authenticated the victim
|
||||||
|
// via the session cookie. Cookie precedence here re-anchors the
|
||||||
|
// gate to the actual authority being ridden.
|
||||||
|
// Drive the parse off the auth-module constant so a rename of
|
||||||
|
// `SESSION_COOKIE_NAME` propagates here instead of silently
|
||||||
|
// reopening the cookie-ride. Split on `=` (not a prefix match) so
|
||||||
|
// an attacker can't shadow with `mangalord_session_x=` etc.
|
||||||
|
let has_session_cookie = headers
|
||||||
|
.get(axum::http::header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|raw| {
|
||||||
|
raw.split(';').any(|p| {
|
||||||
|
p.trim_start().split('=').next()
|
||||||
|
== Some(crate::auth::extractor::SESSION_COOKIE_NAME)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bearer-token-only requests are bot callers and bypass the gate —
|
||||||
|
// a browser can't set Authorization on a cross-site POST. But ONLY
|
||||||
|
// when no session cookie is also attached; with both present we
|
||||||
|
// must treat the request as cookie-auth to defeat the cookie-ride.
|
||||||
|
let is_bearer = headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.trim_start().to_ascii_lowercase().starts_with("bearer "));
|
||||||
|
if is_bearer && !has_session_cookie {
|
||||||
|
return Ok(next.run(req).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth context at all → let the auth extractor return a clean 401
|
||||||
|
// ("log in first"). A no-auth request can't be a CSRF vector — there's
|
||||||
|
// no authority to ride. Without this, an anonymous curl POST to an
|
||||||
|
// admin endpoint surfaces 403 with our CSRF message instead of the
|
||||||
|
// expected 401, which is confusing for both operators and tests.
|
||||||
|
if !has_session_cookie {
|
||||||
return Ok(next.run(req).await);
|
return Ok(next.run(req).await);
|
||||||
}
|
}
|
||||||
let headers = req.headers();
|
|
||||||
let origin = headers.get("origin").and_then(|v| v.to_str().ok());
|
let origin = headers.get("origin").and_then(|v| v.to_str().ok());
|
||||||
let referer = headers.get("referer").and_then(|v| v.to_str().ok());
|
let referer = headers.get("referer").and_then(|v| v.to_str().ok());
|
||||||
// No Origin AND no Referer → server-to-server / curl / extension.
|
let candidate = origin.or(referer);
|
||||||
// Browsers always send one or the other on a cross-site POST.
|
|
||||||
let Some(candidate) = origin.or(referer) else {
|
if state.admin_allowed_origins.is_empty() {
|
||||||
return Ok(next.run(req).await);
|
// Fail-closed: cookie-auth admin mutations without an allowlist
|
||||||
|
// configuration are refused. Set `ADMIN_ALLOWED_ORIGINS` for the
|
||||||
|
// browser deployment, or call with `Authorization: Bearer …`.
|
||||||
|
tracing::warn!(
|
||||||
|
path = %req.uri().path(),
|
||||||
|
"admin CSRF: ADMIN_ALLOWED_ORIGINS is empty — cookie-auth admin mutations are refused (set the env var for browser-exposed deploys)"
|
||||||
|
);
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie-auth path with an allowlist configured: a browser always
|
||||||
|
// sends Origin or Referer on a cross-site POST. A missing pair is
|
||||||
|
// either a no-referrer-policy edge case or a tool deliberately
|
||||||
|
// hiding origin — refuse rather than risk it.
|
||||||
|
let Some(candidate) = candidate else {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %req.uri().path(),
|
||||||
|
"admin CSRF: cookie-auth request with neither Origin nor Referer — refusing"
|
||||||
|
);
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
};
|
};
|
||||||
if origin_in_allowlist(candidate, &state.admin_allowed_origins) {
|
if origin_in_allowlist(candidate, &state.admin_allowed_origins) {
|
||||||
return Ok(next.run(req).await);
|
return Ok(next.run(req).await);
|
||||||
@@ -959,8 +1339,8 @@ fn parse_origin(raw: &str) -> Option<String> {
|
|||||||
let host = url.host_str()?;
|
let host = url.host_str()?;
|
||||||
let scheme = url.scheme();
|
let scheme = url.scheme();
|
||||||
let port_str = match (url.port(), scheme) {
|
let port_str = match (url.port(), scheme) {
|
||||||
(Some(p), "http") if p == 80 => String::new(),
|
(Some(80), "http") => String::new(),
|
||||||
(Some(p), "https") if p == 443 => String::new(),
|
(Some(443), "https") => String::new(),
|
||||||
(Some(p), _) => format!(":{p}"),
|
(Some(p), _) => format!(":{p}"),
|
||||||
(None, _) => String::new(),
|
(None, _) => String::new(),
|
||||||
};
|
};
|
||||||
@@ -1137,4 +1517,108 @@ mod tests {
|
|||||||
assert!(resp.headers().get("access-control-allow-origin").is_none());
|
assert!(resp.headers().get("access-control-allow-origin").is_none());
|
||||||
assert!(resp.headers().get("access-control-allow-credentials").is_none());
|
assert!(resp.headers().get("access-control-allow-credentials").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `spawn_analysis_daemon` runs `crawler::jobs::reclaim_orphaned` at
|
||||||
|
/// startup so an analysis-only deploy refunds expired
|
||||||
|
/// `analyze_page` leases that a crashed previous run left running.
|
||||||
|
/// Without this, the call could be silently removed and the
|
||||||
|
/// reclaim test in `tests/crawler_jobs.rs` would still pass
|
||||||
|
/// (it covers the helper, not the wiring).
|
||||||
|
///
|
||||||
|
/// Seed an expired-lease `analyze_page` row in `crawler_jobs`, call
|
||||||
|
/// `spawn_analysis_daemon`, then assert the row went back to
|
||||||
|
/// `pending` with the attempt refunded.
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn spawn_analysis_daemon_reclaims_orphaned_analyze_leases(pool: PgPool) {
|
||||||
|
use crate::storage::LocalStorage;
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Seed a chapter + page so the worker has something it COULD lease.
|
||||||
|
// We don't need the worker to actually run — we're testing the
|
||||||
|
// reclaim that happens before workers start.
|
||||||
|
let manga_id: Uuid =
|
||||||
|
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let chapter_id: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(manga_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let page_id: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
|
||||||
|
VALUES ($1, 1, 'k/1.png', 'image/png') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Plant an expired-lease analyze_page row that mimics a previous
|
||||||
|
// worker that crashed mid-dispatch (attempts=1, leased_until in
|
||||||
|
// the past, state=running).
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"kind": "analyze_page",
|
||||||
|
"page_id": page_id,
|
||||||
|
"force": false
|
||||||
|
});
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO crawler_jobs (payload, state, attempts, leased_until) \
|
||||||
|
VALUES ($1, 'running', 1, now() - interval '1 hour')",
|
||||||
|
)
|
||||||
|
.bind(payload)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Bind to a local so the TempDir lives for the rest of the test.
|
||||||
|
// `tempfile::tempdir().unwrap().path()` would drop the TempDir
|
||||||
|
// at end-of-expression and `LocalStorage` would hold a path to
|
||||||
|
// a deleted directory.
|
||||||
|
let storage_dir = tempfile::tempdir().unwrap();
|
||||||
|
let storage: Arc<dyn Storage> =
|
||||||
|
Arc::new(LocalStorage::new(storage_dir.path()));
|
||||||
|
// The worker always runs OCR now (vision is dormant — see
|
||||||
|
// `effective_backend`), and the `.rten` models aren't shipped to unit
|
||||||
|
// CI. Point the engine at a path that can't exist so the *engine build*
|
||||||
|
// fails deterministically. Reclaim runs at the very top of
|
||||||
|
// `spawn_analysis_daemon`, before — and independently of — engine
|
||||||
|
// readiness, so the row must still be reclaimed even though spawn
|
||||||
|
// returns Err. That's exactly the regression this test guards (an
|
||||||
|
// analysis-only deploy must reclaim orphaned leases at startup).
|
||||||
|
let cfg = crate::config::AnalysisConfig {
|
||||||
|
ocr_detection_model: "/nonexistent/text-detection.rten".to_string(),
|
||||||
|
ocr_recognition_model: "/nonexistent/text-recognition.rten".to_string(),
|
||||||
|
workers: 1,
|
||||||
|
job_timeout: Duration::from_secs(1),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let events = Arc::new(crate::analysis::events::AnalysisEvents::new());
|
||||||
|
|
||||||
|
let spawned = spawn_analysis_daemon(pool.clone(), storage, &cfg, events).await;
|
||||||
|
assert!(
|
||||||
|
spawned.is_err(),
|
||||||
|
"engine build must fail with a missing model path"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The reclaim must have moved the row back to pending with the
|
||||||
|
// attempt refunded (attempts goes from 1 → 0). Reaching it on the
|
||||||
|
// initial running state would mean reclaim never ran.
|
||||||
|
let (state, attempts): (String, i32) = sqlx::query_as(
|
||||||
|
"SELECT state, attempts FROM crawler_jobs WHERE payload->>'page_id' = $1",
|
||||||
|
)
|
||||||
|
.bind(page_id.to_string())
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state, "pending",
|
||||||
|
"expired-lease analyze_page row must be reclaimed to pending"
|
||||||
|
);
|
||||||
|
assert_eq!(attempts, 0, "reclaim_orphaned refunds the attempt");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted
|
||||||
/// here — this is the substrate for [`RequireAdmin`] and exists precisely
|
/// here — this is the substrate for [`RequireAdmin`] and exists precisely
|
||||||
/// to keep admin authority out of bearer-token reach.
|
/// to keep admin authority out of bearer-token reach.
|
||||||
@@ -120,3 +159,34 @@ impl FromRequestParts<AppState> for RequireAdmin {
|
|||||||
Ok(RequireAdmin(user))
|
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()
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -56,4 +75,24 @@ mod tests {
|
|||||||
let b = hash_password("same").unwrap();
|
let b = hash_password("same").unwrap();
|
||||||
assert_ne!(a, b, "two hashes of the same password must differ (salt)");
|
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]`
|
//! tests run in isolated buckets and won't bleed across `#[sqlx::test]`
|
||||||
//! cases that share a process.
|
//! cases that share a process.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Instant;
|
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
|
/// Tunable limits. `per_sec == 0` disables the limiter — used by the
|
||||||
/// test harness and by anyone who wants to opt out via env config.
|
/// test harness and by anyone who wants to opt out via env config.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -50,6 +58,42 @@ struct Bucket {
|
|||||||
last_refill: Instant,
|
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
|
/// Outcome of [`AuthRateLimiter::try_acquire`]. When `Denied`, the
|
||||||
/// caller can use `retry_after_secs` for a `Retry-After: N` header
|
/// caller can use `retry_after_secs` for a `Retry-After: N` header
|
||||||
/// (RFC 6585 §4) so well-behaved clients back off correctly rather
|
/// (RFC 6585 §4) so well-behaved clients back off correctly rather
|
||||||
@@ -60,51 +104,64 @@ pub enum AcquireResult {
|
|||||||
Denied { retry_after_secs: u64 },
|
Denied { retry_after_secs: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-bucket token-bucket limiter. `try_acquire` is cheap (one
|
/// Token-bucket limiter keyed by client IP, with a shared global bucket for
|
||||||
/// mutex acquire, no allocations) so the auth path doesn't pay a real
|
/// requests whose IP is unknown (no trusted proxy).
|
||||||
/// cost for the check.
|
///
|
||||||
|
/// 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 {
|
pub struct AuthRateLimiter {
|
||||||
cfg: RateLimitConfig,
|
cfg: RateLimitConfig,
|
||||||
bucket: Mutex<Bucket>,
|
global: Mutex<Bucket>,
|
||||||
|
per_ip: Mutex<HashMap<IpAddr, Bucket>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthRateLimiter {
|
impl AuthRateLimiter {
|
||||||
pub fn new(cfg: RateLimitConfig) -> Self {
|
pub fn new(cfg: RateLimitConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
cfg,
|
cfg,
|
||||||
bucket: Mutex::new(Bucket {
|
global: Mutex::new(Bucket::new(cfg.burst)),
|
||||||
tokens: cfg.burst as f64,
|
per_ip: Mutex::new(HashMap::new()),
|
||||||
last_refill: Instant::now(),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume one token if available. Returns `Denied` with a
|
/// Consume one token from the bucket for `key` (per-IP when `Some`, the
|
||||||
/// rounded-up seconds-until-refill so the caller can emit a
|
/// shared global bucket when `None`). Returns `Denied` with a rounded-up
|
||||||
/// `Retry-After` header.
|
/// seconds-until-refill so the caller can emit a `Retry-After` header.
|
||||||
pub fn try_acquire(&self) -> AcquireResult {
|
pub fn try_acquire(&self, key: Option<IpAddr>) -> AcquireResult {
|
||||||
if self.cfg.per_sec == 0 {
|
if self.cfg.per_sec == 0 {
|
||||||
return AcquireResult::Allowed;
|
return AcquireResult::Allowed;
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut bucket = self.bucket.lock().expect("rate limiter mutex poisoned");
|
let Some(ip) = key else {
|
||||||
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
|
return self
|
||||||
bucket.tokens =
|
.global
|
||||||
(bucket.tokens + elapsed * f64::from(self.cfg.per_sec)).min(f64::from(self.cfg.burst));
|
.lock()
|
||||||
bucket.last_refill = now;
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
if bucket.tokens >= 1.0 {
|
.try_take(&self.cfg, now);
|
||||||
bucket.tokens -= 1.0;
|
};
|
||||||
AcquireResult::Allowed
|
let mut map = self.per_ip.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
} else {
|
if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
|
||||||
// ceil((1 - tokens) / per_sec), minimum 1 — a `Retry-After: 0`
|
map.retain(|_, b| !b.is_idle(&self.cfg, now));
|
||||||
// would tell clients to retry immediately, which is what we're
|
if map.len() >= MAX_TRACKED_IPS {
|
||||||
// actively trying to discourage.
|
// Still saturated with active attackers — degrade to the shared
|
||||||
let deficit = 1.0 - bucket.tokens;
|
// bucket rather than grow unbounded. Worst case is the old
|
||||||
let wait_secs = (deficit / f64::from(self.cfg.per_sec)).ceil() as u64;
|
// global-bucket behaviour under an extreme distributed flood.
|
||||||
AcquireResult::Denied {
|
drop(map);
|
||||||
retry_after_secs: wait_secs.max(1),
|
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,
|
burst: 0,
|
||||||
});
|
});
|
||||||
for _ in 0..1000 {
|
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,
|
per_sec: 1,
|
||||||
burst: 3,
|
burst: 3,
|
||||||
});
|
});
|
||||||
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
|
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
|
||||||
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
|
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
|
||||||
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
|
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
|
||||||
match rl.try_acquire() {
|
match rl.try_acquire(None) {
|
||||||
AcquireResult::Denied { retry_after_secs } => {
|
AcquireResult::Denied { retry_after_secs } => {
|
||||||
// Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait.
|
// Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -152,11 +209,11 @@ mod tests {
|
|||||||
per_sec: 10,
|
per_sec: 10,
|
||||||
burst: 1,
|
burst: 1,
|
||||||
});
|
});
|
||||||
assert_eq!(rl.try_acquire(), AcquireResult::Allowed);
|
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
|
||||||
assert!(matches!(rl.try_acquire(), AcquireResult::Denied { .. }));
|
assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
|
||||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rl.try_acquire(),
|
rl.try_acquire(None),
|
||||||
AcquireResult::Allowed,
|
AcquireResult::Allowed,
|
||||||
"token should have refilled"
|
"token should have refilled"
|
||||||
);
|
);
|
||||||
@@ -170,10 +227,95 @@ mod tests {
|
|||||||
per_sec: 1,
|
per_sec: 1,
|
||||||
burst: 1,
|
burst: 1,
|
||||||
});
|
});
|
||||||
slow.try_acquire();
|
slow.try_acquire(None);
|
||||||
match slow.try_acquire() {
|
match slow.try_acquire(None) {
|
||||||
AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1),
|
AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1),
|
||||||
_ => panic!("expected Denied"),
|
_ => 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
|
//! `generate_token` draws 32 bytes from the OS CSPRNG, encodes them as
|
||||||
//! URL-safe base64 (no padding), and returns the raw string alongside its
|
//! 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
|
//! SHA-256 hash. Storage holds only the hash; the raw value lives in the
|
||||||
//! cookie or `Authorization` header. Comparison goes through
|
//! cookie or `Authorization` header. Token lookup is an indexed equality on
|
||||||
//! `constant_time_eq` to keep timing side channels off the table.
|
//! 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::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use subtle::ConstantTimeEq;
|
|
||||||
|
|
||||||
pub const TOKEN_BYTES: usize = 32;
|
pub const TOKEN_BYTES: usize = 32;
|
||||||
pub const HASH_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()
|
hasher.finalize().into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
|
||||||
a.ct_eq(b).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -58,11 +55,4 @@ mod tests {
|
|||||||
assert_eq!(hash_token("abc"), hash_token("abc"));
|
assert_eq!(hash_token("abc"), hash_token("abc"));
|
||||||
assert_ne!(hash_token("abc"), hash_token("abd"));
|
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);
|
cookie_jar.add_cookie_str(&cookie_str, &seed_url);
|
||||||
tracing::info!(domain, "seeded PHPSESSID into reqwest cookie jar");
|
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()
|
let mut http_builder = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.no_proxy()
|
.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);
|
.cookie_provider(cookie_jar);
|
||||||
if let Some(ua) = &user_agent {
|
if let Some(ua) = &user_agent {
|
||||||
http_builder = http_builder.user_agent(ua);
|
http_builder = http_builder.user_agent(ua);
|
||||||
@@ -123,8 +134,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
http_builder = http_builder
|
http_builder = http_builder
|
||||||
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy URL: {proxy}"))?);
|
.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")?;
|
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();
|
let mut options = LaunchOptions::from_env();
|
||||||
if let Some(proxy) = &proxy_url {
|
if let Some(proxy) = &proxy_url {
|
||||||
let chromium_proxy = mangalord::crawler::url_utils::chromium_proxy_arg(proxy);
|
let chromium_proxy = mangalord::crawler::url_utils::chromium_proxy_arg(proxy);
|
||||||
@@ -216,6 +244,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
rate_ms,
|
rate_ms,
|
||||||
cdn_host.as_deref(),
|
cdn_host.as_deref(),
|
||||||
cdn_rate_ms,
|
cdn_rate_ms,
|
||||||
|
Arc::clone(&allowlist),
|
||||||
limit,
|
limit,
|
||||||
skip_chapters,
|
skip_chapters,
|
||||||
skip_chapter_content || !session_ready,
|
skip_chapter_content || !session_ready,
|
||||||
@@ -246,6 +275,7 @@ async fn run(
|
|||||||
rate_ms: u64,
|
rate_ms: u64,
|
||||||
cdn_host: Option<&str>,
|
cdn_host: Option<&str>,
|
||||||
cdn_rate_ms: u64,
|
cdn_rate_ms: u64,
|
||||||
|
allowlist: Arc<mangalord::crawler::safety::DownloadAllowlist>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
skip_chapters: bool,
|
skip_chapters: bool,
|
||||||
skip_chapter_content: bool,
|
skip_chapter_content: bool,
|
||||||
@@ -259,38 +289,18 @@ async fn run(
|
|||||||
}
|
}
|
||||||
let rate = Arc::new(rate);
|
let rate = Arc::new(rate);
|
||||||
|
|
||||||
// SSRF defence: only download from the catalog host + CDN host
|
// Per-image download cap (the allowlist is built in `main` and passed in
|
||||||
// (plus optional CRAWLER_DOWNLOAD_ALLOWLIST extras), and cap
|
// so the HTTP client's redirect policy and this check share one source).
|
||||||
// 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
|
|
||||||
};
|
|
||||||
let max_image_bytes: usize = std::env::var("CRAWLER_MAX_IMAGE_BYTES")
|
let max_image_bytes: usize = std::env::var("CRAWLER_MAX_IMAGE_BYTES")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(mangalord::crawler::safety::DEFAULT_MAX_IMAGE_BYTES);
|
.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(
|
let stats = pipeline::run_metadata_pass(
|
||||||
manager.as_ref(),
|
manager.as_ref(),
|
||||||
@@ -325,6 +335,7 @@ async fn run(
|
|||||||
force_refetch_chapters,
|
force_refetch_chapters,
|
||||||
Arc::clone(&allowlist),
|
Arc::clone(&allowlist),
|
||||||
max_image_bytes,
|
max_image_bytes,
|
||||||
|
max_images_per_chapter,
|
||||||
tor.clone(),
|
tor.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -351,6 +362,7 @@ async fn sync_bookmarked_chapter_content(
|
|||||||
force_refetch: bool,
|
force_refetch: bool,
|
||||||
allowlist: Arc<mangalord::crawler::safety::DownloadAllowlist>,
|
allowlist: Arc<mangalord::crawler::safety::DownloadAllowlist>,
|
||||||
max_image_bytes: usize,
|
max_image_bytes: usize,
|
||||||
|
max_images_per_chapter: usize,
|
||||||
tor: Option<Arc<mangalord::crawler::tor::TorController>>,
|
tor: Option<Arc<mangalord::crawler::tor::TorController>>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let pending: Vec<(Uuid, Uuid, String)> = sqlx::query_as(
|
let pending: Vec<(Uuid, Uuid, String)> = sqlx::query_as(
|
||||||
@@ -416,6 +428,7 @@ async fn sync_bookmarked_chapter_content(
|
|||||||
force_refetch,
|
force_refetch,
|
||||||
allowlist.as_ref(),
|
allowlist.as_ref(),
|
||||||
max_image_bytes,
|
max_image_bytes,
|
||||||
|
max_images_per_chapter,
|
||||||
tor.as_deref(),
|
tor.as_deref(),
|
||||||
// CLI one-shot — no live status surface.
|
// CLI one-shot — no live status surface.
|
||||||
None,
|
None,
|
||||||
@@ -431,6 +444,13 @@ async fn sync_bookmarked_chapter_content(
|
|||||||
s.fetched += 1;
|
s.fetched += 1;
|
||||||
}
|
}
|
||||||
Ok(SyncOutcome::Skipped) => s.skipped += 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) => {
|
Ok(SyncOutcome::SessionExpired) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
%chapter_id,
|
%chapter_id,
|
||||||
@@ -497,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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ pub struct AuthConfig {
|
|||||||
/// so a private instance is locked down with a single switch.
|
/// so a private instance is locked down with a single switch.
|
||||||
/// Defaults to `false` (current public behaviour).
|
/// Defaults to `false` (current public behaviour).
|
||||||
pub private_mode: bool,
|
pub private_mode: bool,
|
||||||
|
/// Whether to trust a proxy-supplied `X-Forwarded-For` header as the
|
||||||
|
/// client IP for per-IP auth rate limiting. Enable ONLY when the backend
|
||||||
|
/// sits behind a trusted reverse proxy that overrides the header (the
|
||||||
|
/// compose deploy: SvelteKit's hooks.server.ts sets it from the real peer
|
||||||
|
/// address). When `false` (default), the header is ignored and the auth
|
||||||
|
/// limiter uses a single shared bucket — a directly-exposed backend must
|
||||||
|
/// keep this off or clients could spoof their IP to dodge the limit.
|
||||||
|
pub trusted_proxy: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthConfig {
|
impl Default for AuthConfig {
|
||||||
@@ -42,6 +50,7 @@ impl Default for AuthConfig {
|
|||||||
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
||||||
allow_self_register: true,
|
allow_self_register: true,
|
||||||
private_mode: false,
|
private_mode: false,
|
||||||
|
trusted_proxy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +64,13 @@ pub struct UploadConfig {
|
|||||||
/// reject a single oversized cover/page without failing the whole
|
/// reject a single oversized cover/page without failing the whole
|
||||||
/// request just because the total happens to fit.
|
/// request just because the total happens to fit.
|
||||||
pub max_file_bytes: usize,
|
pub max_file_bytes: usize,
|
||||||
|
/// Max page images accepted in one chapter upload. Bounds how many
|
||||||
|
/// parts the handler will stage before giving up, so a client can't
|
||||||
|
/// pin a worker streaming an unbounded page count. `0` disables THIS
|
||||||
|
/// cap — the total is then bounded only by `max_request_bytes` (the
|
||||||
|
/// whole-request body limit), which stays the backstop either way.
|
||||||
|
/// Defaults to 2000. `MAX_PAGES_PER_CHAPTER`.
|
||||||
|
pub max_pages_per_chapter: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UploadConfig {
|
impl Default for UploadConfig {
|
||||||
@@ -62,6 +78,46 @@ impl Default for UploadConfig {
|
|||||||
Self {
|
Self {
|
||||||
max_request_bytes: 200 * 1024 * 1024, // 200 MiB
|
max_request_bytes: 200 * 1024 * 1024, // 200 MiB
|
||||||
max_file_bytes: 20 * 1024 * 1024, // 20 MiB
|
max_file_bytes: 20 * 1024 * 1024, // 20 MiB
|
||||||
|
max_pages_per_chapter: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Postgres connection-pool sizing. One pool backs every HTTP handler plus
|
||||||
|
/// the crawler/analysis daemons, so it must be large enough not to starve
|
||||||
|
/// interactive reads and fail fast (rather than hang on the driver's silent
|
||||||
|
/// 30 s default) when genuinely saturated.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DbConfig {
|
||||||
|
/// `DB_MAX_CONNECTIONS`. Upper bound on open connections.
|
||||||
|
pub max_connections: u32,
|
||||||
|
/// `DB_ACQUIRE_TIMEOUT_SECS`. How long a caller waits for a free
|
||||||
|
/// connection before erroring — short so overload surfaces as a fast
|
||||||
|
/// 500 instead of a 30 s hang.
|
||||||
|
pub acquire_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DbConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_connections: 20,
|
||||||
|
acquire_timeout: Duration::from_secs(10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let default = Self::default();
|
||||||
|
Self {
|
||||||
|
// `.max(1)`: a zero-size pool can never hand out a connection and
|
||||||
|
// would deadlock every query — clamp to at least one.
|
||||||
|
max_connections: env_u64("DB_MAX_CONNECTIONS", default.max_connections.into())
|
||||||
|
.max(1) as u32,
|
||||||
|
acquire_timeout: Duration::from_secs(env_u64(
|
||||||
|
"DB_ACQUIRE_TIMEOUT_SECS",
|
||||||
|
default.acquire_timeout.as_secs(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +169,32 @@ impl ResponseFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which engine the analysis worker dispatches each page through. A
|
||||||
|
/// deploy-time choice (the engine is either installed or not), so it lives in
|
||||||
|
/// env only and is not part of the admin-editable `AnalysisSettings`.
|
||||||
|
///
|
||||||
|
/// * `Ocr` — in-process [`crate::analysis::ocr`] (the `ocrs` engine): fast,
|
||||||
|
/// CPU-only, English text. Writes OCR text only (no tags/scene/safety).
|
||||||
|
/// * `Vision` — the local OpenAI-compatible LLM in [`crate::analysis::vision`]:
|
||||||
|
/// full OCR + tags + scene + safety, but heavy.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AnalysisBackend {
|
||||||
|
Ocr,
|
||||||
|
Vision,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalysisBackend {
|
||||||
|
/// Lenient env parse. Defaults to `Ocr` (the Pi-friendly path) for any
|
||||||
|
/// unset or unrecognized value; `vision` opts back into the LLM engine.
|
||||||
|
fn from_str(s: &str) -> AnalysisBackend {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"vision" | "llm" => AnalysisBackend::Vision,
|
||||||
|
// Default (incl. "ocr", "ocrs", and anything unrecognized).
|
||||||
|
_ => AnalysisBackend::Ocr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AI content-analysis worker configuration: the enable gate, the local
|
/// AI content-analysis worker configuration: the enable gate, the local
|
||||||
/// OpenAI-compatible vision endpoint, and the worker / request knobs.
|
/// OpenAI-compatible vision endpoint, and the worker / request knobs.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -120,6 +202,16 @@ pub struct AnalysisConfig {
|
|||||||
/// Master switch (`ANALYSIS_ENABLED`). When `false`, no analysis jobs
|
/// Master switch (`ANALYSIS_ENABLED`). When `false`, no analysis jobs
|
||||||
/// are enqueued and no worker runs. Defaults to `false`.
|
/// are enqueued and no worker runs. Defaults to `false`.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// Which engine the worker dispatches through (`ANALYSIS_BACKEND`):
|
||||||
|
/// `ocr` (default, the in-process `ocrs` engine) or `vision` (the local
|
||||||
|
/// LLM). Deploy-time, env-only — see [`AnalysisBackend`].
|
||||||
|
pub backend: AnalysisBackend,
|
||||||
|
/// Path to the ocrs text-*detection* `.rten` model
|
||||||
|
/// (`OCRS_DETECTION_MODEL`). Only read when `backend == Ocr`.
|
||||||
|
pub ocr_detection_model: String,
|
||||||
|
/// Path to the ocrs text-*recognition* `.rten` model
|
||||||
|
/// (`OCRS_RECOGNITION_MODEL`). Only read when `backend == Ocr`.
|
||||||
|
pub ocr_recognition_model: String,
|
||||||
/// Number of concurrent analysis workers (`ANALYSIS_WORKERS`).
|
/// Number of concurrent analysis workers (`ANALYSIS_WORKERS`).
|
||||||
pub workers: usize,
|
pub workers: usize,
|
||||||
/// OpenAI-compatible chat/completions URL (`ANALYSIS_VISION_URL`).
|
/// OpenAI-compatible chat/completions URL (`ANALYSIS_VISION_URL`).
|
||||||
@@ -131,7 +223,7 @@ pub struct AnalysisConfig {
|
|||||||
/// land a `failed` row. `None` (the default) disables the gate, matching
|
/// land a `failed` row. `None` (the default) disables the gate, matching
|
||||||
/// the prior behavior for always-on endpoints. Env-only, like `api_key`.
|
/// the prior behavior for always-on endpoints. Env-only, like `api_key`.
|
||||||
pub vision_health_url: Option<String>,
|
pub vision_health_url: Option<String>,
|
||||||
/// Model id to request (`ANALYSIS_MODEL`).
|
/// Model id to request (`ANALYSIS_VISION_MODEL`).
|
||||||
pub model: String,
|
pub model: String,
|
||||||
/// Optional bearer token (`ANALYSIS_API_KEY`); local servers usually
|
/// Optional bearer token (`ANALYSIS_API_KEY`); local servers usually
|
||||||
/// don't need one.
|
/// don't need one.
|
||||||
@@ -166,6 +258,13 @@ pub struct AnalysisConfig {
|
|||||||
/// Hard cap on a page image's stored size; larger pages are skipped
|
/// Hard cap on a page image's stored size; larger pages are skipped
|
||||||
/// (`ANALYSIS_MAX_IMAGE_BYTES`).
|
/// (`ANALYSIS_MAX_IMAGE_BYTES`).
|
||||||
pub max_image_bytes: usize,
|
pub max_image_bytes: usize,
|
||||||
|
/// Hard cap on a page image's **decoded** pixel count for the OCR backend
|
||||||
|
/// (`ANALYSIS_OCR_MAX_DECODE_PIXELS`). `max_image_bytes` only bounds the
|
||||||
|
/// *encoded* size; without a decode bound a tiny image declaring
|
||||||
|
/// 50000×50000 inflates to billions of bytes and OOM-kills the worker
|
||||||
|
/// (decompression bomb). Generous by default (100 MP) so legitimately
|
||||||
|
/// tall, un-sliced manga pages still decode.
|
||||||
|
pub ocr_max_decode_pixels: u64,
|
||||||
/// Output-constraint mode (`ANALYSIS_RESPONSE_FORMAT`):
|
/// Output-constraint mode (`ANALYSIS_RESPONSE_FORMAT`):
|
||||||
/// `json_schema` (default) | `json_object` | `none`.
|
/// `json_schema` (default) | `json_object` | `none`.
|
||||||
pub response_format: ResponseFormat,
|
pub response_format: ResponseFormat,
|
||||||
@@ -195,6 +294,9 @@ impl Default for AnalysisConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
backend: AnalysisBackend::Ocr,
|
||||||
|
ocr_detection_model: "/models/text-detection.rten".to_string(),
|
||||||
|
ocr_recognition_model: "/models/text-recognition.rten".to_string(),
|
||||||
workers: 1,
|
workers: 1,
|
||||||
endpoint: "http://localhost:8000/v1/chat/completions".to_string(),
|
endpoint: "http://localhost:8000/v1/chat/completions".to_string(),
|
||||||
vision_health_url: None,
|
vision_health_url: None,
|
||||||
@@ -212,6 +314,7 @@ impl Default for AnalysisConfig {
|
|||||||
tall_aspect_threshold: 1.6,
|
tall_aspect_threshold: 1.6,
|
||||||
max_slices: 16,
|
max_slices: 16,
|
||||||
max_image_bytes: 8 * 1024 * 1024,
|
max_image_bytes: 8 * 1024 * 1024,
|
||||||
|
ocr_max_decode_pixels: 100_000_000,
|
||||||
response_format: ResponseFormat::JsonSchema,
|
response_format: ResponseFormat::JsonSchema,
|
||||||
frequency_penalty: 0.3,
|
frequency_penalty: 0.3,
|
||||||
temperature: 0.0,
|
temperature: 0.0,
|
||||||
@@ -223,16 +326,51 @@ impl Default for AnalysisConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AnalysisConfig {
|
impl AnalysisConfig {
|
||||||
|
/// The backend the worker actually dispatches through.
|
||||||
|
///
|
||||||
|
/// Vision is **temporarily disabled**: the engine code (`analysis::vision`,
|
||||||
|
/// `RealAnalyzeDispatcher`, the readiness probe) is kept intact but never
|
||||||
|
/// selected. Until it's re-enabled, this returns [`AnalysisBackend::Ocr`]
|
||||||
|
/// regardless of the parsed `backend`, logging a warning if `vision` was
|
||||||
|
/// requested so an env override isn't silently ignored. Re-enabling vision
|
||||||
|
/// is then a one-line change here (return `self.backend`).
|
||||||
|
pub fn effective_backend(&self) -> AnalysisBackend {
|
||||||
|
if self.backend == AnalysisBackend::Vision {
|
||||||
|
tracing::warn!(
|
||||||
|
"ANALYSIS_BACKEND=vision requested but the vision backend is temporarily \
|
||||||
|
disabled; running OCR instead"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AnalysisBackend::Ocr
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
let d = AnalysisConfig::default();
|
let d = AnalysisConfig::default();
|
||||||
Self {
|
Self {
|
||||||
enabled: env_bool("ANALYSIS_ENABLED", d.enabled),
|
enabled: env_bool("ANALYSIS_ENABLED", d.enabled),
|
||||||
|
backend: std::env::var("ANALYSIS_BACKEND")
|
||||||
|
.map(|s| AnalysisBackend::from_str(&s))
|
||||||
|
.unwrap_or(d.backend),
|
||||||
|
ocr_detection_model: std::env::var("OCRS_DETECTION_MODEL")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(d.ocr_detection_model),
|
||||||
|
ocr_recognition_model: std::env::var("OCRS_RECOGNITION_MODEL")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(d.ocr_recognition_model),
|
||||||
workers: env_usize("ANALYSIS_WORKERS", d.workers).max(1),
|
workers: env_usize("ANALYSIS_WORKERS", d.workers).max(1),
|
||||||
endpoint: std::env::var("ANALYSIS_VISION_URL").unwrap_or(d.endpoint),
|
endpoint: std::env::var("ANALYSIS_VISION_URL").unwrap_or(d.endpoint),
|
||||||
vision_health_url: std::env::var("ANALYSIS_VISION_HEALTH_URL")
|
vision_health_url: std::env::var("ANALYSIS_VISION_HEALTH_URL")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|s| !s.is_empty()),
|
.filter(|s| !s.is_empty()),
|
||||||
model: std::env::var("ANALYSIS_MODEL").unwrap_or(d.model),
|
// Renamed from `ANALYSIS_MODEL` in 0.87.17 to match the
|
||||||
|
// `ANALYSIS_VISION_*` naming the docs, compose, and 0.87.12's
|
||||||
|
// regression test had already standardised on. Pre-0.87.17
|
||||||
|
// deploys reading the old `ANALYSIS_MODEL` will fall through
|
||||||
|
// to the default — explicitly noted so an operator hitting
|
||||||
|
// an empty `model` after upgrade can grep this file.
|
||||||
|
model: std::env::var("ANALYSIS_VISION_MODEL").unwrap_or(d.model),
|
||||||
api_key: std::env::var("ANALYSIS_API_KEY")
|
api_key: std::env::var("ANALYSIS_API_KEY")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|s| !s.is_empty()),
|
.filter(|s| !s.is_empty()),
|
||||||
@@ -253,6 +391,10 @@ impl AnalysisConfig {
|
|||||||
.max(1.0),
|
.max(1.0),
|
||||||
max_slices: env_usize("ANALYSIS_MAX_SLICES", d.max_slices).max(1),
|
max_slices: env_usize("ANALYSIS_MAX_SLICES", d.max_slices).max(1),
|
||||||
max_image_bytes: env_usize("ANALYSIS_MAX_IMAGE_BYTES", d.max_image_bytes),
|
max_image_bytes: env_usize("ANALYSIS_MAX_IMAGE_BYTES", d.max_image_bytes),
|
||||||
|
ocr_max_decode_pixels: env_u64(
|
||||||
|
"ANALYSIS_OCR_MAX_DECODE_PIXELS",
|
||||||
|
d.ocr_max_decode_pixels,
|
||||||
|
),
|
||||||
response_format: std::env::var("ANALYSIS_RESPONSE_FORMAT")
|
response_format: std::env::var("ANALYSIS_RESPONSE_FORMAT")
|
||||||
.map(|s| ResponseFormat::from_str(&s))
|
.map(|s| ResponseFormat::from_str(&s))
|
||||||
.unwrap_or(d.response_format),
|
.unwrap_or(d.response_format),
|
||||||
@@ -279,6 +421,7 @@ pub struct Config {
|
|||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub bind_address: String,
|
pub bind_address: String,
|
||||||
pub storage_dir: PathBuf,
|
pub storage_dir: PathBuf,
|
||||||
|
pub db: DbConfig,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
pub upload: UploadConfig,
|
pub upload: UploadConfig,
|
||||||
pub cors_allowed_origins: Vec<String>,
|
pub cors_allowed_origins: Vec<String>,
|
||||||
@@ -321,6 +464,10 @@ pub struct CrawlerConfig {
|
|||||||
pub idle_timeout: Duration,
|
pub idle_timeout: Duration,
|
||||||
pub chapter_workers: usize,
|
pub chapter_workers: usize,
|
||||||
pub retention_days: u32,
|
pub retention_days: u32,
|
||||||
|
/// Days to keep `crawl_metrics` timing rows before reaping. `0`
|
||||||
|
/// disables the reaper. Defaults to 90 (volume is low — one row per
|
||||||
|
/// manga/chapter/pass, not per image). `CRAWL_METRICS_RETENTION_DAYS`.
|
||||||
|
pub metrics_retention_days: u32,
|
||||||
pub start_url: Option<String>,
|
pub start_url: Option<String>,
|
||||||
pub rate_ms: u64,
|
pub rate_ms: u64,
|
||||||
pub cdn_host: Option<String>,
|
pub cdn_host: Option<String>,
|
||||||
@@ -351,6 +498,13 @@ pub struct CrawlerConfig {
|
|||||||
pub download_allowlist: DownloadAllowlist,
|
pub download_allowlist: DownloadAllowlist,
|
||||||
/// Hard upper bound on a single image download. Defaults to 32 MiB.
|
/// Hard upper bound on a single image download. Defaults to 32 MiB.
|
||||||
pub max_image_bytes: usize,
|
pub max_image_bytes: usize,
|
||||||
|
/// Hard upper bound on the number of page images in one chapter. A
|
||||||
|
/// hostile reader page could otherwise list thousands of `<img>`
|
||||||
|
/// tags; `max_image_bytes` caps each one but not the count, so the
|
||||||
|
/// product is an unbounded disk-fill. A chapter exceeding this is
|
||||||
|
/// acked failed rather than downloaded. `0` disables the cap.
|
||||||
|
/// Defaults to 2000. `CRAWLER_MAX_IMAGES_PER_CHAPTER`.
|
||||||
|
pub max_images_per_chapter: usize,
|
||||||
/// Max manga detail fetches per metadata pass. `0` means no cap
|
/// Max manga detail fetches per metadata pass. `0` means no cap
|
||||||
/// (full sweep up to the source's own bound). Sourced from
|
/// (full sweep up to the source's own bound). Sourced from
|
||||||
/// `CRAWLER_LIMIT`, mirroring the CLI binary.
|
/// `CRAWLER_LIMIT`, mirroring the CLI binary.
|
||||||
@@ -368,6 +522,16 @@ pub struct CrawlerConfig {
|
|||||||
/// exhausted) that trigger an automatic coordinated browser restart.
|
/// exhausted) that trigger an automatic coordinated browser restart.
|
||||||
/// Defaults to 3. `CRAWLER_BROWSER_RESTART_THRESHOLD`.
|
/// Defaults to 3. `CRAWLER_BROWSER_RESTART_THRESHOLD`.
|
||||||
pub browser_restart_threshold: u32,
|
pub browser_restart_threshold: u32,
|
||||||
|
/// CDP `Fetch` interception that re-validates every headless-browser
|
||||||
|
/// navigation/redirect/subresource against the SSRF check. Default `true`:
|
||||||
|
/// with it off, only the top-level URL string is validated, so a scraped
|
||||||
|
/// page's JS/subresources (which use Chromium's own network stack, not the
|
||||||
|
/// reqwest `SafeResolver`) can reach internal targets like the cloud
|
||||||
|
/// metadata service or postgres. The Fetch hook can't be exercised in CI (no
|
||||||
|
/// Chromium) — the `#[ignore]`d `ssrf_interception_does_not_wedge_allowed_navigation`
|
||||||
|
/// smoke test validates it against a real binary. `CRAWLER_SSRF_INTERCEPT`;
|
||||||
|
/// set `false` only as a break-glass if the hook destabilizes a deployment.
|
||||||
|
pub ssrf_intercept: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CrawlerConfig {
|
impl Default for CrawlerConfig {
|
||||||
@@ -379,6 +543,7 @@ impl Default for CrawlerConfig {
|
|||||||
idle_timeout: Duration::from_secs(600),
|
idle_timeout: Duration::from_secs(600),
|
||||||
chapter_workers: 1,
|
chapter_workers: 1,
|
||||||
retention_days: 7,
|
retention_days: 7,
|
||||||
|
metrics_retention_days: 90,
|
||||||
start_url: None,
|
start_url: None,
|
||||||
rate_ms: 1000,
|
rate_ms: 1000,
|
||||||
cdn_host: None,
|
cdn_host: None,
|
||||||
@@ -394,10 +559,12 @@ impl Default for CrawlerConfig {
|
|||||||
browser: LaunchOptions::headless(),
|
browser: LaunchOptions::headless(),
|
||||||
download_allowlist: DownloadAllowlist::new(),
|
download_allowlist: DownloadAllowlist::new(),
|
||||||
max_image_bytes: DEFAULT_MAX_IMAGE_BYTES,
|
max_image_bytes: DEFAULT_MAX_IMAGE_BYTES,
|
||||||
|
max_images_per_chapter: 2000,
|
||||||
manga_limit: 0,
|
manga_limit: 0,
|
||||||
job_timeout: Duration::from_secs(600),
|
job_timeout: Duration::from_secs(600),
|
||||||
metadata_max_consecutive_failures: 10,
|
metadata_max_consecutive_failures: 10,
|
||||||
browser_restart_threshold: 3,
|
browser_restart_threshold: 3,
|
||||||
|
ssrf_intercept: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,6 +579,7 @@ impl Config {
|
|||||||
storage_dir: std::env::var("STORAGE_DIR")
|
storage_dir: std::env::var("STORAGE_DIR")
|
||||||
.unwrap_or_else(|_| "./data/storage".to_string())
|
.unwrap_or_else(|_| "./data/storage".to_string())
|
||||||
.into(),
|
.into(),
|
||||||
|
db: DbConfig::from_env(),
|
||||||
auth: AuthConfig {
|
auth: AuthConfig {
|
||||||
cookie_secure: env_bool("COOKIE_SECURE", true),
|
cookie_secure: env_bool("COOKIE_SECURE", true),
|
||||||
cookie_domain: std::env::var("COOKIE_DOMAIN")
|
cookie_domain: std::env::var("COOKIE_DOMAIN")
|
||||||
@@ -430,10 +598,12 @@ impl Config {
|
|||||||
},
|
},
|
||||||
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
||||||
private_mode: env_bool("PRIVATE_MODE", false),
|
private_mode: env_bool("PRIVATE_MODE", false),
|
||||||
|
trusted_proxy: env_bool("AUTH_TRUSTED_PROXY", false),
|
||||||
},
|
},
|
||||||
upload: UploadConfig {
|
upload: UploadConfig {
|
||||||
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
||||||
max_file_bytes: env_usize("MAX_FILE_BYTES", 20 * 1024 * 1024),
|
max_file_bytes: env_usize("MAX_FILE_BYTES", 20 * 1024 * 1024),
|
||||||
|
max_pages_per_chapter: env_usize("MAX_PAGES_PER_CHAPTER", 2000),
|
||||||
},
|
},
|
||||||
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
|
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -463,11 +633,30 @@ impl Config {
|
|||||||
/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME`
|
/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME`
|
||||||
/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is
|
/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is
|
||||||
/// treated as "no bootstrap" rather than a hard error, so an operator
|
/// treated as "no bootstrap" rather than a hard error, so an operator
|
||||||
/// can comment out one env var without crashing the server.
|
/// can comment out one env var without crashing the server. Emits a
|
||||||
|
/// warning in the half-set case — silent no-op startled operators when
|
||||||
|
/// the documented compose env vars finally landed in 0.87.12 (a typo
|
||||||
|
/// in ADMIN_USERNAME used to silently disable bootstrap with no log
|
||||||
|
/// trace explaining why no admin appeared).
|
||||||
fn admin_bootstrap_from_env() -> Option<(String, String)> {
|
fn admin_bootstrap_from_env() -> Option<(String, String)> {
|
||||||
let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty())?;
|
let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty());
|
||||||
let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty())?;
|
let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty());
|
||||||
Some((username, password))
|
match (username, password) {
|
||||||
|
(Some(u), Some(p)) => Some((u, p)),
|
||||||
|
(Some(_), None) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"ADMIN_USERNAME is set but ADMIN_PASSWORD is empty — admin bootstrap skipped (set both, or unset both to silence this warning)"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"ADMIN_PASSWORD is set but ADMIN_USERNAME is empty — admin bootstrap skipped (set both, or unset both to silence this warning)"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CrawlerConfig {
|
impl CrawlerConfig {
|
||||||
@@ -500,6 +689,7 @@ impl CrawlerConfig {
|
|||||||
idle_timeout: Duration::from_secs(env_u64("CRAWLER_IDLE_TIMEOUT_S", 600)),
|
idle_timeout: Duration::from_secs(env_u64("CRAWLER_IDLE_TIMEOUT_S", 600)),
|
||||||
chapter_workers: env_u64("CRAWLER_CHAPTER_WORKERS", 1).max(1) as usize,
|
chapter_workers: env_u64("CRAWLER_CHAPTER_WORKERS", 1).max(1) as usize,
|
||||||
retention_days: env_u64("CRAWLER_JOB_RETENTION_DAYS", 7) as u32,
|
retention_days: env_u64("CRAWLER_JOB_RETENTION_DAYS", 7) as u32,
|
||||||
|
metrics_retention_days: env_u64("CRAWL_METRICS_RETENTION_DAYS", 90) as u32,
|
||||||
start_url,
|
start_url,
|
||||||
rate_ms: env_u64("CRAWLER_RATE_MS", 1000),
|
rate_ms: env_u64("CRAWLER_RATE_MS", 1000),
|
||||||
cdn_host,
|
cdn_host,
|
||||||
@@ -531,6 +721,7 @@ impl CrawlerConfig {
|
|||||||
browser: LaunchOptions::from_env(),
|
browser: LaunchOptions::from_env(),
|
||||||
download_allowlist,
|
download_allowlist,
|
||||||
max_image_bytes: env_usize("CRAWLER_MAX_IMAGE_BYTES", DEFAULT_MAX_IMAGE_BYTES),
|
max_image_bytes: env_usize("CRAWLER_MAX_IMAGE_BYTES", DEFAULT_MAX_IMAGE_BYTES),
|
||||||
|
max_images_per_chapter: env_usize("CRAWLER_MAX_IMAGES_PER_CHAPTER", 2000),
|
||||||
manga_limit: env_usize("CRAWLER_LIMIT", 0),
|
manga_limit: env_usize("CRAWLER_LIMIT", 0),
|
||||||
job_timeout: Duration::from_secs(env_u64("CRAWLER_JOB_TIMEOUT_SECS", 600).max(1)),
|
job_timeout: Duration::from_secs(env_u64("CRAWLER_JOB_TIMEOUT_SECS", 600).max(1)),
|
||||||
metadata_max_consecutive_failures: env_u64(
|
metadata_max_consecutive_failures: env_u64(
|
||||||
@@ -539,6 +730,7 @@ impl CrawlerConfig {
|
|||||||
) as u32,
|
) as u32,
|
||||||
browser_restart_threshold: env_u64("CRAWLER_BROWSER_RESTART_THRESHOLD", 3).max(1)
|
browser_restart_threshold: env_u64("CRAWLER_BROWSER_RESTART_THRESHOLD", 3).max(1)
|
||||||
as u32,
|
as u32,
|
||||||
|
ssrf_intercept: env_bool("CRAWLER_SSRF_INTERCEPT", true),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,7 +775,7 @@ fn build_download_allowlist(
|
|||||||
allow
|
allow
|
||||||
}
|
}
|
||||||
|
|
||||||
fn env_u64(name: &str, default: u64) -> u64 {
|
pub(crate) fn env_u64(name: &str, default: u64) -> u64 {
|
||||||
std::env::var(name)
|
std::env::var(name)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
@@ -630,6 +822,18 @@ mod tests {
|
|||||||
// (we set/unset within each guard region).
|
// (we set/unset within each guard region).
|
||||||
static ENV_GUARD: Mutex<()> = Mutex::new(());
|
static ENV_GUARD: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Ensure `DATABASE_URL` is set for `Config::from_env()` WITHOUT clobbering
|
||||||
|
/// or removing an existing value. The `#[sqlx::test]` lib tests in other
|
||||||
|
/// modules read `DATABASE_URL` from the same process env and run in
|
||||||
|
/// parallel with these `ENV_GUARD`-serialised tests; overwriting it (bad
|
||||||
|
/// host) or removing it ("NotPresent") flaked them. CI already exports the
|
||||||
|
/// real URL, so this is a no-op there; locally it provides a placeholder.
|
||||||
|
fn ensure_database_url() {
|
||||||
|
if std::env::var_os("DATABASE_URL").is_none() {
|
||||||
|
std::env::set_var("DATABASE_URL", "postgres://test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crawler_limit_env_populates_manga_limit() {
|
fn crawler_limit_env_populates_manga_limit() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
@@ -674,6 +878,37 @@ mod tests {
|
|||||||
assert_eq!(cfg.browser_restart_threshold, 7);
|
assert_eq!(cfg.browser_restart_threshold, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_pool_defaults_when_unset() {
|
||||||
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
std::env::remove_var("DB_MAX_CONNECTIONS");
|
||||||
|
std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS");
|
||||||
|
let cfg = DbConfig::from_env();
|
||||||
|
assert_eq!(cfg.max_connections, 20);
|
||||||
|
assert_eq!(cfg.acquire_timeout, Duration::from_secs(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_pool_parses_from_env() {
|
||||||
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
std::env::set_var("DB_MAX_CONNECTIONS", "50");
|
||||||
|
std::env::set_var("DB_ACQUIRE_TIMEOUT_SECS", "3");
|
||||||
|
let cfg = DbConfig::from_env();
|
||||||
|
std::env::remove_var("DB_MAX_CONNECTIONS");
|
||||||
|
std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS");
|
||||||
|
assert_eq!(cfg.max_connections, 50);
|
||||||
|
assert_eq!(cfg.acquire_timeout, Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_pool_max_connections_clamps_to_at_least_one() {
|
||||||
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
std::env::set_var("DB_MAX_CONNECTIONS", "0");
|
||||||
|
let cfg = DbConfig::from_env();
|
||||||
|
std::env::remove_var("DB_MAX_CONNECTIONS");
|
||||||
|
assert_eq!(cfg.max_connections, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn analysis_config_defaults_when_unset() {
|
fn analysis_config_defaults_when_unset() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
@@ -681,7 +916,7 @@ mod tests {
|
|||||||
"ANALYSIS_ENABLED",
|
"ANALYSIS_ENABLED",
|
||||||
"ANALYSIS_WORKERS",
|
"ANALYSIS_WORKERS",
|
||||||
"ANALYSIS_VISION_URL",
|
"ANALYSIS_VISION_URL",
|
||||||
"ANALYSIS_MODEL",
|
"ANALYSIS_VISION_MODEL",
|
||||||
"ANALYSIS_API_KEY",
|
"ANALYSIS_API_KEY",
|
||||||
"ANALYSIS_MAX_TOKENS",
|
"ANALYSIS_MAX_TOKENS",
|
||||||
"ANALYSIS_MAX_PIXELS",
|
"ANALYSIS_MAX_PIXELS",
|
||||||
@@ -691,11 +926,18 @@ mod tests {
|
|||||||
"ANALYSIS_MAX_SLICES",
|
"ANALYSIS_MAX_SLICES",
|
||||||
"ANALYSIS_RESPONSE_FORMAT",
|
"ANALYSIS_RESPONSE_FORMAT",
|
||||||
"ANALYSIS_FREQUENCY_PENALTY",
|
"ANALYSIS_FREQUENCY_PENALTY",
|
||||||
|
"ANALYSIS_BACKEND",
|
||||||
|
"OCRS_DETECTION_MODEL",
|
||||||
|
"OCRS_RECOGNITION_MODEL",
|
||||||
] {
|
] {
|
||||||
std::env::remove_var(k);
|
std::env::remove_var(k);
|
||||||
}
|
}
|
||||||
let cfg = AnalysisConfig::from_env();
|
let cfg = AnalysisConfig::from_env();
|
||||||
assert!(!cfg.enabled);
|
assert!(!cfg.enabled);
|
||||||
|
// OCR is the default engine (the Pi-friendly path).
|
||||||
|
assert_eq!(cfg.backend, AnalysisBackend::Ocr);
|
||||||
|
assert_eq!(cfg.ocr_detection_model, "/models/text-detection.rten");
|
||||||
|
assert_eq!(cfg.ocr_recognition_model, "/models/text-recognition.rten");
|
||||||
assert_eq!(cfg.workers, 1);
|
assert_eq!(cfg.workers, 1);
|
||||||
assert_eq!(cfg.max_pixels, 1_000_000);
|
assert_eq!(cfg.max_pixels, 1_000_000);
|
||||||
assert_eq!(cfg.min_slice_height, 640);
|
assert_eq!(cfg.min_slice_height, 640);
|
||||||
@@ -722,13 +964,58 @@ mod tests {
|
|||||||
std::env::remove_var("ANALYSIS_RESPONSE_FORMAT");
|
std::env::remove_var("ANALYSIS_RESPONSE_FORMAT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn analysis_backend_parses_and_defaults_to_ocr() {
|
||||||
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
for (raw, want) in [
|
||||||
|
("ocr", AnalysisBackend::Ocr),
|
||||||
|
("ocrs", AnalysisBackend::Ocr),
|
||||||
|
("vision", AnalysisBackend::Vision),
|
||||||
|
("llm", AnalysisBackend::Vision),
|
||||||
|
("anything-else", AnalysisBackend::Ocr),
|
||||||
|
] {
|
||||||
|
std::env::set_var("ANALYSIS_BACKEND", raw);
|
||||||
|
assert_eq!(AnalysisConfig::from_env().backend, want, "raw={raw}");
|
||||||
|
}
|
||||||
|
// Unset → OCR.
|
||||||
|
std::env::remove_var("ANALYSIS_BACKEND");
|
||||||
|
assert_eq!(AnalysisConfig::from_env().backend, AnalysisBackend::Ocr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_backend_is_ocr_even_when_vision_requested() {
|
||||||
|
// Vision is temporarily disabled: the worker always runs OCR no matter
|
||||||
|
// what `ANALYSIS_BACKEND` parsed to. `backend` still reflects the raw
|
||||||
|
// request (so the override is visible/loggable), but `effective_backend`
|
||||||
|
// is the value the daemon actually dispatches through.
|
||||||
|
let mut cfg = AnalysisConfig {
|
||||||
|
backend: AnalysisBackend::Vision,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||||||
|
cfg.backend = AnalysisBackend::Ocr;
|
||||||
|
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ocr_model_paths_parse_from_env() {
|
||||||
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
std::env::set_var("OCRS_DETECTION_MODEL", "/opt/det.rten");
|
||||||
|
std::env::set_var("OCRS_RECOGNITION_MODEL", "/opt/rec.rten");
|
||||||
|
let cfg = AnalysisConfig::from_env();
|
||||||
|
std::env::remove_var("OCRS_DETECTION_MODEL");
|
||||||
|
std::env::remove_var("OCRS_RECOGNITION_MODEL");
|
||||||
|
assert_eq!(cfg.ocr_detection_model, "/opt/det.rten");
|
||||||
|
assert_eq!(cfg.ocr_recognition_model, "/opt/rec.rten");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn analysis_config_parses_from_env() {
|
fn analysis_config_parses_from_env() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
std::env::set_var("ANALYSIS_ENABLED", "true");
|
std::env::set_var("ANALYSIS_ENABLED", "true");
|
||||||
std::env::set_var("ANALYSIS_WORKERS", "4");
|
std::env::set_var("ANALYSIS_WORKERS", "4");
|
||||||
std::env::set_var("ANALYSIS_VISION_URL", "http://vis/v1/chat");
|
std::env::set_var("ANALYSIS_VISION_URL", "http://vis/v1/chat");
|
||||||
std::env::set_var("ANALYSIS_MODEL", "qwen2-vl");
|
std::env::set_var("ANALYSIS_VISION_MODEL", "qwen2-vl");
|
||||||
std::env::set_var("ANALYSIS_MAX_PIXELS", "768000");
|
std::env::set_var("ANALYSIS_MAX_PIXELS", "768000");
|
||||||
std::env::set_var("ANALYSIS_MAX_SLICES", "8");
|
std::env::set_var("ANALYSIS_MAX_SLICES", "8");
|
||||||
std::env::set_var("ANALYSIS_SLICE_OVERLAP", "0.2");
|
std::env::set_var("ANALYSIS_SLICE_OVERLAP", "0.2");
|
||||||
@@ -737,7 +1024,7 @@ mod tests {
|
|||||||
"ANALYSIS_ENABLED",
|
"ANALYSIS_ENABLED",
|
||||||
"ANALYSIS_WORKERS",
|
"ANALYSIS_WORKERS",
|
||||||
"ANALYSIS_VISION_URL",
|
"ANALYSIS_VISION_URL",
|
||||||
"ANALYSIS_MODEL",
|
"ANALYSIS_VISION_MODEL",
|
||||||
"ANALYSIS_MAX_PIXELS",
|
"ANALYSIS_MAX_PIXELS",
|
||||||
"ANALYSIS_MAX_SLICES",
|
"ANALYSIS_MAX_SLICES",
|
||||||
"ANALYSIS_SLICE_OVERLAP",
|
"ANALYSIS_SLICE_OVERLAP",
|
||||||
@@ -757,10 +1044,13 @@ mod tests {
|
|||||||
fn private_mode_env_parses_true() {
|
fn private_mode_env_parses_true() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
std::env::set_var("PRIVATE_MODE", "true");
|
std::env::set_var("PRIVATE_MODE", "true");
|
||||||
std::env::set_var("DATABASE_URL", "postgres://test");
|
// Don't clobber/unset DATABASE_URL: parallel #[sqlx::test] lib tests
|
||||||
|
// read it from the process env and would flake ("NotPresent") on the
|
||||||
|
// removal. Ensure it's present (CI sets it) without overwriting a real
|
||||||
|
// one, and never remove it.
|
||||||
|
ensure_database_url();
|
||||||
let cfg = Config::from_env().expect("from_env");
|
let cfg = Config::from_env().expect("from_env");
|
||||||
std::env::remove_var("PRIVATE_MODE");
|
std::env::remove_var("PRIVATE_MODE");
|
||||||
std::env::remove_var("DATABASE_URL");
|
|
||||||
assert!(cfg.auth.private_mode);
|
assert!(cfg.auth.private_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,10 +1058,9 @@ mod tests {
|
|||||||
fn private_mode_env_parses_false() {
|
fn private_mode_env_parses_false() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
std::env::set_var("PRIVATE_MODE", "false");
|
std::env::set_var("PRIVATE_MODE", "false");
|
||||||
std::env::set_var("DATABASE_URL", "postgres://test");
|
ensure_database_url();
|
||||||
let cfg = Config::from_env().expect("from_env");
|
let cfg = Config::from_env().expect("from_env");
|
||||||
std::env::remove_var("PRIVATE_MODE");
|
std::env::remove_var("PRIVATE_MODE");
|
||||||
std::env::remove_var("DATABASE_URL");
|
|
||||||
assert!(!cfg.auth.private_mode);
|
assert!(!cfg.auth.private_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,10 +1068,397 @@ mod tests {
|
|||||||
fn private_mode_defaults_to_false() {
|
fn private_mode_defaults_to_false() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
std::env::remove_var("PRIVATE_MODE");
|
std::env::remove_var("PRIVATE_MODE");
|
||||||
std::env::set_var("DATABASE_URL", "postgres://test");
|
ensure_database_url();
|
||||||
let cfg = Config::from_env().expect("from_env");
|
let cfg = Config::from_env().expect("from_env");
|
||||||
std::env::remove_var("DATABASE_URL");
|
|
||||||
assert!(!cfg.auth.private_mode);
|
assert!(!cfg.auth.private_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vars that live in `.env.example` for operator context but that
|
||||||
|
/// are NOT consumed by the backend service (so don't need to be in
|
||||||
|
/// backend.environment). Each entry needs a rationale — if a future
|
||||||
|
/// var is added to `.env.example` without being wired into compose,
|
||||||
|
/// the test fails until either the wiring is added or the var is
|
||||||
|
/// listed here with a justification.
|
||||||
|
///
|
||||||
|
/// - POSTGRES_DB/USER/PASSWORD — composed into DATABASE_URL on the
|
||||||
|
/// compose RHS; the backend container never sees them by name.
|
||||||
|
/// - TOR_CONTROL_PASSWORD — wired through to BOTH tor and backend
|
||||||
|
/// under different names (PASSWORD / CRAWLER_TOR_CONTROL_PASSWORD).
|
||||||
|
/// - BACKEND_URL / BACKEND_PROXY_TIMEOUT_MS — consumed by the
|
||||||
|
/// SvelteKit frontend container, not the backend.
|
||||||
|
/// - VISION_MANAGER_DATABASE_URL — consumed by the vision-manager
|
||||||
|
/// sidecar, not the backend.
|
||||||
|
const NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE: &[&str] = &[
|
||||||
|
"POSTGRES_DB",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"TOR_CONTROL_PASSWORD",
|
||||||
|
"BACKEND_URL",
|
||||||
|
"BACKEND_PROXY_TIMEOUT_MS",
|
||||||
|
"VISION_MANAGER_DATABASE_URL",
|
||||||
|
// Compose-level deployment knobs (port publish interface + per-container
|
||||||
|
// memory ceilings) — consumed by docker-compose.yml itself, never read
|
||||||
|
// by the backend process, so they don't belong in its environment block.
|
||||||
|
"FRONTEND_PUBLISH_ADDR",
|
||||||
|
"BACKEND_MEM_LIMIT",
|
||||||
|
"FRONTEND_MEM_LIMIT",
|
||||||
|
"POSTGRES_MEM_LIMIT",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Keys whose compose RHS is intentionally NOT a `${KEY...}`
|
||||||
|
/// interpolation. Each entry needs a justification:
|
||||||
|
///
|
||||||
|
/// - BIND_ADDRESS / STORAGE_DIR — container-internal binding /
|
||||||
|
/// mount target; overriding from the host would un-net the
|
||||||
|
/// container or break the volume.
|
||||||
|
/// - DATABASE_URL — composed in compose from POSTGRES_USER /
|
||||||
|
/// PASSWORD / DB; the backend container never sees the raw
|
||||||
|
/// `${DATABASE_URL...}` placeholder.
|
||||||
|
/// - CRAWLER_TOR_CONTROL_PASSWORD — wired through TOR_CONTROL_PASSWORD
|
||||||
|
/// (single source of truth for the tor service's own PASSWORD).
|
||||||
|
const COMPOSE_RHS_EXEMPT: &[&str] = &[
|
||||||
|
"BIND_ADDRESS",
|
||||||
|
"STORAGE_DIR",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"CRAWLER_TOR_CONTROL_PASSWORD",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Vars read by backend src code that are intentionally NOT
|
||||||
|
/// operator-facing — kept out of `.env.example` because they are
|
||||||
|
/// either system vars, dev-only debug knobs, or model-tuning hooks
|
||||||
|
/// only meaningful when forking the codebase. Each entry needs a
|
||||||
|
/// rationale (paired here as `(name, why)` for grep-ability):
|
||||||
|
const INTERNAL_NOT_CONFIGURABLE: &[(&str, &str)] = &[
|
||||||
|
("HOME", "system var, fallback for $HOME/.cache/mangalord/chromium when CRAWLER_CHROMIUM_DIR is unset (see crawler::browser::default_chromium_dir)"),
|
||||||
|
("CRAWLER_TOR_CONTROL_PASSWORD", "internal name; compose maps TOR_CONTROL_PASSWORD (.env) into this var so the operator sets one secret in one place"),
|
||||||
|
("ANALYSIS_SYSTEM_PROMPT", "multi-paragraph default; env-override impractical, fork to change"),
|
||||||
|
("ANALYSIS_OCR_PROMPT", "multi-paragraph default; env-override impractical, fork to change"),
|
||||||
|
("ANALYSIS_GROUNDING_PROMPT", "multi-paragraph default; env-override impractical, fork to change"),
|
||||||
|
("CRAWLER_BROWSER_MODE", "local-dev only: chooses between bundled fetcher and system chromium"),
|
||||||
|
("CRAWLER_BROWSER_ARGS", "local-dev only: extra chromium launch args"),
|
||||||
|
("CRAWLER_CHROMIUM_DIR", "local-dev only: override for chromium download cache dir"),
|
||||||
|
("CRAWLER_KEEP_BROWSER_OPEN", "local-dev only: keep headed chromium alive between runs"),
|
||||||
|
("CRAWLER_FORCE_REFETCH_CHAPTERS", "debug-only: re-fetches even already-downloaded chapters"),
|
||||||
|
("CRAWLER_SKIP_CHAPTER_CONTENT", "debug-only: skips page downloads, keeps metadata"),
|
||||||
|
("CRAWLER_SKIP_CHAPTERS", "debug-only: skips chapter pass entirely"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Parse the env-var keys listed at column-0 of `.env.example`.
|
||||||
|
/// Skips comment-only lines and blank lines.
|
||||||
|
fn parse_env_example_keys(env_example: &str) -> std::collections::HashSet<String> {
|
||||||
|
let mut keys = std::collections::HashSet::new();
|
||||||
|
for line in env_example.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only column-0 lines count (avoid grabbing keys mentioned in
|
||||||
|
// mid-line prose inside a comment continuation).
|
||||||
|
if line.starts_with(|c: char| c.is_ascii_uppercase() || c == '_') {
|
||||||
|
if let Some((k, _)) = line.split_once('=') {
|
||||||
|
let k = k.trim();
|
||||||
|
if !k.is_empty()
|
||||||
|
&& k.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||||||
|
{
|
||||||
|
keys.insert(k.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression test for the 0.87.1 compose env-wire-through gap, with
|
||||||
|
/// the 0.87.17 RHS-interpolation strengthening and the 0.87.25
|
||||||
|
/// list-derivation strengthening:
|
||||||
|
///
|
||||||
|
/// The 0.87.12 version substring-matched the env-var KEY in compose
|
||||||
|
/// — blind to whether the RHS substituted from `.env`. The 0.87.17
|
||||||
|
/// version added an RHS-interpolation check but still hardcoded the
|
||||||
|
/// `BACKEND_CONSUMED` list, which drifted (~22 code-read vars not
|
||||||
|
/// in compose). The 0.87.25 version derives the list from
|
||||||
|
/// `.env.example` so the operator-facing surface is the single
|
||||||
|
/// source of truth: anything documented there must reach the
|
||||||
|
/// container, and a new line in the example forces a wiring update.
|
||||||
|
///
|
||||||
|
/// Every key in `.env.example` that isn't in
|
||||||
|
/// `NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE` must:
|
||||||
|
/// * have a `KEY:` line under the backend service `environment:`, AND
|
||||||
|
/// * either be in `COMPOSE_RHS_EXEMPT` (intentional non-interp), OR
|
||||||
|
/// have its RHS substitute from `${KEY` so `.env` actually wins.
|
||||||
|
#[test]
|
||||||
|
fn docker_compose_wires_every_documented_backend_env_var() {
|
||||||
|
// Cargo run dir for `cargo test` is `backend/`; .env.example and
|
||||||
|
// compose file live one up.
|
||||||
|
let env_example = std::fs::read_to_string("../.env.example")
|
||||||
|
.expect("read ../.env.example");
|
||||||
|
let example_keys = parse_env_example_keys(&env_example);
|
||||||
|
let backend_consumed: Vec<&str> = example_keys
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.filter(|k| !NOT_BACKEND_CONSUMED_IN_ENV_EXAMPLE.contains(k))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!backend_consumed.is_empty(),
|
||||||
|
"parse_env_example_keys returned no keys — check .env.example formatting"
|
||||||
|
);
|
||||||
|
|
||||||
|
let compose = std::fs::read_to_string("../docker-compose.yml")
|
||||||
|
.expect("read ../docker-compose.yml");
|
||||||
|
// Extract the backend service block: everything from ` backend:`
|
||||||
|
// up to the next top-level service key (` <name>:` at 2-space
|
||||||
|
// indent, no further indent). Line-based scan instead of the
|
||||||
|
// 0.87.12 `\n frontend:\n` literal so adding/reordering services
|
||||||
|
// doesn't silently break the slicer.
|
||||||
|
let backend_block = extract_compose_service_block(&compose, "backend")
|
||||||
|
.expect("backend service block in docker-compose.yml");
|
||||||
|
|
||||||
|
let mut missing: Vec<String> = Vec::new();
|
||||||
|
let mut not_interpolated: Vec<(String, String)> = Vec::new();
|
||||||
|
for key in &backend_consumed {
|
||||||
|
// The env entries sit at exactly 6 spaces under `environment:`.
|
||||||
|
let needle = format!(" {key}:");
|
||||||
|
let Some(line_start) = backend_block.find(&needle) else {
|
||||||
|
missing.push((*key).to_string());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if COMPOSE_RHS_EXEMPT.contains(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// RHS: everything from the colon to the end of line.
|
||||||
|
let from_colon = &backend_block[line_start + needle.len()..];
|
||||||
|
let rhs_end = from_colon.find('\n').unwrap_or(from_colon.len());
|
||||||
|
let rhs = from_colon[..rhs_end].trim();
|
||||||
|
// Expand-from-env requires `${KEY...}` where KEY matches this
|
||||||
|
// var's name. Accept both `${KEY:-default}` and `${KEY-default}`
|
||||||
|
// (and the bare `${KEY}` form for completeness).
|
||||||
|
let expected_prefixes =
|
||||||
|
[format!("${{{key}:-"), format!("${{{key}-"), format!("${{{key}}}")];
|
||||||
|
if !expected_prefixes.iter().any(|p| rhs.starts_with(p.as_str())) {
|
||||||
|
not_interpolated.push(((*key).to_string(), rhs.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing.sort();
|
||||||
|
not_interpolated.sort();
|
||||||
|
assert!(
|
||||||
|
missing.is_empty(),
|
||||||
|
"docker-compose.yml backend service is missing env wiring for: {missing:?}. \
|
||||||
|
Add `KEY: ${{KEY:-default}}` lines under backend.environment so the var \
|
||||||
|
reaches the container. (Source of truth is .env.example.)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
not_interpolated.is_empty(),
|
||||||
|
"docker-compose.yml backend service has env entries whose RHS does NOT \
|
||||||
|
substitute from the matching `${{KEY...}}` placeholder, so an operator \
|
||||||
|
setting `KEY=…` in `.env` will be silently ignored. Either fix the RHS \
|
||||||
|
to `${{KEY:-default}}` or add the var to `COMPOSE_RHS_EXEMPT` with a \
|
||||||
|
rationale. Offenders: {not_interpolated:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New in 0.87.25: every env::var / env_xxx call in backend/src/
|
||||||
|
/// must read a key that is EITHER documented in `.env.example`
|
||||||
|
/// (operator-tunable) OR listed in `INTERNAL_NOT_CONFIGURABLE` with
|
||||||
|
/// a rationale.
|
||||||
|
///
|
||||||
|
/// This catches the silent-no-op class of bug from the other side:
|
||||||
|
/// a new code path that reads e.g. `ANALYSIS_TEMPERATURE` would
|
||||||
|
/// previously compile and run fine, but an operator setting it in
|
||||||
|
/// `.env` would see no effect because `.env.example` and compose
|
||||||
|
/// never learned about it. Forcing the rationale (operator-facing
|
||||||
|
/// or internal-only?) at test time keeps the surface honest.
|
||||||
|
#[test]
|
||||||
|
fn every_backend_src_env_read_is_documented_or_allowlisted() {
|
||||||
|
let env_example = std::fs::read_to_string("../.env.example")
|
||||||
|
.expect("read ../.env.example");
|
||||||
|
let example_keys = parse_env_example_keys(&env_example);
|
||||||
|
let internal_keys: std::collections::HashSet<&str> = INTERNAL_NOT_CONFIGURABLE
|
||||||
|
.iter()
|
||||||
|
.map(|(k, _)| *k)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Walk backend/src/ for *.rs files.
|
||||||
|
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
walk_rust_files(std::path::Path::new("src"), &mut files)
|
||||||
|
.expect("walk backend/src/");
|
||||||
|
|
||||||
|
let mut undocumented: std::collections::BTreeMap<String, Vec<String>> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for file in &files {
|
||||||
|
let src = std::fs::read_to_string(file).expect("read source");
|
||||||
|
for key in extract_env_reads(&src) {
|
||||||
|
if example_keys.contains(&key) || internal_keys.contains(key.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
undocumented
|
||||||
|
.entry(key)
|
||||||
|
.or_default()
|
||||||
|
.push(file.display().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
undocumented.is_empty(),
|
||||||
|
"backend/src/ reads env vars that are neither in .env.example \
|
||||||
|
(operator-tunable) nor in INTERNAL_NOT_CONFIGURABLE (internal-only \
|
||||||
|
with rationale). For each entry below, either:\n\
|
||||||
|
(a) add it to .env.example AND wire it into docker-compose.yml's \
|
||||||
|
backend.environment as `KEY: ${{KEY:-default}}` so operators \
|
||||||
|
can override it, OR\n\
|
||||||
|
(b) add it to INTERNAL_NOT_CONFIGURABLE in this file with a \
|
||||||
|
one-line rationale (system var, dev-only knob, etc.).\n\
|
||||||
|
Offenders: {undocumented:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect `*.rs` paths under `dir`. Test-only helper
|
||||||
|
/// for the env-read coverage scan above. Skips `target/` and any
|
||||||
|
/// hidden directory just in case.
|
||||||
|
fn walk_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if name_str.starts_with('.') || name_str == "target" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.is_dir() {
|
||||||
|
walk_rust_files(&path, out)?;
|
||||||
|
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find every env-read key in a Rust source string. Matches the
|
||||||
|
/// project's env helpers (`env_bool`, `env_i64`, …, `env_prompt`)
|
||||||
|
/// and the std calls (`std::env::var`, `std::env::var_os`). Returns
|
||||||
|
/// the literal keys passed as the first arg.
|
||||||
|
///
|
||||||
|
/// Treats `KEY` as `[A-Z0-9_]+` to avoid false positives like
|
||||||
|
/// `env::var(some_string)` (not literal). Skips any call with a
|
||||||
|
/// non-literal first arg.
|
||||||
|
fn extract_env_reads(src: &str) -> Vec<String> {
|
||||||
|
const PREFIXES: &[&str] = &[
|
||||||
|
"env::var(\"",
|
||||||
|
"env::var_os(\"",
|
||||||
|
"env_var(\"",
|
||||||
|
"env_var_os(\"",
|
||||||
|
"env_bool(\"",
|
||||||
|
"env_i64(\"",
|
||||||
|
"env_f64(\"",
|
||||||
|
"env_usize(\"",
|
||||||
|
"env_u64(\"",
|
||||||
|
"env_u32(\"",
|
||||||
|
"env_prompt(\"",
|
||||||
|
];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for prefix in PREFIXES {
|
||||||
|
for (i, _) in src.match_indices(prefix) {
|
||||||
|
let rest = &src[i + prefix.len()..];
|
||||||
|
let Some(end) = rest.find('"') else { continue };
|
||||||
|
let key = &rest[..end];
|
||||||
|
if !key.is_empty()
|
||||||
|
&& key
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||||||
|
{
|
||||||
|
out.push(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanity test for the env-read extractor: hand-coded snippet
|
||||||
|
/// must yield the expected keys.
|
||||||
|
///
|
||||||
|
/// The snippet is built at runtime from format! parts so this
|
||||||
|
/// source file doesn't literally contain any prefix-then-key
|
||||||
|
/// pattern that the other coverage scan would otherwise pick up
|
||||||
|
/// and require allowlisting (the scan is text-based and oblivious
|
||||||
|
/// to comments).
|
||||||
|
#[test]
|
||||||
|
fn extract_env_reads_finds_helper_and_std_calls() {
|
||||||
|
let src = format!(
|
||||||
|
"let a = std::env::{v}(\"FOO_A\").unwrap();\n\
|
||||||
|
let b = std::env::{vo}(\"FOO_B\");\n\
|
||||||
|
let c = {eb}(\"FOO_C\", false);\n\
|
||||||
|
let d = {ei}(\"FOO_D\", 0);\n\
|
||||||
|
let e = {ep}(\"FOO_E\", String::new());\n\
|
||||||
|
// Non-literal call must be skipped:\n\
|
||||||
|
let _ = std::env::{v}(some_name).unwrap();\n\
|
||||||
|
// Mixed-case / lowercase keys are skipped (env vars are upper):\n\
|
||||||
|
let _ = std::env::{v}(\"Lowercase\");\n",
|
||||||
|
v = "var",
|
||||||
|
vo = "var_os",
|
||||||
|
eb = "env_bool",
|
||||||
|
ei = "env_i64",
|
||||||
|
ep = "env_prompt",
|
||||||
|
);
|
||||||
|
let mut keys = extract_env_reads(&src);
|
||||||
|
keys.sort();
|
||||||
|
keys.dedup();
|
||||||
|
assert_eq!(keys, vec!["FOO_A", "FOO_B", "FOO_C", "FOO_D", "FOO_E"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a single service's block from a docker-compose YAML.
|
||||||
|
/// Reads from the line `^ <name>:$` (2-space top-level service
|
||||||
|
/// indent) up to the next top-level service header (`^ [A-Za-z…]:$`)
|
||||||
|
/// or end of file. Returns `None` if the service isn't found.
|
||||||
|
///
|
||||||
|
/// Line-based scan instead of `splitn("\n next:\n")` so a service
|
||||||
|
/// added between the target and the literal anchor doesn't silently
|
||||||
|
/// truncate the block. Tolerates blank lines and comments inside the
|
||||||
|
/// block (only the precise 2-space service-header shape ends it).
|
||||||
|
fn extract_compose_service_block<'a>(compose: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let header = format!("\n {name}:\n");
|
||||||
|
let start = compose.find(&header).map(|i| i + 1)?; // include the header line
|
||||||
|
let body = &compose[start + header.len() - 1..]; // after the trailing \n
|
||||||
|
// Find the next 2-space top-level service-key line. The "next"
|
||||||
|
// candidate must be: a newline, then exactly two spaces, then a
|
||||||
|
// word char (services have alphanumeric names), then anything up
|
||||||
|
// to `:` followed by EOL. We scan line-by-line.
|
||||||
|
let mut offset: usize = 0;
|
||||||
|
for line in body.split('\n') {
|
||||||
|
// Detect a top-level service header at 2-space indent. Skip
|
||||||
|
// the first line iteration since `start` already points at
|
||||||
|
// our own header.
|
||||||
|
if offset > 0
|
||||||
|
&& line.starts_with(" ")
|
||||||
|
&& !line.starts_with(" ")
|
||||||
|
&& line.trim_end().ends_with(':')
|
||||||
|
&& line.trim_start().chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||||
|
{
|
||||||
|
return Some(&body[..offset.saturating_sub(1)]);
|
||||||
|
}
|
||||||
|
offset += line.len() + 1; // +1 for the newline
|
||||||
|
}
|
||||||
|
Some(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanity-check the slicer against a hand-built compose so a future
|
||||||
|
/// edit to the helper doesn't silently misframe the wiring test.
|
||||||
|
#[test]
|
||||||
|
fn extract_compose_service_block_finds_named_block() {
|
||||||
|
let yaml = "\
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pg
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
KEY: ${KEY:-default}
|
||||||
|
expose:
|
||||||
|
- \"8080\"
|
||||||
|
frontend:
|
||||||
|
image: f
|
||||||
|
";
|
||||||
|
let backend = extract_compose_service_block(yaml, "backend").expect("found");
|
||||||
|
assert!(backend.contains("KEY: ${KEY:-default}"));
|
||||||
|
// Must NOT bleed into the next service.
|
||||||
|
assert!(!backend.contains("frontend"));
|
||||||
|
assert!(!backend.contains("image: f"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,13 +54,26 @@ pub struct LaunchOptions {
|
|||||||
/// defaults. Example: `vec!["--lang=de-DE".into(),
|
/// defaults. Example: `vec!["--lang=de-DE".into(),
|
||||||
/// "--window-size=1280,800".into()]`.
|
/// "--window-size=1280,800".into()]`.
|
||||||
pub extra_args: Vec<String>,
|
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 {
|
impl LaunchOptions {
|
||||||
pub fn headed() -> Self {
|
pub fn headed() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode: BrowserMode::Headed,
|
mode: BrowserMode::Headed,
|
||||||
extra_args: Vec::new(),
|
extra_args: Vec::new(),
|
||||||
|
user_agent: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +81,17 @@ impl LaunchOptions {
|
|||||||
Self {
|
Self {
|
||||||
mode: BrowserMode::Headless,
|
mode: BrowserMode::Headless,
|
||||||
extra_args: Vec::new(),
|
extra_args: Vec::new(),
|
||||||
|
user_agent: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads `CRAWLER_BROWSER_MODE` (`headless`|`headed`, default
|
/// Reads `CRAWLER_BROWSER_MODE` (`headless`|`headed`, default
|
||||||
/// `headless`) and `CRAWLER_BROWSER_ARGS` (whitespace-separated
|
/// `headless`), `CRAWLER_BROWSER_ARGS` (whitespace-separated
|
||||||
/// Chromium flags). Flags containing whitespace aren't supported
|
/// Chromium flags), and `CRAWLER_USER_AGENT` (the browser UA;
|
||||||
/// through the env var — use the programmatic API for those.
|
/// 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 {
|
pub fn from_env() -> Self {
|
||||||
let mode = match std::env::var("CRAWLER_BROWSER_MODE").as_deref() {
|
let mode = match std::env::var("CRAWLER_BROWSER_MODE").as_deref() {
|
||||||
Ok("headed") => BrowserMode::Headed,
|
Ok("headed") => BrowserMode::Headed,
|
||||||
@@ -83,7 +100,16 @@ impl LaunchOptions {
|
|||||||
let extra_args = std::env::var("CRAWLER_BROWSER_ARGS")
|
let extra_args = std::env::var("CRAWLER_BROWSER_ARGS")
|
||||||
.map(|s| parse_args(&s))
|
.map(|s| parse_args(&s))
|
||||||
.unwrap_or_default();
|
.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
|
// Chromium's sandbox wants. Disable it; the crawler runs in its
|
||||||
// own container anyway.
|
// own container anyway.
|
||||||
.arg("--no-sandbox")
|
.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 {
|
for arg in &options.extra_args {
|
||||||
builder = builder.arg(arg);
|
builder = builder.arg(arg);
|
||||||
}
|
}
|
||||||
@@ -345,6 +375,29 @@ mod tests {
|
|||||||
assert_eq!(LaunchOptions::headed().mode, BrowserMode::Headed);
|
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
|
// Regression: if another Arc<Browser> outlives `Handle::close`, the
|
||||||
// old code awaited the driver task forever because the chromiumoxide
|
// old code awaited the driver task forever because the chromiumoxide
|
||||||
// handler stream doesn't return None on its own. Aborting the driver
|
// handler stream doesn't return None on its own. Aborting the driver
|
||||||
|
|||||||
@@ -234,12 +234,20 @@ impl BrowserManager {
|
|||||||
await_drain(&self.active, drain_deadline).await;
|
await_drain(&self.active, drain_deadline).await;
|
||||||
|
|
||||||
self.set_phase(RestartPhase::Restarting);
|
self.set_phase(RestartPhase::Restarting);
|
||||||
let relaunch = {
|
// 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;
|
let mut guard = self.inner.lock().await;
|
||||||
guard.shared = None;
|
guard.shared = None;
|
||||||
if let Some(handle) = guard.handle.take() {
|
guard.handle.take()
|
||||||
let _ = handle.close().await;
|
};
|
||||||
}
|
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.launch_into(&mut guard).await
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,9 +265,14 @@ impl BrowserManager {
|
|||||||
/// Used on daemon shutdown. After this returns the next acquire will
|
/// Used on daemon shutdown. After this returns the next acquire will
|
||||||
/// re-launch from scratch.
|
/// re-launch from scratch.
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
let mut guard = self.inner.lock().await;
|
// Take-then-drop-then-close: don't hold the lock across Chromium
|
||||||
guard.shared = None;
|
// teardown (see `invalidate` / the idle reaper).
|
||||||
if let Some(handle) = guard.handle.take() {
|
let handle = {
|
||||||
|
let mut guard = self.inner.lock().await;
|
||||||
|
guard.shared = None;
|
||||||
|
guard.handle.take()
|
||||||
|
};
|
||||||
|
if let Some(handle) = handle {
|
||||||
let _ = handle.close().await;
|
let _ = handle.close().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,9 +291,16 @@ impl BrowserManager {
|
|||||||
/// Idempotent: calling on an already-invalidated manager is a
|
/// Idempotent: calling on an already-invalidated manager is a
|
||||||
/// no-op.
|
/// no-op.
|
||||||
pub async fn invalidate(&self) {
|
pub async fn invalidate(&self) {
|
||||||
let mut guard = self.inner.lock().await;
|
// Take the handle out under the lock, then release the lock BEFORE the
|
||||||
guard.shared = None;
|
// slow Chromium close() — otherwise every other worker's `acquire()`
|
||||||
if let Some(handle) = guard.handle.take() {
|
// 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;
|
let _ = handle.close().await;
|
||||||
tracing::warn!("BrowserManager: handle invalidated — next acquire will relaunch");
|
tracing::warn!("BrowserManager: handle invalidated — next acquire will relaunch");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::crawler::detect::PageError;
|
use crate::crawler::detect::PageError;
|
||||||
use crate::crawler::rate_limit::HostRateLimiters;
|
use crate::crawler::rate_limit::HostRateLimiters;
|
||||||
use crate::crawler::safety::{fetch_stream, 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::crawler::session::{self, ChapterProbe};
|
||||||
use crate::storage::{Storage, StorageError};
|
use crate::storage::{Storage, StorageError};
|
||||||
|
|
||||||
@@ -71,6 +71,13 @@ pub enum SyncOutcome {
|
|||||||
/// Session probe failed mid-sync (avatar selector missing on the
|
/// Session probe failed mid-sync (avatar selector missing on the
|
||||||
/// chapter page). Caller should abort the whole crawler run.
|
/// chapter page). Caller should abort the whole crawler run.
|
||||||
SessionExpired,
|
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
|
/// Per-chapter max fetch attempts when TOR is configured. `N = 3` means
|
||||||
@@ -95,6 +102,19 @@ enum ChapterFetchOutcome {
|
|||||||
PersistentTransient,
|
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,
|
/// Single rate-limited Chromium navigation to the chapter URL,
|
||||||
/// returning the page HTML. Extracted from `sync_chapter_content` so
|
/// returning the page HTML. Extracted from `sync_chapter_content` so
|
||||||
/// the recircuit loop can call it once per attempt.
|
/// the recircuit loop can call it once per attempt.
|
||||||
@@ -103,27 +123,36 @@ async fn fetch_chapter_html_once(
|
|||||||
rate: &HostRateLimiters,
|
rate: &HostRateLimiters,
|
||||||
source_url: &str,
|
source_url: &str,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
|
guard_nav_url(source_url)?;
|
||||||
rate.wait_for(source_url).await?;
|
rate.wait_for(source_url).await?;
|
||||||
let page = browser
|
let page = crate::crawler::intercept::open_page(browser, source_url)
|
||||||
.new_page(source_url)
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("open chapter page {source_url}"))?;
|
.with_context(|| format!("open chapter page {source_url}"))?;
|
||||||
crate::crawler::nav::wait_for_nav(&page)
|
// Close the tab on every exit path — a `?` on wait_for_nav / content()
|
||||||
.await
|
// would otherwise leak it (chromiumoxide doesn't close on drop).
|
||||||
.context("wait for chapter nav")?;
|
let closer = page.clone();
|
||||||
// Best-effort wait for the reader marker — same partial-render
|
crate::crawler::nav::close_after(
|
||||||
// race that bit the chapter-list parser can hit here. Timeout is
|
async move {
|
||||||
// not an error; the chapter probe + parser sentinels still catch
|
closer.close().await.ok();
|
||||||
// real failures.
|
},
|
||||||
let _ = crate::crawler::nav::wait_for_selector(
|
async {
|
||||||
&page,
|
crate::crawler::nav::wait_for_nav(&page)
|
||||||
"a#pic_container",
|
.await
|
||||||
crate::crawler::nav::SELECTOR_TIMEOUT,
|
.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;
|
.await
|
||||||
let html = page.content().await.context("read chapter html")?;
|
|
||||||
page.close().await.ok();
|
|
||||||
Ok(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure-over-IO loop: fetch + classify, up to `max_attempts` total
|
/// Pure-over-IO loop: fetch + classify, up to `max_attempts` total
|
||||||
@@ -197,6 +226,10 @@ where
|
|||||||
/// On any failure the chapter stays at `page_count = 0` (no partial
|
/// On any failure the chapter stays at `page_count = 0` (no partial
|
||||||
/// rows) and the blobs already written are deleted best-effort by
|
/// rows) and the blobs already written are deleted best-effort by
|
||||||
/// [`cleanup_orphans`], so a retry starts clean.
|
/// [`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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn sync_chapter_content(
|
pub async fn sync_chapter_content(
|
||||||
browser: &chromiumoxide::Browser,
|
browser: &chromiumoxide::Browser,
|
||||||
@@ -210,6 +243,66 @@ pub async fn sync_chapter_content(
|
|||||||
force_refetch: bool,
|
force_refetch: bool,
|
||||||
allowlist: &DownloadAllowlist,
|
allowlist: &DownloadAllowlist,
|
||||||
max_image_bytes: usize,
|
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>,
|
tor: Option<&crate::crawler::tor::TorController>,
|
||||||
// Optional live-status sink for the realtime page counter. The daemon
|
// Optional live-status sink for the realtime page counter. The daemon
|
||||||
// dispatcher passes the shared handle (the chapter has already been
|
// dispatcher passes the shared handle (the chapter has already been
|
||||||
@@ -270,6 +363,16 @@ pub async fn sync_chapter_content(
|
|||||||
if images.is_empty() {
|
if images.is_empty() {
|
||||||
anyhow::bail!("no page images parsed from {source_url}");
|
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).
|
// Resolve image URLs against the chapter URL (they may be relative).
|
||||||
let base = reqwest::Url::parse(source_url).context("parse chapter URL")?;
|
let base = reqwest::Url::parse(source_url).context("parse chapter URL")?;
|
||||||
@@ -334,6 +437,9 @@ pub(crate) struct StoredPage {
|
|||||||
page_number: i32,
|
page_number: i32,
|
||||||
storage_key: String,
|
storage_key: String,
|
||||||
content_type: 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
|
/// Bytes accumulated for content-type sniffing. `infer` only needs the
|
||||||
@@ -343,6 +449,28 @@ pub(crate) struct StoredPage {
|
|||||||
/// "first 16 bytes" diagnostic in the error path is useful.
|
/// "first 16 bytes" diagnostic in the error path is useful.
|
||||||
const SNIFF_PREFIX_BYTES: usize = 64;
|
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
|
/// Download a single page image, validate it's really an image, and
|
||||||
/// stream it to storage. Returns the storage key + content type. Does
|
/// stream it to storage. Returns the storage key + content type. Does
|
||||||
/// not touch the DB — persistence is batched into one short transaction
|
/// not touch the DB — persistence is batched into one short transaction
|
||||||
@@ -416,11 +544,18 @@ async fn download_and_store_page(
|
|||||||
// straight to storage. The cap is enforced via a running total in
|
// straight to storage. The cap is enforced via a running total in
|
||||||
// the stream adapter so a server that omits Content-Length still
|
// the stream adapter so a server that omits Content-Length still
|
||||||
// can't exhaust memory.
|
// 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 {
|
let prefix_stream = futures_util::stream::once(async move {
|
||||||
Ok::<bytes::Bytes, StorageError>(prefix)
|
Ok::<bytes::Bytes, StorageError>(prefix)
|
||||||
});
|
});
|
||||||
let prefix_len = SNIFF_PREFIX_BYTES.min(max_image_bytes);
|
let mut remaining = remaining_after_prefix(max_image_bytes, prefix_len);
|
||||||
let mut remaining = max_image_bytes.saturating_sub(prefix_len);
|
|
||||||
let url_for_err = url.clone();
|
let url_for_err = url.clone();
|
||||||
let rest_stream = body.map(move |frame| match frame {
|
let rest_stream = body.map(move |frame| match frame {
|
||||||
Ok(chunk) => {
|
Ok(chunk) => {
|
||||||
@@ -437,7 +572,7 @@ async fn download_and_store_page(
|
|||||||
)))),
|
)))),
|
||||||
});
|
});
|
||||||
let combined = prefix_stream.chain(rest_stream);
|
let combined = prefix_stream.chain(rest_stream);
|
||||||
storage
|
let size_bytes = storage
|
||||||
.put_stream(&key, Box::pin(combined))
|
.put_stream(&key, Box::pin(combined))
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("put_stream {key}"))?;
|
.with_context(|| format!("put_stream {key}"))?;
|
||||||
@@ -445,6 +580,7 @@ async fn download_and_store_page(
|
|||||||
page_number: img.page_number,
|
page_number: img.page_number,
|
||||||
storage_key: key,
|
storage_key: key,
|
||||||
content_type: format!("image/{ext}"),
|
content_type: format!("image/{ext}"),
|
||||||
|
size_bytes: size_bytes as i64,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,24 +594,73 @@ pub(crate) async fn persist_pages(
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut tx = db.begin().await.context("open chapter sync tx")?;
|
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_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 {
|
for page in stored {
|
||||||
let (id,): (Uuid,) = sqlx::query_as(
|
// `xmax <> 0` on the RETURNING row distinguishes a conflict UPDATE
|
||||||
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type)
|
// (existing row overwritten) from a fresh INSERT — the standard upsert
|
||||||
VALUES ($1, $2, $3, $4)
|
// 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
|
ON CONFLICT (chapter_id, page_number) DO UPDATE
|
||||||
SET storage_key = EXCLUDED.storage_key,
|
SET storage_key = EXCLUDED.storage_key,
|
||||||
content_type = EXCLUDED.content_type
|
content_type = EXCLUDED.content_type,
|
||||||
RETURNING id",
|
size_bytes = EXCLUDED.size_bytes
|
||||||
|
RETURNING id, (xmax::text::bigint <> 0)",
|
||||||
)
|
)
|
||||||
.bind(chapter_id)
|
.bind(chapter_id)
|
||||||
.bind(page.page_number)
|
.bind(page.page_number)
|
||||||
.bind(&page.storage_key)
|
.bind(&page.storage_key)
|
||||||
.bind(&page.content_type)
|
.bind(&page.content_type)
|
||||||
|
.bind(page.size_bytes)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("insert page row {}", page.page_number))?;
|
.with_context(|| format!("insert page row {}", page.page_number))?;
|
||||||
page_ids.push(id);
|
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
|
||||||
|
.context("prune stale page rows")?;
|
||||||
sqlx::query("UPDATE chapters SET page_count = $1 WHERE id = $2")
|
sqlx::query("UPDATE chapters SET page_count = $1 WHERE id = $2")
|
||||||
.bind(stored.len() as i32)
|
.bind(stored.len() as i32)
|
||||||
.bind(chapter_id)
|
.bind(chapter_id)
|
||||||
@@ -528,6 +713,64 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::storage::LocalStorage;
|
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]
|
#[tokio::test]
|
||||||
async fn cleanup_orphans_deletes_written_keys() {
|
async fn cleanup_orphans_deletes_written_keys() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -576,11 +819,13 @@ mod tests {
|
|||||||
page_number: 1,
|
page_number: 1,
|
||||||
storage_key: "k/0001.jpg".into(),
|
storage_key: "k/0001.jpg".into(),
|
||||||
content_type: "image/jpeg".into(),
|
content_type: "image/jpeg".into(),
|
||||||
|
size_bytes: 111,
|
||||||
},
|
},
|
||||||
StoredPage {
|
StoredPage {
|
||||||
page_number: 2,
|
page_number: 2,
|
||||||
storage_key: "k/0002.jpg".into(),
|
storage_key: "k/0002.jpg".into(),
|
||||||
content_type: "image/jpeg".into(),
|
content_type: "image/jpeg".into(),
|
||||||
|
size_bytes: 222,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
||||||
@@ -599,6 +844,15 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rows, 2);
|
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.
|
// Idempotent re-run (force refetch path): same rows, page_count stable.
|
||||||
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
persist_pages(&pool, chapter_id, &stored, false).await.unwrap();
|
||||||
@@ -611,6 +865,61 @@ mod tests {
|
|||||||
assert_eq!(rows2, 2, "re-run is idempotent via ON CONFLICT");
|
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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn persist_pages_enqueues_analysis_only_when_flag_set(pool: PgPool) {
|
async fn persist_pages_enqueues_analysis_only_when_flag_set(pool: PgPool) {
|
||||||
let manga_id = Uuid::new_v4();
|
let manga_id = Uuid::new_v4();
|
||||||
@@ -630,6 +939,7 @@ mod tests {
|
|||||||
page_number: 1,
|
page_number: 1,
|
||||||
storage_key: "k/0001.jpg".into(),
|
storage_key: "k/0001.jpg".into(),
|
||||||
content_type: "image/jpeg".into(),
|
content_type: "image/jpeg".into(),
|
||||||
|
size_bytes: 100,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
// Flag off: no analyze_page jobs.
|
// Flag off: no analyze_page jobs.
|
||||||
@@ -923,4 +1233,91 @@ mod tests {
|
|||||||
assert_eq!(fetch_n, 1);
|
assert_eq!(fetch_n, 1);
|
||||||
assert!(format!("{err:#}").contains("nav timeout"));
|
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,7 +46,7 @@ use tokio::task::JoinSet;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::crawler::content::SyncOutcome;
|
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::pipeline;
|
||||||
use crate::crawler::status::{Phase, StatusHandle};
|
use crate::crawler::status::{Phase, StatusHandle};
|
||||||
|
|
||||||
@@ -66,6 +66,43 @@ const LEASE_DURATION: Duration = Duration::from_secs(60);
|
|||||||
/// the lease window leaves two missed-beat's slack before expiry.
|
/// the lease window leaves two missed-beat's slack before expiry.
|
||||||
const LEASE_HEARTBEAT: Duration = Duration::from_secs(20);
|
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]
|
#[async_trait]
|
||||||
pub trait MetadataPass: Send + Sync {
|
pub trait MetadataPass: Send + Sync {
|
||||||
async fn run(&self) -> anyhow::Result<pipeline::MetadataStats>;
|
async fn run(&self) -> anyhow::Result<pipeline::MetadataStats>;
|
||||||
@@ -76,6 +113,15 @@ pub trait ChapterDispatcher: Send + Sync {
|
|||||||
async fn dispatch(&self, payload: JobPayload) -> anyhow::Result<SyncOutcome>;
|
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
|
/// Configuration for [`spawn`]. Use `None` for `metadata_pass` to disable
|
||||||
/// the cron entirely (worker-pool-only mode — useful when only the
|
/// the cron entirely (worker-pool-only mode — useful when only the
|
||||||
/// bookmark-triggered enqueue path is wanted).
|
/// bookmark-triggered enqueue path is wanted).
|
||||||
@@ -86,6 +132,7 @@ pub struct DaemonConfig {
|
|||||||
pub daily_at: NaiveTime,
|
pub daily_at: NaiveTime,
|
||||||
pub tz: Tz,
|
pub tz: Tz,
|
||||||
pub retention_days: u32,
|
pub retention_days: u32,
|
||||||
|
pub metrics_retention_days: u32,
|
||||||
pub session_expired: Arc<AtomicBool>,
|
pub session_expired: Arc<AtomicBool>,
|
||||||
/// Live status surface updated by the cron + workers.
|
/// Live status surface updated by the cron + workers.
|
||||||
pub status: StatusHandle,
|
pub status: StatusHandle,
|
||||||
@@ -139,6 +186,7 @@ pub fn spawn(pool: PgPool, cancel: CancellationToken, cfg: DaemonConfig) -> Daem
|
|||||||
daily_at,
|
daily_at,
|
||||||
tz,
|
tz,
|
||||||
retention_days,
|
retention_days,
|
||||||
|
metrics_retention_days,
|
||||||
session_expired,
|
session_expired,
|
||||||
status,
|
status,
|
||||||
job_timeout,
|
job_timeout,
|
||||||
@@ -152,6 +200,7 @@ pub fn spawn(pool: PgPool, cancel: CancellationToken, cfg: DaemonConfig) -> Daem
|
|||||||
daily_at,
|
daily_at,
|
||||||
tz,
|
tz,
|
||||||
retention_days,
|
retention_days,
|
||||||
|
metrics_retention_days,
|
||||||
metadata,
|
metadata,
|
||||||
status: status.clone(),
|
status: status.clone(),
|
||||||
};
|
};
|
||||||
@@ -190,6 +239,7 @@ struct CronContext {
|
|||||||
daily_at: NaiveTime,
|
daily_at: NaiveTime,
|
||||||
tz: Tz,
|
tz: Tz,
|
||||||
retention_days: u32,
|
retention_days: u32,
|
||||||
|
metrics_retention_days: u32,
|
||||||
metadata: Arc<dyn MetadataPass>,
|
metadata: Arc<dyn MetadataPass>,
|
||||||
status: StatusHandle,
|
status: StatusHandle,
|
||||||
}
|
}
|
||||||
@@ -267,10 +317,12 @@ impl CronContext {
|
|||||||
// no future tick would ever run — workers would keep going but
|
// no future tick would ever run — workers would keep going but
|
||||||
// no new metadata work would be scheduled until daemon restart.
|
// no new metadata work would be scheduled until daemon restart.
|
||||||
// The advisory unlock below runs unconditionally so a panicked
|
// 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 metadata = &self.metadata;
|
||||||
let pool = &self.pool;
|
let pool = &self.pool;
|
||||||
let retention_days = self.retention_days;
|
let retention_days = self.retention_days;
|
||||||
|
let metrics_retention_days = self.metrics_retention_days;
|
||||||
let status = &self.status;
|
let status = &self.status;
|
||||||
let body = async move {
|
let body = async move {
|
||||||
match metadata.run().await {
|
match metadata.run().await {
|
||||||
@@ -286,16 +338,35 @@ impl CronContext {
|
|||||||
}
|
}
|
||||||
Err(e) => tracing::error!(?e, "cron: enqueue_bookmarked_pending failed"),
|
Err(e) => tracing::error!(?e, "cron: enqueue_bookmarked_pending failed"),
|
||||||
}
|
}
|
||||||
match jobs::reap_done(pool, retention_days).await {
|
match jobs::reap_terminal(pool, retention_days).await {
|
||||||
Ok(n) => tracing::info!(reaped = n, "cron: done-job reaper finished"),
|
Ok(n) => tracing::info!(reaped = n, "cron: terminal-job reaper finished"),
|
||||||
Err(e) => tracing::error!(?e, "cron: done-job reaper failed"),
|
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 {
|
if let Err(e) = write_last_tick(pool, Utc::now()).await {
|
||||||
tracing::warn!(?e, "cron: persist last_metadata_tick_at failed");
|
tracing::warn!(?e, "cron: persist last_metadata_tick_at failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(_panic) = AssertUnwindSafe(body).catch_unwind().await {
|
// Race the tick body against shutdown. Without this, a long
|
||||||
tracing::error!("cron: tick body panicked — continuing");
|
// 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)")
|
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||||
@@ -322,6 +393,9 @@ struct WorkerContext {
|
|||||||
|
|
||||||
impl WorkerContext {
|
impl WorkerContext {
|
||||||
async fn run(self) {
|
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 {
|
loop {
|
||||||
if self.cancel.is_cancelled() {
|
if self.cancel.is_cancelled() {
|
||||||
tracing::info!(worker = self.id, "worker: shutdown");
|
tracing::info!(worker = self.id, "worker: shutdown");
|
||||||
@@ -333,9 +407,9 @@ impl WorkerContext {
|
|||||||
_ = self.cancel.cancelled() => return,
|
_ = self.cancel.cancelled() => return,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let leases = match jobs::lease(
|
let leases = match jobs::lease_kinds(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
Some(KIND_SYNC_CHAPTER_CONTENT),
|
&[KIND_SYNC_CHAPTER_CONTENT, KIND_SYNC_MANGA],
|
||||||
1,
|
1,
|
||||||
LEASE_DURATION,
|
LEASE_DURATION,
|
||||||
)
|
)
|
||||||
@@ -351,11 +425,14 @@ impl WorkerContext {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(lease) = leases.into_iter().next() else {
|
let Some(lease) = leases.into_iter().next() else {
|
||||||
|
let backoff = idle_backoff(idle_streak);
|
||||||
|
idle_streak = idle_streak.saturating_add(1);
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = tokio::time::sleep(Duration::from_secs(1)) => continue,
|
_ = tokio::time::sleep(backoff) => continue,
|
||||||
_ = self.cancel.cancelled() => return,
|
_ = self.cancel.cancelled() => return,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
idle_streak = 0;
|
||||||
self.process_lease(lease).await;
|
self.process_lease(lease).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,7 +447,7 @@ impl WorkerContext {
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
if matches!(page_count, Some(n) if n > 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,17 +456,34 @@ impl WorkerContext {
|
|||||||
// dispatch runs, so a slow-but-healthy job is never re-leased and
|
// dispatch runs, so a slow-but-healthy job is never re-leased and
|
||||||
// never inflates `attempts` toward `max_attempts`. Stops itself
|
// never inflates `attempts` toward `max_attempts`. Stops itself
|
||||||
// once the job is no longer ours (renew returns false).
|
// 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 heartbeat = {
|
||||||
let hb_pool = self.pool.clone();
|
let hb_pool = self.pool.clone();
|
||||||
let hb_id = lease.id;
|
let hb_id = lease.id;
|
||||||
|
let hb_gen = lease.lease_generation;
|
||||||
|
let hb_lost = hb_lost.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let mut failures: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(LEASE_HEARTBEAT).await;
|
tokio::time::sleep(LEASE_HEARTBEAT).await;
|
||||||
match jobs::renew(&hb_pool, hb_id, LEASE_DURATION).await {
|
match jobs::renew(&hb_pool, hb_id, hb_gen, LEASE_DURATION).await {
|
||||||
Ok(true) => {}
|
Ok(true) => failures = 0,
|
||||||
Ok(false) => break,
|
Ok(false) => break,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(lease_id = %hb_id, ?e, "heartbeat renew failed");
|
failures += 1;
|
||||||
|
tracing::warn!(lease_id = %hb_id, failures, ?e, "heartbeat renew failed");
|
||||||
|
if should_abort_after_renew_failures(failures) {
|
||||||
|
tracing::error!(
|
||||||
|
lease_id = %hb_id,
|
||||||
|
failures,
|
||||||
|
"heartbeat lost the lease after repeated renew failures — signalling abandon"
|
||||||
|
);
|
||||||
|
hb_lost.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,7 +499,44 @@ impl WorkerContext {
|
|||||||
// failed (exponential backoff) rather than wedging the worker.
|
// failed (exponential backoff) rather than wedging the worker.
|
||||||
let dispatch = AssertUnwindSafe(self.dispatcher.dispatch(lease.payload.clone()))
|
let dispatch = AssertUnwindSafe(self.dispatcher.dispatch(lease.payload.clone()))
|
||||||
.catch_unwind();
|
.catch_unwind();
|
||||||
let outcome = tokio::time::timeout(self.job_timeout, dispatch).await;
|
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();
|
heartbeat.abort();
|
||||||
|
|
||||||
let outcome = match outcome {
|
let outcome = match outcome {
|
||||||
@@ -423,6 +554,7 @@ impl WorkerContext {
|
|||||||
"dispatch timed out",
|
"dispatch timed out",
|
||||||
lease.attempts,
|
lease.attempts,
|
||||||
lease.max_attempts,
|
lease.max_attempts,
|
||||||
|
lease.lease_generation,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
@@ -430,7 +562,7 @@ impl WorkerContext {
|
|||||||
};
|
};
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(Ok(SyncOutcome::Fetched { .. } | SyncOutcome::Skipped)) => {
|
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)) => {
|
Ok(Ok(SyncOutcome::SessionExpired)) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -441,7 +573,25 @@ impl WorkerContext {
|
|||||||
self.session_expired.store(true, Ordering::Release);
|
self.session_expired.store(true, Ordering::Release);
|
||||||
// Push the session-expired flip to live status subscribers.
|
// Push the session-expired flip to live status subscribers.
|
||||||
self.status.poke();
|
self.status.poke();
|
||||||
let _ = jobs::release(&self.pool, lease.id).await;
|
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)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -456,6 +606,7 @@ impl WorkerContext {
|
|||||||
&format!("{e:#}"),
|
&format!("{e:#}"),
|
||||||
lease.attempts,
|
lease.attempts,
|
||||||
lease.max_attempts,
|
lease.max_attempts,
|
||||||
|
lease.lease_generation,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -471,6 +622,7 @@ impl WorkerContext {
|
|||||||
"worker panicked",
|
"worker panicked",
|
||||||
lease.attempts,
|
lease.attempts,
|
||||||
lease.max_attempts,
|
lease.max_attempts,
|
||||||
|
lease.lease_generation,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -621,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<
|
pub type DispatchFn = Arc<
|
||||||
dyn Fn(JobPayload) -> futures_util::future::BoxFuture<'static, anyhow::Result<SyncOutcome>>
|
dyn Fn(JobPayload) -> futures_util::future::BoxFuture<'static, anyhow::Result<SyncOutcome>>
|
||||||
+ Send
|
+ Send
|
||||||
@@ -660,6 +841,37 @@ mod tests {
|
|||||||
Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
|
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]
|
#[test]
|
||||||
fn next_fire_in_utc_at_midnight_advances_one_day() {
|
fn next_fire_in_utc_at_midnight_advances_one_day() {
|
||||||
let now = dt_utc(2026, 5, 25, 12, 0); // noon UTC
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum JobPayload {
|
pub enum JobPayload {
|
||||||
/// Fetch one manga's detail page, upsert metadata, enqueue
|
/// Fetch one manga's detail page, upsert metadata, sync its chapter
|
||||||
/// `SyncChapterList`.
|
/// 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 {
|
SyncManga {
|
||||||
source_id: String,
|
source_id: String,
|
||||||
source_manga_key: String,
|
source_manga_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
title: String,
|
||||||
},
|
},
|
||||||
/// Diff the chapter list, enqueue `SyncChapterContent` for new
|
/// Diff the chapter list, enqueue `SyncChapterContent` for new
|
||||||
/// chapters, soft-drop vanished ones.
|
/// chapters, soft-drop vanished ones.
|
||||||
@@ -61,6 +68,11 @@ pub enum JobState {
|
|||||||
/// without re-spelling the literal.
|
/// without re-spelling the literal.
|
||||||
pub const KIND_SYNC_CHAPTER_CONTENT: &str = "sync_chapter_content";
|
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
|
/// Kind discriminator for AI page-analysis jobs. The analysis daemon
|
||||||
/// leases with this filter so it never contends with crawl jobs.
|
/// leases with this filter so it never contends with crawl jobs.
|
||||||
pub const KIND_ANALYZE_PAGE: &str = "analyze_page";
|
pub const KIND_ANALYZE_PAGE: &str = "analyze_page";
|
||||||
@@ -77,6 +89,14 @@ pub struct Lease {
|
|||||||
pub payload: JobPayload,
|
pub payload: JobPayload,
|
||||||
pub attempts: i32,
|
pub attempts: i32,
|
||||||
pub max_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic exponential backoff base for `ack_failed` retries.
|
/// Deterministic exponential backoff base for `ack_failed` retries.
|
||||||
@@ -112,14 +132,17 @@ fn backoff_for(attempts: i32) -> Duration {
|
|||||||
/// `Skipped`. The slot frees again once the previous job leaves the
|
/// `Skipped`. The slot frees again once the previous job leaves the
|
||||||
/// in-flight states (done/failed/dead), so a re-enqueue after a force
|
/// in-flight states (done/failed/dead), so a re-enqueue after a force
|
||||||
/// refetch succeeds.
|
/// 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 json = serde_json::to_value(payload).expect("JobPayload is always serializable");
|
||||||
let id: Option<Uuid> = sqlx::query_scalar(
|
let id: Option<Uuid> = sqlx::query_scalar(
|
||||||
"INSERT INTO crawler_jobs (payload) VALUES ($1) \
|
"INSERT INTO crawler_jobs (payload) VALUES ($1) \
|
||||||
ON CONFLICT DO NOTHING RETURNING id",
|
ON CONFLICT DO NOTHING RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(json)
|
.bind(json)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(executor)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(match id {
|
Ok(match id {
|
||||||
Some(id) => EnqueueResult::Inserted(id),
|
Some(id) => EnqueueResult::Inserted(id),
|
||||||
@@ -147,7 +170,7 @@ pub async fn lease(
|
|||||||
lease_duration: Duration,
|
lease_duration: Duration,
|
||||||
) -> sqlx::Result<Vec<Lease>> {
|
) -> sqlx::Result<Vec<Lease>> {
|
||||||
let lease_ms: i64 = lease_duration.as_millis().min(i64::MAX as u128) as i64;
|
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#"
|
r#"
|
||||||
WITH leased AS (
|
WITH leased AS (
|
||||||
SELECT id FROM crawler_jobs
|
SELECT id FROM crawler_jobs
|
||||||
@@ -161,11 +184,12 @@ pub async fn lease(
|
|||||||
UPDATE crawler_jobs j
|
UPDATE crawler_jobs j
|
||||||
SET state = 'running',
|
SET state = 'running',
|
||||||
attempts = j.attempts + 1,
|
attempts = j.attempts + 1,
|
||||||
|
lease_generation = j.lease_generation + 1,
|
||||||
leased_until = now() + ($3::bigint || ' milliseconds')::interval,
|
leased_until = now() + ($3::bigint || ' milliseconds')::interval,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
FROM leased l
|
FROM leased l
|
||||||
WHERE j.id = l.id
|
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)
|
.bind(kind_filter)
|
||||||
@@ -174,8 +198,56 @@ pub async fn lease(
|
|||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.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());
|
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| {
|
let payload: JobPayload = serde_json::from_value(payload_json).map_err(|e| {
|
||||||
sqlx::Error::Decode(format!("invalid JobPayload JSON for job {id}: {e}").into())
|
sqlx::Error::Decode(format!("invalid JobPayload JSON for job {id}: {e}").into())
|
||||||
})?;
|
})?;
|
||||||
@@ -184,6 +256,7 @@ pub async fn lease(
|
|||||||
payload,
|
payload,
|
||||||
attempts,
|
attempts,
|
||||||
max_attempts,
|
max_attempts,
|
||||||
|
lease_generation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(leases)
|
Ok(leases)
|
||||||
@@ -202,42 +275,52 @@ pub async fn lease(
|
|||||||
pub async fn renew(
|
pub async fn renew(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
lease_id: Uuid,
|
lease_id: Uuid,
|
||||||
|
lease_generation: i64,
|
||||||
lease_duration: Duration,
|
lease_duration: Duration,
|
||||||
) -> sqlx::Result<bool> {
|
) -> sqlx::Result<bool> {
|
||||||
let lease_ms: i64 = lease_duration.as_millis().min(i64::MAX as u128) as i64;
|
let lease_ms: i64 = lease_duration.as_millis().min(i64::MAX as u128) as i64;
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"UPDATE crawler_jobs \
|
"UPDATE crawler_jobs \
|
||||||
SET leased_until = now() + ($2::bigint || ' milliseconds')::interval, \
|
SET leased_until = now() + ($3::bigint || ' milliseconds')::interval, \
|
||||||
updated_at = now() \
|
updated_at = now() \
|
||||||
WHERE id = $1 AND state = 'running'",
|
WHERE id = $1 AND lease_generation = $2 AND state = 'running'",
|
||||||
)
|
)
|
||||||
.bind(lease_id)
|
.bind(lease_id)
|
||||||
|
.bind(lease_generation)
|
||||||
.bind(lease_ms)
|
.bind(lease_ms)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(res.rows_affected() > 0)
|
Ok(res.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a leased job as successfully completed. The `state = 'running'`
|
/// Mark a leased job as successfully completed. The
|
||||||
/// predicate guards against a late ack from a worker whose lease expired
|
/// `(lease_generation, state = 'running')` predicate guards against a
|
||||||
/// and was already re-leased by another worker: without it, the late ack
|
/// late ack from a *specific* dead lease — the case where the worker's
|
||||||
/// would clobber the new lease's `state` and `leased_until`. `rows_affected
|
/// lease was released or expired and a successor re-leased the same id.
|
||||||
/// == 0` means we lost the lease — surfaced as a warn rather than an
|
/// Before migration 0032 the guard was state-only, so a late ack from
|
||||||
/// error because the new lease holder is doing real work; the late ack
|
/// the original could still match (state was running again, owned by the
|
||||||
/// just has to step aside.
|
/// successor) and clobber. Scoping to the original's `lease_generation`
|
||||||
pub async fn ack_done(pool: &PgPool, lease_id: Uuid) -> sqlx::Result<()> {
|
/// 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(
|
let res = sqlx::query(
|
||||||
"UPDATE crawler_jobs \
|
"UPDATE crawler_jobs \
|
||||||
SET state = 'done', leased_until = NULL, updated_at = now() \
|
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_id)
|
||||||
|
.bind(lease_generation)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if res.rows_affected() == 0 {
|
if res.rows_affected() == 0 {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%lease_id,
|
%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(())
|
Ok(())
|
||||||
@@ -254,15 +337,17 @@ pub async fn ack_failed(
|
|||||||
error: &str,
|
error: &str,
|
||||||
attempts: i32,
|
attempts: i32,
|
||||||
max_attempts: i32,
|
max_attempts: i32,
|
||||||
|
lease_generation: i64,
|
||||||
) -> sqlx::Result<()> {
|
) -> sqlx::Result<()> {
|
||||||
let res = if attempts >= max_attempts {
|
let res = if attempts >= max_attempts {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE crawler_jobs \
|
"UPDATE crawler_jobs \
|
||||||
SET state = 'dead', last_error = $2, leased_until = NULL, updated_at = now() \
|
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(lease_id)
|
||||||
.bind(error)
|
.bind(error)
|
||||||
|
.bind(lease_generation)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
@@ -272,66 +357,173 @@ pub async fn ack_failed(
|
|||||||
SET state = 'pending', last_error = $2, leased_until = NULL, \
|
SET state = 'pending', last_error = $2, leased_until = NULL, \
|
||||||
scheduled_at = now() + ($3::bigint || ' milliseconds')::interval, \
|
scheduled_at = now() + ($3::bigint || ' milliseconds')::interval, \
|
||||||
updated_at = now() \
|
updated_at = now() \
|
||||||
WHERE id = $1 AND state = 'running'",
|
WHERE id = $1 AND lease_generation = $4 AND state = 'running'",
|
||||||
)
|
)
|
||||||
.bind(lease_id)
|
.bind(lease_id)
|
||||||
.bind(error)
|
.bind(error)
|
||||||
.bind(backoff_ms)
|
.bind(backoff_ms)
|
||||||
|
.bind(lease_generation)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
if res.rows_affected() == 0 {
|
if res.rows_affected() == 0 {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%lease_id,
|
%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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a leased job to `pending` without burning a retry attempt.
|
/// Return a leased job to `pending` without burning a retry attempt.
|
||||||
/// Used on graceful shutdown and on session-expired aborts where the
|
/// Used by workers on graceful shutdown and on session-expired aborts
|
||||||
/// failure isn't the job's fault. See [`ack_done`] for the
|
/// where the failure isn't the job's fault. The
|
||||||
/// `state = 'running'` guard rationale — important here because
|
/// `(lease_generation, state = 'running')` guard scopes the release to
|
||||||
/// `attempts - 1` would otherwise spuriously decrement the new lease's
|
/// the *specific* lease the caller was holding — otherwise a late
|
||||||
/// attempt count.
|
/// release could clobber a successor's lease (drop the row back to
|
||||||
pub async fn release(pool: &PgPool, lease_id: Uuid) -> sqlx::Result<()> {
|
/// 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(
|
let res = sqlx::query(
|
||||||
"UPDATE crawler_jobs \
|
"UPDATE crawler_jobs \
|
||||||
SET state = 'pending', leased_until = NULL, \
|
SET state = 'pending', leased_until = NULL, \
|
||||||
attempts = GREATEST(0, attempts - 1), updated_at = now() \
|
attempts = GREATEST(0, attempts - 1), \
|
||||||
WHERE id = $1 AND state = 'running'",
|
lease_generation = lease_generation + 1, \
|
||||||
|
updated_at = now() \
|
||||||
|
WHERE id = $1 AND lease_generation = $2 AND state = 'running'",
|
||||||
)
|
)
|
||||||
.bind(lease_id)
|
.bind(lease_id)
|
||||||
|
.bind(lease_generation)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if res.rows_affected() == 0 {
|
if res.rows_affected() == 0 {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%lease_id,
|
%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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete `done` jobs whose `updated_at` is older than `retention_days`
|
/// Release a `running` lease without owning it: caller has only a
|
||||||
/// days. `0` disables the reaper without touching the table. Returns the
|
/// job id, not a [`Lease`] struct. Used by force-analyze (admin
|
||||||
/// number of rows removed.
|
/// click that drops an in-flight lease so a refreshed payload is
|
||||||
pub async fn reap_done(pool: &PgPool, retention_days: u32) -> sqlx::Result<u64> {
|
/// picked up) and ops tools.
|
||||||
if retention_days == 0 {
|
///
|
||||||
return Ok(0);
|
/// Unconditionally matches the row by id and bumps
|
||||||
}
|
/// `lease_generation` so the dropped lease's pending ack from
|
||||||
let result = sqlx::query(
|
/// the original worker becomes a no-op. Returns the attempt to
|
||||||
"DELETE FROM crawler_jobs \
|
/// `pending` and refunds the retry attempt (the operator click
|
||||||
WHERE state = 'done' \
|
/// isn't a job-level failure).
|
||||||
AND updated_at < now() - ($1::bigint || ' days')::interval",
|
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)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -359,6 +551,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn backoff_base_grows_exponentially_and_caps_at_one_hour() {
|
fn backoff_base_grows_exponentially_and_caps_at_one_hour() {
|
||||||
// attempts == 1 → 60s, doubling each step.
|
// attempts == 1 → 60s, doubling each step.
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ pub mod content;
|
|||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
pub mod intercept;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
pub mod reconcile;
|
||||||
pub mod resync;
|
pub mod resync;
|
||||||
pub mod safety;
|
pub mod safety;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
@@ -85,6 +85,28 @@ pub async fn wait_for_selector(
|
|||||||
/// under a second.
|
/// under a second.
|
||||||
pub const SELECTOR_TIMEOUT: Duration = Duration::from_secs(10);
|
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 {
|
impl NavError {
|
||||||
/// Does this navigation error indicate the underlying Chromium
|
/// Does this navigation error indicate the underlying Chromium
|
||||||
/// process has died or its CDP connection has dropped? Used by the
|
/// 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");
|
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]
|
#[test]
|
||||||
fn nav_error_timeout_message_includes_duration() {
|
fn nav_error_timeout_message_includes_duration() {
|
||||||
let e = NavError::Timeout(Duration::from_secs(30));
|
let e = NavError::Timeout(Duration::from_secs(30));
|
||||||
@@ -164,8 +221,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn anyhow_with_nav_timeout_in_chain_is_flagged() {
|
fn anyhow_with_nav_timeout_in_chain_is_flagged() {
|
||||||
let inner: Result<(), NavError> = Err(NavError::Timeout(NAV_TIMEOUT));
|
let outer = NavError::Timeout(NAV_TIMEOUT);
|
||||||
let outer = inner.unwrap_err();
|
|
||||||
let wrapped: anyhow::Error =
|
let wrapped: anyhow::Error =
|
||||||
anyhow::Error::new(outer).context("wait for chapter nav");
|
anyhow::Error::new(outer).context("wait for chapter nav");
|
||||||
assert!(anyhow_looks_browser_dead(&wrapped));
|
assert!(anyhow_looks_browser_dead(&wrapped));
|
||||||
|
|||||||
@@ -76,6 +76,213 @@ pub(crate) fn should_abort_pass(consecutive: u32, threshold: u32) -> bool {
|
|||||||
threshold > 0 && consecutive >= threshold
|
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
|
/// Runs the discover → fetch → upsert → cover → chapter-list-diff pipeline
|
||||||
/// for the target source. Pure metadata; chapter content is enqueued as
|
/// for the target source. Pure metadata; chapter content is enqueued as
|
||||||
/// separate `SyncChapterContent` jobs by the caller after this returns.
|
/// separate `SyncChapterContent` jobs by the caller after this returns.
|
||||||
@@ -118,6 +325,7 @@ pub async fn run_metadata_pass(
|
|||||||
status: Option<&crate::crawler::status::StatusHandle>,
|
status: Option<&crate::crawler::status::StatusHandle>,
|
||||||
tor: Option<&crate::crawler::tor::TorController>,
|
tor: Option<&crate::crawler::tor::TorController>,
|
||||||
) -> anyhow::Result<MetadataStats> {
|
) -> anyhow::Result<MetadataStats> {
|
||||||
|
let pass_started = std::time::Instant::now();
|
||||||
let lease = browser_manager
|
let lease = browser_manager
|
||||||
.acquire()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
@@ -243,12 +451,42 @@ pub async fn run_metadata_pass(
|
|||||||
key = %r.source_manga_key,
|
key = %r.source_manga_key,
|
||||||
"fetching metadata"
|
"fetching metadata"
|
||||||
);
|
);
|
||||||
let manga = match source.fetch_manga(&ctx, &r).await {
|
match process_manga_ref(
|
||||||
Ok(m) => {
|
&ctx,
|
||||||
|
&source,
|
||||||
|
db,
|
||||||
|
storage,
|
||||||
|
http,
|
||||||
|
rate,
|
||||||
|
&r,
|
||||||
|
skip_chapters,
|
||||||
|
allowlist,
|
||||||
|
max_image_bytes,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => {
|
||||||
consecutive_failures = 0;
|
consecutive_failures = 0;
|
||||||
m
|
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(e) => {
|
Err(RefError::Fetch(e)) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
key = %r.source_manga_key,
|
key = %r.source_manga_key,
|
||||||
url = %r.url,
|
url = %r.url,
|
||||||
@@ -267,163 +505,19 @@ pub async fn run_metadata_pass(
|
|||||||
);
|
);
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
Err(RefError::Skip(e)) => {
|
||||||
|
// Fetch succeeded (so the breaker resets) but the ref was
|
||||||
// Partial-render guard: an empty chapter list paired with a
|
// skipped — partial render or upsert error. Left out of
|
||||||
// prior count > 0 is overwhelmingly a chromium snapshot
|
// `seen` so a reappearance in a later batch retries.
|
||||||
// taken between the #chapter_table wrapper render and its
|
tracing::warn!(
|
||||||
// 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"
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let upsert = match repo::crawler::upsert_manga_from_source(
|
|
||||||
db, source_id, &r.url, &manga,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
key = %r.source_manga_key,
|
key = %r.source_manga_key,
|
||||||
error = ?e,
|
error = ?e,
|
||||||
"upsert_manga_from_source failed"
|
"manga ref skipped (partial-render or upsert error)"
|
||||||
);
|
);
|
||||||
|
consecutive_failures = 0;
|
||||||
stats.mangas_failed += 1;
|
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() {
|
|
||||||
// RAII: the guard clears `current_cover` on every
|
|
||||||
// exit path (success, panic, future early-return).
|
|
||||||
// Mirrors the chapter-side ChapterGuard.
|
|
||||||
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_result = download_and_store_cover(
|
|
||||||
db,
|
|
||||||
storage,
|
|
||||||
http,
|
|
||||||
rate,
|
|
||||||
&r.url,
|
|
||||||
upsert.manga_id,
|
|
||||||
cover_url,
|
|
||||||
allowlist,
|
|
||||||
max_image_bytes,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
match cover_result {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,6 +552,21 @@ pub async fn run_metadata_pass(
|
|||||||
"metadata pass complete"
|
"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);
|
drop(lease);
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
}
|
}
|
||||||
@@ -694,6 +803,7 @@ pub async fn backfill_missing_covers(
|
|||||||
manga_title: manga.title.clone(),
|
manga_title: manga.title.clone(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let cover_started = std::time::Instant::now();
|
||||||
let cover_result = download_and_store_cover(
|
let cover_result = download_and_store_cover(
|
||||||
db,
|
db,
|
||||||
storage,
|
storage,
|
||||||
@@ -706,6 +816,17 @@ pub async fn backfill_missing_covers(
|
|||||||
max_image_bytes,
|
max_image_bytes,
|
||||||
)
|
)
|
||||||
.await;
|
.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 {
|
match cover_result {
|
||||||
Ok(()) => stats.fetched += 1,
|
Ok(()) => stats.fetched += 1,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -768,7 +889,7 @@ pub(crate) async fn download_and_store_cover(
|
|||||||
.put(&key, &bytes)
|
.put(&key, &bytes)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("store cover at {key}"))?;
|
.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
|
.await
|
||||||
.with_context(|| format!("update cover_image_path for {manga_id}"))?;
|
.with_context(|| format!("update cover_image_path for {manga_id}"))?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -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
|
/// Per-host rate limiter map. The outer `Mutex<HashMap>` is held only
|
||||||
/// during the entry-or-insert + Arc clone; the per-host `Mutex<RateLimiter>`
|
/// during the entry-or-insert + Arc clone; the per-host `Mutex<RateLimiter>`
|
||||||
/// is held during the actual `wait().await`. So N workers calling
|
/// is held during the actual `wait().await`. So N workers calling
|
||||||
@@ -54,7 +73,7 @@ impl RateLimiter {
|
|||||||
pub struct HostRateLimiters {
|
pub struct HostRateLimiters {
|
||||||
default_interval: Duration,
|
default_interval: Duration,
|
||||||
overrides: HashMap<String, Duration>,
|
overrides: HashMap<String, Duration>,
|
||||||
map: Mutex<HashMap<String, Arc<Mutex<RateLimiter>>>>,
|
map: Mutex<HashMap<String, HostEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostRateLimiters {
|
impl HostRateLimiters {
|
||||||
@@ -82,20 +101,37 @@ impl HostRateLimiters {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("no host in url: {url}"))?;
|
.ok_or_else(|| anyhow::anyhow!("no host in url: {url}"))?;
|
||||||
let limiter = {
|
let limiter = {
|
||||||
let mut map = self.map.lock().await;
|
let mut map = self.map.lock().await;
|
||||||
map.entry(host.clone())
|
let now = Instant::now();
|
||||||
.or_insert_with(|| {
|
// Bound the map: when it grows past the soft cap, drop hosts that
|
||||||
let interval = self
|
// have been idle past the TTL. Only fires over-cap, so the common
|
||||||
.overrides
|
// path is a plain lookup. A host still being crawled is touched
|
||||||
.get(&host)
|
// every `interval` and so never ages out.
|
||||||
.copied()
|
if map.len() >= MAX_TRACKED_HOSTS {
|
||||||
.unwrap_or(self.default_interval);
|
map.retain(|_, e| now.duration_since(e.last_used) < IDLE_EVICT_AFTER);
|
||||||
Arc::new(Mutex::new(RateLimiter::new(interval)))
|
}
|
||||||
})
|
let entry = map.entry(host.clone()).or_insert_with(|| {
|
||||||
.clone()
|
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;
|
limiter.lock().await.wait().await;
|
||||||
Ok(())
|
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
|
// `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)]
|
#[tokio::test(start_paused = true)]
|
||||||
async fn host_rate_limiters_honor_overrides() {
|
async fn host_rate_limiters_honor_overrides() {
|
||||||
let rl = HostRateLimiters::new(Duration::from_millis(1000))
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ pub struct RealResyncService {
|
|||||||
pub rate: Arc<HostRateLimiters>,
|
pub rate: Arc<HostRateLimiters>,
|
||||||
pub download_allowlist: DownloadAllowlist,
|
pub download_allowlist: DownloadAllowlist,
|
||||||
pub max_image_bytes: usize,
|
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>>,
|
pub tor: Option<Arc<TorController>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,28 +135,7 @@ impl ResyncService for RealResyncService {
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("fetch_manga during resync of {manga_id}"))?;
|
.with_context(|| format!("fetch_manga during resync of {manga_id}"))?;
|
||||||
|
|
||||||
// Partial-render guard: same logic as run_metadata_pass.
|
|
||||||
let source_id = source.id();
|
let source_id = source.id();
|
||||||
if !manga.chapters.is_empty() || {
|
|
||||||
let prior = repo::crawler::live_chapter_count_for_source_manga(
|
|
||||||
&self.db,
|
|
||||||
source_id,
|
|
||||||
&source_manga_key,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
prior == 0
|
|
||||||
} {
|
|
||||||
// Either the new fetch surfaced chapters, or there were
|
|
||||||
// none before either — chapter sync is safe to run.
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
%manga_id,
|
|
||||||
source_url = %source_url,
|
|
||||||
"resync_manga: fetch returned empty chapters but prior count > 0; skipping chapter sync to avoid soft-drop"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let upsert = repo::crawler::upsert_manga_from_source(
|
let upsert = repo::crawler::upsert_manga_from_source(
|
||||||
&self.db,
|
&self.db,
|
||||||
source_id,
|
source_id,
|
||||||
@@ -199,6 +180,11 @@ impl ResyncService for RealResyncService {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.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 {
|
if !manga.chapters.is_empty() || prior_chapter_count == 0 {
|
||||||
match repo::crawler::sync_manga_chapters(
|
match repo::crawler::sync_manga_chapters(
|
||||||
&self.db,
|
&self.db,
|
||||||
@@ -221,6 +207,12 @@ impl ResyncService for RealResyncService {
|
|||||||
"resync_manga: chapter sync failed"
|
"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);
|
drop(lease);
|
||||||
@@ -256,6 +248,7 @@ impl ResyncService for RealResyncService {
|
|||||||
true,
|
true,
|
||||||
&self.download_allowlist,
|
&self.download_allowlist,
|
||||||
self.max_image_bytes,
|
self.max_image_bytes,
|
||||||
|
self.max_images_per_chapter,
|
||||||
self.tor.as_deref(),
|
self.tor.as_deref(),
|
||||||
// Admin resync isn't a daemon worker slot — no live status.
|
// Admin resync isn't a daemon worker slot — no live status.
|
||||||
None,
|
None,
|
||||||
@@ -277,6 +270,12 @@ impl ResyncService for RealResyncService {
|
|||||||
SyncOutcome::SessionExpired => {
|
SyncOutcome::SessionExpired => {
|
||||||
anyhow::bail!("source session expired — operator must refresh PHPSESSID")
|
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.
|
//! URL string, and the byte accumulator is keyed off a generic stream.
|
||||||
//! Easy to unit-test without a live network or browser.
|
//! 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 anyhow::{bail, Context};
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
|
|
||||||
/// Default per-image download cap. A page image is generally <2 MiB;
|
/// Default per-image download cap. A page image is generally <2 MiB;
|
||||||
@@ -124,6 +126,34 @@ impl DownloadAllowlist {
|
|||||||
/// An empty allowlist rejects everything (the conservative default —
|
/// An empty allowlist rejects everything (the conservative default —
|
||||||
/// callers must explicitly allow the catalog and CDN hosts).
|
/// callers must explicitly allow the catalog and CDN hosts).
|
||||||
pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSafetyError> {
|
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 url = Url::parse(raw_url).map_err(|_| UrlSafetyError::Unparseable)?;
|
||||||
let scheme = url.scheme();
|
let scheme = url.scheme();
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
@@ -148,13 +178,10 @@ pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSa
|
|||||||
return Err(UrlSafetyError::PrivateIp(ip));
|
return Err(UrlSafetyError::PrivateIp(ip));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !allow.contains(&lower_host) {
|
Ok(url)
|
||||||
return Err(UrlSafetyError::HostNotAllowed(lower_host));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
pub(crate) fn is_private_ip(ip: &IpAddr) -> bool {
|
||||||
match ip {
|
match ip {
|
||||||
IpAddr::V4(v4) => {
|
IpAddr::V4(v4) => {
|
||||||
v4.is_loopback()
|
v4.is_loopback()
|
||||||
@@ -168,11 +195,14 @@ fn is_private_ip(ip: &IpAddr) -> bool {
|
|||||||
|| v4.octets()[0] == 0
|
|| v4.octets()[0] == 0
|
||||||
}
|
}
|
||||||
IpAddr::V6(v6) => {
|
IpAddr::V6(v6) => {
|
||||||
// IPv4-mapped IPv6 (::ffff:0:0/96): unwrap to the embedded
|
// Any IPv6 form that *embeds* an IPv4 address (mapped
|
||||||
// IPv4 and recurse so `::ffff:127.0.0.1` is caught by the
|
// `::ffff:0:0/96`, compatible `::/96`, NAT64 `64:ff9b::/96`,
|
||||||
// IPv4 loopback check rather than passing through.
|
// 6to4 `2002::/16`) is unwrapped and re-checked as its IPv4 —
|
||||||
// `Ipv6Addr::is_loopback()` only matches `::1` exactly.
|
// otherwise `::127.0.0.1` / `2002:7f00:1::` / `64:ff9b::7f00:1`
|
||||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
// 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));
|
return is_private_ip(&IpAddr::V4(v4));
|
||||||
}
|
}
|
||||||
v6.is_loopback()
|
v6.is_loopback()
|
||||||
@@ -185,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)]
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
pub enum UrlSafetyError {
|
pub enum UrlSafetyError {
|
||||||
#[error("URL is not parseable")]
|
#[error("URL is not parseable")]
|
||||||
@@ -201,6 +339,73 @@ pub enum UrlSafetyError {
|
|||||||
HostNotAllowed(String),
|
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
|
/// Drain a byte stream into a single buffer, bailing out as soon as
|
||||||
/// the running total exceeds `max_bytes`. Generic over the stream so
|
/// the running total exceeds `max_bytes`. Generic over the stream so
|
||||||
/// it's testable without a live HTTP response.
|
/// it's testable without a live HTTP response.
|
||||||
@@ -302,6 +507,26 @@ mod tests {
|
|||||||
DownloadAllowlist::new().allow(host)
|
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]
|
#[test]
|
||||||
fn allow_any_admits_arbitrary_public_host() {
|
fn allow_any_admits_arbitrary_public_host() {
|
||||||
// Operators who can't pre-enumerate a numbered-CDN fleet
|
// Operators who can't pre-enumerate a numbered-CDN fleet
|
||||||
@@ -383,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]
|
#[test]
|
||||||
fn safe_url_blocks_rfc1918() {
|
fn safe_url_blocks_rfc1918() {
|
||||||
let allow = allow_just("10.0.0.1");
|
let allow = allow_just("10.0.0.1");
|
||||||
@@ -452,6 +742,79 @@ mod tests {
|
|||||||
assert!(matches!(err, UrlSafetyError::PrivateIp(_)));
|
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]
|
#[test]
|
||||||
fn safe_url_blocks_non_http_schemes() {
|
fn safe_url_blocks_non_http_schemes() {
|
||||||
let allow = allow_just("anywhere");
|
let allow = allow_just("anywhere");
|
||||||
@@ -488,6 +851,54 @@ mod tests {
|
|||||||
assert!(is_safe_url("https://CDN.EXAMPLE.com/x.jpg", &allow).is_ok());
|
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]
|
#[tokio::test]
|
||||||
async fn accumulate_capped_returns_full_body_under_cap() {
|
async fn accumulate_capped_returns_full_body_under_cap() {
|
||||||
let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = vec![
|
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> {
|
async fn fetch_probe_html(browser: &Browser, probe_url: &str) -> anyhow::Result<String> {
|
||||||
let page = browser
|
// Guard the probe navigation for parity with the list/detail and
|
||||||
.new_page(probe_url)
|
// 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
|
.await
|
||||||
.with_context(|| format!("open probe page {probe_url}"))?;
|
.with_context(|| format!("open probe page {probe_url}"))?;
|
||||||
crate::crawler::nav::wait_for_nav(&page)
|
// Close the tab on every exit path — a `?` on wait_for_nav / content()
|
||||||
.await
|
// would otherwise leak it (chromiumoxide doesn't close on drop).
|
||||||
.context("wait for nav on probe")?;
|
let closer = page.clone();
|
||||||
// Best-effort wait for the layout marker. Timeout is fine — the
|
crate::crawler::nav::close_after(
|
||||||
// probe classifier handles a missing `#logo` as Transient anyway,
|
async move {
|
||||||
// and the verify loop retries on Transient.
|
closer.close().await.ok();
|
||||||
let _ = crate::crawler::nav::wait_for_selector(
|
},
|
||||||
&page,
|
async {
|
||||||
"#logo",
|
crate::crawler::nav::wait_for_nav(&page)
|
||||||
crate::crawler::nav::SELECTOR_TIMEOUT,
|
.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;
|
.await
|
||||||
let html = page.content().await.context("read probe html")?;
|
|
||||||
page.close().await.ok();
|
|
||||||
Ok(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ use super::{
|
|||||||
use crate::crawler::detect::{
|
use crate::crawler::detect::{
|
||||||
has_logo_sentinel, is_broken_page_body, retry_on_transient_with_hook, PageError,
|
has_logo_sentinel, is_broken_page_body, retry_on_transient_with_hook, PageError,
|
||||||
};
|
};
|
||||||
use crate::crawler::nav::{wait_for_nav, wait_for_selector, NavError, SELECTOR_TIMEOUT};
|
use crate::crawler::nav::{close_after, wait_for_nav, wait_for_selector, NavError, SELECTOR_TIMEOUT};
|
||||||
|
use crate::crawler::safety::ensure_public_target;
|
||||||
|
|
||||||
/// `sources.id` value for this Source impl. Exposed as a const so the
|
/// `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
|
/// 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_CHAPTERS_MARKER: &str = "#chapter_table td h4 a.chico";
|
||||||
const DETAIL_PAGE_LAYOUT_MARKER: &str = "#logo";
|
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
|
/// Single point of rate-limited navigation. Every Source request goes
|
||||||
/// through here, so the per-host limiter map is the only knob that
|
/// through here, so the per-host limiter map is the only knob that
|
||||||
/// controls per-origin RPS. Also the choke point for transient-page
|
/// controls per-origin RPS. Also the choke point for transient-page
|
||||||
@@ -237,32 +251,39 @@ async fn navigate(
|
|||||||
url: &str,
|
url: &str,
|
||||||
marker: &str,
|
marker: &str,
|
||||||
) -> Result<String, PageError> {
|
) -> Result<String, PageError> {
|
||||||
|
guard_navigate_url(url)?;
|
||||||
ctx.rate.wait_for(url).await?;
|
ctx.rate.wait_for(url).await?;
|
||||||
let page = ctx
|
let page = crate::crawler::intercept::open_page(ctx.browser, url)
|
||||||
.browser
|
|
||||||
.new_page(url)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
|
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
|
||||||
match wait_for_nav(&page).await {
|
// Close the tab on every exit path — the previous code closed on the two
|
||||||
Ok(()) => {}
|
// nav-error branches but leaked it on a content() read error.
|
||||||
Err(NavError::Timeout(_)) => {
|
let closer = page.clone();
|
||||||
page.close().await.ok();
|
close_after(
|
||||||
return Err(PageError::transient("nav timeout"));
|
async move {
|
||||||
}
|
closer.close().await.ok();
|
||||||
Err(NavError::Cdp(e)) => {
|
},
|
||||||
page.close().await.ok();
|
async {
|
||||||
return Err(PageError::Other(anyhow::Error::from(e)));
|
match wait_for_nav(&page).await {
|
||||||
}
|
Ok(()) => {}
|
||||||
}
|
Err(NavError::Timeout(_)) => {
|
||||||
// Best-effort wait for the page-type marker. We deliberately
|
return Err(PageError::transient("nav timeout"));
|
||||||
// discard a timeout here — see fn-level doc.
|
}
|
||||||
let _ = wait_for_selector(&page, marker, SELECTOR_TIMEOUT).await;
|
Err(NavError::Cdp(e)) => {
|
||||||
let html = page
|
return Err(PageError::Other(anyhow::Error::from(e)));
|
||||||
.content()
|
}
|
||||||
.await
|
}
|
||||||
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
|
// Best-effort wait for the page-type marker. We deliberately
|
||||||
page.close().await.ok();
|
// discard a timeout here — see fn-level doc.
|
||||||
classify_navigate_html(html)
|
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
|
/// 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) {
|
if !has_logo_sentinel(doc) {
|
||||||
return Err(PageError::transient("manga list: #logo sentinel missing"));
|
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();
|
let sel = scraper::Selector::parse("#left_side .pic_list .updatesli span a").unwrap();
|
||||||
Ok(doc
|
let mut refs = Vec::new();
|
||||||
.select(&sel)
|
let mut dropped = 0usize;
|
||||||
.filter_map(|a| {
|
for a in doc.select(&sel) {
|
||||||
let url = a.value().attr("href")?.trim().to_string();
|
let url = a
|
||||||
if url.is_empty() {
|
.value()
|
||||||
return None;
|
.attr("href")
|
||||||
}
|
.map(|h| h.trim().to_string())
|
||||||
let title = collapse_whitespace(&a.text().collect::<String>());
|
.unwrap_or_default();
|
||||||
if title.is_empty() {
|
if url.is_empty() {
|
||||||
return None;
|
dropped += 1;
|
||||||
}
|
continue;
|
||||||
Some(SourceMangaRef {
|
}
|
||||||
source_manga_key: derive_key_from_url(&url),
|
let title = collapse_whitespace(&a.text().collect::<String>());
|
||||||
title,
|
if title.is_empty() {
|
||||||
url,
|
dropped += 1;
|
||||||
})
|
continue;
|
||||||
})
|
}
|
||||||
.collect())
|
refs.push(SourceMangaRef {
|
||||||
|
source_manga_key: derive_key_from_url(&url),
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(refs, dropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_manga_detail(
|
fn parse_manga_detail(
|
||||||
@@ -699,6 +749,35 @@ mod tests {
|
|||||||
assert_eq!(refs[1].source_manga_key, "bar-baz");
|
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]
|
#[test]
|
||||||
fn parse_manga_list_returns_transient_when_logo_missing() {
|
fn parse_manga_list_returns_transient_when_logo_missing() {
|
||||||
// Broken-page response: no #logo, no listing. Empty Vec would
|
// 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");
|
.expect("metadata-only parse must not require chapter table");
|
||||||
assert!(manga.chapters.is_empty());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ pub enum Phase {
|
|||||||
/// Backfilling covers that failed on first attempt. `index`/`total`
|
/// Backfilling covers that failed on first attempt. `index`/`total`
|
||||||
/// track progress through this tick's batch.
|
/// track progress through this tick's batch.
|
||||||
CoverBackfill { index: usize, total: usize },
|
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
|
/// A chapter being downloaded right now, with a live page count. Keyed in
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ pub struct ApiToken {
|
|||||||
pub token_hash: Vec<u8>,
|
pub token_hash: Vec<u8>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub last_used_at: Option<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 title: Option<String>,
|
||||||
pub page_count: i32,
|
pub page_count: i32,
|
||||||
pub created_at: DateTime<Utc>,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
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>,
|
||||||
|
}
|
||||||
@@ -43,6 +43,12 @@ pub struct MangaDetail {
|
|||||||
pub genres: Vec<GenreRef>,
|
pub genres: Vec<GenreRef>,
|
||||||
pub tags: Vec<TagRef>,
|
pub tags: Vec<TagRef>,
|
||||||
pub content_warnings: Vec<ContentWarning>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ pub mod author;
|
|||||||
pub mod bookmark;
|
pub mod bookmark;
|
||||||
pub mod chapter;
|
pub mod chapter;
|
||||||
pub mod collection;
|
pub mod collection;
|
||||||
|
pub mod crawl_metrics;
|
||||||
pub mod genre;
|
pub mod genre;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod page_analysis;
|
pub mod page_analysis;
|
||||||
pub mod page_tag;
|
pub mod page_tag;
|
||||||
pub mod patch;
|
pub mod patch;
|
||||||
|
pub mod reaction;
|
||||||
pub mod read_progress;
|
pub mod read_progress;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod storage_stats;
|
||||||
pub mod sync_state;
|
pub mod sync_state;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod upload_entry;
|
pub mod upload_entry;
|
||||||
@@ -24,6 +27,7 @@ pub use author::{Author, AuthorRef, AuthorWithCount};
|
|||||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||||
pub use chapter::Chapter;
|
pub use chapter::Chapter;
|
||||||
pub use collection::{Collection, CollectionPageItem, CollectionSummary};
|
pub use collection::{Collection, CollectionPageItem, CollectionSummary};
|
||||||
|
pub use crawl_metrics::{OpRow, OpSummary};
|
||||||
pub use genre::{Genre, GenreRef};
|
pub use genre::{Genre, GenreRef};
|
||||||
pub use manga::{Manga, MangaCard, MangaDetail};
|
pub use manga::{Manga, MangaCard, MangaDetail};
|
||||||
pub use page::Page;
|
pub use page::Page;
|
||||||
@@ -38,6 +42,7 @@ pub use page_tag::{
|
|||||||
pub use patch::Patch;
|
pub use patch::Patch;
|
||||||
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
||||||
pub use session::Session;
|
pub use session::Session;
|
||||||
|
pub use storage_stats::{StorageStats, TopChapter, TopManga};
|
||||||
pub use sync_state::{ChapterSyncState, MangaSyncState};
|
pub use sync_state::{ChapterSyncState, MangaSyncState};
|
||||||
pub use tag::{Tag, TagRef};
|
pub use tag::{Tag, TagRef};
|
||||||
pub use upload_entry::UploadEntry;
|
pub use upload_entry::UploadEntry;
|
||||||
|
|||||||
@@ -157,6 +157,46 @@ pub struct PageStatusItem {
|
|||||||
pub status: String,
|
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.
|
/// One OCR line in the page-detail view.
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct OcrLine {
|
pub struct OcrLine {
|
||||||
|
|||||||
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
|
/// `None` when the chapter was deleted after this row was written
|
||||||
/// (FK ON DELETE SET NULL on `chapter_id`).
|
/// (FK ON DELETE SET NULL on `chapter_id`).
|
||||||
pub chapter_number: Option<i32>,
|
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 page: i32,
|
||||||
pub updated_at: DateTime<Utc>,
|
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
|
/// Returned by `GET /me/read-progress/:manga_id`. Same shape as
|
||||||
@@ -40,6 +51,11 @@ pub struct ReadProgressForManga {
|
|||||||
pub chapter_number: Option<i32>,
|
pub chapter_number: Option<i32>,
|
||||||
pub page: i32,
|
pub page: i32,
|
||||||
pub updated_at: DateTime<Utc>,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
57
backend/src/domain/storage_stats.rs
Normal file
57
backend/src/domain/storage_stats.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! Admin storage-usage stats. Pure data; assembled by
|
||||||
|
//! `crate::api::admin::storage` from `repo::storage_stats` aggregates
|
||||||
|
//! plus the storage backend's volume size.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Storage usage of uploaded content (covers + chapter pages), for the
|
||||||
|
/// admin dashboard System tab. Sizes are bytes; the frontend formats
|
||||||
|
/// them with the binary (MiB/GiB) helper.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct StorageStats {
|
||||||
|
/// covers + chapter pages.
|
||||||
|
pub total_bytes: i64,
|
||||||
|
pub covers_bytes: i64,
|
||||||
|
pub chapters_bytes: i64,
|
||||||
|
/// Volume size of the storage root (`statvfs`). `None` when the
|
||||||
|
/// backend has no local path (e.g. a future S3 backend).
|
||||||
|
pub disk_total_bytes: Option<u64>,
|
||||||
|
/// `total_bytes / disk_total_bytes`. `None` when the disk total is
|
||||||
|
/// unknown — the frontend hides the ratio rather than fabricate one.
|
||||||
|
pub ratio_of_disk: Option<f64>,
|
||||||
|
/// Average image size across covers + chapter pages. `None` for an
|
||||||
|
/// empty library (no division by zero).
|
||||||
|
pub avg_image_bytes: Option<f64>,
|
||||||
|
pub avg_cover_bytes: Option<f64>,
|
||||||
|
pub avg_chapter_page_bytes: Option<f64>,
|
||||||
|
/// Largest mangas by total stored bytes (cover + all chapter pages).
|
||||||
|
/// Only fully-measured mangas are ranked.
|
||||||
|
pub top_mangas: Vec<TopManga>,
|
||||||
|
/// Largest chapters by total stored page bytes. Only fully-measured
|
||||||
|
/// chapters are ranked.
|
||||||
|
pub top_chapters: Vec<TopChapter>,
|
||||||
|
/// Page rows still awaiting a size backfill. When > 0, every figure
|
||||||
|
/// above is a partial (lower-bound) measurement and the leaderboards
|
||||||
|
/// omit not-yet-measured content — the dashboard surfaces this so the
|
||||||
|
/// numbers aren't read as complete.
|
||||||
|
pub unmeasured_pages: i64,
|
||||||
|
/// Cover blobs still awaiting a size backfill.
|
||||||
|
pub unmeasured_covers: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TopManga {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub total_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TopChapter {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub manga_id: Uuid,
|
||||||
|
pub number: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub total_bytes: i64,
|
||||||
|
}
|
||||||
@@ -39,10 +39,10 @@ pub enum AppError {
|
|||||||
details: serde_json::Value,
|
details: serde_json::Value,
|
||||||
},
|
},
|
||||||
/// 501 — the wire shape is accepted but the feature isn't built yet.
|
/// 501 — the wire shape is accepted but the feature isn't built yet.
|
||||||
/// Carries a `&'static str` snake_case code so clients can detect
|
/// Carries a `&'static str` snake_case code so clients can detect the
|
||||||
/// the specific pending feature (`text_search_not_yet_supported`,
|
/// specific pending feature without parsing the message. Generic; kept
|
||||||
/// etc.) without parsing the message. Used today by the `?text=`
|
/// for future reservations (the `?text=` page-tag reservation that first
|
||||||
/// reservation on the page-tag aggregation endpoints.
|
/// used it now performs real OCR search).
|
||||||
#[error("not implemented: {code}")]
|
#[error("not implemented: {code}")]
|
||||||
NotImplemented {
|
NotImplemented {
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
@@ -207,4 +207,68 @@ mod tests {
|
|||||||
"text_search_not_yet_supported"
|
"text_search_not_yet_supported"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_mapping_is_stable() {
|
||||||
|
// Pin the code -> HTTP status mapping so a refactor of into_response
|
||||||
|
// can't silently change a status (e.g. 501 -> 500). The 501 NotImplemented
|
||||||
|
// path and a deterministic 429 Retry-After were previously unexercised
|
||||||
|
// (audit GAP B).
|
||||||
|
let cases: Vec<(AppError, StatusCode)> = vec![
|
||||||
|
(AppError::NotFound, StatusCode::NOT_FOUND),
|
||||||
|
(AppError::InvalidInput("x".into()), StatusCode::BAD_REQUEST),
|
||||||
|
(AppError::Unauthenticated, StatusCode::UNAUTHORIZED),
|
||||||
|
(AppError::Forbidden, StatusCode::FORBIDDEN),
|
||||||
|
(AppError::Conflict("x".into()), StatusCode::CONFLICT),
|
||||||
|
(AppError::PayloadTooLarge("x".into()), StatusCode::PAYLOAD_TOO_LARGE),
|
||||||
|
(
|
||||||
|
AppError::UnsupportedMediaType("x".into()),
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AppError::ServiceUnavailable("x".into()),
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AppError::ValidationFailed {
|
||||||
|
message: "x".into(),
|
||||||
|
details: json!({}),
|
||||||
|
},
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AppError::NotImplemented {
|
||||||
|
code: "c",
|
||||||
|
message: "m",
|
||||||
|
},
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AppError::TooManyRequests {
|
||||||
|
retry_after_secs: None,
|
||||||
|
},
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AppError::Other(anyhow::anyhow!("boom")),
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (err, want) in cases {
|
||||||
|
let code = err.code();
|
||||||
|
assert_eq!(err.into_response().status(), want, "status for code {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 429 with retry_after carries the Retry-After header deterministically
|
||||||
|
// (without needing a live rate limiter).
|
||||||
|
let resp = AppError::TooManyRequests {
|
||||||
|
retry_after_secs: Some(7),
|
||||||
|
}
|
||||||
|
.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers().get(axum::http::header::RETRY_AFTER).unwrap(),
|
||||||
|
"7"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,102 @@
|
|||||||
//! Admin-action audit log writes.
|
//! Admin-action audit log writes + the admin-facing read query.
|
||||||
//!
|
//!
|
||||||
//! Insert is always called from inside the same transaction as the
|
//! Insert is always called from inside the same transaction as the
|
||||||
//! action it audits — the executor parameter is `PgExecutor` so the
|
//! action it audits — the executor parameter is `PgExecutor` so the
|
||||||
//! caller passes `&mut *tx` directly.
|
//! caller passes `&mut *tx` directly. [`list`] backs the audit-log viewer.
|
||||||
|
|
||||||
use sqlx::PgExecutor;
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{FromRow, PgExecutor, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::AppResult;
|
use crate::error::AppResult;
|
||||||
|
|
||||||
|
/// One audit row joined to the actor's current username (NULL when the
|
||||||
|
/// actor account was deleted — `actor_user_id` is `ON DELETE SET NULL`).
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct AuditEntryRow {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub actor_user_id: Option<Uuid>,
|
||||||
|
pub actor_username: Option<String>,
|
||||||
|
pub action: String,
|
||||||
|
pub target_kind: String,
|
||||||
|
pub target_id: Option<Uuid>,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional filters for [`list`]. `None`/absent widens the scope; an
|
||||||
|
/// empty-string action/target_kind is treated as absent by the caller.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct AuditFilter<'a> {
|
||||||
|
pub action: Option<&'a str>,
|
||||||
|
pub target_kind: Option<&'a str>,
|
||||||
|
pub actor_user_id: Option<Uuid>,
|
||||||
|
pub since: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated, newest-first audit log. Returns the page slice plus the
|
||||||
|
/// filtered total. Ordering by `at DESC` uses `admin_audit_at_idx`.
|
||||||
|
pub async fn list(
|
||||||
|
pool: &PgPool,
|
||||||
|
filter: AuditFilter<'_>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> AppResult<(Vec<AuditEntryRow>, i64)> {
|
||||||
|
let items = sqlx::query_as::<_, AuditEntryRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.actor_user_id,
|
||||||
|
u.username AS actor_username,
|
||||||
|
a.action,
|
||||||
|
a.target_kind,
|
||||||
|
a.target_id,
|
||||||
|
a.payload,
|
||||||
|
a.at
|
||||||
|
FROM admin_audit a
|
||||||
|
LEFT JOIN users u ON u.id = a.actor_user_id
|
||||||
|
WHERE ($1::text IS NULL OR a.action = $1)
|
||||||
|
AND ($2::text IS NULL OR a.target_kind = $2)
|
||||||
|
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
|
||||||
|
AND ($4::timestamptz IS NULL OR a.at >= $4)
|
||||||
|
-- `id` tiebreaker for stable pagination: a single admin action
|
||||||
|
-- can write multiple audit rows in the same statement_timestamp
|
||||||
|
-- (e.g. bulk requeue) and ties on the timestamp would otherwise
|
||||||
|
-- skip or repeat rows across pages.
|
||||||
|
ORDER BY a.at DESC, a.id DESC
|
||||||
|
LIMIT $5 OFFSET $6
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.action)
|
||||||
|
.bind(filter.target_kind)
|
||||||
|
.bind(filter.actor_user_id)
|
||||||
|
.bind(filter.since)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (total,): (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM admin_audit a
|
||||||
|
WHERE ($1::text IS NULL OR a.action = $1)
|
||||||
|
AND ($2::text IS NULL OR a.target_kind = $2)
|
||||||
|
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
|
||||||
|
AND ($4::timestamptz IS NULL OR a.at >= $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.action)
|
||||||
|
.bind(filter.target_kind)
|
||||||
|
.bind(filter.actor_user_id)
|
||||||
|
.bind(filter.since)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn insert<'e, E: PgExecutor<'e>>(
|
pub async fn insert<'e, E: PgExecutor<'e>>(
|
||||||
executor: E,
|
executor: E,
|
||||||
actor_user_id: Uuid,
|
actor_user_id: Uuid,
|
||||||
|
|||||||
@@ -83,6 +83,125 @@ const MANGA_SYNC_STATE_CASE: &str = r#"
|
|||||||
END
|
END
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Library shape for the admin overview: total mangas split by derived
|
||||||
|
/// sync state, plus library-wide chapter/page totals and the newest manga.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MangaStats {
|
||||||
|
pub total: i64,
|
||||||
|
pub synced: i64,
|
||||||
|
pub in_progress: i64,
|
||||||
|
pub dropped: i64,
|
||||||
|
pub total_chapters: i64,
|
||||||
|
pub total_pages: i64,
|
||||||
|
pub newest_title: Option<String>,
|
||||||
|
pub newest_seen_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate the manga library for the overview dashboard.
|
||||||
|
///
|
||||||
|
/// Unlike the paginated Mangas tab (`list_mangas_with_sync_state`, which
|
||||||
|
/// evaluates `MANGA_SYNC_STATE_CASE` for only the page slice), this scans the
|
||||||
|
/// WHOLE library. The per-row correlated form is therefore O(mangas x jobs):
|
||||||
|
/// each manga seqscans `crawler_jobs`, and the disabled-analysis backlog
|
||||||
|
/// inflates every scan. Instead we collect the set of "in-flight" manga ids in
|
||||||
|
/// ONE pass over the in-flight sync jobs (index-backed: 0020 + 0029), then
|
||||||
|
/// classify each manga with a hash semi-join — O(mangas + jobs).
|
||||||
|
///
|
||||||
|
/// The `in_progress` rule here MUST stay in lockstep with the first arm of
|
||||||
|
/// `MANGA_SYNC_STATE_CASE`: pending/running `sync_chapter_list` (by manga_id)
|
||||||
|
/// OR `sync_manga` (resolved through `manga_sources`). The `dropped`/`synced`
|
||||||
|
/// arms are identical to the shared case.
|
||||||
|
pub async fn manga_stats(pool: &PgPool) -> AppResult<MangaStats> {
|
||||||
|
let counts_sql = r#"
|
||||||
|
WITH in_progress_mangas AS (
|
||||||
|
-- sync_chapter_list jobs carry the target manga_id directly
|
||||||
|
SELECT (cj.payload->>'manga_id')::uuid AS manga_id
|
||||||
|
FROM crawler_jobs cj
|
||||||
|
WHERE cj.state IN ('pending', 'running')
|
||||||
|
AND cj.payload->>'kind' = 'sync_chapter_list'
|
||||||
|
AND cj.payload->>'manga_id' IS NOT NULL
|
||||||
|
UNION
|
||||||
|
-- sync_manga jobs resolve to a manga via manga_sources
|
||||||
|
SELECT ms.manga_id
|
||||||
|
FROM crawler_jobs cj
|
||||||
|
JOIN manga_sources ms
|
||||||
|
ON ms.source_id = cj.payload->>'source_id'
|
||||||
|
AND ms.source_manga_key = cj.payload->>'source_manga_key'
|
||||||
|
WHERE cj.state IN ('pending', 'running')
|
||||||
|
AND cj.payload->>'kind' = 'sync_manga'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::bigint AS total,
|
||||||
|
COUNT(*) FILTER (WHERE s = 'synced')::bigint AS synced,
|
||||||
|
COUNT(*) FILTER (WHERE s = 'in_progress')::bigint AS in_progress,
|
||||||
|
COUNT(*) FILTER (WHERE s = 'dropped')::bigint AS dropped
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN m.id IN (SELECT manga_id FROM in_progress_mangas)
|
||||||
|
THEN 'in_progress'
|
||||||
|
WHEN EXISTS (SELECT 1 FROM manga_sources ms WHERE ms.manga_id = m.id)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM manga_sources ms
|
||||||
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
||||||
|
)
|
||||||
|
THEN 'dropped'
|
||||||
|
ELSE 'synced'
|
||||||
|
END AS s
|
||||||
|
FROM mangas m
|
||||||
|
) q
|
||||||
|
"#;
|
||||||
|
// Backstop: even with the single-pass rewrite + indexes this is cheap, but
|
||||||
|
// a `statement_timeout` guarantees a pathological plan can never pin a
|
||||||
|
// backend for minutes and let pollers stack again. SET LOCAL is scoped to
|
||||||
|
// the transaction.
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
sqlx::query("SET LOCAL statement_timeout = '5s'")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let (total, synced, in_progress, dropped): (i64, i64, i64, i64) =
|
||||||
|
sqlx::query_as(counts_sql).fetch_one(&mut *tx).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let (total_chapters,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*)::bigint FROM chapters")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let (total_pages,): (i64,) = sqlx::query_as("SELECT COUNT(*)::bigint FROM pages")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Newest by creation, with its own latest non-dropped crawl sighting
|
||||||
|
// (NULL for user uploads with no source rows).
|
||||||
|
let newest: Option<(String, Option<DateTime<Utc>>)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT m.title,
|
||||||
|
(SELECT MAX(ms.last_seen_at) FROM manga_sources ms
|
||||||
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL)
|
||||||
|
FROM mangas m
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let (newest_title, newest_seen_at) = match newest {
|
||||||
|
Some((title, seen)) => (Some(title), seen),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(MangaStats {
|
||||||
|
total,
|
||||||
|
synced,
|
||||||
|
in_progress,
|
||||||
|
dropped,
|
||||||
|
total_chapters,
|
||||||
|
total_pages,
|
||||||
|
newest_title,
|
||||||
|
newest_seen_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Paginated admin manga list with derived sync state and total count.
|
/// Paginated admin manga list with derived sync state and total count.
|
||||||
/// Filters by `search` (substring on title, case-insensitive) and
|
/// Filters by `search` (substring on title, case-insensitive) and
|
||||||
/// `sync_state` (post-derivation). The CTE keeps the case expression
|
/// `sync_state` (post-derivation). The CTE keeps the case expression
|
||||||
@@ -95,7 +214,7 @@ pub async fn list_mangas_with_sync_state(
|
|||||||
let search_pat = q
|
let search_pat = q
|
||||||
.search
|
.search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
// sqlx::Type → text: bind the snake_case representation manually so
|
// sqlx::Type → text: bind the snake_case representation manually so
|
||||||
// the SQL can compare it as text without an explicit cast.
|
// the SQL can compare it as text without an explicit cast.
|
||||||
@@ -116,11 +235,15 @@ pub async fn list_mangas_with_sync_state(
|
|||||||
(SELECT MAX(last_seen_at) FROM manga_sources ms
|
(SELECT MAX(last_seen_at) FROM manga_sources ms
|
||||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at
|
||||||
FROM mangas m
|
FROM mangas m
|
||||||
WHERE ($1::text IS NULL OR m.title ILIKE $1)
|
WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
)
|
)
|
||||||
SELECT * FROM classified
|
SELECT * FROM classified
|
||||||
WHERE ($2::text IS NULL OR sync_state = $2)
|
WHERE ($2::text IS NULL OR sync_state = $2)
|
||||||
ORDER BY updated_at DESC
|
-- `id` tiebreaker stabilises pagination across mangas that share an
|
||||||
|
-- `updated_at` (multi-row crawl-tick bulk updates land within the
|
||||||
|
-- same statement_timestamp() and tie on Postgres' default ts
|
||||||
|
-- resolution). Without it, page boundaries can skip or repeat rows.
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
case = MANGA_SYNC_STATE_CASE
|
case = MANGA_SYNC_STATE_CASE
|
||||||
@@ -138,7 +261,7 @@ pub async fn list_mangas_with_sync_state(
|
|||||||
WITH classified AS (
|
WITH classified AS (
|
||||||
SELECT {case} AS sync_state
|
SELECT {case} AS sync_state
|
||||||
FROM mangas m
|
FROM mangas m
|
||||||
WHERE ($1::text IS NULL OR m.title ILIKE $1)
|
WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) FROM classified
|
SELECT COUNT(*) FROM classified
|
||||||
WHERE ($2::text IS NULL OR sync_state = $2)
|
WHERE ($2::text IS NULL OR sync_state = $2)
|
||||||
@@ -213,7 +336,12 @@ pub async fn list_chapters_with_sync_state(
|
|||||||
WHERE cs.chapter_id = c.id AND cs.dropped_at IS NULL) AS latest_seen_at
|
WHERE cs.chapter_id = c.id AND cs.dropped_at IS NULL) AS latest_seen_at
|
||||||
FROM chapters c
|
FROM chapters c
|
||||||
WHERE c.manga_id = $1
|
WHERE c.manga_id = $1
|
||||||
ORDER BY c.number ASC
|
-- `id` tiebreaker: migration 0013 dropped the (manga_id, number)
|
||||||
|
-- UNIQUE constraint to allow variants ("Ch.14: PH" / "Ch.14:
|
||||||
|
-- Official") and non-numeric entries (all parse to 0). Without a
|
||||||
|
-- tiebreaker, pagination over those tied rows can skip or repeat
|
||||||
|
-- depending on Postgres' chosen sort order.
|
||||||
|
ORDER BY c.number ASC, c.id ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! token; the raw value is shown to the user once at creation and never
|
//! token; the raw value is shown to the user once at creation and never
|
||||||
//! stored.
|
//! stored.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -13,28 +14,48 @@ pub async fn create(
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
name: &str,
|
name: &str,
|
||||||
token_hash: &[u8],
|
token_hash: &[u8],
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
) -> AppResult<ApiToken> {
|
) -> AppResult<ApiToken> {
|
||||||
let row = sqlx::query_as::<_, ApiToken>(
|
let row = sqlx::query_as::<_, ApiToken>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO api_tokens (user_id, name, token_hash)
|
INSERT INTO api_tokens (user_id, name, token_hash, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, user_id, name, token_hash, created_at, last_used_at
|
RETURNING id, user_id, name, token_hash, created_at, last_used_at, expires_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
|
.bind(expires_at)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The caller's tokens, newest first. `token_hash` is `#[serde(skip)]` on the
|
||||||
|
/// domain type, so returning the full row never leaks the secret.
|
||||||
|
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<ApiToken>> {
|
||||||
|
let rows = sqlx::query_as::<_, ApiToken>(
|
||||||
|
r#"
|
||||||
|
SELECT id, user_id, name, token_hash, created_at, last_used_at, expires_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<ApiToken>> {
|
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<ApiToken>> {
|
||||||
let row = sqlx::query_as::<_, ApiToken>(
|
let row = sqlx::query_as::<_, ApiToken>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, name, token_hash, created_at, last_used_at
|
SELECT id, user_id, name, token_hash, created_at, last_used_at, expires_at
|
||||||
FROM api_tokens
|
FROM api_tokens
|
||||||
WHERE token_hash = $1
|
WHERE token_hash = $1
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ pub async fn list(
|
|||||||
SELECT id, name, created_at
|
SELECT id, name, created_at
|
||||||
FROM authors
|
FROM authors
|
||||||
WHERE $1::text IS NULL
|
WHERE $1::text IS NULL
|
||||||
OR name ILIKE '%' || $1 || '%'
|
OR name ILIKE '%' || $4 || '%' ESCAPE '\'
|
||||||
OR name % $1
|
OR name % $1
|
||||||
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
|
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
|
||||||
lower(name) ASC
|
lower(name) ASC
|
||||||
@@ -91,6 +91,9 @@ pub async fn list(
|
|||||||
.bind(search)
|
.bind(search)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
|
// $4: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the
|
||||||
|
// trigram/similarity branches keep the raw $1.
|
||||||
|
.bind(search.map(crate::repo::escape_like))
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
|
|||||||
@@ -6,13 +6,23 @@ use uuid::Uuid;
|
|||||||
use crate::domain::{Bookmark, BookmarkSummary};
|
use crate::domain::{Bookmark, BookmarkSummary};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
/// Add a bookmark, idempotently. Returns the bookmark plus whether it was
|
||||||
|
/// newly created (`true`) or already existed (`false`), so the handler can
|
||||||
|
/// answer 201 vs 200. Re-adding an existing bookmark is a no-op success rather
|
||||||
|
/// than a 409 — matching the idempotent collection semantics, so the UI doesn't
|
||||||
|
/// show a false "Could not add bookmark" toast when the manga is already saved.
|
||||||
|
///
|
||||||
|
/// Uniqueness is per `(user_id, manga_id, chapter_id)` — enforced by the 0001
|
||||||
|
/// constraint for chapter-level rows and the 0004 partial index for manga-level
|
||||||
|
/// (NULL chapter) rows. `page` is not part of the key, so the existing row is
|
||||||
|
/// returned unchanged (its page is not overwritten).
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
manga_id: Uuid,
|
manga_id: Uuid,
|
||||||
chapter_id: Option<Uuid>,
|
chapter_id: Option<Uuid>,
|
||||||
page: Option<i32>,
|
page: Option<i32>,
|
||||||
) -> AppResult<Bookmark> {
|
) -> AppResult<(Bookmark, bool)> {
|
||||||
let result = sqlx::query_as::<_, Bookmark>(
|
let result = sqlx::query_as::<_, Bookmark>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO bookmarks (user_id, manga_id, chapter_id, page)
|
INSERT INTO bookmarks (user_id, manga_id, chapter_id, page)
|
||||||
@@ -28,10 +38,27 @@ pub async fn create(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(b) => Ok(b),
|
Ok(b) => Ok((b, true)),
|
||||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => Err(
|
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
|
||||||
AppError::Conflict("bookmark already exists for this manga/chapter".into()),
|
// A bookmark for this (user, manga, chapter) already exists — fetch
|
||||||
),
|
// and return it. `IS NOT DISTINCT FROM` matches a NULL chapter_id
|
||||||
|
// (manga-level bookmark) as well as a concrete one, covering both
|
||||||
|
// uniqueness paths with a single lookup.
|
||||||
|
let existing = sqlx::query_as::<_, Bookmark>(
|
||||||
|
r#"
|
||||||
|
SELECT id, user_id, manga_id, chapter_id, page, created_at
|
||||||
|
FROM bookmarks
|
||||||
|
WHERE user_id = $1 AND manga_id = $2
|
||||||
|
AND chapter_id IS NOT DISTINCT FROM $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(manga_id)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok((existing, false))
|
||||||
|
}
|
||||||
Err(e) => Err(AppError::Database(e)),
|
Err(e) => Err(AppError::Database(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +90,13 @@ pub async fn list_for_user(
|
|||||||
INNER JOIN mangas m ON m.id = b.manga_id
|
INNER JOIN mangas m ON m.id = b.manga_id
|
||||||
LEFT JOIN chapters c ON c.id = b.chapter_id
|
LEFT JOIN chapters c ON c.id = b.chapter_id
|
||||||
WHERE b.user_id = $1
|
WHERE b.user_id = $1
|
||||||
ORDER BY b.created_at DESC
|
-- `id` tiebreaker for deterministic pagination. The bookmarks PK
|
||||||
|
-- is `id` (see migration 0001); the (user_id, manga_id) pair
|
||||||
|
-- alone is not unique — migration 0004 lets a user keep both a
|
||||||
|
-- chapter-level bookmark and a manga-level bookmark on the same
|
||||||
|
-- manga, so two rows can tie on (created_at, manga_id). A 0.87.6
|
||||||
|
-- earlier revision mistakenly used `manga_id` here.
|
||||||
|
ORDER BY b.created_at DESC, b.id DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,20 +12,46 @@ pub async fn list_for_manga(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> AppResult<Vec<Chapter>> {
|
) -> AppResult<Vec<Chapter>> {
|
||||||
// Display order = source-site order reversed. The crawler stamps
|
// Display order. Crawled chapters carry `source_index` = position in the
|
||||||
// `source_index` = position in the source DOM (0 = first = newest
|
// source DOM (0 = newest on this site, migration 0021); we display them
|
||||||
// on this site, see migration 0021), so DESC puts the oldest
|
// reversed (oldest first) via the `-source_index` key, which keeps the
|
||||||
// chapter first and keeps the site's variant grouping and the
|
// site's variant grouping and non-numeric entries (e.g. "notice.") in the
|
||||||
// placement of non-numeric entries (e.g. "notice. : Officials")
|
// spot the site placed them — NOT clustered at number 0.
|
||||||
// intact. NULLS LAST keeps user-uploaded chapters (no source row)
|
//
|
||||||
// and rows that pre-date the migration below crawled rows; the
|
// User-uploaded chapters have no `source_index`. Instead of dumping them
|
||||||
// (number, created_at) tail then orders them deterministically.
|
// after every crawled chapter (the old `NULLS LAST` bug, which put an
|
||||||
|
// uploaded chapter 5 after crawled chapter 100), each is slotted by NUMBER:
|
||||||
|
// just before the crawled chapter with the smallest number greater than it,
|
||||||
|
// so it interleaves. An upload newer than every crawled chapter goes last;
|
||||||
|
// when there are no crawled chapters at all, uploads fall back to the
|
||||||
|
// `number, created_at` tail.
|
||||||
let rows = sqlx::query_as::<_, Chapter>(
|
let rows = sqlx::query_as::<_, Chapter>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, manga_id, number, title, page_count, created_at
|
SELECT id, manga_id, number, title, page_count, created_at,
|
||||||
|
(SELECT CASE
|
||||||
|
WHEN count(*) = 0 THEN 0
|
||||||
|
WHEN bool_or(p.size_bytes IS NULL) THEN NULL
|
||||||
|
ELSE sum(p.size_bytes)
|
||||||
|
END
|
||||||
|
FROM pages p WHERE p.chapter_id = chapters.id)::bigint AS size_bytes
|
||||||
FROM chapters
|
FROM chapters
|
||||||
WHERE manga_id = $1
|
WHERE manga_id = $1
|
||||||
ORDER BY source_index DESC NULLS LAST, number ASC, created_at ASC
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN source_index IS NOT NULL THEN (-source_index)::float8
|
||||||
|
ELSE COALESCE(
|
||||||
|
(SELECT MIN(-c2.source_index)::float8 - 0.5
|
||||||
|
FROM chapters c2
|
||||||
|
WHERE c2.manga_id = chapters.manga_id
|
||||||
|
AND c2.source_index IS NOT NULL
|
||||||
|
AND c2.number > chapters.number),
|
||||||
|
(SELECT COALESCE(MAX(-c3.source_index), 0)::float8 + 0.5
|
||||||
|
FROM chapters c3
|
||||||
|
WHERE c3.manga_id = chapters.manga_id
|
||||||
|
AND c3.source_index IS NOT NULL)
|
||||||
|
)
|
||||||
|
END,
|
||||||
|
number ASC, created_at ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -46,7 +72,13 @@ pub async fn find_by_id_in_manga(
|
|||||||
) -> AppResult<Option<Chapter>> {
|
) -> AppResult<Option<Chapter>> {
|
||||||
let row = sqlx::query_as::<_, Chapter>(
|
let row = sqlx::query_as::<_, Chapter>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, manga_id, number, title, page_count, created_at
|
SELECT id, manga_id, number, title, page_count, created_at,
|
||||||
|
(SELECT CASE
|
||||||
|
WHEN count(*) = 0 THEN 0
|
||||||
|
WHEN bool_or(p.size_bytes IS NULL) THEN NULL
|
||||||
|
ELSE sum(p.size_bytes)
|
||||||
|
END
|
||||||
|
FROM pages p WHERE p.chapter_id = chapters.id)::bigint AS size_bytes
|
||||||
FROM chapters
|
FROM chapters
|
||||||
WHERE manga_id = $1 AND id = $2
|
WHERE manga_id = $1 AND id = $2
|
||||||
"#,
|
"#,
|
||||||
@@ -81,7 +113,8 @@ pub async fn create<'e, E: PgExecutor<'e>>(
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO chapters (manga_id, number, title, uploaded_by)
|
INSERT INTO chapters (manga_id, number, title, uploaded_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, manga_id, number, title, page_count, created_at
|
RETURNING id, manga_id, number, title, page_count, created_at, 0::bigint AS size_bytes
|
||||||
|
-- A freshly created chapter has no pages yet → size 0 (known).
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(manga_id)
|
.bind(manga_id)
|
||||||
|
|||||||
203
backend/src/repo/crawl_metrics.rs
Normal file
203
backend/src/repo/crawl_metrics.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
//! DB access for the `crawl_metrics` timing log (migration 0028).
|
||||||
|
//!
|
||||||
|
//! `record` is the best-effort write called from each crawl operation;
|
||||||
|
//! `summary` and `list_ops` are the admin Metrics-tab reads. Mirrors the
|
||||||
|
//! plain-fn + `query_as` style used across `repo`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::crawl_metrics::{MetricsBucket, OpRow, OpSummary};
|
||||||
|
|
||||||
|
/// Time-bucket granularity for trend series. A closed enum (not a free
|
||||||
|
/// string) so the `date_trunc` unit can never be attacker-controlled.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Bucket {
|
||||||
|
Hour,
|
||||||
|
Day,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bucket {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Bucket::Hour => "hour",
|
||||||
|
Bucket::Day => "day",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operation kinds, kept as consts so call sites and the CHECK constraint
|
||||||
|
/// don't drift from a free-typed literal.
|
||||||
|
pub const OP_MANGA_LIST: &str = "manga_list";
|
||||||
|
pub const OP_MANGA_DETAIL: &str = "manga_detail";
|
||||||
|
pub const OP_MANGA_COVER: &str = "manga_cover";
|
||||||
|
pub const OP_CHAPTER: &str = "chapter";
|
||||||
|
|
||||||
|
/// Insert one completed-operation timing row. Best-effort: callers log and
|
||||||
|
/// continue on error so a metrics-write failure never fails the actual crawl
|
||||||
|
/// work. `outcome` is `"ok"` or `"failed"`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn record(
|
||||||
|
pool: &PgPool,
|
||||||
|
op: &str,
|
||||||
|
manga_id: Option<Uuid>,
|
||||||
|
chapter_id: Option<Uuid>,
|
||||||
|
outcome: &str,
|
||||||
|
duration_ms: i64,
|
||||||
|
items: Option<i32>,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> sqlx::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO crawl_metrics \
|
||||||
|
(op, manga_id, chapter_id, outcome, duration_ms, items, error) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||||
|
)
|
||||||
|
.bind(op)
|
||||||
|
.bind(manga_id)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.bind(outcome)
|
||||||
|
.bind(duration_ms)
|
||||||
|
.bind(items)
|
||||||
|
.bind(error)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-op average roll-up over an optional time window (`since = None` → all
|
||||||
|
/// time). One row per `op` that has any metric in the window, with mean
|
||||||
|
/// duration, counts, success split, and mean `items`.
|
||||||
|
pub async fn summary(
|
||||||
|
pool: &PgPool,
|
||||||
|
since: Option<DateTime<Utc>>,
|
||||||
|
) -> sqlx::Result<Vec<OpSummary>> {
|
||||||
|
sqlx::query_as::<_, OpSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
op,
|
||||||
|
AVG(duration_ms)::float8 AS avg_ms,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
COUNT(*) FILTER (WHERE outcome = 'ok') AS ok,
|
||||||
|
COUNT(*) FILTER (WHERE outcome = 'failed') AS failed,
|
||||||
|
AVG(items)::float8 AS avg_items
|
||||||
|
FROM crawl_metrics
|
||||||
|
WHERE ($1::timestamptz IS NULL OR finished_at >= $1)
|
||||||
|
GROUP BY op
|
||||||
|
ORDER BY op
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(since)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bucketed throughput/success/duration series over an optional window.
|
||||||
|
/// Plain `GROUP BY date_trunc(...)` — empty intervals are absent and the
|
||||||
|
/// client fills the continuous axis. Uses `crawl_metrics_time_idx`.
|
||||||
|
pub async fn series(
|
||||||
|
pool: &PgPool,
|
||||||
|
bucket: Bucket,
|
||||||
|
since: Option<DateTime<Utc>>,
|
||||||
|
) -> sqlx::Result<Vec<MetricsBucket>> {
|
||||||
|
sqlx::query_as::<_, MetricsBucket>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
date_trunc($1, finished_at) AS t,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
COUNT(*) FILTER (WHERE outcome = 'ok') AS ok,
|
||||||
|
COUNT(*) FILTER (WHERE outcome = 'failed') AS failed,
|
||||||
|
AVG(duration_ms)::float8 AS avg_ms
|
||||||
|
FROM crawl_metrics
|
||||||
|
WHERE ($2::timestamptz IS NULL OR finished_at >= $2)
|
||||||
|
GROUP BY t
|
||||||
|
ORDER BY t
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(bucket.as_str())
|
||||||
|
.bind(since)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters for [`list_ops`]. `None`/empty widens the scope.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct OpFilter<'a> {
|
||||||
|
pub op: Option<&'a str>,
|
||||||
|
pub outcome: Option<&'a str>,
|
||||||
|
pub since: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated, newest-first recent-operations log resolved to manga/chapter
|
||||||
|
/// labels. Returns the page slice plus the filtered total.
|
||||||
|
pub async fn list_ops(
|
||||||
|
pool: &PgPool,
|
||||||
|
filter: OpFilter<'_>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> sqlx::Result<(Vec<OpRow>, i64)> {
|
||||||
|
let items = sqlx::query_as::<_, OpRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
cm.id,
|
||||||
|
cm.op,
|
||||||
|
cm.manga_id,
|
||||||
|
m.title AS manga_title,
|
||||||
|
cm.chapter_id,
|
||||||
|
c.number AS chapter_number,
|
||||||
|
cm.outcome,
|
||||||
|
cm.duration_ms,
|
||||||
|
cm.items,
|
||||||
|
cm.error,
|
||||||
|
cm.finished_at
|
||||||
|
FROM crawl_metrics cm
|
||||||
|
LEFT JOIN mangas m ON m.id = cm.manga_id
|
||||||
|
LEFT JOIN chapters c ON c.id = cm.chapter_id
|
||||||
|
WHERE ($1::text IS NULL OR cm.op = $1)
|
||||||
|
AND ($2::text IS NULL OR cm.outcome = $2)
|
||||||
|
AND ($3::timestamptz IS NULL OR cm.finished_at >= $3)
|
||||||
|
ORDER BY cm.finished_at DESC
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.op)
|
||||||
|
.bind(filter.outcome)
|
||||||
|
.bind(filter.since)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (total,): (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM crawl_metrics cm
|
||||||
|
WHERE ($1::text IS NULL OR cm.op = $1)
|
||||||
|
AND ($2::text IS NULL OR cm.outcome = $2)
|
||||||
|
AND ($3::timestamptz IS NULL OR cm.finished_at >= $3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.op)
|
||||||
|
.bind(filter.outcome)
|
||||||
|
.bind(filter.since)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete metric rows older than `retention_days`. `0` disables the reaper
|
||||||
|
/// (returns 0 without touching the table). Mirrors `jobs::reap_terminal`.
|
||||||
|
pub async fn reap(pool: &PgPool, retention_days: u32) -> sqlx::Result<u64> {
|
||||||
|
if retention_days == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM crawl_metrics \
|
||||||
|
WHERE finished_at < now() - ($1::bigint || ' days')::interval",
|
||||||
|
)
|
||||||
|
.bind(retention_days as i64)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
@@ -247,10 +247,13 @@ async fn sync_genres(
|
|||||||
let genre_id = match existing {
|
let genre_id = match existing {
|
||||||
Some((id,)) => id,
|
Some((id,)) => id,
|
||||||
None => {
|
None => {
|
||||||
|
// Conflict on lower(name) (0038) not name, so a racing insert of
|
||||||
|
// a differently-cased variant resolves to the existing row
|
||||||
|
// instead of raising a unique violation.
|
||||||
let (id,): (Uuid,) = sqlx::query_as(
|
let (id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO genres (name) VALUES ($1)
|
INSERT INTO genres (name) VALUES ($1)
|
||||||
ON CONFLICT (name) DO UPDATE SET name = genres.name
|
ON CONFLICT (lower(name)) DO UPDATE SET name = genres.name
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -619,6 +622,67 @@ pub async fn last_run_completed_cleanly(
|
|||||||
.unwrap_or(true))
|
.unwrap_or(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Timestamp of the most recent metadata-pass cron tick, or `None` if the
|
||||||
|
/// daemon has never ticked (fresh DB / cron disabled). Stored by the daemon
|
||||||
|
/// under `crawler_state['last_metadata_tick_at']` as `{"at": <rfc3339>}`.
|
||||||
|
/// Used by the health check that flags a stale/stuck cron.
|
||||||
|
pub async fn last_metadata_tick_at(
|
||||||
|
pool: &PgPool,
|
||||||
|
) -> sqlx::Result<Option<DateTime<Utc>>> {
|
||||||
|
let row: Option<serde_json::Value> =
|
||||||
|
sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = 'last_metadata_tick_at'")
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.and_then(|v| {
|
||||||
|
v.get("at")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| dt.with_timezone(&Utc))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reconcile: find source-list mangas missing from the DB.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// All `source_manga_key`s we have a `manga_sources` row for under
|
||||||
|
/// `source_id`. Intentionally **not** filtered by `dropped_at`: a
|
||||||
|
/// soft-dropped row still counts as "present" so reconcile only enqueues
|
||||||
|
/// mangas it has truly never seen (strict `NOT EXISTS`).
|
||||||
|
pub async fn existing_source_keys(
|
||||||
|
pool: &PgPool,
|
||||||
|
source_id: &str,
|
||||||
|
) -> sqlx::Result<std::collections::HashSet<String>> {
|
||||||
|
let rows: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT source_manga_key FROM manga_sources WHERE source_id = $1")
|
||||||
|
.bind(source_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `source_manga_key`s that already have a `SyncManga` job in a state that
|
||||||
|
/// should block re-enqueue: `pending`/`running` (in flight) **and** `dead`
|
||||||
|
/// (leave-dead — a gone manga is not retried by a later reconcile). `done`
|
||||||
|
/// and `failed` (mid-backoff) do not block.
|
||||||
|
pub async fn sync_manga_keys_with_blocking_job(
|
||||||
|
pool: &PgPool,
|
||||||
|
source_id: &str,
|
||||||
|
) -> sqlx::Result<std::collections::HashSet<String>> {
|
||||||
|
let rows: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT DISTINCT payload->>'source_manga_key' \
|
||||||
|
FROM crawler_jobs \
|
||||||
|
WHERE payload->>'kind' = 'sync_manga' \
|
||||||
|
AND payload->>'source_id' = $1 \
|
||||||
|
AND state IN ('pending', 'running', 'dead') \
|
||||||
|
AND payload->>'source_manga_key' IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(source_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dead-letter jobs: admin observability + requeue.
|
// Dead-letter jobs: admin observability + requeue.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -635,6 +699,14 @@ pub struct DeadJob {
|
|||||||
pub manga_id: Option<Uuid>,
|
pub manga_id: Option<Uuid>,
|
||||||
pub manga_title: Option<String>,
|
pub manga_title: Option<String>,
|
||||||
pub chapter_number: Option<i32>,
|
pub chapter_number: Option<i32>,
|
||||||
|
/// Title carried in the job payload. For `sync_manga` jobs whose manga
|
||||||
|
/// was never upserted there is no `manga_id`/`manga_title`, so the UI
|
||||||
|
/// falls back to this.
|
||||||
|
pub payload_title: Option<String>,
|
||||||
|
/// Source detail URL carried in the payload (currently `sync_manga`).
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
/// Source-native key carried in the payload (currently `sync_manga`).
|
||||||
|
pub source_key: Option<String>,
|
||||||
pub attempts: i32,
|
pub attempts: i32,
|
||||||
pub max_attempts: i32,
|
pub max_attempts: i32,
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
@@ -651,7 +723,7 @@ pub async fn list_dead_jobs(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> sqlx::Result<(Vec<DeadJob>, i64)> {
|
) -> sqlx::Result<(Vec<DeadJob>, i64)> {
|
||||||
let search_pat = search
|
let search_pat = search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items: Vec<DeadJob> = sqlx::query_as(
|
let items: Vec<DeadJob> = sqlx::query_as(
|
||||||
@@ -663,6 +735,9 @@ pub async fn list_dead_jobs(
|
|||||||
c.manga_id AS manga_id,
|
c.manga_id AS manga_id,
|
||||||
m.title AS manga_title,
|
m.title AS manga_title,
|
||||||
c.number AS chapter_number,
|
c.number AS chapter_number,
|
||||||
|
cj.payload->>'title' AS payload_title,
|
||||||
|
cj.payload->>'url' AS source_url,
|
||||||
|
cj.payload->>'source_manga_key' AS source_key,
|
||||||
cj.attempts,
|
cj.attempts,
|
||||||
cj.max_attempts,
|
cj.max_attempts,
|
||||||
cj.last_error,
|
cj.last_error,
|
||||||
@@ -671,7 +746,7 @@ pub async fn list_dead_jobs(
|
|||||||
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state = 'dead'
|
WHERE cj.state = 'dead'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY cj.updated_at DESC
|
ORDER BY cj.updated_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -689,7 +764,7 @@ pub async fn list_dead_jobs(
|
|||||||
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state = 'dead'
|
WHERE cj.state = 'dead'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&search_pat)
|
.bind(&search_pat)
|
||||||
@@ -725,7 +800,7 @@ pub async fn list_active_jobs(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> sqlx::Result<(Vec<ActiveJob>, i64)> {
|
) -> sqlx::Result<(Vec<ActiveJob>, i64)> {
|
||||||
let search_pat = search
|
let search_pat = search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items: Vec<ActiveJob> = sqlx::query_as(
|
let items: Vec<ActiveJob> = sqlx::query_as(
|
||||||
@@ -745,7 +820,7 @@ pub async fn list_active_jobs(
|
|||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state IN ('pending','running')
|
WHERE cj.state IN ('pending','running')
|
||||||
AND cj.payload->>'kind' = 'sync_chapter_content'
|
AND cj.payload->>'kind' = 'sync_chapter_content'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at
|
ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -764,7 +839,7 @@ pub async fn list_active_jobs(
|
|||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state IN ('pending','running')
|
WHERE cj.state IN ('pending','running')
|
||||||
AND cj.payload->>'kind' = 'sync_chapter_content'
|
AND cj.payload->>'kind' = 'sync_chapter_content'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&search_pat)
|
.bind(&search_pat)
|
||||||
@@ -774,6 +849,145 @@ pub async fn list_active_jobs(
|
|||||||
Ok((items, total))
|
Ok((items, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Job history: unified, searchable, filterable view over the queue table.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A `crawler_jobs` row resolved to human context for the admin history
|
||||||
|
/// table. Works across all job kinds: the target columns are best-effort
|
||||||
|
/// `Option`s resolved through whichever payload reference the kind carries
|
||||||
|
/// (`chapter_id`, `manga_id`, or `page_id` via the page breadcrumb), so a
|
||||||
|
/// `sync_manga` job (which has neither yet) simply leaves them `None` and
|
||||||
|
/// falls back to `source_key`.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct JobHistoryRow {
|
||||||
|
pub id: Uuid,
|
||||||
|
/// `pending` | `running` | `done` | `dead` (the live queue states).
|
||||||
|
pub state: String,
|
||||||
|
/// `sync_manga` | `sync_chapter_list` | `sync_chapter_content` | `analyze_page`.
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub manga_id: Option<Uuid>,
|
||||||
|
pub manga_title: Option<String>,
|
||||||
|
pub chapter_id: Option<Uuid>,
|
||||||
|
pub chapter_number: Option<i32>,
|
||||||
|
/// Set only for `analyze_page` (resolved through the page breadcrumb).
|
||||||
|
pub page_number: Option<i32>,
|
||||||
|
/// Source-side key, the only target a `sync_manga` job carries.
|
||||||
|
pub source_key: Option<String>,
|
||||||
|
/// Title carried in the payload — the display fallback for `sync_manga`
|
||||||
|
/// jobs whose manga was never upserted (no `manga_title`).
|
||||||
|
pub payload_title: Option<String>,
|
||||||
|
/// Source detail URL carried in the payload (currently `sync_manga`).
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
pub attempts: i32,
|
||||||
|
pub max_attempts: i32,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
/// How long the job's work took, when a timing was recorded: the latest
|
||||||
|
/// `chapter` crawl-metric for a chapter job, else the page's analysis
|
||||||
|
/// duration for an `analyze_page` job. `None` for kinds we don't time or
|
||||||
|
/// jobs not yet completed.
|
||||||
|
pub duration_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters for [`list_job_history`]. All optional; `None` widens the scope.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct JobHistoryFilter<'a> {
|
||||||
|
/// Exact queue state (`done`, `dead`, `running`, `pending`).
|
||||||
|
pub state: Option<&'a str>,
|
||||||
|
/// Exact payload `kind`.
|
||||||
|
pub kind: Option<&'a str>,
|
||||||
|
/// Case-insensitive manga-title substring.
|
||||||
|
pub search: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated, newest-first view of the job queue across every state and
|
||||||
|
/// kind — the searchable/filterable history surface. Joins each job to its
|
||||||
|
/// manga/chapter/page context (best-effort) so the table can label rows.
|
||||||
|
/// Returns the page slice plus the filtered total for pagination.
|
||||||
|
///
|
||||||
|
/// History depth is bounded by the terminal-job reaper (`reap_terminal`):
|
||||||
|
/// `done` and `dead` jobs older than the retention window are gone.
|
||||||
|
pub async fn list_job_history(
|
||||||
|
pool: &PgPool,
|
||||||
|
filter: JobHistoryFilter<'_>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> sqlx::Result<(Vec<JobHistoryRow>, i64)> {
|
||||||
|
let search_pat = filter
|
||||||
|
.search
|
||||||
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
|
// The same FROM/JOIN/WHERE drives both the page slice and the count, so
|
||||||
|
// they stay in lockstep. `pg` resolves analyze_page → page → chapter;
|
||||||
|
// `COALESCE` lets one set of joins serve every kind.
|
||||||
|
let items: Vec<JobHistoryRow> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
cj.id,
|
||||||
|
cj.state,
|
||||||
|
cj.payload->>'kind' AS kind,
|
||||||
|
COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid) AS manga_id,
|
||||||
|
m.title AS manga_title,
|
||||||
|
COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id) AS chapter_id,
|
||||||
|
c.number AS chapter_number,
|
||||||
|
pg.page_number AS page_number,
|
||||||
|
cj.payload->>'source_manga_key' AS source_key,
|
||||||
|
cj.payload->>'title' AS payload_title,
|
||||||
|
cj.payload->>'url' AS source_url,
|
||||||
|
cj.attempts,
|
||||||
|
cj.max_attempts,
|
||||||
|
cj.last_error,
|
||||||
|
cj.updated_at,
|
||||||
|
COALESCE(cm.duration_ms, pa.duration_ms) AS duration_ms
|
||||||
|
FROM crawler_jobs cj
|
||||||
|
LEFT JOIN pages pg ON pg.id = (cj.payload->>'page_id')::uuid
|
||||||
|
LEFT JOIN chapters c ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id)
|
||||||
|
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
|
||||||
|
LEFT JOIN page_analysis pa ON pa.page_id = (cj.payload->>'page_id')::uuid
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT duration_ms FROM crawl_metrics
|
||||||
|
WHERE op = 'chapter'
|
||||||
|
AND chapter_id = (cj.payload->>'chapter_id')::uuid
|
||||||
|
ORDER BY finished_at DESC LIMIT 1
|
||||||
|
) cm ON true
|
||||||
|
WHERE ($1::text IS NULL OR cj.state = $1)
|
||||||
|
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
|
||||||
|
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\')
|
||||||
|
ORDER BY cj.updated_at DESC
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.state)
|
||||||
|
.bind(filter.kind)
|
||||||
|
.bind(&search_pat)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM crawler_jobs cj
|
||||||
|
LEFT JOIN pages pg ON pg.id = (cj.payload->>'page_id')::uuid
|
||||||
|
LEFT JOIN chapters c ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id)
|
||||||
|
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
|
||||||
|
WHERE ($1::text IS NULL OR cj.state = $1)
|
||||||
|
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
|
||||||
|
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.state)
|
||||||
|
.bind(filter.kind)
|
||||||
|
.bind(&search_pat)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
/// A manga whose cover is still missing (queued for cover fetch).
|
/// A manga whose cover is still missing (queued for cover fetch).
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct MissingCoverRow {
|
pub struct MissingCoverRow {
|
||||||
@@ -807,7 +1021,7 @@ pub async fn list_missing_cover_mangas(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> sqlx::Result<(Vec<MissingCoverRow>, i64)> {
|
) -> sqlx::Result<(Vec<MissingCoverRow>, i64)> {
|
||||||
let search_pat = search
|
let search_pat = search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items: Vec<MissingCoverRow> = sqlx::query_as(
|
let items: Vec<MissingCoverRow> = sqlx::query_as(
|
||||||
@@ -819,7 +1033,7 @@ pub async fn list_missing_cover_mangas(
|
|||||||
SELECT 1 FROM manga_sources ms
|
SELECT 1 FROM manga_sources ms
|
||||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
||||||
)
|
)
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY m.updated_at DESC
|
ORDER BY m.updated_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -838,7 +1052,7 @@ pub async fn list_missing_cover_mangas(
|
|||||||
SELECT 1 FROM manga_sources ms
|
SELECT 1 FROM manga_sources ms
|
||||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
||||||
)
|
)
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&search_pat)
|
.bind(&search_pat)
|
||||||
|
|||||||
@@ -5,27 +5,55 @@
|
|||||||
//! handlers depend only on `sqlx::PgPool`, not on a trait object. Swap to
|
//! handlers depend only on `sqlx::PgPool`, not on a trait object. Swap to
|
||||||
//! a trait + impl if a second backend ever becomes necessary.
|
//! a trait + impl if a second backend ever becomes necessary.
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use sqlx::{PgConnection, PgExecutor, PgPool};
|
use sqlx::{PgConnection, PgExecutor, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::domain::manga::{Manga, MangaCard, MangaDetail};
|
use crate::domain::manga::{Manga, MangaCard, MangaDetail};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo::{self, escape_like};
|
||||||
|
|
||||||
/// Status values mirror the CHECK constraint in 0009. Centralized so
|
/// Status values mirror the CHECK constraint in 0009. Centralized so
|
||||||
/// the API layer can validate uploads against the same vocabulary.
|
/// the API layer can validate uploads against the same vocabulary.
|
||||||
pub const STATUSES: &[&str] = &["ongoing", "completed"];
|
pub const STATUSES: &[&str] = &["ongoing", "completed"];
|
||||||
pub const DEFAULT_STATUS: &str = "ongoing";
|
pub const DEFAULT_STATUS: &str = "ongoing";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
/// Which column the listing is ordered by. The direction lives in a separate
|
||||||
#[serde(rename_all = "snake_case")]
|
/// [`SortOrder`] so any field can be sorted either way. Wire values are parsed
|
||||||
pub enum ListSort {
|
/// (with validation and the legacy `recent` alias) in `api::mangas`, not via
|
||||||
/// Newest first (default).
|
/// serde, so this stays a plain domain enum.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum SortField {
|
||||||
|
/// Creation time.
|
||||||
|
Created,
|
||||||
|
/// Last-modified time (default — "last updated first" pairs with `Desc`).
|
||||||
#[default]
|
#[default]
|
||||||
Recent,
|
Updated,
|
||||||
/// A→Z by title (case-insensitive).
|
/// Title (case-insensitive).
|
||||||
Title,
|
Title,
|
||||||
|
/// Alphabetically-first attached author (case-insensitive); authorless
|
||||||
|
/// mangas sort last regardless of direction.
|
||||||
|
Author,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortField {
|
||||||
|
/// The direction applied when the client omits `order`. Dates read
|
||||||
|
/// newest-first (`Desc`); text reads A→Z (`Asc`). This mirrors the
|
||||||
|
/// frontend's `defaultOrderFor` (see `frontend/src/lib/mangaSort.ts`) so a
|
||||||
|
/// bare `?sort=<field>` link means the same thing to the UI and the API.
|
||||||
|
pub fn default_order(self) -> SortOrder {
|
||||||
|
match self {
|
||||||
|
SortField::Created | SortField::Updated => SortOrder::Desc,
|
||||||
|
SortField::Title | SortField::Author => SortOrder::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort direction.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum SortOrder {
|
||||||
|
Asc,
|
||||||
|
#[default]
|
||||||
|
Desc,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -42,7 +70,8 @@ pub struct ListQuery {
|
|||||||
pub cw_exclude: Vec<String>,
|
pub cw_exclude: Vec<String>,
|
||||||
pub limit: i64,
|
pub limit: i64,
|
||||||
pub offset: i64,
|
pub offset: i64,
|
||||||
pub sort: ListSort,
|
pub sort: SortField,
|
||||||
|
pub order: SortOrder,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single source of truth for the `mangas` columns that hydrate a [`Manga`],
|
/// Single source of truth for the `mangas` columns that hydrate a [`Manga`],
|
||||||
@@ -81,13 +110,13 @@ fn manga_cols(alias: &str) -> String {
|
|||||||
/// true.
|
/// true.
|
||||||
const FILTER_WHERE: &str = r#"
|
const FILTER_WHERE: &str = r#"
|
||||||
($1::text IS NULL
|
($1::text IS NULL
|
||||||
OR title ILIKE '%' || $1 || '%'
|
OR title ILIKE '%' || $8 || '%' ESCAPE '\'
|
||||||
OR title % $1
|
OR title % $1
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM manga_authors ma
|
SELECT 1 FROM manga_authors ma
|
||||||
JOIN authors a ON a.id = ma.author_id
|
JOIN authors a ON a.id = ma.author_id
|
||||||
WHERE ma.manga_id = mangas.id
|
WHERE ma.manga_id = mangas.id
|
||||||
AND (a.name ILIKE '%' || $1 || '%' OR a.name % $1)
|
AND (a.name ILIKE '%' || $8 || '%' ESCAPE '\' OR a.name % $1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND ($2::text IS NULL OR status = $2)
|
AND ($2::text IS NULL OR status = $2)
|
||||||
@@ -115,31 +144,48 @@ const FILTER_WHERE: &str = r#"
|
|||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM unnest($6::text[]) AS req(w)
|
SELECT 1 FROM unnest($6::text[]) AS req(w)
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM page_content_warnings pw
|
SELECT 1 FROM manga_content_warnings mcw
|
||||||
JOIN pages p ON p.id = pw.page_id
|
WHERE mcw.manga_id = mangas.id AND mcw.warning = req.w
|
||||||
JOIN chapters c ON c.id = p.chapter_id
|
|
||||||
WHERE c.manga_id = mangas.id AND pw.warning = req.w
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM page_content_warnings pw
|
SELECT 1 FROM manga_content_warnings mcw
|
||||||
JOIN pages p ON p.id = pw.page_id
|
WHERE mcw.manga_id = mangas.id AND mcw.warning = ANY($7::text[])
|
||||||
JOIN chapters c ON c.id = p.chapter_id
|
|
||||||
WHERE c.manga_id = mangas.id AND pw.warning = ANY($7::text[])
|
|
||||||
)
|
)
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Returns the page of mangas matching `query` plus the unfiltered total
|
/// Returns the page of mangas matching `query` plus the unfiltered total
|
||||||
/// count for the same filter. The trigram GIN indexes (see 0005_search.sql
|
/// count for the same filter. The date/title sort columns are index-backed
|
||||||
/// and 0009_manga_metadata.sql) keep both queries cheap as the library
|
/// (`mangas_created_at_idx`, `mangas_updated_at_idx`, `mangas_title_lower_idx`)
|
||||||
/// grows.
|
/// and the trigram GIN indexes (0005_search.sql, 0009_manga_metadata.sql) keep
|
||||||
pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i64)> {
|
/// the search filter cheap as the library grows.
|
||||||
// `order_by` is interpolated from a hard-coded enum, never from request
|
pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Option<i64>)> {
|
||||||
// input, so this is not a SQL injection seam.
|
// Both `col` and `dir` are interpolated from hard-coded enums, never from
|
||||||
let order_by = match query.sort {
|
// request input, so this is not a SQL injection seam. The trailing `id` is
|
||||||
ListSort::Recent => "created_at DESC, id",
|
// a stable tie-break that keeps pagination deterministic across rows with
|
||||||
ListSort::Title => "lower(title) ASC, id",
|
// equal sort keys.
|
||||||
|
let col = match query.sort {
|
||||||
|
SortField::Created => "created_at",
|
||||||
|
SortField::Updated => "updated_at",
|
||||||
|
SortField::Title => "lower(title)",
|
||||||
|
// Sorts on the alphabetically-first attached author. Precomputed into
|
||||||
|
// `mangas.sort_author` (migration 0037, maintained by triggers on
|
||||||
|
// manga_authors) and index-backed by `mangas_sort_author_idx`, so this
|
||||||
|
// is a plain column read rather than a per-row correlated subquery.
|
||||||
|
SortField::Author => "sort_author",
|
||||||
};
|
};
|
||||||
|
let dir = match query.order {
|
||||||
|
SortOrder::Asc => "ASC",
|
||||||
|
SortOrder::Desc => "DESC",
|
||||||
|
};
|
||||||
|
// Only the author key can be NULL (authorless mangas); keep those last in
|
||||||
|
// both directions. The date/title columns are NOT NULL, so they need no
|
||||||
|
// NULLS clause — and omitting it lets a reverse index scan satisfy `DESC`.
|
||||||
|
let nulls = match query.sort {
|
||||||
|
SortField::Author => " NULLS LAST",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let order_by = format!("{col} {dir}{nulls}, id");
|
||||||
|
|
||||||
let search = query.search.as_deref();
|
let search = query.search.as_deref();
|
||||||
let status = query.status.as_deref();
|
let status = query.status.as_deref();
|
||||||
@@ -150,11 +196,15 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
|||||||
FROM mangas
|
FROM mangas
|
||||||
WHERE {FILTER_WHERE}
|
WHERE {FILTER_WHERE}
|
||||||
ORDER BY {order_by}
|
ORDER BY {order_by}
|
||||||
LIMIT $8 OFFSET $9
|
LIMIT $9 OFFSET $10
|
||||||
"#,
|
"#,
|
||||||
cols = manga_cols(""),
|
cols = manga_cols(""),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// $8 is the LIKE-escaped search term used by the ILIKE branches so `%`/`_`
|
||||||
|
// in the term match literally; the trigram `%` branches keep the raw $1.
|
||||||
|
let search_escaped = search.map(escape_like);
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, Manga>(&list_sql)
|
let rows = sqlx::query_as::<_, Manga>(&list_sql)
|
||||||
.bind(search)
|
.bind(search)
|
||||||
.bind(status)
|
.bind(status)
|
||||||
@@ -163,27 +213,40 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
|||||||
.bind(&query.tag_ids)
|
.bind(&query.tag_ids)
|
||||||
.bind(&query.cw_include)
|
.bind(&query.cw_include)
|
||||||
.bind(&query.cw_exclude)
|
.bind(&query.cw_exclude)
|
||||||
|
.bind(&search_escaped)
|
||||||
.bind(query.limit)
|
.bind(query.limit)
|
||||||
.bind(query.offset)
|
.bind(query.offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let count_sql = format!(
|
// The count reuses FILTER_WHERE — up to five correlated NOT EXISTS/unnest
|
||||||
r#"
|
// subqueries (plus a page_content_warnings join under CW filters) over the
|
||||||
SELECT count(*) FROM mangas
|
// whole filtered set. Recomputing it on every page made pagination scale
|
||||||
WHERE {FILTER_WHERE}
|
// with catalog size. It doesn't change as the caller walks pages, so
|
||||||
"#
|
// compute it once on the first page (offset 0) and return None thereafter;
|
||||||
);
|
// the pagination envelope serialises that as `total: null`.
|
||||||
let (total,): (i64,) = sqlx::query_as(&count_sql)
|
let total = if query.offset == 0 {
|
||||||
.bind(search)
|
let count_sql = format!(
|
||||||
.bind(status)
|
r#"
|
||||||
.bind(&query.author_ids)
|
SELECT count(*) FROM mangas
|
||||||
.bind(&query.genre_ids)
|
WHERE {FILTER_WHERE}
|
||||||
.bind(&query.tag_ids)
|
"#
|
||||||
.bind(&query.cw_include)
|
);
|
||||||
.bind(&query.cw_exclude)
|
let (total,): (i64,) = sqlx::query_as(&count_sql)
|
||||||
.fetch_one(pool)
|
.bind(search)
|
||||||
.await?;
|
.bind(status)
|
||||||
|
.bind(&query.author_ids)
|
||||||
|
.bind(&query.genre_ids)
|
||||||
|
.bind(&query.tag_ids)
|
||||||
|
.bind(&query.cw_include)
|
||||||
|
.bind(&query.cw_exclude)
|
||||||
|
.bind(&search_escaped)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Some(total)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Ok((rows, total))
|
Ok((rows, total))
|
||||||
}
|
}
|
||||||
@@ -194,7 +257,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
|||||||
pub async fn list_cards(
|
pub async fn list_cards(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
query: &ListQuery,
|
query: &ListQuery,
|
||||||
) -> AppResult<(Vec<MangaCard>, i64)> {
|
) -> AppResult<(Vec<MangaCard>, Option<i64>)> {
|
||||||
let (rows, total) = list(pool, query).await?;
|
let (rows, total) = list(pool, query).await?;
|
||||||
let cards = cards_from_rows(pool, rows).await?;
|
let cards = cards_from_rows(pool, rows).await?;
|
||||||
Ok((cards, total))
|
Ok((cards, total))
|
||||||
@@ -252,6 +315,73 @@ pub async fn list_similar(
|
|||||||
cards_from_rows(pool, rows).await
|
cards_from_rows(pool, rows).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Content-based "Recommended for you": rank mangas by weighted tag overlap
|
||||||
|
/// with the user's taste. Signals: explicit like = +1.0, bookmark = +0.5,
|
||||||
|
/// dislike = -1.0 (a reaction overrides a bookmark on the same manga). Per
|
||||||
|
/// tag we sum those weights into an affinity, then score each candidate by
|
||||||
|
/// the sum of its tags' affinities, normalized by the candidate's tag count
|
||||||
|
/// (same anti-tag-stuffing rationale as `list_similar`). Candidates the user
|
||||||
|
/// already reacted to, bookmarked, or read are excluded; net-negative
|
||||||
|
/// candidates (dominated by disliked-tag affinity) are dropped, so a dislike
|
||||||
|
/// down-ranks rather than the manga being hidden from normal browse. No
|
||||||
|
/// signals → empty. Reuses `cards_from_rows` for author/genre hydration.
|
||||||
|
pub async fn list_recommendations(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<MangaCard>> {
|
||||||
|
let sql = format!(
|
||||||
|
r#"
|
||||||
|
WITH signals AS (
|
||||||
|
SELECT s.manga_id,
|
||||||
|
CASE WHEN r.reaction = 'dislike' THEN -1.0
|
||||||
|
WHEN r.reaction = 'like' THEN 1.0
|
||||||
|
ELSE 0.5 END AS weight
|
||||||
|
FROM (
|
||||||
|
SELECT manga_id FROM manga_reactions WHERE user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT manga_id FROM bookmarks WHERE user_id = $1
|
||||||
|
) s
|
||||||
|
LEFT JOIN manga_reactions r
|
||||||
|
ON r.user_id = $1 AND r.manga_id = s.manga_id
|
||||||
|
),
|
||||||
|
tag_affinity AS (
|
||||||
|
SELECT mt.tag_id, SUM(sig.weight) AS affinity
|
||||||
|
FROM signals sig
|
||||||
|
JOIN manga_tags mt ON mt.manga_id = sig.manga_id
|
||||||
|
GROUP BY mt.tag_id
|
||||||
|
)
|
||||||
|
SELECT {cols}
|
||||||
|
FROM manga_tags cand
|
||||||
|
JOIN tag_affinity ta ON ta.tag_id = cand.tag_id
|
||||||
|
JOIN mangas m ON m.id = cand.manga_id
|
||||||
|
WHERE cand.manga_id NOT IN (SELECT manga_id FROM signals)
|
||||||
|
AND cand.manga_id NOT IN (
|
||||||
|
SELECT manga_id FROM read_progress WHERE user_id = $1
|
||||||
|
)
|
||||||
|
GROUP BY m.id
|
||||||
|
HAVING SUM(ta.affinity) > 0
|
||||||
|
ORDER BY
|
||||||
|
SUM(ta.affinity)
|
||||||
|
/ (SELECT count(*) FROM manga_tags WHERE manga_id = m.id) DESC,
|
||||||
|
SUM(ta.affinity) DESC,
|
||||||
|
m.updated_at DESC,
|
||||||
|
lower(m.title) ASC,
|
||||||
|
m.id
|
||||||
|
LIMIT $2
|
||||||
|
"#,
|
||||||
|
cols = manga_cols("m"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, Manga>(&sql)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
cards_from_rows(pool, rows).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
|
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
|
||||||
/// authors and genres in two batched round-trips. The input order is
|
/// authors and genres in two batched round-trips. The input order is
|
||||||
/// preserved (callers rely on this to keep list/ranking order), so we
|
/// preserved (callers rely on this to keep list/ranking order), so we
|
||||||
@@ -291,7 +421,15 @@ pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult<MangaDetail> {
|
|||||||
let genres = repo::genre::list_for_manga(pool, id).await?;
|
let genres = repo::genre::list_for_manga(pool, id).await?;
|
||||||
let tags = repo::tag::list_for_manga(pool, id).await?;
|
let tags = repo::tag::list_for_manga(pool, id).await?;
|
||||||
let content_warnings = repo::page_analysis::warnings_for_manga(pool, id).await?;
|
let content_warnings = repo::page_analysis::warnings_for_manga(pool, id).await?;
|
||||||
Ok(MangaDetail { manga, authors, genres, tags, content_warnings })
|
let chapter_storage_bytes = repo::storage_stats::manga_chapter_bytes(pool, id).await?;
|
||||||
|
Ok(MangaDetail {
|
||||||
|
manga,
|
||||||
|
authors,
|
||||||
|
genres,
|
||||||
|
tags,
|
||||||
|
content_warnings,
|
||||||
|
chapter_storage_bytes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert just the manga row. Relations (authors, genres) are written
|
/// Insert just the manga row. Relations (authors, genres) are written
|
||||||
@@ -372,12 +510,17 @@ pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>(
|
|||||||
executor: E,
|
executor: E,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
key: &str,
|
key: &str,
|
||||||
|
size_bytes: i64,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
sqlx::query("UPDATE mangas SET cover_image_path = $1, updated_at = now() WHERE id = $2")
|
sqlx::query(
|
||||||
.bind(key)
|
"UPDATE mangas SET cover_image_path = $1, cover_size_bytes = $2, updated_at = now() \
|
||||||
.bind(id)
|
WHERE id = $3",
|
||||||
.execute(executor)
|
)
|
||||||
.await?;
|
.bind(key)
|
||||||
|
.bind(size_bytes)
|
||||||
|
.bind(id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,10 +528,13 @@ pub async fn clear_cover_image_path<'e, E: PgExecutor<'e>>(
|
|||||||
executor: E,
|
executor: E,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
sqlx::query("UPDATE mangas SET cover_image_path = NULL, updated_at = now() WHERE id = $1")
|
sqlx::query(
|
||||||
.bind(id)
|
"UPDATE mangas SET cover_image_path = NULL, cover_size_bytes = NULL, updated_at = now() \
|
||||||
.execute(executor)
|
WHERE id = $1",
|
||||||
.await?;
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,60 @@ pub mod author;
|
|||||||
pub mod bookmark;
|
pub mod bookmark;
|
||||||
pub mod chapter;
|
pub mod chapter;
|
||||||
pub mod collection;
|
pub mod collection;
|
||||||
|
pub mod crawl_metrics;
|
||||||
pub mod crawler;
|
pub mod crawler;
|
||||||
pub mod genre;
|
pub mod genre;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod page_analysis;
|
pub mod page_analysis;
|
||||||
pub mod page_tag;
|
pub mod page_tag;
|
||||||
|
pub mod reaction;
|
||||||
pub mod read_progress;
|
pub mod read_progress;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod storage_stats;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod upload_history;
|
pub mod upload_history;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod user_preferences;
|
pub mod user_preferences;
|
||||||
|
|
||||||
|
/// Escape the LIKE/ILIKE metacharacters (`%`, `_`, and the escape char `\`
|
||||||
|
/// itself) in a user-supplied search term so they match literally rather than
|
||||||
|
/// as wildcards. Pair the resulting value with `ESCAPE '\'` in the SQL — a
|
||||||
|
/// single backslash, which under `standard_conforming_strings` (Postgres
|
||||||
|
/// default) is one backslash in a single-quoted literal.
|
||||||
|
///
|
||||||
|
/// This is a search-correctness fix, not an injection fix: every term is
|
||||||
|
/// already a bound parameter, so `%`/`_` can never break out of the string —
|
||||||
|
/// they were just silently acting as wildcards (`50%` matching everything,
|
||||||
|
/// `a_b` matching `axb`). Callers that build a substring pattern wrap the
|
||||||
|
/// escaped term themselves, e.g. `format!("%{}%", escape_like(term))`.
|
||||||
|
pub(crate) fn escape_like(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for ch in s.chars() {
|
||||||
|
if matches!(ch, '\\' | '%' | '_') {
|
||||||
|
out.push('\\');
|
||||||
|
}
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::escape_like;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_wildcards_and_the_escape_char() {
|
||||||
|
assert_eq!(escape_like("50%"), r"50\%");
|
||||||
|
assert_eq!(escape_like("a_b"), r"a\_b");
|
||||||
|
assert_eq!(escape_like(r"back\slash"), r"back\\slash");
|
||||||
|
// A already-escaped-looking input is double-escaped so it stays literal.
|
||||||
|
assert_eq!(escape_like(r"\%"), r"\\\%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_ordinary_text_untouched() {
|
||||||
|
assert_eq!(escape_like("naruto"), "naruto");
|
||||||
|
assert_eq!(escape_like(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ pub async fn create<'e, E: PgExecutor<'e>>(
|
|||||||
page_number: i32,
|
page_number: i32,
|
||||||
storage_key: &str,
|
storage_key: &str,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
|
size_bytes: i64,
|
||||||
) -> AppResult<Page> {
|
) -> AppResult<Page> {
|
||||||
let row = sqlx::query_as::<_, Page>(
|
let row = sqlx::query_as::<_, Page>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO pages (chapter_id, page_number, storage_key, content_type)
|
INSERT INTO pages (chapter_id, page_number, storage_key, content_type, size_bytes)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, chapter_id, page_number, storage_key, content_type
|
RETURNING id, chapter_id, page_number, storage_key, content_type
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -24,6 +25,7 @@ pub async fn create<'e, E: PgExecutor<'e>>(
|
|||||||
.bind(page_number)
|
.bind(page_number)
|
||||||
.bind(storage_key)
|
.bind(storage_key)
|
||||||
.bind(content_type)
|
.bind(content_type)
|
||||||
|
.bind(size_bytes)
|
||||||
.fetch_one(executor)
|
.fetch_one(executor)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
@@ -58,6 +60,27 @@ pub async fn locate(
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`locate`] but also resolves the manga title and chapter number, so
|
||||||
|
/// live analysis events can carry human labels for the "now analyzing"
|
||||||
|
/// banner without the dashboard having to look them up. Returns
|
||||||
|
/// `(manga_id, manga_title, chapter_id, chapter_number, page_number)`.
|
||||||
|
pub async fn locate_labeled(
|
||||||
|
pool: &PgPool,
|
||||||
|
page_id: Uuid,
|
||||||
|
) -> AppResult<Option<(Uuid, String, Uuid, i32, i32)>> {
|
||||||
|
let row: Option<(Uuid, String, Uuid, i32, i32)> = sqlx::query_as(
|
||||||
|
"SELECT c.manga_id, m.title, p.chapter_id, c.number, p.page_number \
|
||||||
|
FROM pages p \
|
||||||
|
JOIN chapters c ON c.id = p.chapter_id \
|
||||||
|
JOIN mangas m ON m.id = c.manga_id \
|
||||||
|
WHERE p.id = $1",
|
||||||
|
)
|
||||||
|
.bind(page_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_for_chapter(pool: &PgPool, chapter_id: Uuid) -> AppResult<Vec<Page>> {
|
pub async fn list_for_chapter(pool: &PgPool, chapter_id: Uuid) -> AppResult<Vec<Page>> {
|
||||||
let rows = sqlx::query_as::<_, Page>(
|
let rows = sqlx::query_as::<_, Page>(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::crawler::jobs::{self, JobPayload};
|
use crate::crawler::jobs::{self, JobPayload};
|
||||||
use crate::domain::page_analysis::{
|
use crate::domain::page_analysis::{
|
||||||
ChapterCoverage, ContentWarning, MangaCoverage, OcrKind, OcrLine, PageAnalysis,
|
AnalysisHistoryRow, AnalysisMetrics, ChapterCoverage, ContentWarning, MangaCoverage,
|
||||||
PageAnalysisDetail, PageSearchItem, PageStatusItem, VisionAnalysis,
|
ModelDuration, OcrKind, OcrLine, PageAnalysis, PageAnalysisDetail, PageSearchItem,
|
||||||
|
PageStatusItem, VisionAnalysis,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use crate::error::AppResult;
|
use crate::error::AppResult;
|
||||||
|
|
||||||
/// Filter set for [`page_search`]. `tags` are AND-ed (a page must carry
|
/// Filter set for [`page_search`]. `tags` are AND-ed (a page must carry
|
||||||
@@ -42,12 +44,126 @@ pub struct PageSearchQuery {
|
|||||||
/// aborting the whole page's analysis on one bad model output.
|
/// aborting the whole page's analysis on one bad model output.
|
||||||
const MAX_TAG_CHARS: usize = 64;
|
const MAX_TAG_CHARS: usize = 64;
|
||||||
|
|
||||||
|
/// Outcome of [`enqueue_for_page`] — surfaces whether the admin's intent
|
||||||
|
/// actually landed as a worker-visible change.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EnqueueForPageOutcome {
|
||||||
|
/// A fresh `analyze_page` job row was inserted.
|
||||||
|
Inserted,
|
||||||
|
/// A pending `force=false` row was upgraded in-place to `force=true`,
|
||||||
|
/// because the partial unique index `crawler_jobs_analyze_page_dedup_idx`
|
||||||
|
/// blocks a second pending insert per page. Returned only for force
|
||||||
|
/// requests.
|
||||||
|
UpgradedToForce,
|
||||||
|
/// A matching `(pending|running)` job already encodes the request;
|
||||||
|
/// nothing changed. For force requests this is the retry-race tail:
|
||||||
|
/// the INSERT was Skipped, the UPDATE matched zero rows (the sibling
|
||||||
|
/// drained between the two), and the second INSERT was Skipped too
|
||||||
|
/// (a fresh sibling landed). The next ack of either job will leave
|
||||||
|
/// the page in the admin's intended state without further action.
|
||||||
|
AlreadyEnqueued,
|
||||||
|
}
|
||||||
|
|
||||||
/// Enqueue an `analyze_page` job for `page_id`. `force` re-analyzes a page
|
/// Enqueue an `analyze_page` job for `page_id`. `force` re-analyzes a page
|
||||||
/// that is already `done`. Enqueue is idempotent at the job level only in
|
/// that is already `done`.
|
||||||
/// that duplicate pending jobs are harmless — processing is idempotent.
|
///
|
||||||
pub async fn enqueue_for_page(pool: &PgPool, page_id: Uuid, force: bool) -> AppResult<()> {
|
/// **Force-vs-dedup correctness.** Migration 0031's partial unique index
|
||||||
jobs::enqueue(pool, &JobPayload::AnalyzePage { page_id, force }).await?;
|
/// is keyed on `page_id` (not on `(page_id, force)`), so a plain
|
||||||
Ok(())
|
/// `jobs::enqueue` of a `force=true` payload when a `force=false` job is
|
||||||
|
/// already pending silently goes to `Skipped` — the worker would then
|
||||||
|
/// pick up the non-force row, hit the skip-if-done net, and ack done
|
||||||
|
/// without re-analyzing. The admin's force request was lost.
|
||||||
|
///
|
||||||
|
/// This function fixes that by: (1) trying the insert; (2) on Skipped
|
||||||
|
/// with `force=true`, running an UPDATE that flips the existing pending
|
||||||
|
/// row's `force` flag to `true`; (3) reporting which path ran so the
|
||||||
|
/// caller can audit accurately.
|
||||||
|
/// Pool convenience wrapper for [`enqueue_for_page_conn`]. Use this from
|
||||||
|
/// callers that don't need to share a transaction (chapter upload, the
|
||||||
|
/// crawler). The admin force-reanalyze handler uses the `_conn` form so the
|
||||||
|
/// enqueue and its `admin_audit` row commit together.
|
||||||
|
pub async fn enqueue_for_page(
|
||||||
|
pool: &PgPool,
|
||||||
|
page_id: Uuid,
|
||||||
|
force: bool,
|
||||||
|
) -> AppResult<EnqueueForPageOutcome> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
enqueue_for_page_conn(&mut conn, page_id, force).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue (or force-upgrade) an `analyze_page` job on a caller-supplied
|
||||||
|
/// connection, so the admin handler can run it inside the same transaction as
|
||||||
|
/// its audit insert. The body is a sequence of single statements, each
|
||||||
|
/// reborrowing `&mut *conn`.
|
||||||
|
pub async fn enqueue_for_page_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
page_id: Uuid,
|
||||||
|
force: bool,
|
||||||
|
) -> AppResult<EnqueueForPageOutcome> {
|
||||||
|
use crate::crawler::jobs::EnqueueResult;
|
||||||
|
match jobs::enqueue(&mut *conn, &JobPayload::AnalyzePage { page_id, force }).await? {
|
||||||
|
EnqueueResult::Inserted(_) => Ok(EnqueueForPageOutcome::Inserted),
|
||||||
|
EnqueueResult::Skipped if !force => Ok(EnqueueForPageOutcome::AlreadyEnqueued),
|
||||||
|
EnqueueResult::Skipped => {
|
||||||
|
// Force-specific upgrade. The WHERE clause matches the same
|
||||||
|
// partial index predicate (`pending|running` + `analyze_page`
|
||||||
|
// + this page_id) and adds `force=false` so we never overwrite
|
||||||
|
// a row that's already what the admin wants. RETURNING tells
|
||||||
|
// us whether the upgrade actually moved anything AND its
|
||||||
|
// pre-update state so the running-state race fix below can
|
||||||
|
// release the in-flight lease.
|
||||||
|
let upgraded: Vec<(Uuid, String)> = sqlx::query_as(
|
||||||
|
"UPDATE crawler_jobs \
|
||||||
|
SET payload = jsonb_set(payload, '{force}', 'true'::jsonb), \
|
||||||
|
updated_at = now() \
|
||||||
|
WHERE state IN ('pending', 'running') \
|
||||||
|
AND payload->>'kind' = 'analyze_page' \
|
||||||
|
AND payload->>'page_id' = $1 \
|
||||||
|
AND (payload->>'force')::boolean = false \
|
||||||
|
RETURNING id, state",
|
||||||
|
)
|
||||||
|
.bind(page_id.to_string())
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
if upgraded.is_empty() {
|
||||||
|
// Race: between the skipped INSERT and the UPDATE the
|
||||||
|
// sibling row could have been completed (job state moves
|
||||||
|
// out of pending/running). At that point a re-INSERT
|
||||||
|
// would succeed. Retry once to close the race without
|
||||||
|
// unbounded loops.
|
||||||
|
return Ok(
|
||||||
|
match jobs::enqueue(&mut *conn, &JobPayload::AnalyzePage { page_id, force })
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
EnqueueResult::Inserted(_) => EnqueueForPageOutcome::Inserted,
|
||||||
|
EnqueueResult::Skipped => EnqueueForPageOutcome::AlreadyEnqueued,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Race fix for the running-state branch (0.87.20 followup):
|
||||||
|
// a worker that has already leased the row holds the
|
||||||
|
// pre-upgrade `force=false` in its in-memory `lease.payload`
|
||||||
|
// (see `analysis::daemon::WorkerContext::process_lease`).
|
||||||
|
// Even though we've flipped the row's payload to
|
||||||
|
// `force=true`, the worker's skip-if-done check uses its
|
||||||
|
// own pre-upgrade value and will ack done without
|
||||||
|
// re-analyzing. `release_unowned` returns the row to
|
||||||
|
// `pending` AND bumps `lease_generation` so the original
|
||||||
|
// worker's later `ack_done(id, old_generation)` finds no
|
||||||
|
// matching row and is a no-op — the successor's lease (a
|
||||||
|
// fresh generation) is preserved. The state-only guard
|
||||||
|
// that previously protected this (and was the 0.87.20
|
||||||
|
// narrowing) wasn't enough on its own: a successor that
|
||||||
|
// re-leased the id would re-enter `state='running'`, and
|
||||||
|
// the original's ack would clobber it.
|
||||||
|
for (id, state) in &upgraded {
|
||||||
|
if state == "running" {
|
||||||
|
let _ = jobs::release_unowned(&mut *conn, *id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(EnqueueForPageOutcome::UpgradedToForce)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How wide a bulk re-enqueue reaches.
|
/// How wide a bulk re-enqueue reaches.
|
||||||
@@ -71,11 +187,14 @@ pub enum ReenqueueScope {
|
|||||||
/// skip-if-done net would no-op them). Pages with a pending/running
|
/// skip-if-done net would no-op them). Pages with a pending/running
|
||||||
/// `analyze_page` job are always skipped so repeated calls don't pile up
|
/// `analyze_page` job are always skipped so repeated calls don't pile up
|
||||||
/// duplicates. Returns the number of jobs enqueued.
|
/// duplicates. Returns the number of jobs enqueued.
|
||||||
pub async fn enqueue_pages(
|
pub async fn enqueue_pages<'e, E>(
|
||||||
pool: &PgPool,
|
executor: E,
|
||||||
scope: ReenqueueScope,
|
scope: ReenqueueScope,
|
||||||
only_unanalyzed: bool,
|
only_unanalyzed: bool,
|
||||||
) -> AppResult<u64> {
|
) -> AppResult<u64>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
// Scope predicate; the bound uuid (when present) is always $2.
|
// Scope predicate; the bound uuid (when present) is always $2.
|
||||||
let scope_clause = match scope {
|
let scope_clause = match scope {
|
||||||
ReenqueueScope::All => "",
|
ReenqueueScope::All => "",
|
||||||
@@ -84,6 +203,14 @@ pub async fn enqueue_pages(
|
|||||||
}
|
}
|
||||||
ReenqueueScope::Chapter(_) => "AND p.chapter_id = $2",
|
ReenqueueScope::Chapter(_) => "AND p.chapter_id = $2",
|
||||||
};
|
};
|
||||||
|
// The `NOT EXISTS(... pending|running ...)` pre-check used to live in
|
||||||
|
// this query, but it races with concurrent admin clicks (two requests
|
||||||
|
// both see "no in-flight job" and both INSERT). The partial unique
|
||||||
|
// index `crawler_jobs_analyze_page_dedup_idx` (migration 0031) covers
|
||||||
|
// (payload->>'page_id') WHERE state IN ('pending', 'running') AND
|
||||||
|
// kind = 'analyze_page', so `ON CONFLICT DO NOTHING` is now the
|
||||||
|
// atomic primitive — duplicates can't land and concurrent enqueue
|
||||||
|
// calls are both observably correct.
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO crawler_jobs (payload)
|
INSERT INTO crawler_jobs (payload)
|
||||||
@@ -93,11 +220,7 @@ pub async fn enqueue_pages(
|
|||||||
SELECT 1 FROM page_analysis pa
|
SELECT 1 FROM page_analysis pa
|
||||||
WHERE pa.page_id = p.id AND pa.status = 'done'))
|
WHERE pa.page_id = p.id AND pa.status = 'done'))
|
||||||
{scope_clause}
|
{scope_clause}
|
||||||
AND NOT EXISTS (
|
ON CONFLICT DO NOTHING
|
||||||
SELECT 1 FROM crawler_jobs j
|
|
||||||
WHERE j.payload->>'kind' = 'analyze_page'
|
|
||||||
AND j.payload->>'page_id' = p.id::text
|
|
||||||
AND j.state IN ('pending', 'running'))
|
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
let query = sqlx::query(&sql).bind(only_unanalyzed);
|
let query = sqlx::query(&sql).bind(only_unanalyzed);
|
||||||
@@ -105,7 +228,7 @@ pub async fn enqueue_pages(
|
|||||||
ReenqueueScope::All => query,
|
ReenqueueScope::All => query,
|
||||||
ReenqueueScope::Manga(id) | ReenqueueScope::Chapter(id) => query.bind(id),
|
ReenqueueScope::Manga(id) | ReenqueueScope::Chapter(id) => query.bind(id),
|
||||||
};
|
};
|
||||||
Ok(query.execute(pool).await?.rows_affected())
|
Ok(query.execute(executor).await?.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-manga analysis coverage for the admin overview. Only mangas that
|
/// Per-manga analysis coverage for the admin overview. Only mangas that
|
||||||
@@ -118,6 +241,10 @@ pub async fn manga_coverage(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> AppResult<(Vec<MangaCoverage>, i64)> {
|
) -> AppResult<(Vec<MangaCoverage>, i64)> {
|
||||||
|
// LIKE-escape so `%`/`_` in the title filter match literally. No trigram
|
||||||
|
// branch here, so binding the escaped term directly (rather than appending a
|
||||||
|
// second param) is safe — $1 feeds only the ILIKE.
|
||||||
|
let search = search.map(crate::repo::escape_like);
|
||||||
let rows = sqlx::query_as::<_, MangaCoverage>(
|
let rows = sqlx::query_as::<_, MangaCoverage>(
|
||||||
r#"
|
r#"
|
||||||
SELECT m.id AS manga_id, m.title,
|
SELECT m.id AS manga_id, m.title,
|
||||||
@@ -127,13 +254,13 @@ pub async fn manga_coverage(
|
|||||||
JOIN chapters c ON c.manga_id = m.id
|
JOIN chapters c ON c.manga_id = m.id
|
||||||
JOIN pages p ON p.chapter_id = c.id
|
JOIN pages p ON p.chapter_id = c.id
|
||||||
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
||||||
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
|
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||||
GROUP BY m.id, m.title
|
GROUP BY m.id, m.title
|
||||||
ORDER BY lower(m.title), m.id
|
ORDER BY lower(m.title), m.id
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(search)
|
.bind(&search)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -146,17 +273,35 @@ pub async fn manga_coverage(
|
|||||||
FROM mangas m
|
FROM mangas m
|
||||||
JOIN chapters c ON c.manga_id = m.id
|
JOIN chapters c ON c.manga_id = m.id
|
||||||
JOIN pages p ON p.chapter_id = c.id
|
JOIN pages p ON p.chapter_id = c.id
|
||||||
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
|
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||||
GROUP BY m.id
|
GROUP BY m.id
|
||||||
) x
|
) x
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(search)
|
.bind(&search)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((rows, total))
|
Ok((rows, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Library-wide analysis coverage `(analyzed_pages, total_pages)` for the
|
||||||
|
/// admin overview. `total_pages` is every page; `analyzed_pages` are those
|
||||||
|
/// with a `done` analysis row.
|
||||||
|
pub async fn library_coverage(pool: &PgPool) -> AppResult<(i64, i64)> {
|
||||||
|
let row: (i64, i64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE pa.status = 'done')::bigint AS analyzed,
|
||||||
|
count(p.id)::bigint AS total
|
||||||
|
FROM pages p
|
||||||
|
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-chapter analysis coverage for one manga, ordered by chapter number.
|
/// Per-chapter analysis coverage for one manga, ordered by chapter number.
|
||||||
pub async fn chapter_coverage(
|
pub async fn chapter_coverage(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
@@ -212,6 +357,90 @@ pub async fn chapter_page_status(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filters for [`list_history`]. `None` widens the scope.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct AnalysisHistoryFilter<'a> {
|
||||||
|
/// Exact analysis status (`done` or `failed`).
|
||||||
|
pub status: Option<&'a str>,
|
||||||
|
/// Only NSFW-flagged pages.
|
||||||
|
pub nsfw_only: bool,
|
||||||
|
/// Case-insensitive manga-title substring.
|
||||||
|
pub search: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated, newest-first analysis history — every completed/failed page
|
||||||
|
/// analysis resolved to its manga/chapter/page context. Reads the
|
||||||
|
/// persistent `page_analysis` table, so unlike the crawler job history it
|
||||||
|
/// is not bounded by job reaping. Returns the page slice plus the filtered
|
||||||
|
/// total. `pending` rows are excluded — history is terminal outcomes only.
|
||||||
|
pub async fn list_history(
|
||||||
|
pool: &PgPool,
|
||||||
|
filter: AnalysisHistoryFilter<'_>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
|
||||||
|
let search_pat = filter
|
||||||
|
.search
|
||||||
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
|
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
pa.page_id,
|
||||||
|
p.page_number,
|
||||||
|
p.chapter_id,
|
||||||
|
c.number AS chapter_number,
|
||||||
|
c.manga_id,
|
||||||
|
m.title AS manga_title,
|
||||||
|
pa.status,
|
||||||
|
pa.is_nsfw,
|
||||||
|
pa.model,
|
||||||
|
pa.error,
|
||||||
|
pa.analyzed_at,
|
||||||
|
pa.duration_ms
|
||||||
|
FROM page_analysis pa
|
||||||
|
JOIN pages p ON p.id = pa.page_id
|
||||||
|
JOIN chapters c ON c.id = p.chapter_id
|
||||||
|
JOIN mangas m ON m.id = c.manga_id
|
||||||
|
WHERE pa.status <> 'pending'
|
||||||
|
AND ($1::text IS NULL OR pa.status = $1)
|
||||||
|
AND ($2::bool IS FALSE OR pa.is_nsfw)
|
||||||
|
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\')
|
||||||
|
ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.status)
|
||||||
|
.bind(filter.nsfw_only)
|
||||||
|
.bind(&search_pat)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (total,): (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT count(*)
|
||||||
|
FROM page_analysis pa
|
||||||
|
JOIN pages p ON p.id = pa.page_id
|
||||||
|
JOIN chapters c ON c.id = p.chapter_id
|
||||||
|
JOIN mangas m ON m.id = c.manga_id
|
||||||
|
WHERE pa.status <> 'pending'
|
||||||
|
AND ($1::text IS NULL OR pa.status = $1)
|
||||||
|
AND ($2::bool IS FALSE OR pa.is_nsfw)
|
||||||
|
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(filter.status)
|
||||||
|
.bind(filter.nsfw_only)
|
||||||
|
.bind(&search_pat)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
/// Full analysis detail for one page. `None` when the page itself doesn't
|
/// Full analysis detail for one page. `None` when the page itself doesn't
|
||||||
/// exist; an existing-but-unanalyzed page returns `status = "none"` with
|
/// exist; an existing-but-unanalyzed page returns `status = "none"` with
|
||||||
/// empty OCR/tags/warnings so the UI can show "not analyzed yet".
|
/// empty OCR/tags/warnings so the UI can show "not analyzed yet".
|
||||||
@@ -331,6 +560,99 @@ pub async fn mark_failed(pool: &PgPool, page_id: Uuid, error: &str) -> AppResult
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record the wall-clock the worker spent on a page. Best-effort: updates the
|
||||||
|
/// existing `page_analysis` row (written by `persist_analysis`/`mark_failed`);
|
||||||
|
/// a transient failure with no row yet simply updates nothing.
|
||||||
|
pub async fn record_duration(
|
||||||
|
pool: &PgPool,
|
||||||
|
page_id: Uuid,
|
||||||
|
duration_ms: i64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query("UPDATE page_analysis SET duration_ms = $2 WHERE page_id = $1")
|
||||||
|
.bind(page_id)
|
||||||
|
.bind(duration_ms)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate analysis timing + outcome roll-up over an optional time window
|
||||||
|
/// (`since = None` → all time). Mean duration ignores NULL `duration_ms`
|
||||||
|
/// (pre-tracking rows); `by_model` breaks the mean out per model.
|
||||||
|
pub async fn analysis_metrics(
|
||||||
|
pool: &PgPool,
|
||||||
|
since: Option<DateTime<Utc>>,
|
||||||
|
) -> AppResult<AnalysisMetrics> {
|
||||||
|
let (n, ok, failed, avg_ms): (i64, i64, i64, Option<f64>) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS n,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done') AS ok,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||||
|
-- Mean over successful pages only, so it reconciles with the
|
||||||
|
-- by-model breakdown below (which is done-only). Failed /
|
||||||
|
-- timed-out durations would otherwise skew the headline up.
|
||||||
|
AVG(duration_ms) FILTER (WHERE status = 'done')::float8 AS avg_ms
|
||||||
|
FROM page_analysis
|
||||||
|
WHERE status <> 'pending'
|
||||||
|
AND ($1::timestamptz IS NULL OR analyzed_at >= $1)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(since)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let by_model = sqlx::query_as::<_, ModelDuration>(
|
||||||
|
r#"
|
||||||
|
SELECT model, AVG(duration_ms)::float8 AS avg_ms, COUNT(*) AS n
|
||||||
|
FROM page_analysis
|
||||||
|
WHERE status = 'done'
|
||||||
|
AND duration_ms IS NOT NULL
|
||||||
|
AND ($1::timestamptz IS NULL OR analyzed_at >= $1)
|
||||||
|
GROUP BY model
|
||||||
|
ORDER BY n DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(since)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(AnalysisMetrics { n, ok, failed, avg_ms, by_model })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bucketed analysis throughput/success/duration series over an optional
|
||||||
|
/// window, for the Analysis-tab trend charts. `ok`/`failed` follow the
|
||||||
|
/// `done`/`failed` statuses; mean duration is over completed pages only.
|
||||||
|
/// Empty intervals are absent (client fills the axis). The
|
||||||
|
/// `page_analysis_analyzed_at_idx` (migration 0030) backs the scan.
|
||||||
|
pub async fn analysis_series(
|
||||||
|
pool: &PgPool,
|
||||||
|
bucket: crate::repo::crawl_metrics::Bucket,
|
||||||
|
since: Option<DateTime<Utc>>,
|
||||||
|
) -> AppResult<Vec<crate::domain::crawl_metrics::MetricsBucket>> {
|
||||||
|
let rows = sqlx::query_as::<_, crate::domain::crawl_metrics::MetricsBucket>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
date_trunc($1, analyzed_at) AS t,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done') AS ok,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||||
|
AVG(duration_ms) FILTER (WHERE status = 'done')::float8 AS avg_ms
|
||||||
|
FROM page_analysis
|
||||||
|
WHERE status <> 'pending'
|
||||||
|
AND analyzed_at IS NOT NULL
|
||||||
|
AND ($2::timestamptz IS NULL OR analyzed_at >= $2)
|
||||||
|
GROUP BY t
|
||||||
|
ORDER BY t
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(bucket.as_str())
|
||||||
|
.bind(since)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist a completed analysis for a page, replacing any previous result.
|
/// Persist a completed analysis for a page, replacing any previous result.
|
||||||
///
|
///
|
||||||
/// The model's free-form `kind` / `content_type` strings are mapped onto
|
/// The model's free-form `kind` / `content_type` strings are mapped onto
|
||||||
|
|||||||
@@ -120,27 +120,11 @@ pub async fn list_for_page(
|
|||||||
Ok(rows.into_iter().map(|(t,)| t).collect())
|
Ok(rows.into_iter().map(|(t,)| t).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape a string for use as a LIKE pattern fragment: `%`, `_`, and
|
// LIKE-escaping the autocomplete prefix is defence-in-depth: the public API
|
||||||
/// `\` get a leading backslash so they're matched literally rather
|
// already rejects `%`/`_`/`\` in `normalize_tag` before they reach this repo, so
|
||||||
/// than as wildcards / escapes. The matching queries below pair this
|
// a stray wildcard can only arrive from a future internal caller (worker, CLI)
|
||||||
/// with `ESCAPE '\'` for explicitness — a single backslash, since the
|
// that bypasses the normalizer. Shared with the other search sites.
|
||||||
/// SQL lives in a raw string and Postgres treats `\\` in a single-
|
use crate::repo::escape_like as escape_like_prefix;
|
||||||
/// quoted literal as one backslash under `standard_conforming_strings`.
|
|
||||||
///
|
|
||||||
/// The public API rejects `%`/`_`/`\` in `normalize_tag` before
|
|
||||||
/// they reach this repo, so this is defence-in-depth — a future
|
|
||||||
/// internal caller (worker, CLI) that bypasses the normalizer can't
|
|
||||||
/// turn a prefix filter into a wildcard search by accident.
|
|
||||||
fn escape_like_prefix(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
for ch in s.chars() {
|
|
||||||
if matches!(ch, '\\' | '%' | '_') {
|
|
||||||
out.push('\\');
|
|
||||||
}
|
|
||||||
out.push(ch);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paged list of `user_id`'s tagged pages, with breadcrumb. When
|
/// Paged list of `user_id`'s tagged pages, with breadcrumb. When
|
||||||
/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the
|
/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the
|
||||||
@@ -247,6 +231,11 @@ pub async fn distinct_tags_for_user(
|
|||||||
/// storage keys (page-number ascending) so the row can render a
|
/// storage keys (page-number ascending) so the row can render a
|
||||||
/// thumbnail strip without a follow-up fetch.
|
/// thumbnail strip without a follow-up fetch.
|
||||||
///
|
///
|
||||||
|
/// When `text` is non-blank, results are further restricted to pages whose
|
||||||
|
/// analysis `search_doc` matches the query (OCR text search), and both
|
||||||
|
/// `match_count` and the sample thumbnails reflect that filtered set —
|
||||||
|
/// i.e. `match_count` counts tagged pages whose OCR also matches `text`.
|
||||||
|
///
|
||||||
/// `order` is inlined via `format!()` — the enum value space is
|
/// `order` is inlined via `format!()` — the enum value space is
|
||||||
/// closed (`ASC` / `DESC`) so this is not a SQL-injection vector.
|
/// closed (`ASC` / `DESC`) so this is not a SQL-injection vector.
|
||||||
pub async fn aggregate_chapters_for_tag(
|
pub async fn aggregate_chapters_for_tag(
|
||||||
@@ -256,7 +245,30 @@ pub async fn aggregate_chapters_for_tag(
|
|||||||
order: Order,
|
order: Order,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
|
text: Option<&str>,
|
||||||
) -> AppResult<(Vec<TaggedChapterAggregate>, i64)> {
|
) -> AppResult<(Vec<TaggedChapterAggregate>, i64)> {
|
||||||
|
// OCR text filter: when present, additionally require the page's analysis
|
||||||
|
// `search_doc` to match the query. Reuses the precomputed tsvector exactly
|
||||||
|
// like `repo::page_analysis::page_search`. `None`/blank ⇒ tag-only.
|
||||||
|
let text = text.map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
let (text_join, text_where) = match text {
|
||||||
|
// `$5` in the main query, `$3` in the count query (see binds below).
|
||||||
|
Some(_) => (
|
||||||
|
"JOIN page_analysis pa ON pa.page_id = p.id",
|
||||||
|
"AND pa.search_doc @@ plainto_tsquery('simple', {n})",
|
||||||
|
),
|
||||||
|
None => ("", ""),
|
||||||
|
};
|
||||||
|
// Same filter inside the correlated sample-thumbnail subquery (its page is
|
||||||
|
// aliased `p`), so the thumbnails match what the text search matched. The
|
||||||
|
// subquery lives in the main query, so it reuses the `$5` text bind.
|
||||||
|
let (sample_join, sample_where) = match text {
|
||||||
|
Some(_) => (
|
||||||
|
"JOIN page_analysis pa2 ON pa2.page_id = p.id",
|
||||||
|
"AND pa2.search_doc @@ plainto_tsquery('simple', $5)",
|
||||||
|
),
|
||||||
|
None => ("", ""),
|
||||||
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -274,9 +286,11 @@ pub async fn aggregate_chapters_for_tag(
|
|||||||
FROM pages p
|
FROM pages p
|
||||||
JOIN page_tags pt2 ON pt2.page_id = p.id
|
JOIN page_tags pt2 ON pt2.page_id = p.id
|
||||||
JOIN tags t2 ON t2.id = pt2.tag_id
|
JOIN tags t2 ON t2.id = pt2.tag_id
|
||||||
|
{sample_join}
|
||||||
WHERE p.chapter_id = ch.id
|
WHERE p.chapter_id = ch.id
|
||||||
AND pt2.user_id = $1
|
AND pt2.user_id = $1
|
||||||
AND lower(t2.name) = $2
|
AND lower(t2.name) = $2
|
||||||
|
{sample_where}
|
||||||
ORDER BY p.page_number ASC
|
ORDER BY p.page_number ASC
|
||||||
LIMIT 3
|
LIMIT 3
|
||||||
) p2
|
) p2
|
||||||
@@ -288,43 +302,60 @@ pub async fn aggregate_chapters_for_tag(
|
|||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
JOIN chapters ch ON ch.id = p.chapter_id
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
JOIN mangas m ON m.id = ch.manga_id
|
JOIN mangas m ON m.id = ch.manga_id
|
||||||
|
{text_join}
|
||||||
WHERE pt.user_id = $1
|
WHERE pt.user_id = $1
|
||||||
AND lower(t.name) = $2
|
AND lower(t.name) = $2
|
||||||
|
{text_where}
|
||||||
GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title
|
GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title
|
||||||
ORDER BY match_count {dir}, ch.id
|
ORDER BY match_count {dir}, ch.id
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
dir = order.as_sql(),
|
dir = order.as_sql(),
|
||||||
|
text_join = text_join,
|
||||||
|
text_where = text_where.replace("{n}", "$5"),
|
||||||
|
sample_join = sample_join,
|
||||||
|
sample_where = sample_where,
|
||||||
);
|
);
|
||||||
let rows = sqlx::query_as::<_, TaggedChapterAggregate>(&sql)
|
let mut q = sqlx::query_as::<_, TaggedChapterAggregate>(&sql)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(tag)
|
.bind(tag)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset);
|
||||||
.fetch_all(pool)
|
if let Some(text) = text {
|
||||||
.await?;
|
q = q.bind(text);
|
||||||
|
}
|
||||||
|
let rows = q.fetch_all(pool).await?;
|
||||||
|
|
||||||
let (total,): (i64,) = sqlx::query_as(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT count(*) FROM (
|
SELECT count(*) FROM (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM page_tags pt
|
FROM page_tags pt
|
||||||
JOIN tags t ON t.id = pt.tag_id
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
|
{text_join}
|
||||||
WHERE pt.user_id = $1 AND lower(t.name) = $2
|
WHERE pt.user_id = $1 AND lower(t.name) = $2
|
||||||
|
{text_where}
|
||||||
GROUP BY p.chapter_id
|
GROUP BY p.chapter_id
|
||||||
) c
|
) c
|
||||||
"#,
|
"#,
|
||||||
)
|
text_join = text_join,
|
||||||
.bind(user_id)
|
text_where = text_where.replace("{n}", "$3"),
|
||||||
.bind(tag)
|
);
|
||||||
.fetch_one(pool)
|
let mut cq = sqlx::query_as::<_, (i64,)>(&count_sql).bind(user_id).bind(tag);
|
||||||
.await?;
|
if let Some(text) = text {
|
||||||
|
cq = cq.bind(text);
|
||||||
|
}
|
||||||
|
let (total,) = cq.fetch_one(pool).await?;
|
||||||
Ok((rows, total))
|
Ok((rows, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paged list of mangas containing pages tagged `tag` for `user_id`,
|
/// Paged list of mangas containing pages tagged `tag` for `user_id`,
|
||||||
/// ranked by `match_count` summed across all their chapters.
|
/// ranked by `match_count` summed across all their chapters.
|
||||||
|
///
|
||||||
|
/// `text` behaves as in [`aggregate_chapters_for_tag`]: non-blank restricts to
|
||||||
|
/// pages whose OCR `search_doc` matches, and both `match_count` and the sample
|
||||||
|
/// thumbnails reflect that filtered set.
|
||||||
pub async fn aggregate_mangas_for_tag(
|
pub async fn aggregate_mangas_for_tag(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -332,7 +363,26 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
order: Order,
|
order: Order,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
|
text: Option<&str>,
|
||||||
) -> AppResult<(Vec<TaggedMangaAggregate>, i64)> {
|
) -> AppResult<(Vec<TaggedMangaAggregate>, i64)> {
|
||||||
|
// OCR text filter — see `aggregate_chapters_for_tag` for the rationale.
|
||||||
|
let text = text.map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
let (text_join, text_where) = match text {
|
||||||
|
Some(_) => (
|
||||||
|
"JOIN page_analysis pa ON pa.page_id = p.id",
|
||||||
|
"AND pa.search_doc @@ plainto_tsquery('simple', {n})",
|
||||||
|
),
|
||||||
|
None => ("", ""),
|
||||||
|
};
|
||||||
|
// Same filter inside the sample-thumbnail subquery (page aliased `p`),
|
||||||
|
// reusing the main query's `$5` text bind.
|
||||||
|
let (sample_join, sample_where) = match text {
|
||||||
|
Some(_) => (
|
||||||
|
"JOIN page_analysis pa2 ON pa2.page_id = p.id",
|
||||||
|
"AND pa2.search_doc @@ plainto_tsquery('simple', $5)",
|
||||||
|
),
|
||||||
|
None => ("", ""),
|
||||||
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -349,9 +399,11 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
JOIN chapters ch2 ON ch2.id = p.chapter_id
|
JOIN chapters ch2 ON ch2.id = p.chapter_id
|
||||||
JOIN page_tags pt2 ON pt2.page_id = p.id
|
JOIN page_tags pt2 ON pt2.page_id = p.id
|
||||||
JOIN tags t2 ON t2.id = pt2.tag_id
|
JOIN tags t2 ON t2.id = pt2.tag_id
|
||||||
|
{sample_join}
|
||||||
WHERE ch2.manga_id = m.id
|
WHERE ch2.manga_id = m.id
|
||||||
AND pt2.user_id = $1
|
AND pt2.user_id = $1
|
||||||
AND lower(t2.name) = $2
|
AND lower(t2.name) = $2
|
||||||
|
{sample_where}
|
||||||
ORDER BY p.page_number ASC
|
ORDER BY p.page_number ASC
|
||||||
LIMIT 3
|
LIMIT 3
|
||||||
) p2
|
) p2
|
||||||
@@ -363,23 +415,31 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
JOIN chapters ch ON ch.id = p.chapter_id
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
JOIN mangas m ON m.id = ch.manga_id
|
JOIN mangas m ON m.id = ch.manga_id
|
||||||
|
{text_join}
|
||||||
WHERE pt.user_id = $1
|
WHERE pt.user_id = $1
|
||||||
AND lower(t.name) = $2
|
AND lower(t.name) = $2
|
||||||
|
{text_where}
|
||||||
GROUP BY m.id, m.title, m.cover_image_path
|
GROUP BY m.id, m.title, m.cover_image_path
|
||||||
ORDER BY match_count {dir}, m.id
|
ORDER BY match_count {dir}, m.id
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
dir = order.as_sql(),
|
dir = order.as_sql(),
|
||||||
|
text_join = text_join,
|
||||||
|
text_where = text_where.replace("{n}", "$5"),
|
||||||
|
sample_join = sample_join,
|
||||||
|
sample_where = sample_where,
|
||||||
);
|
);
|
||||||
let rows = sqlx::query_as::<_, TaggedMangaAggregate>(&sql)
|
let mut q = sqlx::query_as::<_, TaggedMangaAggregate>(&sql)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(tag)
|
.bind(tag)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset);
|
||||||
.fetch_all(pool)
|
if let Some(text) = text {
|
||||||
.await?;
|
q = q.bind(text);
|
||||||
|
}
|
||||||
|
let rows = q.fetch_all(pool).await?;
|
||||||
|
|
||||||
let (total,): (i64,) = sqlx::query_as(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT count(*) FROM (
|
SELECT count(*) FROM (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -387,15 +447,20 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
JOIN tags t ON t.id = pt.tag_id
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
JOIN chapters ch ON ch.id = p.chapter_id
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
|
{text_join}
|
||||||
WHERE pt.user_id = $1 AND lower(t.name) = $2
|
WHERE pt.user_id = $1 AND lower(t.name) = $2
|
||||||
|
{text_where}
|
||||||
GROUP BY ch.manga_id
|
GROUP BY ch.manga_id
|
||||||
) m
|
) m
|
||||||
"#,
|
"#,
|
||||||
)
|
text_join = text_join,
|
||||||
.bind(user_id)
|
text_where = text_where.replace("{n}", "$3"),
|
||||||
.bind(tag)
|
);
|
||||||
.fetch_one(pool)
|
let mut cq = sqlx::query_as::<_, (i64,)>(&count_sql).bind(user_id).bind(tag);
|
||||||
.await?;
|
if let Some(text) = text {
|
||||||
|
cq = cq.bind(text);
|
||||||
|
}
|
||||||
|
let (total,) = cq.fetch_one(pool).await?;
|
||||||
Ok((rows, total))
|
Ok((rows, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
backend/src/repo/reaction.rs
Normal file
66
backend/src/repo/reaction.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! Per-user manga reaction (like/dislike) persistence.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::reaction::Reaction;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
/// Insert-or-overwrite the user's reaction on this manga (like ↔ dislike).
|
||||||
|
/// A foreign-key violation (unknown manga) maps to `NotFound` so the API
|
||||||
|
/// returns 404 rather than 500 — mirrors `read_progress::upsert`.
|
||||||
|
pub async fn upsert(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
manga_id: Uuid,
|
||||||
|
reaction: Reaction,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO manga_reactions (user_id, manga_id, reaction, created_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (user_id, manga_id) DO UPDATE
|
||||||
|
SET reaction = EXCLUDED.reaction,
|
||||||
|
created_at = now()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(manga_id)
|
||||||
|
.bind(reaction.as_str())
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
|
||||||
|
AppError::NotFound
|
||||||
|
}
|
||||||
|
other => AppError::Database(other),
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the user's reaction on this manga. Idempotent — clearing a
|
||||||
|
/// non-existent reaction is a no-op.
|
||||||
|
pub async fn clear(pool: &PgPool, user_id: Uuid, manga_id: Uuid) -> AppResult<()> {
|
||||||
|
sqlx::query("DELETE FROM manga_reactions WHERE user_id = $1 AND manga_id = $2")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(manga_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's current reaction on this manga, or `None` if unset.
|
||||||
|
pub async fn get(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
manga_id: Uuid,
|
||||||
|
) -> AppResult<Option<Reaction>> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT reaction FROM manga_reactions WHERE user_id = $1 AND manga_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(manga_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.and_then(|(s,)| Reaction::parse(&s)))
|
||||||
|
}
|
||||||
@@ -80,7 +80,17 @@ pub async fn get_for_manga(
|
|||||||
rp.chapter_id,
|
rp.chapter_id,
|
||||||
c.number AS chapter_number,
|
c.number AS chapter_number,
|
||||||
rp.page,
|
rp.page,
|
||||||
rp.updated_at
|
rp.updated_at,
|
||||||
|
-- Distinct chapter numbers past the reader's last-read chapter
|
||||||
|
-- (see list_for_user for the non-unique-number rationale). 0
|
||||||
|
-- when the last-read chapter is unknown.
|
||||||
|
(
|
||||||
|
SELECT count(DISTINCT c2.number)
|
||||||
|
FROM chapters c2
|
||||||
|
WHERE c2.manga_id = rp.manga_id
|
||||||
|
AND c.number IS NOT NULL
|
||||||
|
AND c2.number > c.number
|
||||||
|
) AS new_chapters_count
|
||||||
FROM read_progress rp
|
FROM read_progress rp
|
||||||
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
||||||
WHERE rp.user_id = $1 AND rp.manga_id = $2
|
WHERE rp.user_id = $1 AND rp.manga_id = $2
|
||||||
@@ -131,8 +141,23 @@ pub async fn list_for_user(
|
|||||||
m.cover_image_path AS manga_cover_image_path,
|
m.cover_image_path AS manga_cover_image_path,
|
||||||
rp.chapter_id,
|
rp.chapter_id,
|
||||||
c.number AS chapter_number,
|
c.number AS chapter_number,
|
||||||
|
c.page_count AS chapter_page_count,
|
||||||
rp.page,
|
rp.page,
|
||||||
rp.updated_at
|
rp.updated_at,
|
||||||
|
-- Personal "new since last read": how many distinct chapter
|
||||||
|
-- numbers sit past the reader's last-read chapter.
|
||||||
|
-- COUNT(DISTINCT number) — not COUNT(*) — because
|
||||||
|
-- (manga_id, number) is non-unique (scanlations share a
|
||||||
|
-- number, migration 0013), so raw rows would over-count. When
|
||||||
|
-- `c.number` is NULL (manga-level progress or a deleted
|
||||||
|
-- chapter) the predicate matches no rows, yielding 0.
|
||||||
|
(
|
||||||
|
SELECT count(DISTINCT c2.number)
|
||||||
|
FROM chapters c2
|
||||||
|
WHERE c2.manga_id = rp.manga_id
|
||||||
|
AND c.number IS NOT NULL
|
||||||
|
AND c2.number > c.number
|
||||||
|
) AS new_chapters_count
|
||||||
FROM read_progress rp
|
FROM read_progress rp
|
||||||
JOIN mangas m ON m.id = rp.manga_id
|
JOIN mangas m ON m.id = rp.manga_id
|
||||||
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
LEFT JOIN chapters c ON c.id = rp.chapter_id
|
||||||
|
|||||||
@@ -65,3 +65,15 @@ pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> AppResult
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete every session whose `expires_at` has passed, returning the number
|
||||||
|
/// reaped. `find_active` already refuses expired sessions, so this only
|
||||||
|
/// reclaims storage — running it on any schedule (or not at all) is safe.
|
||||||
|
/// Backed by `sessions_expires_idx` (0002). Called by the periodic reaper in
|
||||||
|
/// `app::build`.
|
||||||
|
pub async fn delete_expired(pool: &PgPool) -> AppResult<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user