Add comix.to and kagane.to support #13
+10
-2
@@ -5,8 +5,8 @@
|
|||||||
API_TOKEN=changeme-generate-a-long-random-token
|
API_TOKEN=changeme-generate-a-long-random-token
|
||||||
|
|
||||||
# Comma-separated origins allowed to call the API (CORS). Both Asura domains
|
# Comma-separated origins allowed to call the API (CORS). Both Asura domains
|
||||||
# plus Demonic. Add/remove as the sites' hostnames change.
|
# plus Demonic, Comix, and Kagane. Add/remove as the sites' hostnames change.
|
||||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
|
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to
|
||||||
|
|
||||||
# --- Prod override (Traefik) only ---
|
# --- Prod override (Traefik) only ---
|
||||||
# Subdomain Traefik routes to this service (required by the prod override).
|
# Subdomain Traefik routes to this service (required by the prod override).
|
||||||
@@ -52,3 +52,11 @@ WEB_PASSWORD=
|
|||||||
# BATCH x (COOLDOWN / INTERVAL) series hold the cooldown cadence — 84 with these
|
# BATCH x (COOLDOWN / INTERVAL) series hold the cooldown cadence — 84 with these
|
||||||
# defaults. Beyond that the cadence stretches uniformly rather than breaking;
|
# defaults. Beyond that the cadence stretches uniformly rather than breaking;
|
||||||
# raise BATCH or lower INTERVAL. Keep BATCH x STAGGER under INTERVAL.
|
# raise BATCH or lower INTERVAL. Keep BATCH x STAGGER under INTERVAL.
|
||||||
|
|
||||||
|
# Headless-shell CDP endpoint for sites behind a JavaScript challenge (kagane).
|
||||||
|
# Unset disables browser polling; those sites then rely on the userscript alone.
|
||||||
|
# Leave commented — the compose files' own default (ws://172.28.0.10:9222) is
|
||||||
|
# correct. Do NOT set this to the "headless-shell" DNS name: Chrome's DevTools
|
||||||
|
# HTTP handler 500s any /json/version request whose Host header isn't an IP or
|
||||||
|
# "localhost", which silently breaks every kagane poll.
|
||||||
|
# BROWSER_WS_URL=ws://172.28.0.10:9222
|
||||||
|
|||||||
@@ -27,17 +27,43 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Backend** (`backend/`): stdlib `net/http` (handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
|
- **Backend** (`backend/`): stdlib `net/http` (handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
|
||||||
|
Single binary, split into packages under `backend/internal/`: `store`
|
||||||
|
(Bookmark type, SQLite persistence, migrations), `latest` (background
|
||||||
|
poller, site parsers, TLS fetcher), `session` (cookie signing, login
|
||||||
|
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
|
||||||
|
bookmark handlers), `userscript` (userscript-serving handler), `web`
|
||||||
|
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
|
||||||
|
`backend/main.go` is the composition root — the only place that wires
|
||||||
|
packages together into `newRouter`. Root-level `*_test.go` hold
|
||||||
|
integration tests that exercise the full router; unit tests for a
|
||||||
|
package live beside it under `internal/`.
|
||||||
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema + endpoint list in plan.
|
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema + endpoint list in plan.
|
||||||
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
||||||
- **Web UI:** same binary serves password-gated browser UI on second
|
- **Web UI:** same binary serves password-gated browser UI on second
|
||||||
hostname — `GET /` (list, or login page when no session),
|
hostname — `GET /` (list, or login page when no session),
|
||||||
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||||
under `/ui/*`. Templates/assets `go:embed`-ed, so `backend/Dockerfile`
|
under `/ui/*`. Templates + assets `go:embed`-ed under
|
||||||
must copy `templates/` and `static/` plus `*.go`. Sessions = stateless
|
`backend/internal/web/`, so `backend/Dockerfile` must copy the whole
|
||||||
|
`internal/` tree, not just `*.go`. Sessions = stateless
|
||||||
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, when empty
|
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, when empty
|
||||||
web routes not registered at all. UI mutations read-modify-write
|
web routes not registered at all. UI mutations read-modify-write
|
||||||
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
||||||
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
||||||
|
**Design-tool caveat:** templates link `/static/style.css` root-absolutely
|
||||||
|
(correct — served from `/`), but impeccable detector resolves
|
||||||
|
stylesheet href with `path.resolve(fileDir, href)`, drops directory
|
||||||
|
on leading `/` and silently skips file. Relative hrefs don't help
|
||||||
|
either: template's directory isn't its served path. So
|
||||||
|
`detect.mjs backend/internal/web/templates` reports **false clean** —
|
||||||
|
always pass `backend/internal/web/static` too. One finding there,
|
||||||
|
`overused-font` on "Instrument Serif", deliberate identity choice, not debt.
|
||||||
|
- **Every action that moves series out of list is confirm-gated.**
|
||||||
|
Archive, finish, remove each open own `.confirm-row` disclosure
|
||||||
|
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
||||||
|
`archive|finish|remove`); restore fires instantly since it's the reversal.
|
||||||
|
Remove's row wears ember wash, two reversible ones wear `.calm` grey.
|
||||||
|
`--ember` stays reserved for new-chapter signal: busy bar and inline
|
||||||
|
error use `--mute`.
|
||||||
- **Latest-chapter poller:** ticker goroutine in same binary re-checks
|
- **Latest-chapter poller:** ticker goroutine in same binary re-checks
|
||||||
each bookmarked series' newest published chapter from backend's own
|
each bookmarked series' newest published chapter from backend's own
|
||||||
network access, so `latest_chapter` stays fresh when user not
|
network access, so `latest_chapter` stays fresh when user not
|
||||||
@@ -96,7 +122,14 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
||||||
(critical on mobile). Three tabs (All / Favourites / Archived) + row of
|
(critical on mobile). Three tabs (All / Favourites / Archived) + row of
|
||||||
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
||||||
block next to `API_BASE`.
|
block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area
|
||||||
|
widened to `28 × 72` by invisible `#hit` child; `#fab` must keep
|
||||||
|
`touch-action: none` and must **not** regain `overflow: hidden`. Since
|
||||||
|
`touch-action` resolved at gesture start, strip can't be both
|
||||||
|
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe
|
||||||
|
from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms
|
||||||
|
reposition drag, visible sliver drags with no hold. See
|
||||||
|
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
|
||||||
6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires w/o reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires w/o reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
||||||
|
|
||||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
||||||
@@ -135,15 +168,55 @@ Smoke test: `curl` endpoints w/ `Authorization: Bearer <token>`; confirm `OPTION
|
|||||||
|
|
||||||
`tea` prints output as rendered boxes not plain text; PR URL lands on last line.
|
`tea` prints output as rendered boxes not plain text; PR URL lands on last line.
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
|
||||||
|
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
|
||||||
|
— source of truth Claude Design project `mangaBookmark Web UI`
|
||||||
|
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
|
||||||
|
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
|
||||||
|
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
|
||||||
|
state (busy, error, destruction) may use `--ember`; destruction gets
|
||||||
|
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
|
||||||
|
only (never hardcode hex outside `:root`), both colour branches touched
|
||||||
|
together. Any move that pulls series out of list (archive/finish/remove)
|
||||||
|
must be confirm-gated via its own `.confirm-row`; only restore fires
|
||||||
|
instantly.
|
||||||
|
|
||||||
## Security invariants
|
## Security invariants
|
||||||
|
|
||||||
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise.
|
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise.
|
||||||
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` w/ `204`.
|
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` w/ `204`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Comment only if code alone can't carry info. Cost per read — must earn spot.
|
||||||
|
|
||||||
|
Write for:
|
||||||
|
- Why not what. Tradeoffs, non-obvious decisions.
|
||||||
|
- Load-bearing detail looking incidental — say so if "simplify" breaks it.
|
||||||
|
- Non-local consequence, invisible from function alone.
|
||||||
|
- Wire format / encoding / interface contract — save callers re-deriving.
|
||||||
|
- Gotcha/workaround, with ref if exists.
|
||||||
|
- Domain/business rule not derivable from code.
|
||||||
|
|
||||||
|
Skip:
|
||||||
|
- Restating code (no `// increment i` above `i++`).
|
||||||
|
- Trivial getter/setter/pass-through.
|
||||||
|
- Banners, dividers, `// helpers`.
|
||||||
|
- Change narration (`// fix bug`, `// as requested`, `// new impl`) — git's job.
|
||||||
|
- Commented-out code — delete.
|
||||||
|
- TODO without concrete action.
|
||||||
|
|
||||||
|
Style: one dense comment over function beats one per line inside. Tight, no worked example unless bug subtle. Wrong comment worse than none — update/delete on change. Default fewer — sparse+high-signal beats comprehensive.
|
||||||
|
|
||||||
|
Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.
|
||||||
|
|
||||||
## Relevant skills
|
## Relevant skills
|
||||||
|
|
||||||
`multi-stage-dockerfile` and `docker-compose-orchestration` for container work (referenced in plan).
|
`multi-stage-dockerfile` and `docker-compose-orchestration` for container work (referenced in plan).
|
||||||
|
|
||||||
|
`golang-code-style`, `golang-error-handling`, `golang-performance`, `golang-testing` for backend Go work.
|
||||||
|
|
||||||
## graphify
|
## graphify
|
||||||
|
|
||||||
Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships.
|
Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships.
|
||||||
|
|||||||
@@ -1,137 +1,153 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
Guidance for Claude Code (claude.ai/code) working in this repo.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Greenfield. Only `plans/mangaBookmark.md` exists — no code yet. That plan is the spec; read it before building. Two deliverables: a Go sync backend and a single Bromite-compatible userscript.
|
Greenfield. Only `plans/mangaBookmark.md` exist — no code yet. Plan = spec; read before build. Two deliverables: Go sync backend, single Violentmonkey-compatible userscript.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A manga read-progress tracker for a user reading on **asurascans.com** (the current domain; asuracomic.net 301s here) and **demonicscans.org** from **Bromite** (mobile Chromium). A userscript injects on-page UI (floating button + slide-in panel) and syncs progress to a self-hosted Go backend so bookmarks unify across both sites and across devices.
|
Manga read-progress tracker, user read on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** via **Violentmonkey**. Userscript inject on-page UI (floating button + slide-in panel), sync progress to self-hosted Go backend so bookmarks unify across both sites and devices.
|
||||||
|
|
||||||
## Hard constraints (these drive the design — do not violate)
|
## Hard constraints (drive design — don't violate)
|
||||||
|
|
||||||
Bromite uses Chromium's **native** userscript engine, not Tampermonkey:
|
Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
|
||||||
- **No `GM_*` APIs anywhere.** No `GM_setValue`/`GM_getValue` (use page `localStorage`), no `GM_registerMenuCommand` (inject on-page UI), no `GM_xmlhttpRequest` for cross-origin (use plain `fetch()`). Keeping the script GM-free also lets it run in desktop Tampermonkey/Violentmonkey for faster iteration.
|
- **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin.
|
||||||
- Cross-origin `fetch()` works **only** against a CORS-enabled backend. Manga sites are `https://`, so backend **must be HTTPS** (mixed-content block otherwise).
|
- Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block).
|
||||||
- Asura and Demonic are **separate origins with separate `localStorage`** — a shared remote store is the only way to unify bookmarks. Cloud sync is required, not optional.
|
- Asura and Demonic are **separate origins with separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional.
|
||||||
- Userscript runs in an **isolated world**, so the embedded API token is safe from the site's JS.
|
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
||||||
- Cloudflare's block on fetching the manga sites is **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both the CGNAT dev machine *and* the deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. This contradicts an earlier, untested assumption that the CGNAT dev IP would be blocked; it was not, at least on this date. Treat "does curl work right now" as a live, time-varying fact to re-check, not a fixed property of a given machine — Cloudflare's bot scoring can flip a previously-clean IP without notice. Any backend fetcher still needs a graceful-degrade path for when it does get challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, or a direct probe) before finalizing, not assumed from a single earlier test.
|
- Cloudflare's block on manga sites **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare's bot scoring can flip previously-clean IP without notice. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, or direct probe) before finalize, not assumed from single earlier test.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
|
||||||
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
|
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Backend** (`backend/`): stdlib `net/http` (a handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). The reverse proxy terminates TLS; the Go service listens plain `:8080`.
|
- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
|
||||||
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync is **last-write-wins**. Schema and endpoint list are in the plan.
|
Single binary, split into packages under `backend/internal/`: `store`
|
||||||
|
(Bookmark type, SQLite persistence, migrations), `latest` (background
|
||||||
|
poller, site parsers, TLS fetcher), `session` (cookie signing, login
|
||||||
|
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
|
||||||
|
bookmark handlers), `userscript` (userscript-serving handler), `web`
|
||||||
|
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
|
||||||
|
`backend/main.go` is the composition root — the only place that wires
|
||||||
|
packages together into `newRouter`. Root-level `*_test.go` hold
|
||||||
|
integration tests that exercise the full router; unit tests for a
|
||||||
|
package live beside it under `internal/`.
|
||||||
|
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`|`comix`|`kagane`). Sync **last-write-wins**. Schema and endpoint list in plan.
|
||||||
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
||||||
- **Web UI:** the same binary serves a password-gated browser UI on a second
|
- **Web UI:** same binary serve password-gated browser UI on second
|
||||||
hostname — `GET /` (list, or login page when there is no session),
|
hostname — `GET /` (list, or login page when no session),
|
||||||
`POST /login`, `POST /logout`, `GET /static/*`, and htmx fragment endpoints
|
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||||
under `/ui/*`. Templates and assets are `go:embed`-ed, so `backend/Dockerfile`
|
under `/ui/*`. Templates + assets `go:embed`-ed under
|
||||||
must copy `templates/` and `static/` as well as `*.go`. Sessions are stateless
|
`backend/internal/web/`, so `backend/Dockerfile` must copy the whole
|
||||||
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them and, when empty,
|
`internal/` tree, not just `*.go`. Sessions stateless
|
||||||
the web routes are not registered at all. UI mutations read-modify-write
|
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty,
|
||||||
through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one
|
web routes not registered at all. UI mutations read-modify-write
|
||||||
|
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
||||||
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
||||||
**Design-tool caveat:** the templates link `/static/style.css` root-absolutely
|
**Design-tool caveat:** templates link `/static/style.css` root-absolutely
|
||||||
(correct — they are served from `/`), but the impeccable detector resolves a
|
(correct — served from `/`), but impeccable detector resolves
|
||||||
stylesheet href with `path.resolve(fileDir, href)`, which drops the directory
|
stylesheet href with `path.resolve(fileDir, href)`, drops directory
|
||||||
on a leading `/` and silently skips the file. A relative href does not help
|
on leading `/` and silently skip file. Relative href don't help
|
||||||
either: the template's directory is not its served path. So
|
either: template's directory isn't its served path. So
|
||||||
`detect.mjs backend/templates` reports a **false clean** — always pass
|
`detect.mjs backend/internal/web/templates` reports **false clean** —
|
||||||
`backend/static` too. Its one finding there, `overused-font` on "Instrument
|
always pass `backend/internal/web/static` too. One finding there,
|
||||||
Serif", is a deliberate identity choice, not debt.
|
`overused-font` on "Instrument Serif", deliberate identity choice, not debt.
|
||||||
- **Every action that moves a series out of the list is confirm-gated.**
|
- **Every action that moves series out of list is confirm-gated.**
|
||||||
Archive, finish, and remove each open their own `.confirm-row` disclosure
|
Archive, finish, remove each open own `.confirm-row` disclosure
|
||||||
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
||||||
`archive|finish|remove`); restore fires instantly because it is the reversal.
|
`archive|finish|remove`); restore fire instantly since it's the reversal.
|
||||||
Remove's row wears the ember wash, the two reversible ones wear `.calm` grey.
|
Remove's row wear ember wash, two reversible ones wear `.calm` grey.
|
||||||
`--ember` stays reserved for the new-chapter signal: busy bar and inline
|
`--ember` stay reserved for new-chapter signal: busy bar and inline
|
||||||
error use `--mute`.
|
error use `--mute`.
|
||||||
- **Latest-chapter poller:** a ticker goroutine in the same binary re-checks
|
- **Latest-chapter poller:** ticker goroutine in same binary re-check
|
||||||
each bookmarked series' newest published chapter from the backend's own
|
each bookmarked series' newest published chapter from backend's own
|
||||||
network access, so `latest_chapter` stays fresh when the user is not
|
network access, so `latest_chapter` stay fresh when user not
|
||||||
browsing. It is a *second, parallel* signal — the userscript keeps its own
|
browsing. Second, parallel signal — userscript keep own
|
||||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
||||||
Two independent clocks: a per-bookmark cooldown (`latest_checked_at` column,
|
Two independent clocks: per-bookmark cooldown (`latest_checked_at` column,
|
||||||
enforced by `Store.DueForLatestCheck`'s WHERE clause) and a wake interval.
|
enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval.
|
||||||
The row is stamped *before* the fetch so a broken series waits out a full
|
Row stamped *before* fetch so broken series wait out full
|
||||||
cooldown instead of retrying every tick, and writes go through
|
cooldown instead of retrying every tick, and writes go through
|
||||||
`Store.Get` + `Store.Upsert` so a new chapter never reorders the list.
|
`Store.Get` + `Store.Upsert` so new chapter never reorders list.
|
||||||
Fetches use `bogdanfinn/tls-client` with a Chrome profile as defence in depth
|
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
|
||||||
against fingerprint-based blocking; any failure logs and skips. See
|
against fingerprint-based blocking; any failure log and skip. kagane sits
|
||||||
|
behind a Cloudflare JavaScript challenge the TLS client can't clear, so it is
|
||||||
|
browser-only: fetched over CDP via `BROWSER_WS_URL`, and simply not polled
|
||||||
|
when that's unset. See
|
||||||
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
|
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
|
||||||
The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so
|
Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so
|
||||||
a userscript `PUT` that commits between the two can be overwritten by the
|
userscript `PUT` that commits between the two can get overwritten by
|
||||||
poller's stale re-read — reverting that read progress and, since the stored
|
poller's stale re-read — reverting that read progress and, since stored
|
||||||
value now differs, moving `updated_at` and reordering the list. This is a
|
value now differs, moving `updated_at` and reordering list. Known,
|
||||||
known, accepted limitation for a single-user deployment, not a bug to fix.
|
accepted limitation for single-user deployment, not bug to fix.
|
||||||
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
- **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
||||||
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
||||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||||
their own tab — not in All, Updated, Favourites, or the recent strip. The
|
own tab — not in All, Updated, Favourites, or recent strip. Poller keeps
|
||||||
poller keeps checking archived series and skips finished ones. `finished` is
|
checking archived series and skip finished ones. `finished` settable
|
||||||
settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400.
|
only from web UI; `PUT /bookmarks/{key}` reject it with 400.
|
||||||
**An empty incoming status means "keep the stored one"** — resolved on the
|
**Empty incoming status means "keep stored one"** — resolved on the
|
||||||
`VALUES` side of `Store.Upsert`, not in the conflict clause, because
|
`VALUES` side of `Store.Upsert`, not conflict clause, since
|
||||||
`excluded.*` is the post-evaluation row and a default applied there would
|
`excluded.*` is post-evaluation row and default applied there would
|
||||||
wipe the bucket on every PUT from a client that predates the column. See
|
wipe bucket on every PUT from client that predates column. See
|
||||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||||
(gates the browser UI; unset disables it),
|
(gates browser UI; unset disable it),
|
||||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||||
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||||
default `/userscript/manga-bookmark.user.js`, supplied by a bindmount).
|
default `/userscript/manga-bookmark.user.js`, supplied by bindmount).
|
||||||
|
`BROWSER_WS_URL` (headless-shell CDP endpoint for kagane; unset disables
|
||||||
|
browser polling and leaves that site to the userscript alone).
|
||||||
|
|
||||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||||
|
|
||||||
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
||||||
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
||||||
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
||||||
4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so a
|
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
|
||||||
failed mutation is parked in `localStorage` (`mangabm:queue`) and replayed on
|
failed mutation park in `localStorage` (`mangabm:queue`) and replayed on
|
||||||
the next navigation, reconnect, or `refresh()`. Entries are markers
|
next navigation, reconnect, or `refresh()`. Entries are markers
|
||||||
(`{key, op, sendStatus, attempts}`), never payloads — the body is read from
|
(`{key, op, sendStatus, attempts}`), never payloads — body read from
|
||||||
the cache at send time, so one entry per key gives ordering and coalescing for
|
cache at send time, so one entry per key give ordering and coalescing for
|
||||||
free. `sendStatus` is **sticky**: while an archive is pending, later writes to
|
free. `sendStatus` is **sticky**: while archive pending, later writes to
|
||||||
that key keep carrying the bucket, which is what stops a successful
|
that key keep carrying bucket, which stop successful
|
||||||
in-between write from silently un-archiving the series. `refresh()` drains
|
in-between write from silently un-archiving series. `refresh()` drains
|
||||||
before it fetches and overlays anything still pending, so the list never
|
before it fetches and overlays anything still pending, so list never
|
||||||
flaps. A 400 drops the entry, a 401 aborts the pass and keeps the queue, and
|
flaps. 400 drops entry, 401 abort pass and keep queue, and
|
||||||
transient failures retry to a cap of 10. Latest-chapter writes deliberately
|
transient failures retry to cap of 10. Latest-chapter writes deliberately
|
||||||
stay out of the queue. See
|
stay out of queue. See
|
||||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||||
5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
||||||
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
(critical on mobile). Three tabs (All / Favourites / Archived) and row of
|
||||||
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
||||||
block next to `API_BASE`. The FAB is a `7 × 44` edge tab whose *hit* area is
|
block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area
|
||||||
widened to `28 × 72` by an invisible `#hit` child; `#fab` must keep
|
widened to `28 × 72` by invisible `#hit` child; `#fab` must keep
|
||||||
`touch-action: none` and must **not** regain `overflow: hidden`. Because
|
`touch-action: none` and must **not** regain `overflow: hidden`. Since
|
||||||
`touch-action` is resolved at gesture start, the strip cannot be both
|
`touch-action` resolved at gesture start, strip can't be both
|
||||||
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: a
|
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe
|
||||||
swipe from `#hit` scrolls via `window.scrollBy`, a hold of `ARM_MS` arms a
|
from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms
|
||||||
reposition drag, and the visible sliver drags with no hold. See
|
reposition drag, visible sliver drags with no hold. See
|
||||||
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
|
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
|
||||||
6. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fire without reload. Demonic uses classic reloads (initial `document-idle` run suffice).
|
||||||
|
|
||||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust)
|
||||||
|
|
||||||
- **asurascans.com**: series `/comics/<slug>` (slug carries a trailing
|
- **asurascans.com**: series `/comics/<slug>` (slug carries trailing
|
||||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
||||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
||||||
the hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in the userscript,
|
hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript,
|
||||||
`asuraBuildHash` in the backend); URLs keep the full slug — stale-hash
|
`asuraBuildHash` in backend); URLs keep full slug — stale-hash
|
||||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
||||||
server HTML.
|
server HTML.
|
||||||
- **demonicscans.org**: series `/manga/<slug>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` form still exists as redirect, what series-page chapter-list anchors link through).
|
- **demonicscans.org**: series `/manga/<slug>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` form still exists as redirect, what series-page chapter-list anchors link through).
|
||||||
Encodings (incl. triple-encoded punctuation like `%25252D`) are identical
|
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||||
2026-07-28.
|
2026-07-28.
|
||||||
|
|
||||||
@@ -144,34 +160,74 @@ Backend (`cd backend`):
|
|||||||
|
|
||||||
Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`).
|
Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`).
|
||||||
|
|
||||||
Smoke test: `curl` the endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200.
|
Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
|
||||||
|
|
||||||
## Forge: Gitea, not GitHub
|
## Forge: Gitea, not GitHub
|
||||||
|
|
||||||
`origin` is a self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` does not work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones:
|
`origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` don't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones:
|
||||||
|
|
||||||
- Open a PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
|
- Open PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
|
||||||
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
|
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
|
||||||
- Issues: `tea issue create`, `tea issue list`
|
- Issues: `tea issue create`, `tea issue list`
|
||||||
- Auth lives in `tea login`, not a `GH_TOKEN` env var.
|
- Auth lives in `tea login`, not `GH_TOKEN` env var.
|
||||||
|
|
||||||
`tea` prints its output as rendered boxes rather than plain text; the PR URL lands on the last line.
|
`tea` print output as rendered boxes rather than plain text; PR URL lands on last line.
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
|
||||||
|
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
|
||||||
|
— source of truth Claude Design project `mangaBookmark Web UI`
|
||||||
|
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
|
||||||
|
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
|
||||||
|
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
|
||||||
|
state (busy, error, destruction) may use `--ember`; destruction gets
|
||||||
|
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
|
||||||
|
only (never hardcode hex outside `:root`), both colour branches touched
|
||||||
|
together. Any move that pulls series out of list (archive/finish/remove)
|
||||||
|
must be confirm-gated via its own `.confirm-row`; only restore fires
|
||||||
|
instantly.
|
||||||
|
|
||||||
## Security invariants
|
## Security invariants
|
||||||
|
|
||||||
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise.
|
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise.
|
||||||
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`.
|
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Comment only if code alone can't carry info. Cost per read — must earn spot.
|
||||||
|
|
||||||
|
Write for:
|
||||||
|
- Why not what. Tradeoffs, non-obvious decisions.
|
||||||
|
- Load-bearing detail looking incidental — say so if "simplify" breaks it.
|
||||||
|
- Non-local consequence, invisible from function alone.
|
||||||
|
- Wire format / encoding / interface contract — save callers re-deriving.
|
||||||
|
- Gotcha/workaround, with ref if exists.
|
||||||
|
- Domain/business rule not derivable from code.
|
||||||
|
|
||||||
|
Skip:
|
||||||
|
- Restating code (no `// increment i` above `i++`).
|
||||||
|
- Trivial getter/setter/pass-through.
|
||||||
|
- Banners, dividers, `// helpers`.
|
||||||
|
- Change narration (`// fix bug`, `// as requested`, `// new impl`) — git's job.
|
||||||
|
- Commented-out code — delete.
|
||||||
|
- TODO without concrete action.
|
||||||
|
|
||||||
|
Style: one dense comment over function beats one per line inside. Tight, no worked example unless bug subtle. Wrong comment worse than none — update/delete on change. Default fewer — sparse+high-signal beats comprehensive.
|
||||||
|
|
||||||
|
Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.
|
||||||
|
|
||||||
## Relevant skills
|
## Relevant skills
|
||||||
|
|
||||||
`multi-stage-dockerfile` and `docker-compose-orchestration` for the container work (referenced in the plan).
|
`multi-stage-dockerfile` and `docker-compose-orchestration` for container work (referenced in plan).
|
||||||
|
|
||||||
|
`golang-code-style`, `golang-error-handling`, `golang-performance`, `golang-testing` for backend Go work.
|
||||||
|
|
||||||
## graphify
|
## graphify
|
||||||
|
|
||||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
- If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing.
|
||||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context.
|
||||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
- After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost).
|
||||||
@@ -36,7 +36,7 @@ Edit `.env`:
|
|||||||
API_TOKEN=<paste output of: openssl rand -hex 32>
|
API_TOKEN=<paste output of: openssl rand -hex 32>
|
||||||
|
|
||||||
# CORS allowlist — leave as-is unless a site changes hostname.
|
# CORS allowlist — leave as-is unless a site changes hostname.
|
||||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
|
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to
|
||||||
|
|
||||||
# Required for the Traefik override. Both have no fallback — compose refuses
|
# Required for the Traefik override. Both have no fallback — compose refuses
|
||||||
# to start without them. MANGA_WEB_HOST is required even if you never set
|
# to start without them. MANGA_WEB_HOST is required even if you never set
|
||||||
@@ -116,6 +116,12 @@ This merges the base file (build/image/env/volume) with the prod override
|
|||||||
(no host port, Traefik network + router labels). Always pass **both** `-f`
|
(no host port, Traefik network + router labels). Always pass **both** `-f`
|
||||||
flags — the prod file is not standalone.
|
flags — the prod file is not standalone.
|
||||||
|
|
||||||
|
Two services come up: `manga-api` (the backend) and `headless-shell`, a CDP
|
||||||
|
sidecar the poller uses to fetch kagane (behind a Cloudflare JS challenge).
|
||||||
|
It has no published port — only `manga-api` can reach it, over
|
||||||
|
`BROWSER_WS_URL`. Missing or unreachable, the poller just skips kagane and
|
||||||
|
logs it; nothing else is affected.
|
||||||
|
|
||||||
Check it's up and healthy:
|
Check it's up and healthy:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Manga Bookmark
|
# Manga Bookmark
|
||||||
|
|
||||||
Track manga read-progress on **asurascans.com** (a.k.a. asuracomic.net) and
|
Track manga read-progress on **asurascans.com** (a.k.a. asuracomic.net),
|
||||||
**demonicscans.org** from a phone (Bromite / mobile Chromium), synced to a
|
**demonicscans.org**, **comix.to**, and **kagane.to** from a phone (Bromite /
|
||||||
self-hosted Go backend so bookmarks unify across both sites and all devices.
|
mobile Chromium), synced to a self-hosted Go backend so bookmarks unify across
|
||||||
|
all four sites and all devices.
|
||||||
|
|
||||||
Two parts:
|
Two parts:
|
||||||
|
|
||||||
@@ -25,9 +26,10 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
|||||||
| Var | Default | Notes |
|
| Var | Default | Notes |
|
||||||
|-----|---------|-------|
|
|-----|---------|-------|
|
||||||
| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. |
|
| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. |
|
||||||
| `ALLOWED_ORIGINS` | Asura + Demonic origins | Comma-separated CORS allowlist. |
|
| `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. |
|
||||||
| `DB_PATH` | `/data/bookmarks.db` | SQLite file location. |
|
| `DB_PATH` | `/data/bookmarks.db` | SQLite file location. |
|
||||||
| `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. |
|
| `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. |
|
||||||
|
| `BROWSER_WS_URL` | `ws://172.28.0.10:9222` | Headless-shell CDP endpoint used to poll Kagane past its JS challenge. Must be an IP or `localhost` — Chrome's DevTools handler 500s any other Host header. |
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
@@ -39,8 +41,9 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
|||||||
| `GET` | `/healthz` | none | `200 ok`. |
|
| `GET` | `/healthz` | none | `200 ok`. |
|
||||||
| `GET` | `/u/{token}/manga-bookmark.user.js` | token in path | Serves the userscript with an mtime-derived `@version`. |
|
| `GET` | `/u/{token}/manga-bookmark.user.js` | token in path | Serves the userscript with an mtime-derived `@version`. |
|
||||||
|
|
||||||
`key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`
|
`key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`,
|
||||||
or `demonic:Infinite-Level-Up-in-Murim`. Sync is last-write-wins.
|
`demonic:Infinite-Level-Up-in-Murim`, `comix:12345`, or
|
||||||
|
`kagane:3fa85f64-5717-4562-b3fc-2c963f66afa6`. Sync is last-write-wins.
|
||||||
|
|
||||||
`updated_at` orders the bookmark list, so it moves only on real reading
|
`updated_at` orders the bookmark list, so it moves only on real reading
|
||||||
progress: the server applies its timestamp when the row is new or
|
progress: the server applies its timestamp when the row is new or
|
||||||
@@ -199,6 +202,8 @@ The site adapters key everything off URL regex, with `title`/`cover` from
|
|||||||
|------|-----------|-------------|-------------|
|
|------|-----------|-------------|-------------|
|
||||||
| **Asura** (`asurascans.com`) | `/comics/<slug-hash>` | `/comics/<slug-hash>/chapter/<n>` | `<slug-hash>` |
|
| **Asura** (`asurascans.com`) | `/comics/<slug-hash>` | `/comics/<slug-hash>/chapter/<n>` | `<slug-hash>` |
|
||||||
| **Demonic** (`demonicscans.org`) | `/manga/<slug>` | `/title/<slug>/chapter/<n>/<page>` (`chaptered.php?manga=<id>&chapter=<n>` 301s here) | `<slug>` |
|
| **Demonic** (`demonicscans.org`) | `/manga/<slug>` | `/title/<slug>/chapter/<n>/<page>` (`chaptered.php?manga=<id>&chapter=<n>` 301s here) | `<slug>` |
|
||||||
|
| **Comix** (`comix.to`) | `/title/<id>-<slug>` | `/title/<id>-<slug>/<uploadId>-chapter-<n>` | `<id>` |
|
||||||
|
| **Kagane** (`kagane.to`) | `/series/<uuid>` | `/series/<uuid>/reader/<bookUuid>` | `<uuid>` |
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- **`asuracomic.net` deep links are dead (re-checked 2026-07-25).** They 301 to
|
- **`asuracomic.net` deep links are dead (re-checked 2026-07-25).** They 301 to
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
# Only go source + module files, plus the go:embed'd templates/static
|
# Only go source + module files, plus internal/ (which carries the
|
||||||
# directories, are needed in the build context.
|
# go:embed'd templates/static directories), are needed in the build context.
|
||||||
*
|
*
|
||||||
!go.mod
|
!go.mod
|
||||||
!go.sum
|
!go.sum
|
||||||
!*.go
|
!*.go
|
||||||
!templates/
|
!internal/
|
||||||
!templates/**
|
!internal/**
|
||||||
!static/
|
|
||||||
!static/**
|
|
||||||
|
|||||||
+5
-6
@@ -1,19 +1,18 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# --- build stage: compile a static, CGO-free binary ---
|
# --- build stage: compile a static, CGO-free binary ---
|
||||||
FROM golang:1.24-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Dependencies first for layer caching (changes rarely).
|
# Dependencies first for layer caching (changes rarely).
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Then source (changes often).
|
# Then source (changes often). internal/web carries the go:embed'd
|
||||||
# Source plus the go:embed'd assets. Missing either directory turns the embed
|
# templates/static assets — missing them turns the embed directive into a
|
||||||
# directive into a build error, so both must be copied before `go build`.
|
# build error, so the whole tree must land before `go build`.
|
||||||
COPY *.go ./
|
COPY *.go ./
|
||||||
COPY templates/ ./templates/
|
COPY internal/ ./internal/
|
||||||
COPY static/ ./static/
|
|
||||||
|
|
||||||
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
|
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
|
||||||
# -trimpath + -ldflags strip paths and debug info for a smaller image.
|
# -trimpath + -ldflags strip paths and debug info for a smaller image.
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "s3cret-token"
|
||||||
|
|
||||||
|
func testConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Token: testToken,
|
||||||
|
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
||||||
|
Port: "8080",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
s, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return newRouter(s, testConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func auth(req *http.Request) *http.Request {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(f float64) *float64 { return &f }
|
||||||
|
|
||||||
|
// seedForCheck inserts a bookmark and forces its latest_checked_at.
|
||||||
|
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
|
Key: key,
|
||||||
|
Site: "asura",
|
||||||
|
SeriesID: key,
|
||||||
|
SeriesURL: seriesURL,
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", key, err)
|
||||||
|
}
|
||||||
|
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
||||||
|
t.Fatalf("seed mark %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ts, err := s.LatestCheckedAt(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestCheckedAt %q: %v", key, err)
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthzNoAuth(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("healthz status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if rr.Body.String() != "ok" {
|
||||||
|
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequired(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
}{
|
||||||
|
{"no header", ""},
|
||||||
|
{"bad token", "Bearer wrong"},
|
||||||
|
{"not bearer", "Basic " + testToken},
|
||||||
|
{"empty bearer", "Bearer "},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Authorization", tc.header)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthAccepted(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "[]\n" {
|
||||||
|
t.Fatalf("empty list body = %q, want []", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSPreflight(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
|
||||||
|
req.Header.Set("Origin", "https://asuracomic.net")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "PUT")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("preflight status = %d, want 204", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
|
||||||
|
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
||||||
|
t.Fatal("Allow-Methods missing")
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
||||||
|
t.Fatal("Allow-Headers missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSDisallowedOrigin(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBookmarkRoundTrip(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := "asura:solo-leveling-123"
|
||||||
|
in := store.Bookmark{
|
||||||
|
Title: "Solo Leveling",
|
||||||
|
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
||||||
|
Cover: "https://asuracomic.net/cover.jpg",
|
||||||
|
LastChapter: "Chapter 10",
|
||||||
|
LastChapterNum: 10,
|
||||||
|
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
// PUT
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
var stored store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
|
||||||
|
t.Fatalf("decode PUT response: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
|
||||||
|
t.Fatalf("derived fields wrong: %+v", stored)
|
||||||
|
}
|
||||||
|
if stored.UpdatedAt == 0 {
|
||||||
|
t.Fatal("server did not set updated_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
var list []store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatalf("decode list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
|
||||||
|
t.Fatalf("GET list wrong: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT again (upsert, progress advance)
|
||||||
|
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
|
||||||
|
body, _ = json.Marshal(in)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second PUT status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &list)
|
||||||
|
if len(list) != 1 || list[0].LastChapterNum != 11 {
|
||||||
|
t.Fatalf("upsert did not update in place: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("DELETE status = %d, want 204", rr.Code)
|
||||||
|
}
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &list)
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("after delete list = %+v, want empty", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
|
||||||
|
// which is the row as actually stored (not the request payload).
|
||||||
|
func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(b)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var out store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode PUT response: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark {
|
||||||
|
t.Helper()
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
var list []store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatalf("decode list: %v", err)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// updated_at drives list ordering, so it must move only on a real progress
|
||||||
|
// advance — never on a favorite toggle or a latest-chapter capture.
|
||||||
|
func TestUpsertConditionalUpdatedAt(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(store.Bookmark) store.Bookmark
|
||||||
|
wantBumped bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unchanged progress",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark { return b },
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "changed progress",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "favorite only",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.Favorite = true
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "latest chapter only",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated metadata only",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := fmt.Sprintf("asura:cond-%d", i)
|
||||||
|
|
||||||
|
first := putBookmark(t, srv, key, store.Bookmark{
|
||||||
|
Title: "Test",
|
||||||
|
LastChapter: "Chapter 10",
|
||||||
|
LastChapterNum: 10,
|
||||||
|
})
|
||||||
|
if first.UpdatedAt == 0 {
|
||||||
|
t.Fatal("new bookmark did not get updated_at set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee a later wall-clock ms so a real bump is observable.
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
|
||||||
|
second := putBookmark(t, srv, key, tc.mutate(first))
|
||||||
|
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
|
||||||
|
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
|
||||||
|
}
|
||||||
|
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
|
||||||
|
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PUT response must match what a subsequent GET reports.
|
||||||
|
list := getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("list = %+v, want 1 item", list)
|
||||||
|
}
|
||||||
|
if list[0].UpdatedAt != second.UpdatedAt {
|
||||||
|
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFavoriteRoundTrip(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := "demonic:some-series"
|
||||||
|
|
||||||
|
stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true})
|
||||||
|
if !stored.Favorite {
|
||||||
|
t.Fatalf("PUT response favorite = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || !list[0].Favorite {
|
||||||
|
t.Fatalf("favorite did not round-trip: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
|
||||||
|
stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false})
|
||||||
|
if stored.Favorite {
|
||||||
|
t.Fatal("PUT response favorite = true after unfavorite")
|
||||||
|
}
|
||||||
|
list = getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || list[0].Favorite {
|
||||||
|
t.Fatalf("unfavorite did not round-trip: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestChapterNullable(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := "asura:latest-test"
|
||||||
|
|
||||||
|
// Never captured: latest_chapter_num must serialize as JSON null.
|
||||||
|
body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
|
||||||
|
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || list[0].LatestChapterNum != nil {
|
||||||
|
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once captured it round-trips as a value.
|
||||||
|
stored := putBookmark(t, srv, key, store.Bookmark{
|
||||||
|
Title: "No latest yet",
|
||||||
|
LatestChapter: "Chapter 162",
|
||||||
|
LatestChapterNum: floatPtr(162),
|
||||||
|
})
|
||||||
|
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
|
||||||
|
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
|
||||||
|
}
|
||||||
|
list = getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
|
||||||
|
t.Fatalf("latest chapter did not round-trip: %+v", list)
|
||||||
|
}
|
||||||
|
if list[0].LatestChapter != "Chapter 162" {
|
||||||
|
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigWebPassword(t *testing.T) {
|
||||||
|
t.Setenv("API_TOKEN", "token-abc")
|
||||||
|
t.Setenv("WEB_PASSWORD", "hunter2")
|
||||||
|
if got := loadConfig().WebPassword; got != "hunter2" {
|
||||||
|
t.Fatalf("WebPassword = %q, want hunter2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("WEB_PASSWORD", "")
|
||||||
|
if got := loadConfig().WebPassword; got != "" {
|
||||||
|
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A userscript PUT body has no latest_checked_at field. If the column is ever
|
||||||
|
// moved into bookmarkColumns, this test catches it: the PUT would reset the
|
||||||
|
// cooldown and the poller would re-fetch that series on every single tick.
|
||||||
|
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
s, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
srv := newRouter(s, testConfig())
|
||||||
|
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
|
||||||
|
|
||||||
|
// Exactly what the userscript sends: no latest_checked_at key at all.
|
||||||
|
body := `{"key":"asura:x","site":"asura","series_id":"x",
|
||||||
|
"series_url":"https://asurascans.com/comics/x",
|
||||||
|
"last_chapter":"Chapter 5","last_chapter_num":5}`
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 {
|
||||||
|
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The userscript route is registered outside the `if cfg.WebPassword != ""`
|
||||||
|
// block in newRouter, so it must keep working on a deployment that never set
|
||||||
|
// WEB_PASSWORD — see internal/userscript for the handler's own behaviour.
|
||||||
|
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
||||||
|
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "nopass.db")
|
||||||
|
s, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
cfg := testConfig() // WebPassword empty
|
||||||
|
cfg.UserscriptPath = path
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil)
|
||||||
|
newRouter(s, cfg).ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -1,10 +1,12 @@
|
|||||||
module mangabm/backend
|
module mangabm/backend
|
||||||
|
|
||||||
go 1.24.1
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bogdanfinn/fhttp v0.6.8
|
github.com/bogdanfinn/fhttp v0.6.8
|
||||||
github.com/bogdanfinn/tls-client v1.15.1
|
github.com/bogdanfinn/tls-client v1.15.1
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f
|
||||||
|
github.com/chromedp/chromedp v0.16.0
|
||||||
modernc.org/sqlite v1.34.4
|
modernc.org/sqlite v1.34.4
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,7 +17,12 @@ require (
|
|||||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
||||||
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
|
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
|
||||||
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
|
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
|
||||||
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
@@ -26,7 +33,7 @@ require (
|
|||||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||||
golang.org/x/crypto v0.46.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
modernc.org/libc v1.55.3 // indirect
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
|||||||
+20
-2
@@ -14,10 +14,24 @@ github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5Zl
|
|||||||
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
|
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
|
||||||
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
|
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
|
||||||
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
|
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||||
|
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||||
|
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||||
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -26,10 +40,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
@@ -56,8 +74,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -6,10 +6,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type bookmarkHandler struct {
|
// Handler serves the userscript-facing JSON bookmark API.
|
||||||
store *Store
|
type Handler struct {
|
||||||
|
Store *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -22,9 +25,9 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// list returns all bookmarks. GET /bookmarks
|
// List returns all bookmarks. GET /bookmarks
|
||||||
func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := h.store.List()
|
items, err := h.Store.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("list: %v", err)
|
log.Printf("list: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -33,15 +36,15 @@ func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, items)
|
writeJSON(w, http.StatusOK, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// put upserts one bookmark. PUT /bookmarks/{key}
|
// Put upserts one bookmark. PUT /bookmarks/{key}
|
||||||
func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var b Bookmark
|
var b store.Bookmark
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
|
||||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -65,9 +68,9 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
||||||
// rather than trusting every client to leave it alone.
|
// rather than trusting every client to leave it alone.
|
||||||
switch b.Status {
|
switch b.Status {
|
||||||
case "", statusReading, statusArchived:
|
case "", store.StatusReading, store.StatusArchived:
|
||||||
case statusFinished:
|
case store.StatusFinished:
|
||||||
http.Error(w, "status "+statusFinished+" can only be set from the web UI",
|
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
@@ -79,7 +82,7 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
// reading progress actually moved. Any client value is ignored.
|
// reading progress actually moved. Any client value is ignored.
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
stored, err := h.store.Upsert(b)
|
stored, err := h.Store.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("upsert: %v", err)
|
log.Printf("upsert: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -90,14 +93,14 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, stored)
|
writeJSON(w, http.StatusOK, stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete removes one bookmark. DELETE /bookmarks/{key}
|
// Delete removes one bookmark. DELETE /bookmarks/{key}
|
||||||
func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.store.Delete(key); err != nil {
|
if err := h.Store.Delete(key); err != nil {
|
||||||
log.Printf("delete: %v", err)
|
log.Printf("delete: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -105,7 +108,8 @@ func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
// Healthz answers the unauthenticated liveness check. GET /healthz
|
||||||
|
func Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package httpmw
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
const bearerPrefix = "Bearer "
|
const bearerPrefix = "Bearer "
|
||||||
|
|
||||||
// withAuth guards a handler with a constant-time bearer-token check.
|
// Auth guards a handler with a constant-time bearer-token check.
|
||||||
func withAuth(token string, next http.Handler) http.Handler {
|
func Auth(token string, next http.Handler) http.Handler {
|
||||||
want := []byte(token)
|
want := []byte(token)
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := r.Header.Get("Authorization")
|
h := r.Header.Get("Authorization")
|
||||||
@@ -71,11 +71,11 @@ func (w *gzipWriter) Write(b []byte) (int, error) {
|
|||||||
return w.ResponseWriter.Write(b)
|
return w.ResponseWriter.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withGzip compresses text responses for clients that ask. The templates,
|
// Gzip compresses text responses for clients that ask. The templates,
|
||||||
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
||||||
// of that gzipped, which is the difference between a fast and a slow first load
|
// of that gzipped, which is the difference between a fast and a slow first load
|
||||||
// on mobile data.
|
// on mobile data.
|
||||||
func withGzip(next http.Handler) http.Handler {
|
func Gzip(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -92,11 +92,11 @@ func withGzip(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// withCORS reflects the request Origin only when it is in allowed, answers
|
// CORS reflects the request Origin only when it is in allowed, answers
|
||||||
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
||||||
// auth middleware so preflight (which carries no Authorization header) is never
|
// auth middleware so preflight (which carries no Authorization header) is never
|
||||||
// rejected by auth.
|
// rejected by auth.
|
||||||
func withCORS(allowed []string, next http.Handler) http.Handler {
|
func CORS(allowed []string, next http.Handler) http.Handler {
|
||||||
set := make(map[string]struct{}, len(allowed))
|
set := make(map[string]struct{}, len(allowed))
|
||||||
for _, o := range allowed {
|
for _, o := range allowed {
|
||||||
set[o] = struct{}{}
|
set[o] = struct{}{}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package latest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// challengeTimeout bounds one navigate-and-solve. A Cloudflare managed
|
||||||
|
// challenge clears in a few seconds when it clears at all; anything longer is a
|
||||||
|
// challenge that is not going to pass, and the caller's cooldown was already
|
||||||
|
// stamped before this ran.
|
||||||
|
const challengeTimeout = 45 * time.Second
|
||||||
|
|
||||||
|
var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
|
||||||
|
|
||||||
|
// BrowserFetcher retrieves pages through a remote headless Chrome over the
|
||||||
|
// DevTools Protocol.
|
||||||
|
//
|
||||||
|
// It exists for one reason: kagane.to sits behind a Cloudflare JavaScript
|
||||||
|
// challenge. Verified 2026-08-03 from the deployment host, plain HTTP and
|
||||||
|
// bogdanfinn/tls-client with a Chrome_133 profile both get 403 with
|
||||||
|
// cf-mitigated: challenge on every path, including the API, robots.txt and
|
||||||
|
// images. Clearing it requires executing the challenge script, which only a
|
||||||
|
// real browser does.
|
||||||
|
//
|
||||||
|
// The request is made *inside* the page rather than by extracting cf_clearance
|
||||||
|
// and replaying it through TLSFetcher. That cookie is bound to IP, User-Agent
|
||||||
|
// and often the TLS fingerprint, so replaying it means keeping three things in
|
||||||
|
// sync that break silently and separately. The browser's own cookie jar
|
||||||
|
// persists across polls, so the challenge is solved once every few hours.
|
||||||
|
type BrowserFetcher struct {
|
||||||
|
allocCtx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
// One page at a time: caps the sidecar's memory and keeps series from
|
||||||
|
// sharing page state.
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Fetcher = (*BrowserFetcher)(nil)
|
||||||
|
|
||||||
|
// NewBrowserFetcher connects to a headless-shell over CDP. wsURL must name the
|
||||||
|
// sidecar by IP, e.g. ws://172.28.0.10:9222 — not by Docker DNS name. Chrome's
|
||||||
|
// DevTools HTTP handler 500s any /json/version request whose Host header
|
||||||
|
// isn't an IP or "localhost" (confirmed 2026-08-03 against
|
||||||
|
// chromedp/headless-shell:stable), so the compose network pins the sidecar's
|
||||||
|
// address for this to resolve at all.
|
||||||
|
//
|
||||||
|
// Do not add chromedp.NoModifyURL here: that option skips the /json/version
|
||||||
|
// discovery request entirely and dials wsURL as if it were already the full
|
||||||
|
// debugger endpoint, but Chrome only accepts connections at
|
||||||
|
// /devtools/browser/<uuid>, a path chosen fresh at every Chrome start — dialing
|
||||||
|
// the bare host:port 404s. The default (discovery) path works precisely
|
||||||
|
// because Chrome's /json/version response echoes back the Host header of the
|
||||||
|
// discovery request in webSocketDebuggerUrl, so as long as wsURL is a
|
||||||
|
// container-reachable IP, the URL chromedp gets back already points at it.
|
||||||
|
func NewBrowserFetcher(wsURL string) (*BrowserFetcher, error) {
|
||||||
|
if wsURL == "" {
|
||||||
|
return nil, fmt.Errorf("empty browser websocket url")
|
||||||
|
}
|
||||||
|
ctx, cancel := chromedp.NewRemoteAllocator(context.Background(), wsURL)
|
||||||
|
return &BrowserFetcher{allocCtx: ctx, cancel: cancel}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *BrowserFetcher) Close() {
|
||||||
|
f.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get navigates to seriesURL, lets any challenge resolve, then reads the site's
|
||||||
|
// JSON API from inside the page so the request carries the clearance cookie.
|
||||||
|
// The returned body is API JSON, which is what latestChapterFrom's kagane case
|
||||||
|
// expects — it is not HTML.
|
||||||
|
func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int, error) {
|
||||||
|
apiURL, ok := kaganeAPIURL(seriesURL)
|
||||||
|
if !ok {
|
||||||
|
return "", 0, fmt.Errorf("not a fetchable kagane series url: %q", seriesURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, challengeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
// A fresh tab per fetch, closed on return, so one wedged page cannot
|
||||||
|
// poison later polls.
|
||||||
|
tabCtx, cancelTab := chromedp.NewContext(f.allocCtx)
|
||||||
|
defer cancelTab()
|
||||||
|
// Bind the caller's deadline to the tab.
|
||||||
|
tabCtx, cancelDeadline := context.WithCancel(tabCtx)
|
||||||
|
defer cancelDeadline()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
cancelDeadline()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := chromedp.Run(tabCtx,
|
||||||
|
chromedp.Navigate(seriesURL),
|
||||||
|
// The challenge reloads the page itself when it passes; waiting for the
|
||||||
|
// site's own root element is what tells us we are through it.
|
||||||
|
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(
|
||||||
|
`fetch(`+jsString(apiURL)+`).then(r => r.ok ? r.text() : "")`,
|
||||||
|
&body,
|
||||||
|
awaitPromise,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("browser fetch %q: %w", seriesURL, err)
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
// Challenge still up, or the API refused. Indistinguishable from here
|
||||||
|
// and handled identically by the caller.
|
||||||
|
return "", 403, nil
|
||||||
|
}
|
||||||
|
return body, 200, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kaganeAPIURL maps a stored series_url to the JSON endpoint carrying its
|
||||||
|
// chapter list. Returning false for anything else is a second line of defence
|
||||||
|
// behind fetchableSeriesURL: a headless browser is a strong SSRF primitive and
|
||||||
|
// series_url is client-supplied, so the host is pinned here too.
|
||||||
|
func kaganeAPIURL(seriesURL string) (string, bool) {
|
||||||
|
u, err := url.Parse(seriesURL)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Hostname() != "kagane.to" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
m := kaganeSeriesRe.FindStringSubmatch(u.Path)
|
||||||
|
if m == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return "https://kagane.to/api/v2/series/" + m[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitPromise makes Evaluate resolve the promise rather than returning a
|
||||||
|
// serialised Promise object.
|
||||||
|
func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
|
||||||
|
return p.WithAwaitPromise(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsString renders s as a JavaScript string literal for embedding in an
|
||||||
|
// Evaluate expression. The URL is host-pinned by kaganeAPIURL before it gets
|
||||||
|
// here, but quoting it properly is what keeps that guarantee intact.
|
||||||
|
func jsString(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package latest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestKaganeAPIURL(t *testing.T) {
|
||||||
|
const uuid = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
seriesURL string
|
||||||
|
want string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "series page maps to its API endpoint",
|
||||||
|
seriesURL: "https://kagane.to/series/" + uuid,
|
||||||
|
want: "https://kagane.to/api/v2/series/" + uuid,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing slash is tolerated",
|
||||||
|
seriesURL: "https://kagane.to/series/" + uuid + "/",
|
||||||
|
want: "https://kagane.to/api/v2/series/" + uuid,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{"not a series path", "https://kagane.to/search", "", false},
|
||||||
|
{"foreign host", "https://evil.example/series/" + uuid, "", false},
|
||||||
|
{"garbage", "://", "", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := kaganeAPIURL(tt.seriesURL)
|
||||||
|
if ok != tt.wantOK || got != tt.want {
|
||||||
|
t.Errorf("kaganeAPIURL(%q) = %q, %v; want %q, %v",
|
||||||
|
tt.seriesURL, got, ok, tt.want, tt.wantOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -20,20 +20,20 @@ const maxBodyBytes = 4 << 20
|
|||||||
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " +
|
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " +
|
||||||
"(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"
|
"(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"
|
||||||
|
|
||||||
// tlsFetcher fetches series pages with a Chrome TLS fingerprint.
|
// TLSFetcher fetches series pages with a Chrome TLS fingerprint.
|
||||||
//
|
//
|
||||||
// Plain net/http was verified working against both sites on 2026-07-26, so this
|
// Plain net/http was verified working against both sites on 2026-07-26, so this
|
||||||
// is not fixing an observed block — it is deliberate defence-in-depth against a
|
// is not fixing an observed block — it is deliberate defence-in-depth against a
|
||||||
// future fingerprint-based one, chosen up front rather than reacted to later.
|
// future fingerprint-based one, chosen up front rather than reacted to later.
|
||||||
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the
|
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the
|
||||||
// distroless image are all unaffected.
|
// distroless image are all unaffected.
|
||||||
type tlsFetcher struct {
|
type TLSFetcher struct {
|
||||||
client tls_client.HttpClient
|
client tls_client.HttpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ fetcher = (*tlsFetcher)(nil)
|
var _ Fetcher = (*TLSFetcher)(nil)
|
||||||
|
|
||||||
func newTLSFetcher() (*tlsFetcher, error) {
|
func NewTLSFetcher() (*TLSFetcher, error) {
|
||||||
c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
|
c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
|
||||||
tls_client.WithTimeoutSeconds(30),
|
tls_client.WithTimeoutSeconds(30),
|
||||||
tls_client.WithClientProfile(profiles.Chrome_133),
|
tls_client.WithClientProfile(profiles.Chrome_133),
|
||||||
@@ -41,13 +41,13 @@ func newTLSFetcher() (*tlsFetcher, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new tls client: %w", err)
|
return nil, fmt.Errorf("new tls client: %w", err)
|
||||||
}
|
}
|
||||||
return &tlsFetcher{client: c}, nil
|
return &TLSFetcher{client: c}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fetches url and returns the body and status. Redirects are followed: the
|
// Get fetches url and returns the body and status. Redirects are followed: the
|
||||||
// demonic chapter anchors are a redirect form, and asura has moved domains
|
// demonic chapter anchors are a redirect form, and asura has moved domains
|
||||||
// before.
|
// before.
|
||||||
func (f *tlsFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
func (f *TLSFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
||||||
req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil)
|
req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("build request %q: %w", url, err)
|
return "", 0, fmt.Errorf("build request %q: %w", url, err)
|
||||||
@@ -1,40 +1,58 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fetcher retrieves a series page. It exists as an interface so tests can inject
|
// Fetcher retrieves a series page. It exists as an interface so tests can inject
|
||||||
// a fake: nothing in the test suite may touch the network or the TLS client.
|
// a fake: nothing in the test suite may touch the network or the TLS client.
|
||||||
type fetcher interface {
|
type Fetcher interface {
|
||||||
Get(ctx context.Context, url string) (body string, status int, err error)
|
Get(ctx context.Context, url string) (body string, status int, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestPoller re-checks each bookmarked series' newest published chapter on a
|
// Poller re-checks each bookmarked series' newest published chapter on a
|
||||||
// schedule, independent of the userscript's own in-browser checks. The two run
|
// schedule, independent of the userscript's own in-browser checks. The two run
|
||||||
// in parallel and report the same observable fact, so whichever writes last wins
|
// in parallel and report the same observable fact, so whichever writes last wins
|
||||||
// and neither needs to know about the other.
|
// and neither needs to know about the other.
|
||||||
//
|
//
|
||||||
// Two clocks, deliberately independent:
|
// Two clocks, deliberately independent:
|
||||||
//
|
//
|
||||||
// - interval is how often this goroutine wakes up and looks.
|
// - Interval is how often this goroutine wakes up and looks.
|
||||||
// - cooldown is how long one bookmark rests since its own last check.
|
// - Cooldown is how long one bookmark rests since its own last check.
|
||||||
//
|
//
|
||||||
// Only the cooldown is per bookmark, and it is enforced by the WHERE clause in
|
// Only the cooldown is per bookmark, and it is enforced by the WHERE clause in
|
||||||
// DueForLatestCheck rather than by any timer. Shortening interval therefore
|
// DueForLatestCheck rather than by any timer. Shortening Interval therefore
|
||||||
// cannot shorten anyone's cooldown; it only makes the poller wake up and find
|
// cannot shorten anyone's cooldown; it only makes the poller wake up and find
|
||||||
// nothing due more often.
|
// nothing due more often.
|
||||||
type latestPoller struct {
|
type Poller struct {
|
||||||
store *Store
|
Store *store.Store
|
||||||
fetch fetcher
|
Fetch Fetcher
|
||||||
now func() time.Time // injected so tests can freeze it
|
// BrowserFetch handles sites behind a JavaScript challenge that Fetch
|
||||||
cooldown time.Duration
|
// cannot clear. Nil disables those sites entirely rather than falling back
|
||||||
interval time.Duration
|
// to Fetch, which would only ever retrieve a challenge page.
|
||||||
stagger time.Duration
|
BrowserFetch Fetcher
|
||||||
batch int
|
Now func() time.Time // injected so tests can freeze it
|
||||||
|
Cooldown time.Duration
|
||||||
|
Interval time.Duration
|
||||||
|
Stagger time.Duration
|
||||||
|
Batch int
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetcherFor returns the fetcher a site needs, or nil when the site cannot be
|
||||||
|
// fetched at all right now. kagane sits behind a Cloudflare JavaScript
|
||||||
|
// challenge that no TLS fingerprint clears — verified 2026-08-03 from the
|
||||||
|
// deployment host with the same Chrome profile TLSFetcher uses — so it is
|
||||||
|
// browser-only or nothing.
|
||||||
|
func (p *Poller) fetcherFor(site string) Fetcher {
|
||||||
|
if site == "kagane" {
|
||||||
|
return p.BrowserFetch
|
||||||
|
}
|
||||||
|
return p.Fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run polls until ctx is cancelled.
|
// Run polls until ctx is cancelled.
|
||||||
@@ -43,10 +61,10 @@ type latestPoller struct {
|
|||||||
// next one instead of stacking a second batch on top of it. That is the intended
|
// next one instead of stacking a second batch on top of it. That is the intended
|
||||||
// failure mode for a misconfigured batch x stagger: a slower cadence, never
|
// failure mode for a misconfigured batch x stagger: a slower cadence, never
|
||||||
// concurrent fetch storms.
|
// concurrent fetch storms.
|
||||||
func (p *latestPoller) Run(ctx context.Context) {
|
func (p *Poller) Run(ctx context.Context) {
|
||||||
log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s",
|
log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s",
|
||||||
p.interval, p.cooldown, p.batch, p.stagger)
|
p.Interval, p.Cooldown, p.Batch, p.Stagger)
|
||||||
t := time.NewTicker(p.interval)
|
t := time.NewTicker(p.Interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -60,9 +78,9 @@ func (p *latestPoller) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runOnce processes one batch of due bookmarks.
|
// runOnce processes one batch of due bookmarks.
|
||||||
func (p *latestPoller) runOnce(ctx context.Context) {
|
func (p *Poller) runOnce(ctx context.Context) {
|
||||||
cutoff := p.now().Add(-p.cooldown).UnixMilli()
|
cutoff := p.Now().Add(-p.Cooldown).UnixMilli()
|
||||||
due, err := p.store.DueForLatestCheck(cutoff, p.batch)
|
due, err := p.Store.DueForLatestCheck(cutoff, p.Batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll: due query: %v", err)
|
log.Printf("latest poll: due query: %v", err)
|
||||||
return
|
return
|
||||||
@@ -78,11 +96,11 @@ func (p *latestPoller) runOnce(ctx context.Context) {
|
|||||||
// bot score. This is the server-side analogue of the userscript's "one
|
// bot score. This is the server-side analogue of the userscript's "one
|
||||||
// series per navigation ... indistinguishable from browsing" (L455-456).
|
// series per navigation ... indistinguishable from browsing" (L455-456).
|
||||||
stopped := false
|
stopped := false
|
||||||
if i > 0 && p.stagger > 0 {
|
if i > 0 && p.Stagger > 0 {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
stopped = true
|
stopped = true
|
||||||
case <-time.After(p.stagger):
|
case <-time.After(p.Stagger):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stopped {
|
if stopped {
|
||||||
@@ -100,7 +118,7 @@ func (p *latestPoller) runOnce(ctx context.Context) {
|
|||||||
// checkOne re-checks one series. Every failure path here is "log and move on":
|
// checkOne re-checks one series. Every failure path here is "log and move on":
|
||||||
// the poller is a best-effort enhancement, and no single bad series may stall a
|
// the poller is a best-effort enhancement, and no single bad series may stall a
|
||||||
// batch or take down the process.
|
// batch or take down the process.
|
||||||
func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("latest poll %q: recovered from panic: %v", b.Key, r)
|
log.Printf("latest poll %q: recovered from panic: %v", b.Key, r)
|
||||||
@@ -111,7 +129,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
// mid-request still consumes the cooldown. Otherwise a renamed or deleted
|
// mid-request still consumes the cooldown. Otherwise a renamed or deleted
|
||||||
// series would be retried on every single tick forever. The userscript
|
// series would be retried on every single tick forever. The userscript
|
||||||
// stamps in the same order and for the same reason (L471-473).
|
// stamps in the same order and for the same reason (L471-473).
|
||||||
if err := p.store.MarkLatestChecked(b.Key, p.now().UnixMilli()); err != nil {
|
if err := p.Store.MarkLatestChecked(b.Key, p.Now().UnixMilli()); err != nil {
|
||||||
log.Printf("latest poll %q: mark checked: %v", b.Key, err)
|
log.Printf("latest poll %q: mark checked: %v", b.Key, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,7 +146,13 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, status, err := p.fetch.Get(ctx, b.SeriesURL)
|
f := p.fetcherFor(b.Site)
|
||||||
|
if f == nil {
|
||||||
|
log.Printf("latest poll %q: no fetcher for site %q", b.Key, b.Site)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, status, err := f.Get(ctx, b.SeriesURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err)
|
log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err)
|
||||||
return
|
return
|
||||||
@@ -155,7 +179,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
// progress or a status change, and moving updated_at because the stored
|
// progress or a status change, and moving updated_at because the stored
|
||||||
// value now differs. Accepted for a single-user deployment: the window is
|
// value now differs. Accepted for a single-user deployment: the window is
|
||||||
// milliseconds and the loser is one poll cycle.
|
// milliseconds and the loser is one poll cycle.
|
||||||
cur, found, err := p.store.Get(b.Key)
|
cur, found, err := p.Store.Get(b.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
||||||
return
|
return
|
||||||
@@ -174,8 +198,8 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
cur.LatestChapterNum = &num
|
cur.LatestChapterNum = &num
|
||||||
// A candidate only. last_chapter_num is untouched, so the CASE in Upsert
|
// A candidate only. last_chapter_num is untouched, so the CASE in Upsert
|
||||||
// keeps the stored updated_at and the bookmark list does not reorder.
|
// keeps the stored updated_at and the bookmark list does not reorder.
|
||||||
cur.UpdatedAt = p.now().UnixMilli()
|
cur.UpdatedAt = p.Now().UnixMilli()
|
||||||
if _, err := p.store.Upsert(cur); err != nil {
|
if _, err := p.Store.Upsert(cur); err != nil {
|
||||||
log.Printf("latest poll %q: upsert: %v", b.Key, err)
|
log.Printf("latest poll %q: upsert: %v", b.Key, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -183,13 +207,18 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchableSeriesURL reports whether site is a site latestChapterFrom knows how
|
// fetchableSeriesURL reports whether site is a site latestChapterFrom knows how
|
||||||
// to parse and seriesURL is safe to hand to the fetcher: an https URL with a
|
// to parse and seriesURL is safe to hand to a fetcher: an https URL with a
|
||||||
// non-empty host. series_url comes from client-supplied PUT bodies, so this is
|
// non-empty host. series_url comes from client-supplied PUT bodies, so this is
|
||||||
// a defence against the poller being used to probe arbitrary hosts from the
|
// a defence against the poller being used to probe arbitrary hosts from the
|
||||||
// server's own network position, not just a check against wasted requests.
|
// server's own network position, not just a check against wasted requests.
|
||||||
|
//
|
||||||
|
// kagane is held to a stricter rule: it is fetched by a headless browser, which
|
||||||
|
// executes JavaScript and carries cookies, and is therefore a far stronger SSRF
|
||||||
|
// primitive than an HTTP GET. Its host must match exactly, not merely be
|
||||||
|
// non-empty.
|
||||||
func fetchableSeriesURL(site, seriesURL string) bool {
|
func fetchableSeriesURL(site, seriesURL string) bool {
|
||||||
switch site {
|
switch site {
|
||||||
case "asura", "demonic":
|
case "asura", "demonic", "comix", "kagane":
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -197,5 +226,11 @@ func fetchableSeriesURL(site, seriesURL string) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return u.Scheme == "https" && u.Host != ""
|
if u.Scheme != "https" || u.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if site == "kagane" {
|
||||||
|
return u.Hostname() == "kagane.to"
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,53 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// newTestStore opens a fresh SQLite store in a temp dir.
|
||||||
|
func newTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedForCheck inserts a bookmark and forces its latest_checked_at.
|
||||||
|
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
|
Key: key,
|
||||||
|
Site: "asura",
|
||||||
|
SeriesID: key,
|
||||||
|
SeriesURL: seriesURL,
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", key, err)
|
||||||
|
}
|
||||||
|
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
||||||
|
t.Fatalf("seed mark %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ts, err := s.LatestCheckedAt(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestCheckedAt %q: %v", key, err)
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
// fakeFetcher stands in for the network. Every poller test uses it, so nothing
|
// fakeFetcher stands in for the network. Every poller test uses it, so nothing
|
||||||
// in this file can reach tls-client or a real site.
|
// in this file can reach tls-client or a real site.
|
||||||
type fakeFetcher struct {
|
type fakeFetcher struct {
|
||||||
@@ -44,16 +84,16 @@ func (f *fakeFetcher) callCount() int {
|
|||||||
|
|
||||||
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run
|
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run
|
||||||
// instantly and deterministically.
|
// instantly and deterministically.
|
||||||
func newTestPoller(t *testing.T, s *Store, f fetcher, at time.Time) *latestPoller {
|
func newTestPoller(t *testing.T, s *store.Store, f Fetcher, at time.Time) *Poller {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &latestPoller{
|
return &Poller{
|
||||||
store: s,
|
Store: s,
|
||||||
fetch: f,
|
Fetch: f,
|
||||||
now: func() time.Time { return at },
|
Now: func() time.Time { return at },
|
||||||
cooldown: time.Hour,
|
Cooldown: time.Hour,
|
||||||
interval: 10 * time.Minute,
|
Interval: 10 * time.Minute,
|
||||||
stagger: 0,
|
Stagger: 0,
|
||||||
batch: 14,
|
Batch: 14,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +129,7 @@ func TestRunOnceDoesNotReorderList(t *testing.T) {
|
|||||||
const key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
const key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
|
||||||
// "other" is the most recently read, so it must stay at the top of List().
|
// "other" is the most recently read, so it must stay at the top of List().
|
||||||
if _, err := s.Upsert(Bookmark{
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
Key: "asura:other", Site: "asura", SeriesID: "other",
|
Key: "asura:other", Site: "asura", SeriesID: "other",
|
||||||
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
|
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -166,7 +206,7 @@ func TestRunOnceRespectsBatchLimit(t *testing.T) {
|
|||||||
|
|
||||||
f := &fakeFetcher{body: "", status: 200}
|
f := &fakeFetcher{body: "", status: 200}
|
||||||
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
|
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
|
||||||
p.batch = 5
|
p.Batch = 5
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
|
|
||||||
if got := f.callCount(); got != 5 {
|
if got := f.callCount(); got != 5 {
|
||||||
@@ -218,13 +258,13 @@ func TestRunOnceHonoursCooldownAcrossPasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Same instant, and again 59 minutes later: both inside the 1h cooldown.
|
// Same instant, and again 59 minutes later: both inside the 1h cooldown.
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
p.now = func() time.Time { return now.Add(59 * time.Minute) }
|
p.Now = func() time.Time { return now.Add(59 * time.Minute) }
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
if got := f.callCount(); got != 1 {
|
if got := f.callCount(); got != 1 {
|
||||||
t.Fatalf("fetched %d times inside the cooldown, want 1", got)
|
t.Fatalf("fetched %d times inside the cooldown, want 1", got)
|
||||||
}
|
}
|
||||||
// Past the cooldown, it is due again.
|
// Past the cooldown, it is due again.
|
||||||
p.now = func() time.Time { return now.Add(61 * time.Minute) }
|
p.Now = func() time.Time { return now.Add(61 * time.Minute) }
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
if got := f.callCount(); got != 2 {
|
if got := f.callCount(); got != 2 {
|
||||||
t.Fatalf("fetched %d times after the cooldown, want 2", got)
|
t.Fatalf("fetched %d times after the cooldown, want 2", got)
|
||||||
@@ -239,7 +279,7 @@ func TestRunOnceCorrectsDownward(t *testing.T) {
|
|||||||
const key = "demonic:Catastrophic-Necromancer"
|
const key = "demonic:Catastrophic-Necromancer"
|
||||||
|
|
||||||
high := 400.0
|
high := 400.0
|
||||||
if _, err := s.Upsert(Bookmark{
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
|
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
|
||||||
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
|
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
|
||||||
UpdatedAt: 1000,
|
UpdatedAt: 1000,
|
||||||
@@ -278,7 +318,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
key := tt.site + ":x"
|
key := tt.site + ":x"
|
||||||
if _, err := s.Upsert(Bookmark{
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
||||||
UpdatedAt: 1000,
|
UpdatedAt: 1000,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -287,7 +327,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
|||||||
|
|
||||||
now := time.UnixMilli(4_000_000)
|
now := time.UnixMilli(4_000_000)
|
||||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||||
newTestPoller(t, s, f, now).checkOne(context.Background(), Bookmark{
|
newTestPoller(t, s, f, now).checkOne(context.Background(), store.Bookmark{
|
||||||
Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
|
Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -318,3 +358,98 @@ func TestRunOnceStopsOnCancelledContext(t *testing.T) {
|
|||||||
t.Fatalf("fetched %d series with a cancelled context, want 0", got)
|
t.Fatalf("fetched %d series with a cancelled context, want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchableSeriesURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
site string
|
||||||
|
seriesURL string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"asura https", "asura", "https://asurascans.com/comics/x-aabbccdd", true},
|
||||||
|
{"demonic https", "demonic", "https://demonicscans.org/manga/X", true},
|
||||||
|
{"comix https", "comix", "https://comix.to/title/n8we-dungeons-and-crayons", true},
|
||||||
|
{"kagane on its own host", "kagane", "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b", true},
|
||||||
|
// The browser fetcher runs JavaScript and carries cookies, so a
|
||||||
|
// client-supplied series_url must not be able to aim it anywhere else.
|
||||||
|
{"kagane on a foreign host", "kagane", "https://evil.example/series/x", false},
|
||||||
|
{"kagane on a lookalike host", "kagane", "https://kagane.to.evil.example/series/x", false},
|
||||||
|
{"unknown site", "mangadex", "https://mangadex.org/title/x", false},
|
||||||
|
{"non-https", "comix", "http://comix.to/title/x", false},
|
||||||
|
{"no host", "comix", "https:///title/x", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := fetchableSeriesURL(tt.site, tt.seriesURL); got != tt.want {
|
||||||
|
t.Errorf("fetchableSeriesURL(%q, %q) = %v, want %v",
|
||||||
|
tt.site, tt.seriesURL, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A kagane row must not be handed to the plain TLS fetcher: it would only ever
|
||||||
|
// receive a challenge page, and the browser fetcher is the whole reason kagane
|
||||||
|
// is pollable at all.
|
||||||
|
func TestKaganeSkippedWhenNoBrowserFetcher(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
|
Key: "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
Site: "kagane",
|
||||||
|
SeriesID: "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
SeriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := &Poller{
|
||||||
|
Store: s, Fetch: f,
|
||||||
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
||||||
|
Cooldown: time.Hour, Interval: time.Hour, Batch: 10,
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
|
||||||
|
if len(f.calls) != 0 {
|
||||||
|
t.Errorf("TLS fetcher was called for kagane: %v", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a browser fetcher wired up, kagane goes to it and not to the TLS one.
|
||||||
|
func TestKaganeUsesBrowserFetcher(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
key := "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||||
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
|
Key: key,
|
||||||
|
Site: "kagane",
|
||||||
|
SeriesID: "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
SeriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsF := &fakeFetcher{body: "", status: 200}
|
||||||
|
browserF := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := &Poller{
|
||||||
|
Store: s, Fetch: tlsF, BrowserFetch: browserF,
|
||||||
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
||||||
|
Cooldown: time.Hour, Interval: time.Hour, Batch: 10,
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
|
||||||
|
if len(tlsF.calls) != 0 {
|
||||||
|
t.Errorf("TLS fetcher was called for kagane: %v", tlsF.calls)
|
||||||
|
}
|
||||||
|
if len(browserF.calls) != 1 {
|
||||||
|
t.Fatalf("browser fetcher calls = %v, want 1", browserF.calls)
|
||||||
|
}
|
||||||
|
got, found, err := s.Get(key)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("Get: %v found=%v", err, found)
|
||||||
|
}
|
||||||
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 41 {
|
||||||
|
t.Errorf("LatestChapterNum = %v, want 41", got.LatestChapterNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// latestChapter is the newest chapter a series page advertises.
|
// latestChapter is the newest chapter a series page advertises.
|
||||||
@@ -23,6 +25,15 @@ var asuraSlugRe = regexp.MustCompile(`/comics/([^/?#]+)`)
|
|||||||
// through. Both the raw "&" and the HTML-escaped "&" forms occur.
|
// through. Both the raw "&" and the HTML-escaped "&" forms occur.
|
||||||
var demonicChapterRe = regexp.MustCompile(`chaptered\.php\?manga=\d+&(?:amp;)?chapter=([0-9.]+)`)
|
var demonicChapterRe = regexp.MustCompile(`chaptered\.php\?manga=\d+&(?:amp;)?chapter=([0-9.]+)`)
|
||||||
|
|
||||||
|
// comixSlugRe pulls the "<id>-<slug>" segment out of a stored series_url.
|
||||||
|
// Only the id prefix is stable; the slug tail follows the title.
|
||||||
|
var comixSlugRe = regexp.MustCompile(`/title/([^/?#]+)`)
|
||||||
|
|
||||||
|
// kaganeChapterRe matches the chapter numbers in a kagane API response. This
|
||||||
|
// branch is fed by the browser fetcher, so the body is JSON rather than HTML —
|
||||||
|
// there are no anchors to scan.
|
||||||
|
var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`)
|
||||||
|
|
||||||
// latestChapterFrom returns the highest chapter number body advertises for this
|
// latestChapterFrom returns the highest chapter number body advertises for this
|
||||||
// series. ok is false when the body yields nothing usable — an unknown site, an
|
// series. ok is false when the body yields nothing usable — an unknown site, an
|
||||||
// empty body, a Cloudflare challenge page, and a site redesign all land here,
|
// empty body, a Cloudflare challenge page, and a site redesign all land here,
|
||||||
@@ -53,12 +64,28 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
|||||||
// chapter hrefs in the fetched body carry the current one. Strip to
|
// chapter hrefs in the fetched body carry the current one. Strip to
|
||||||
// the stable ID (same rule as migrateAsuraKeys) and make the hash
|
// the stable ID (same rule as migrateAsuraKeys) and make the hash
|
||||||
// optional in the pattern, so scoping survives rotations.
|
// optional in the pattern, so scoping survives rotations.
|
||||||
slug := asuraBuildHash.ReplaceAllString(m[1], "")
|
slug := store.AsuraBuildHash.ReplaceAllString(m[1], "")
|
||||||
// Compiled per call rather than cached: this runs once per fetch, which
|
// Compiled per call rather than cached: this runs once per fetch, which
|
||||||
// is at most a few times a minute, and the slug varies per series.
|
// is at most a few times a minute, and the slug varies per series.
|
||||||
re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`)
|
re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`)
|
||||||
case "demonic":
|
case "demonic":
|
||||||
re = demonicChapterRe
|
re = demonicChapterRe
|
||||||
|
case "comix":
|
||||||
|
m := comixSlugRe.FindStringSubmatch(seriesURL)
|
||||||
|
if m == nil {
|
||||||
|
return latestChapter{}, false
|
||||||
|
}
|
||||||
|
// comix ships an SPA: the served HTML carries a JSON state blob instead
|
||||||
|
// of chapter anchors, and latestChapterUrl is the only place the newest
|
||||||
|
// chapter appears. Scoping to this series' id prefix keeps a
|
||||||
|
// "recommended" strip's entries from winning the maximum.
|
||||||
|
id := m[1]
|
||||||
|
if i := strings.Index(id, "-"); i != -1 {
|
||||||
|
id = id[:i]
|
||||||
|
}
|
||||||
|
re = regexp.MustCompile(`"latestChapterUrl":"/title/` + regexp.QuoteMeta(id) + `-[^"]*-chapter-([0-9.]+)"`)
|
||||||
|
case "kagane":
|
||||||
|
re = kaganeChapterRe
|
||||||
default:
|
default:
|
||||||
return latestChapter{}, false
|
return latestChapter{}, false
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -34,6 +34,24 @@ const challengeFixture = `<!DOCTYPE html><html><head><title>Just a moment...</ti
|
|||||||
<script src="/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1"></script></head>
|
<script src="/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1"></script></head>
|
||||||
<body><div id="challenge-running">Checking your browser</div></body></html>`
|
<body><div id="challenge-running">Checking your browser</div></body></html>`
|
||||||
|
|
||||||
|
// Trimmed from the server-rendered HTML of
|
||||||
|
// https://comix.to/title/n8we-dungeons-and-crayons fetched 2026-08-03. comix is
|
||||||
|
// an SPA: the page ships a JSON state blob rather than a list of chapter
|
||||||
|
// anchors, and latestChapterUrl is where the newest chapter actually lives.
|
||||||
|
const comixSeriesFixture = `
|
||||||
|
{"firstChapterUrl":"/title/n8we-dungeons-and-crayons/5038739-chapter-1","latestChapterUrl":"/title/n8we-dungeons-and-crayons/11139891-chapter-80"},
|
||||||
|
{""manga","recommended","n8we",1]":{"items":[{"latestChapterUrl":"/title/qqwrm-full-time-awakening/99999999-chapter-999"}]}
|
||||||
|
`
|
||||||
|
|
||||||
|
// The kagane branch is fed by the browser fetcher, so the body is API JSON, not
|
||||||
|
// HTML. Trimmed from GET /api/v2/series/<uuid> on 2026-08-03.
|
||||||
|
const kaganeAPIFixture = `
|
||||||
|
{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption",
|
||||||
|
"series_books":[{"book_id":"a","title":"Episode 1","chapter_no":"1","sort_no":1},
|
||||||
|
{"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41},
|
||||||
|
{"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]}
|
||||||
|
`
|
||||||
|
|
||||||
func TestLatestChapterFrom(t *testing.T) {
|
func TestLatestChapterFrom(t *testing.T) {
|
||||||
const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||||
const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
||||||
@@ -100,6 +118,34 @@ func TestLatestChapterFrom(t *testing.T) {
|
|||||||
site: "mangadex", seriesURL: "https://example.com/x", body: asuraSeriesFixture,
|
site: "mangadex", seriesURL: "https://example.com/x", body: asuraSeriesFixture,
|
||||||
wantOK: false,
|
wantOK: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "comix reads latestChapterUrl, scoped to this series",
|
||||||
|
site: "comix",
|
||||||
|
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||||||
|
body: comixSeriesFixture,
|
||||||
|
wantOK: true, wantNum: 80, wantLabel: "Chapter 80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comix yields nothing on a challenge page",
|
||||||
|
site: "comix",
|
||||||
|
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||||||
|
body: challengeFixture,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kagane takes the max chapter_no from API json",
|
||||||
|
site: "kagane",
|
||||||
|
seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
body: kaganeAPIFixture,
|
||||||
|
wantOK: true, wantNum: 41, wantLabel: "Chapter 41",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kagane yields nothing on a challenge page",
|
||||||
|
site: "kagane",
|
||||||
|
seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
||||||
|
body: challengeFixture,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sessionCookieName = "mangabm_session"
|
CookieName = "mangabm_session"
|
||||||
// 60 days: long enough that a phone stays logged in between reading spells.
|
// 60 days: long enough that a phone stays logged in between reading spells.
|
||||||
sessionTTL = 60 * 24 * time.Hour
|
sessionTTL = 60 * 24 * time.Hour
|
||||||
// Domain separation, so the session key can never collide with any other
|
// Domain separation, so the session key can never collide with any other
|
||||||
@@ -23,18 +23,18 @@ const (
|
|||||||
sessionKeyPurpose = "mangabm-web-session-v1"
|
sessionKeyPurpose = "mangabm-web-session-v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionKey derives the cookie-signing key from both secrets. Sessions are
|
// Key derives the cookie-signing key from both secrets. Sessions are
|
||||||
// stateless — there is no session table — so rotating either API_TOKEN or
|
// stateless — there is no session table — so rotating either API_TOKEN or
|
||||||
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
|
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
|
||||||
// separator prevents the concatenation ambiguity a bare apiToken+webPassword
|
// separator prevents the concatenation ambiguity a bare apiToken+webPassword
|
||||||
// would have (e.g. "ab"+"c" colliding with "a"+"bc").
|
// would have (e.g. "ab"+"c" colliding with "a"+"bc").
|
||||||
func sessionKey(apiToken, webPassword string) []byte {
|
func Key(apiToken, webPassword string) []byte {
|
||||||
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
|
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
|
||||||
return sum[:]
|
return sum[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// signSession encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
|
// Sign encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
|
||||||
func signSession(key []byte, expiryMs int64) string {
|
func Sign(key []byte, expiryMs int64) string {
|
||||||
payload := strconv.FormatInt(expiryMs, 10)
|
payload := strconv.FormatInt(expiryMs, 10)
|
||||||
return payload + "." + sessionMAC(key, payload)
|
return payload + "." + sessionMAC(key, payload)
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,10 @@ func sessionMAC(key []byte, payload string) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifySession checks shape, then expiry, then the signature — in that order.
|
// Verify checks shape, then expiry, then the signature — in that order.
|
||||||
// The signature comparison is constant-time; the checks before it only look at
|
// The signature comparison is constant-time; the checks before it only look at
|
||||||
// data the holder already supplied, so their timing leaks nothing.
|
// data the holder already supplied, so their timing leaks nothing.
|
||||||
func verifySession(key []byte, value string, nowMs int64) bool {
|
func Verify(key []byte, value string, nowMs int64) bool {
|
||||||
payload, sig, ok := strings.Cut(value, ".")
|
payload, sig, ok := strings.Cut(value, ".")
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -69,10 +69,10 @@ func isHTTPS(r *http.Request) bool {
|
|||||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
func SetCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: CookieName,
|
||||||
Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()),
|
Value: Sign(key, time.Now().Add(sessionTTL).UnixMilli()),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(sessionTTL / time.Second),
|
MaxAge: int(sessionTTL / time.Second),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -81,9 +81,9 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
func ClearCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: CookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
@@ -94,11 +94,11 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
loginMaxFailures = 10
|
MaxFailures = 10
|
||||||
loginWindow = 20 * time.Minute
|
Window = 20 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// clientIP returns the address the reverse proxy actually observed.
|
// ClientIP returns the address the reverse proxy actually observed.
|
||||||
//
|
//
|
||||||
// Traefik appends the peer address to whatever X-Forwarded-For the client sent,
|
// Traefik appends the peer address to whatever X-Forwarded-For the client sent,
|
||||||
// so the leftmost entry is attacker-controlled and the rightmost is not. Go's
|
// so the leftmost entry is attacker-controlled and the rightmost is not. Go's
|
||||||
@@ -106,7 +106,7 @@ const (
|
|||||||
// by sending its own; Values covers every line so the true last hop is found.
|
// by sending its own; Values covers every line so the true last hop is found.
|
||||||
// RemoteAddr is useless behind the proxy — it is always the Traefik container —
|
// RemoteAddr is useless behind the proxy — it is always the Traefik container —
|
||||||
// so it serves only as the direct-connection fallback for local development.
|
// so it serves only as the direct-connection fallback for local development.
|
||||||
func clientIP(r *http.Request) string {
|
func ClientIP(r *http.Request) string {
|
||||||
if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 {
|
if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 {
|
||||||
hops := strings.Split(vals[len(vals)-1], ",")
|
hops := strings.Split(vals[len(vals)-1], ",")
|
||||||
if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" {
|
if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" {
|
||||||
@@ -120,8 +120,8 @@ func clientIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginLimiter throttles password guessing: loginMaxFailures failures inside a
|
// LoginLimiter throttles password guessing: MaxFailures failures inside a
|
||||||
// rolling loginWindow blocks further attempts from that IP until the oldest one
|
// rolling Window blocks further attempts from that IP until the oldest one
|
||||||
// ages out. There is no permanent ban and no unlock step.
|
// ages out. There is no permanent ban and no unlock step.
|
||||||
//
|
//
|
||||||
// Behind carrier-grade NAT this budget is shared with every other subscriber on
|
// Behind carrier-grade NAT this budget is shared with every other subscriber on
|
||||||
@@ -132,34 +132,34 @@ func clientIP(r *http.Request) string {
|
|||||||
// State is in memory and per-process, so a restart clears it. Entries are
|
// State is in memory and per-process, so a restart clears it. Entries are
|
||||||
// pruned lazily on access; for a single-user deployment the map cannot grow
|
// pruned lazily on access; for a single-user deployment the map cannot grow
|
||||||
// past the handful of addresses that ever attempt a login.
|
// past the handful of addresses that ever attempt a login.
|
||||||
type loginLimiter struct {
|
type LoginLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
failures map[string][]time.Time
|
failures map[string][]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLoginLimiter() *loginLimiter {
|
func NewLoginLimiter() *LoginLimiter {
|
||||||
return &loginLimiter{failures: make(map[string][]time.Time)}
|
return &LoginLimiter{failures: make(map[string][]time.Time)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryAfter returns how long ip must wait, or zero when it may try now.
|
// retryAfter returns how long ip must wait, or zero when it may try now.
|
||||||
func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration {
|
func (l *LoginLimiter) RetryAfter(ip string, now time.Time) time.Duration {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
recent := l.pruneLocked(ip, now)
|
recent := l.pruneLocked(ip, now)
|
||||||
if len(recent) < loginMaxFailures {
|
if len(recent) < MaxFailures {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return recent[0].Add(loginWindow).Sub(now)
|
return recent[0].Add(Window).Sub(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *loginLimiter) fail(ip string, now time.Time) {
|
func (l *LoginLimiter) Fail(ip string, now time.Time) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
l.failures[ip] = append(l.pruneLocked(ip, now), now)
|
l.failures[ip] = append(l.pruneLocked(ip, now), now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *loginLimiter) reset(ip string) {
|
func (l *LoginLimiter) Reset(ip string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
delete(l.failures, ip)
|
delete(l.failures, ip)
|
||||||
@@ -167,8 +167,8 @@ func (l *loginLimiter) reset(ip string) {
|
|||||||
|
|
||||||
// pruneLocked drops attempts older than the window and returns what is left.
|
// pruneLocked drops attempts older than the window and returns what is left.
|
||||||
// The caller must hold l.mu.
|
// The caller must hold l.mu.
|
||||||
func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
func (l *LoginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
||||||
cutoff := now.Add(-loginWindow)
|
cutoff := now.Add(-Window)
|
||||||
// In-place filter: kept reuses the backing array of the slice being
|
// In-place filter: kept reuses the backing array of the slice being
|
||||||
// ranged over. Safe to alias because append writes at index len(kept),
|
// ranged over. Safe to alias because append writes at index len(kept),
|
||||||
// which is always <= the range index i, and element i is read before
|
// which is always <= the range index i, and element i is read before
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -10,18 +10,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionRoundTrip(t *testing.T) {
|
func TestSessionRoundTrip(t *testing.T) {
|
||||||
key := sessionKey("token-abc", "pw-abc")
|
key := Key("token-abc", "pw-abc")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
value := signSession(key, now+60_000)
|
value := Sign(key, now+60_000)
|
||||||
if !verifySession(key, value, now) {
|
if !Verify(key, value, now) {
|
||||||
t.Fatal("verifySession = false for a freshly signed cookie, want true")
|
t.Fatal("Verify = false for a freshly signed cookie, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionRejects(t *testing.T) {
|
func TestSessionRejects(t *testing.T) {
|
||||||
key := sessionKey("token-abc", "pw-abc")
|
key := Key("token-abc", "pw-abc")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
valid := signSession(key, now+60_000)
|
valid := Sign(key, now+60_000)
|
||||||
payload, sig, _ := strings.Cut(valid, ".")
|
payload, sig, _ := strings.Cut(valid, ".")
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -31,15 +31,15 @@ func TestSessionRejects(t *testing.T) {
|
|||||||
{"empty", ""},
|
{"empty", ""},
|
||||||
{"no separator", payload + sig},
|
{"no separator", payload + sig},
|
||||||
{"unparseable expiry", "notanumber." + sig},
|
{"unparseable expiry", "notanumber." + sig},
|
||||||
{"expired", signSession(key, now-1)},
|
{"expired", Sign(key, now-1)},
|
||||||
{"tampered signature", payload + "." + flipLastChar(sig)},
|
{"tampered signature", payload + "." + flipLastChar(sig)},
|
||||||
{"tampered expiry", "99999999999999." + sig},
|
{"tampered expiry", "99999999999999." + sig},
|
||||||
{"signed with another key", signSession(sessionKey("other-token", "pw-abc"), now+60_000)},
|
{"signed with another key", Sign(Key("other-token", "pw-abc"), now+60_000)},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if verifySession(key, tc.value, now) {
|
if Verify(key, tc.value, now) {
|
||||||
t.Fatalf("verifySession(%q) = true, want false", tc.value)
|
t.Fatalf("Verify(%q) = true, want false", tc.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,18 +57,18 @@ func flipLastChar(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionKeyDependsOnToken(t *testing.T) {
|
func TestSessionKeyDependsOnToken(t *testing.T) {
|
||||||
a := sessionKey("token-a", "pw-abc")
|
a := Key("token-a", "pw-abc")
|
||||||
b := sessionKey("token-b", "pw-abc")
|
b := Key("token-b", "pw-abc")
|
||||||
if string(a) == string(b) {
|
if string(a) == string(b) {
|
||||||
t.Fatal("sessionKey collided for different API tokens")
|
t.Fatal("Key collided for different API tokens")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
||||||
a := sessionKey("token-abc", "pw-a")
|
a := Key("token-abc", "pw-a")
|
||||||
b := sessionKey("token-abc", "pw-b")
|
b := Key("token-abc", "pw-b")
|
||||||
if string(a) == string(b) {
|
if string(a) == string(b) {
|
||||||
t.Fatal("sessionKey collided for different web passwords with the same API token")
|
t.Fatal("Key collided for different web passwords with the same API token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,15 +94,15 @@ func TestSetSessionCookieAttributes(t *testing.T) {
|
|||||||
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc"))
|
SetCookie(rr, r, Key("token-abc", "pw-abc"))
|
||||||
|
|
||||||
cookies := rr.Result().Cookies()
|
cookies := rr.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
t.Fatalf("got %d cookies, want 1", len(cookies))
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
||||||
}
|
}
|
||||||
c := cookies[0]
|
c := cookies[0]
|
||||||
if c.Name != sessionCookieName {
|
if c.Name != CookieName {
|
||||||
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName)
|
t.Fatalf("cookie name = %q, want %q", c.Name, CookieName)
|
||||||
}
|
}
|
||||||
if !c.HttpOnly {
|
if !c.HttpOnly {
|
||||||
t.Fatal("cookie HttpOnly = false, want true")
|
t.Fatal("cookie HttpOnly = false, want true")
|
||||||
@@ -126,7 +126,7 @@ func TestSetSessionCookieAttributes(t *testing.T) {
|
|||||||
func TestClearSessionCookie(t *testing.T) {
|
func TestClearSessionCookie(t *testing.T) {
|
||||||
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
clearSessionCookie(rr, r)
|
ClearCookie(rr, r)
|
||||||
|
|
||||||
cookies := rr.Result().Cookies()
|
cookies := rr.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
@@ -168,65 +168,65 @@ func TestClientIP(t *testing.T) {
|
|||||||
for _, v := range tc.xff {
|
for _, v := range tc.xff {
|
||||||
r.Header.Add("X-Forwarded-For", v)
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
}
|
}
|
||||||
if got := clientIP(r); got != tc.want {
|
if got := ClientIP(r); got != tc.want {
|
||||||
t.Fatalf("clientIP() = %q, want %q", got, tc.want)
|
t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
||||||
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures)
|
t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures)
|
||||||
}
|
}
|
||||||
l.fail("1.2.3.4", now)
|
l.Fail("1.2.3.4", now)
|
||||||
}
|
}
|
||||||
wait := l.retryAfter("1.2.3.4", now)
|
wait := l.RetryAfter("1.2.3.4", now)
|
||||||
if wait <= 0 {
|
if wait <= 0 {
|
||||||
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures)
|
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures)
|
||||||
}
|
}
|
||||||
if wait > loginWindow {
|
if wait > Window {
|
||||||
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow)
|
t.Fatalf("retryAfter = %v, want <= %v", wait, Window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterWindowExpires(t *testing.T) {
|
func TestLoginLimiterWindowExpires(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
l.fail("1.2.3.4", start)
|
l.Fail("1.2.3.4", start)
|
||||||
}
|
}
|
||||||
if l.retryAfter("1.2.3.4", start) == 0 {
|
if l.RetryAfter("1.2.3.4", start) == 0 {
|
||||||
t.Fatal("expected block immediately after the failures")
|
t.Fatal("expected block immediately after the failures")
|
||||||
}
|
}
|
||||||
later := start.Add(loginWindow + time.Second)
|
later := start.Add(Window + time.Second)
|
||||||
if wait := l.retryAfter("1.2.3.4", later); wait != 0 {
|
if wait := l.RetryAfter("1.2.3.4", later); wait != 0 {
|
||||||
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
l.fail("1.2.3.4", now)
|
l.Fail("1.2.3.4", now)
|
||||||
}
|
}
|
||||||
l.reset("1.2.3.4")
|
l.Reset("1.2.3.4")
|
||||||
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
||||||
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterIsPerIP(t *testing.T) {
|
func TestLoginLimiterIsPerIP(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
l.fail("1.2.3.4", now)
|
l.Fail("1.2.3.4", now)
|
||||||
}
|
}
|
||||||
if wait := l.retryAfter("5.6.7.8", now); wait != 0 {
|
if wait := l.RetryAfter("5.6.7.8", now); wait != 0 {
|
||||||
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -86,11 +86,21 @@ func (b Bookmark) ContinueURL() string {
|
|||||||
return b.SeriesURL
|
return b.SeriesURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial is the monogram the web UI shows in place of a cover when the
|
||||||
|
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
||||||
|
// the title is missing, so the slot is never empty.
|
||||||
|
func (b Bookmark) Initial() string {
|
||||||
|
for _, r := range b.Title {
|
||||||
|
return strings.ToUpper(string(r))
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||||
const (
|
const (
|
||||||
statusReading = "reading"
|
StatusReading = "reading"
|
||||||
statusArchived = "archived"
|
StatusArchived = "archived"
|
||||||
statusFinished = "finished"
|
StatusFinished = "finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schema = `
|
const schema = `
|
||||||
@@ -137,7 +147,7 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
||||||
func OpenStore(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
||||||
// concurrent requests; a single writer connection keeps writes serialized.
|
// concurrent requests; a single writer connection keeps writes serialized.
|
||||||
dsn := path
|
dsn := path
|
||||||
@@ -182,11 +192,11 @@ func migrateColumns(db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura
|
// AsuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura
|
||||||
// appends to every series slug. It rotates on each site redeploy, so it
|
// appends to every series slug. It rotates on each site redeploy, so it
|
||||||
// must not be part of series_id. Must stay in sync with stripBuildHash in
|
// must not be part of series_id. Must stay in sync with stripBuildHash in
|
||||||
// userscript/manga-bookmark.user.js.
|
// userscript/manga-bookmark.user.js.
|
||||||
var asuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`)
|
var AsuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`)
|
||||||
|
|
||||||
// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries
|
// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries
|
||||||
// the build hash to the stable, hashless ID. Rows keyed with a hash are
|
// the build hash to the stable, hashless ID. Rows keyed with a hash are
|
||||||
@@ -218,7 +228,7 @@ func migrateAsuraKeys(db *sql.DB) error {
|
|||||||
|
|
||||||
groups := map[string][]row{}
|
groups := map[string][]row{}
|
||||||
for _, r := range all {
|
for _, r := range all {
|
||||||
stripped := asuraBuildHash.ReplaceAllString(r.id, "")
|
stripped := AsuraBuildHash.ReplaceAllString(r.id, "")
|
||||||
groups[stripped] = append(groups[stripped], r)
|
groups[stripped] = append(groups[stripped], r)
|
||||||
}
|
}
|
||||||
for stripped, g := range groups {
|
for stripped, g := range groups {
|
||||||
@@ -305,8 +315,8 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|||||||
// leave the row in no list at all, so anything outside the three known
|
// leave the row in no list at all, so anything outside the three known
|
||||||
// buckets reads as the default rather than being passed through.
|
// buckets reads as the default rather than being passed through.
|
||||||
b.Status = status.String
|
b.Status = status.String
|
||||||
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
|
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
||||||
b.Status = statusReading
|
b.Status = StatusReading
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
@@ -481,3 +491,16 @@ func (s *Store) MarkLatestChecked(key string, ts int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||||
|
// tests outside this package (the poller's own tests assert on cooldown
|
||||||
|
// bookkeeping) — see MarkLatestChecked for why the field itself stays off
|
||||||
|
// Bookmark.
|
||||||
|
func (s *Store) LatestCheckedAt(key string) (int64, error) {
|
||||||
|
var ts int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT latest_checked_at FROM bookmarks WHERE key = ?`, key).Scan(&ts); err != nil {
|
||||||
|
return 0, fmt.Errorf("latest checked at %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
@@ -1,42 +1,15 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testToken = "s3cret-token"
|
|
||||||
|
|
||||||
func testConfig() Config {
|
|
||||||
return Config{
|
|
||||||
Token: testToken,
|
|
||||||
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
|
||||||
Port: "8080",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T) http.Handler {
|
|
||||||
t.Helper()
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
||||||
store, err := OpenStore(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
return newRouter(store, testConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestStore(t *testing.T) *Store {
|
func newTestStore(t *testing.T) *Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
store, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore: %v", err)
|
t.Fatalf("OpenStore: %v", err)
|
||||||
}
|
}
|
||||||
@@ -44,346 +17,6 @@ func newTestStore(t *testing.T) *Store {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
func auth(req *http.Request) *http.Request {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthzNoAuth(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("healthz status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
if rr.Body.String() != "ok" {
|
|
||||||
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRequired(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
header string
|
|
||||||
}{
|
|
||||||
{"no header", ""},
|
|
||||||
{"bad token", "Bearer wrong"},
|
|
||||||
{"not bearer", "Basic " + testToken},
|
|
||||||
{"empty bearer", "Bearer "},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
|
||||||
if tc.header != "" {
|
|
||||||
req.Header.Set("Authorization", tc.header)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
if rr.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401", rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthAccepted(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
if got := rr.Body.String(); got != "[]\n" {
|
|
||||||
t.Fatalf("empty list body = %q, want []", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSPreflight(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
|
|
||||||
req.Header.Set("Origin", "https://asuracomic.net")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "PUT")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("preflight status = %d, want 204", rr.Code)
|
|
||||||
}
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
|
|
||||||
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
|
|
||||||
}
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
||||||
t.Fatal("Allow-Methods missing")
|
|
||||||
}
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
|
||||||
t.Fatal("Allow-Headers missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSDisallowedOrigin(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
|
|
||||||
req.Header.Set("Origin", "https://evil.example")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBookmarkRoundTrip(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := "asura:solo-leveling-123"
|
|
||||||
in := Bookmark{
|
|
||||||
Title: "Solo Leveling",
|
|
||||||
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
|
||||||
Cover: "https://asuracomic.net/cover.jpg",
|
|
||||||
LastChapter: "Chapter 10",
|
|
||||||
LastChapterNum: 10,
|
|
||||||
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(in)
|
|
||||||
|
|
||||||
// PUT
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
var stored Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
|
|
||||||
t.Fatalf("decode PUT response: %v", err)
|
|
||||||
}
|
|
||||||
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
|
|
||||||
t.Fatalf("derived fields wrong: %+v", stored)
|
|
||||||
}
|
|
||||||
if stored.UpdatedAt == 0 {
|
|
||||||
t.Fatal("server did not set updated_at")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
var list []Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
||||||
t.Fatalf("decode list: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
|
|
||||||
t.Fatalf("GET list wrong: %+v", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT again (upsert, progress advance)
|
|
||||||
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
|
|
||||||
body, _ = json.Marshal(in)
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("second PUT status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
json.Unmarshal(rr.Body.Bytes(), &list)
|
|
||||||
if len(list) != 1 || list[0].LastChapterNum != 11 {
|
|
||||||
t.Fatalf("upsert did not update in place: %+v", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
|
|
||||||
if rr.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("DELETE status = %d, want 204", rr.Code)
|
|
||||||
}
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
json.Unmarshal(rr.Body.Bytes(), &list)
|
|
||||||
if len(list) != 0 {
|
|
||||||
t.Fatalf("after delete list = %+v, want empty", list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
|
|
||||||
// which is the row as actually stored (not the request payload).
|
|
||||||
func putBookmark(t *testing.T, srv http.Handler, key string, b Bookmark) Bookmark {
|
|
||||||
t.Helper()
|
|
||||||
body, _ := json.Marshal(b)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
var out Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
||||||
t.Fatalf("decode PUT response: %v", err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBookmarks(t *testing.T, srv http.Handler) []Bookmark {
|
|
||||||
t.Helper()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
var list []Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
||||||
t.Fatalf("decode list: %v", err)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func floatPtr(f float64) *float64 { return &f }
|
|
||||||
|
|
||||||
// updated_at drives list ordering, so it must move only on a real progress
|
|
||||||
// advance — never on a favorite toggle or a latest-chapter capture.
|
|
||||||
func TestUpsertConditionalUpdatedAt(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(Bookmark) Bookmark
|
|
||||||
wantBumped bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unchanged progress",
|
|
||||||
mutate: func(b Bookmark) Bookmark { return b },
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "changed progress",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "favorite only",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.Favorite = true
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "latest chapter only",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unrelated metadata only",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := fmt.Sprintf("asura:cond-%d", i)
|
|
||||||
|
|
||||||
first := putBookmark(t, srv, key, Bookmark{
|
|
||||||
Title: "Test",
|
|
||||||
LastChapter: "Chapter 10",
|
|
||||||
LastChapterNum: 10,
|
|
||||||
})
|
|
||||||
if first.UpdatedAt == 0 {
|
|
||||||
t.Fatal("new bookmark did not get updated_at set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee a later wall-clock ms so a real bump is observable.
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
|
|
||||||
second := putBookmark(t, srv, key, tc.mutate(first))
|
|
||||||
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
|
|
||||||
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
|
|
||||||
}
|
|
||||||
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
|
|
||||||
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The PUT response must match what a subsequent GET reports.
|
|
||||||
list := getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("list = %+v, want 1 item", list)
|
|
||||||
}
|
|
||||||
if list[0].UpdatedAt != second.UpdatedAt {
|
|
||||||
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFavoriteRoundTrip(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := "demonic:some-series"
|
|
||||||
|
|
||||||
stored := putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: true})
|
|
||||||
if !stored.Favorite {
|
|
||||||
t.Fatalf("PUT response favorite = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
list := getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || !list[0].Favorite {
|
|
||||||
t.Fatalf("favorite did not round-trip: %+v", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
|
|
||||||
stored = putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: false})
|
|
||||||
if stored.Favorite {
|
|
||||||
t.Fatal("PUT response favorite = true after unfavorite")
|
|
||||||
}
|
|
||||||
list = getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || list[0].Favorite {
|
|
||||||
t.Fatalf("unfavorite did not round-trip: %+v", list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLatestChapterNullable(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := "asura:latest-test"
|
|
||||||
|
|
||||||
// Never captured: latest_chapter_num must serialize as JSON null.
|
|
||||||
body, _ := json.Marshal(Bookmark{Title: "No latest yet"})
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
|
|
||||||
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
list := getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || list[0].LatestChapterNum != nil {
|
|
||||||
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once captured it round-trips as a value.
|
|
||||||
stored := putBookmark(t, srv, key, Bookmark{
|
|
||||||
Title: "No latest yet",
|
|
||||||
LatestChapter: "Chapter 162",
|
|
||||||
LatestChapterNum: floatPtr(162),
|
|
||||||
})
|
|
||||||
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
|
|
||||||
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
|
|
||||||
}
|
|
||||||
list = getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
|
|
||||||
t.Fatalf("latest chapter did not round-trip: %+v", list)
|
|
||||||
}
|
|
||||||
if list[0].LatestChapter != "Chapter 162" {
|
|
||||||
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The deployed database predates favorite/latest_chapter*, and CREATE TABLE
|
|
||||||
// IF NOT EXISTS will not add them — OpenStore must migrate in place.
|
|
||||||
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
||||||
|
|
||||||
@@ -415,7 +48,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
|||||||
t.Fatalf("close legacy db: %v", err)
|
t.Fatalf("close legacy db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := OpenStore(dbPath)
|
store, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore on legacy db: %v", err)
|
t.Fatalf("OpenStore on legacy db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -437,7 +70,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reopening an already-migrated database must be a no-op, not an error.
|
// Reopening an already-migrated database must be a no-op, not an error.
|
||||||
store2, err := OpenStore(dbPath)
|
store2, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore is not idempotent: %v", err)
|
t.Fatalf("OpenStore is not idempotent: %v", err)
|
||||||
}
|
}
|
||||||
@@ -516,19 +149,6 @@ func TestBookmarkContinueURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfigWebPassword(t *testing.T) {
|
|
||||||
t.Setenv("API_TOKEN", "token-abc")
|
|
||||||
t.Setenv("WEB_PASSWORD", "hunter2")
|
|
||||||
if got := loadConfig().WebPassword; got != "hunter2" {
|
|
||||||
t.Fatalf("WebPassword = %q, want hunter2", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("WEB_PASSWORD", "")
|
|
||||||
if got := loadConfig().WebPassword; got != "" {
|
|
||||||
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLatestCheckedAt reads the column directly. It is deliberately absent from
|
// readLatestCheckedAt reads the column directly. It is deliberately absent from
|
||||||
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
|
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
|
||||||
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
||||||
@@ -672,7 +292,7 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
|
|||||||
t.Fatalf("close: %v", err)
|
t.Fatalf("close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := OpenStore(path)
|
s, err := Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore on pre-existing db: %v", err)
|
t.Fatalf("OpenStore on pre-existing db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -691,38 +311,6 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A userscript PUT body has no latest_checked_at field. If the column is ever
|
|
||||||
// moved into bookmarkColumns, this test catches it: the PUT would reset the
|
|
||||||
// cooldown and the poller would re-fetch that series on every single tick.
|
|
||||||
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
||||||
store, err := OpenStore(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
srv := newRouter(store, testConfig())
|
|
||||||
|
|
||||||
seedForCheck(t, store, "asura:x", "https://asurascans.com/comics/x", 777)
|
|
||||||
|
|
||||||
// Exactly what the userscript sends: no latest_checked_at key at all.
|
|
||||||
body := `{"key":"asura:x","site":"asura","series_id":"x",
|
|
||||||
"series_url":"https://asurascans.com/comics/x",
|
|
||||||
"last_chapter":"Chapter 5","last_chapter_num":5}`
|
|
||||||
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
|
||||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if got := readLatestCheckedAt(t, store, "asura:x"); got != 777 {
|
|
||||||
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
stored, err := store.Upsert(Bookmark{
|
stored, err := store.Upsert(Bookmark{
|
||||||
@@ -732,8 +320,8 @@ func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusReading {
|
if stored.Status != StatusReading {
|
||||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +331,7 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(base); err != nil {
|
if _, err := store.Upsert(base); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
@@ -755,8 +343,8 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusArchived {
|
if stored.Status != StatusArchived {
|
||||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,7 +356,7 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(base); err != nil {
|
if _, err := store.Upsert(base); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
@@ -788,8 +376,8 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusArchived {
|
if stored.Status != StatusArchived {
|
||||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,19 +385,19 @@ func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(base); err != nil {
|
if _, err := store.Upsert(base); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Status = statusReading
|
base.Status = StatusReading
|
||||||
stored, err := store.Upsert(base)
|
stored, err := store.Upsert(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusReading {
|
if stored.Status != StatusReading {
|
||||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +414,7 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Status = statusArchived
|
base.Status = StatusArchived
|
||||||
base.UpdatedAt = first.UpdatedAt + 60_000
|
base.UpdatedAt = first.UpdatedAt + 60_000
|
||||||
stored, err := store.Upsert(base)
|
stored, err := store.Upsert(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -857,7 +445,7 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
store, err := OpenStore(path)
|
store, err := Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore: %v", err)
|
t.Fatalf("OpenStore: %v", err)
|
||||||
}
|
}
|
||||||
@@ -867,8 +455,8 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
|||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
if b.Status != statusReading {
|
if b.Status != StatusReading {
|
||||||
t.Fatalf("Status = %q, want %q", b.Status, statusReading)
|
t.Fatalf("Status = %q, want %q", b.Status, StatusReading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,9 +465,9 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
|||||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
for _, tc := range []struct{ key, status string }{
|
for _, tc := range []struct{ key, status string }{
|
||||||
{"asura:reading", statusReading},
|
{"asura:reading", StatusReading},
|
||||||
{"asura:archived", statusArchived},
|
{"asura:archived", StatusArchived},
|
||||||
{"asura:finished", statusFinished},
|
{"asura:finished", StatusFinished},
|
||||||
} {
|
} {
|
||||||
if _, err := store.Upsert(Bookmark{
|
if _, err := store.Upsert(Bookmark{
|
||||||
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
||||||
@@ -912,7 +500,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
|||||||
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
||||||
|
|
||||||
store, err := OpenStore(dbPath)
|
store, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -938,7 +526,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
|||||||
t.Fatalf("close: %v", err)
|
t.Fatalf("close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reopened, err := OpenStore(dbPath)
|
reopened, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopen: %v", err)
|
t.Fatalf("reopen: %v", err)
|
||||||
}
|
}
|
||||||
@@ -977,7 +565,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent: a third open changes nothing.
|
// Idempotent: a third open changes nothing.
|
||||||
third, err := OpenStore(dbPath)
|
third, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("third open: %v", err)
|
t.Fatalf("third open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -990,7 +578,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
|||||||
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
||||||
|
|
||||||
store, err := OpenStore(dbPath)
|
store, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1010,7 +598,7 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
|||||||
t.Fatalf("close: %v", err)
|
t.Fatalf("close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reopened, err := OpenStore(dbPath)
|
reopened, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopen: %v", err)
|
t.Fatalf("reopen: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package userscript
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -35,7 +35,7 @@ func stampVersion(src []byte, mod time.Time) []byte {
|
|||||||
//
|
//
|
||||||
// The file is read per request — that is what lets a bindmounted copy be edited
|
// The file is read per request — that is what lets a bindmounted copy be edited
|
||||||
// on the host without a restart. It is ~50 KB and polled about once a day.
|
// on the host without a restart. It is ~50 KB and polled about once a day.
|
||||||
func userscriptHandler(token, path string) http.HandlerFunc {
|
func Handler(token, path string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package userscript
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testToken = "s3cret-token"
|
||||||
|
|
||||||
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
||||||
// @version line, plus a body that must survive the rewrite untouched.
|
// @version line, plus a body that must survive the rewrite untouched.
|
||||||
const sampleScript = `// ==UserScript==
|
const sampleScript = `// ==UserScript==
|
||||||
@@ -35,16 +37,12 @@ func writeScript(t *testing.T, body string) (path, wantVersion string) {
|
|||||||
return path, "2026.07.28.1642"
|
return path, "2026.07.28.1642"
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUserscriptServer(t *testing.T, path string) http.Handler {
|
// newTestMux registers Handler the same way main.go's router does, without
|
||||||
t.Helper()
|
// pulling in the store or the rest of the app.
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
func newTestMux(token, path string) http.Handler {
|
||||||
if err != nil {
|
mux := http.NewServeMux()
|
||||||
t.Fatalf("OpenStore: %v", err)
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
|
||||||
}
|
return mux
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
cfg := testConfig()
|
|
||||||
cfg.UserscriptPath = path
|
|
||||||
return newRouter(store, cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
|
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||||
@@ -56,7 +54,7 @@ func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseR
|
|||||||
|
|
||||||
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
||||||
path, wantVersion := writeScript(t, sampleScript)
|
path, wantVersion := writeScript(t, sampleScript)
|
||||||
rr := getScript(t, newUserscriptServer(t, path), testToken)
|
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
@@ -83,10 +81,13 @@ func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The empty-token case ("/u//manga-bookmark.user.js") is covered at the
|
||||||
|
// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to
|
||||||
|
// "/u/manga-bookmark.user.js" before this handler's own token check ever runs.
|
||||||
func TestUserscriptWrongTokenIs404(t *testing.T) {
|
func TestUserscriptWrongTokenIs404(t *testing.T) {
|
||||||
path, _ := writeScript(t, sampleScript)
|
path, _ := writeScript(t, sampleScript)
|
||||||
srv := newUserscriptServer(t, path)
|
srv := newTestMux(testToken, path)
|
||||||
for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} {
|
for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
|
||||||
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
||||||
t.Errorf("token %q: status = %d, want 404", tok, got)
|
t.Errorf("token %q: status = %d, want 404", tok, got)
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ func TestUserscriptWrongTokenIs404(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUserscriptMissingFileIs404(t *testing.T) {
|
func TestUserscriptMissingFileIs404(t *testing.T) {
|
||||||
srv := newUserscriptServer(t, filepath.Join(t.TempDir(), "absent.user.js"))
|
srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
|
||||||
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
||||||
t.Fatalf("status = %d, want 404", got)
|
t.Fatalf("status = %d, want 404", got)
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,7 @@ func TestUserscriptMissingFileIs404(t *testing.T) {
|
|||||||
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
||||||
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
||||||
path, _ := writeScript(t, noVersion)
|
path, _ := writeScript(t, noVersion)
|
||||||
rr := getScript(t, newUserscriptServer(t, path), testToken)
|
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
@@ -112,21 +113,3 @@ func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
|||||||
t.Fatalf("body = %q, want it unmodified", rr.Body.String())
|
t.Fatalf("body = %q, want it unmodified", rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The endpoint must work on a deployment that never set WEB_PASSWORD, since
|
|
||||||
// the web routes are not registered at all in that case.
|
|
||||||
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
|
||||||
path, _ := writeScript(t, sampleScript)
|
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "nopass.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
cfg := testConfig()
|
|
||||||
cfg.WebPassword = ""
|
|
||||||
cfg.UserscriptPath = path
|
|
||||||
|
|
||||||
if got := getScript(t, newRouter(store, cfg), testToken).Code; got != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 973 B After Width: | Height: | Size: 973 B |
@@ -94,6 +94,8 @@
|
|||||||
|
|
||||||
--asura: #7d93a5;
|
--asura: #7d93a5;
|
||||||
--demonic: #a98a78;
|
--demonic: #a98a78;
|
||||||
|
--comix: #8a9a7d;
|
||||||
|
--kagane: #9a8aa5;
|
||||||
|
|
||||||
/* Covers are often missing; the hatch keeps the slot honest instead of
|
/* Covers are often missing; the hatch keeps the slot honest instead of
|
||||||
faking artwork. */
|
faking artwork. */
|
||||||
@@ -146,6 +148,8 @@
|
|||||||
|
|
||||||
--asura: #4f6b80;
|
--asura: #4f6b80;
|
||||||
--demonic: #8a6a55;
|
--demonic: #8a6a55;
|
||||||
|
--comix: #5f7250;
|
||||||
|
--kagane: #6f5f7d;
|
||||||
|
|
||||||
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
|
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
|
||||||
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
|
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
|
||||||
@@ -479,11 +483,14 @@ button { cursor: pointer; }
|
|||||||
.meta .sep { color: var(--faint); }
|
.meta .sep { color: var(--faint); }
|
||||||
.site-asura { color: var(--asura); }
|
.site-asura { color: var(--asura); }
|
||||||
.site-demonic { color: var(--demonic); }
|
.site-demonic { color: var(--demonic); }
|
||||||
|
.site-comix { color: var(--comix); }
|
||||||
|
.site-kagane { color: var(--kagane); }
|
||||||
.new-chapter { color: var(--ember); }
|
.new-chapter { color: var(--ember); }
|
||||||
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
|
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
|
||||||
.state svg { width: 10px; height: 10px; }
|
.state svg { width: 10px; height: 10px; }
|
||||||
.is-dim .meta { color: var(--mute-2); }
|
.is-dim .meta { color: var(--mute-2); }
|
||||||
.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); }
|
.is-dim .site-asura, .is-dim .site-demonic,
|
||||||
|
.is-dim .site-comix, .is-dim .site-kagane { color: var(--mute); filter: grayscale(.6); }
|
||||||
|
|
||||||
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
||||||
.actions {
|
.actions {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/session"
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates
|
//go:embed templates
|
||||||
@@ -21,25 +24,25 @@ var templateFS embed.FS
|
|||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
// recentCount is how many series the "Continue reading" strip shows.
|
// RecentCount is how many series the "Continue reading" strip shows.
|
||||||
const recentCount = 5
|
const RecentCount = 5
|
||||||
|
|
||||||
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||||
// It is a separate handler from bookmarkHandler because the two speak different
|
// It is a separate handler from api.Handler because the two speak different
|
||||||
// representations (HTML versus JSON) to different clients under different auth.
|
// representations (HTML versus JSON) to different clients under different auth.
|
||||||
type webHandler struct {
|
type Handler struct {
|
||||||
store *Store
|
store *store.Store
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
key []byte
|
key []byte
|
||||||
password string
|
password string
|
||||||
limiter *loginLimiter
|
limiter *session.LoginLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// listView is what every list-rendering template receives.
|
// listView is what every list-rendering template receives.
|
||||||
type listView struct {
|
type listView struct {
|
||||||
Tab string // "all", "fav", or "new"
|
Tab string // "all", "fav", or "new"
|
||||||
Recent []Bookmark
|
Recent []store.Bookmark
|
||||||
Items []Bookmark
|
Items []store.Bookmark
|
||||||
// NewCount is the badge on the Updated tab: how many series being read
|
// NewCount is the badge on the Updated tab: how many series being read
|
||||||
// have a chapter out that has not been read. It is counted over the whole
|
// have a chapter out that has not been read. It is counted over the whole
|
||||||
// reading set, not the active tab, so the badge does not change meaning as
|
// reading set, not the active tab, so the badge does not change meaning as
|
||||||
@@ -50,38 +53,28 @@ type listView struct {
|
|||||||
OOB bool
|
OOB bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial is the monogram the templates show in place of a cover when the
|
|
||||||
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
|
||||||
// the title is missing, so the slot is never empty.
|
|
||||||
func (b Bookmark) Initial() string {
|
|
||||||
for _, r := range b.Title {
|
|
||||||
return strings.ToUpper(string(r))
|
|
||||||
}
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
// loginView is what the login template receives.
|
// loginView is what the login template receives.
|
||||||
type loginView struct {
|
type loginView struct {
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWebHandler parses every template up front so a broken one kills the
|
// New parses every template up front so a broken one kills the process at
|
||||||
// process at startup rather than the first request that touches it.
|
// startup rather than the first request that touches it.
|
||||||
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
func New(s *store.Store, apiToken, webPassword string) (*Handler, error) {
|
||||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &webHandler{
|
return &Handler{
|
||||||
store: store,
|
store: s,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
key: sessionKey(cfg.Token, cfg.WebPassword),
|
key: session.Key(apiToken, webPassword),
|
||||||
password: cfg.WebPassword,
|
password: webPassword,
|
||||||
limiter: newLoginLimiter(),
|
limiter: session.NewLoginLimiter(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) register(mux *http.ServeMux) {
|
func (h *Handler) Register(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /{$}", h.index)
|
mux.HandleFunc("GET /{$}", h.index)
|
||||||
mux.HandleFunc("POST /login", h.login)
|
mux.HandleFunc("POST /login", h.login)
|
||||||
mux.HandleFunc("POST /logout", h.logout)
|
mux.HandleFunc("POST /logout", h.logout)
|
||||||
@@ -118,15 +111,15 @@ func staticHandler() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// authed reports whether the request carries a valid session cookie.
|
// authed reports whether the request carries a valid session cookie.
|
||||||
func (h *webHandler) authed(r *http.Request) bool {
|
func (h *Handler) authed(r *http.Request) bool {
|
||||||
c, err := r.Cookie(sessionCookieName)
|
c, err := r.Cookie(session.CookieName)
|
||||||
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
return err == nil && session.Verify(h.key, c.Value, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireSession guards the fragment endpoints. It answers 401 rather than
|
// requireSession guards the fragment endpoints. It answers 401 rather than
|
||||||
// redirecting, because htmx swaps whatever body it receives into the page and a
|
// redirecting, because htmx swaps whatever body it receives into the page and a
|
||||||
// redirected login page would be spliced into the card list.
|
// redirected login page would be spliced into the card list.
|
||||||
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
func (h *Handler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.authed(r) {
|
if !h.authed(r) {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -136,7 +129,7 @@ func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
func (h *Handler) render(w http.ResponseWriter, status int, name string, data any) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
@@ -148,7 +141,7 @@ func (h *webHandler) render(w http.ResponseWriter, status int, name string, data
|
|||||||
// index renders the list, or the login page when there is no session. The login
|
// index renders the list, or the login page when there is no session. The login
|
||||||
// page is served at / with status 200 rather than as a redirect to a separate
|
// page is served at / with status 200 rather than as a redirect to a separate
|
||||||
// URL: one page, no redirect loop to reason about.
|
// URL: one page, no redirect loop to reason about.
|
||||||
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.authed(r) {
|
if !h.authed(r) {
|
||||||
h.render(w, http.StatusOK, "login", loginView{})
|
h.render(w, http.StatusOK, "login", loginView{})
|
||||||
return
|
return
|
||||||
@@ -164,8 +157,8 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// filterBookmarks returns the subset keep reports true for, preserving order.
|
// filterBookmarks returns the subset keep reports true for, preserving order.
|
||||||
// It always returns a non-nil slice so an empty tab renders its empty state.
|
// It always returns a non-nil slice so an empty tab renders its empty state.
|
||||||
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []store.Bookmark {
|
||||||
out := []Bookmark{}
|
out := []store.Bookmark{}
|
||||||
for _, b := range all {
|
for _, b := range all {
|
||||||
if keep(b) {
|
if keep(b) {
|
||||||
out = append(out, b)
|
out = append(out, b)
|
||||||
@@ -181,25 +174,25 @@ func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
|||||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
||||||
// archived favourite therefore shows only under Archived: Favourites means
|
// archived favourite therefore shows only under Archived: Favourites means
|
||||||
// "favourites I am currently reading".
|
// "favourites I am currently reading".
|
||||||
func (h *webHandler) buildListView(tab string) (listView, error) {
|
func (h *Handler) buildListView(tab string) (listView, error) {
|
||||||
all, err := h.store.List() // already ordered updated_at DESC
|
all, err := h.store.List() // already ordered updated_at DESC
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return listView{}, err
|
return listView{}, err
|
||||||
}
|
}
|
||||||
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
|
||||||
|
|
||||||
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
|
||||||
|
|
||||||
var items []Bookmark
|
var items []store.Bookmark
|
||||||
switch tab {
|
switch tab {
|
||||||
case "fav":
|
case "fav":
|
||||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
items = filterBookmarks(reading, func(b store.Bookmark) bool { return b.Favorite })
|
||||||
case "new":
|
case "new":
|
||||||
items = withNew
|
items = withNew
|
||||||
case "archived":
|
case "archived":
|
||||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
||||||
case "finished":
|
case "finished":
|
||||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
|
||||||
default:
|
default:
|
||||||
tab = "all"
|
tab = "all"
|
||||||
items = reading
|
items = reading
|
||||||
@@ -213,17 +206,17 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
|
|||||||
// It therefore disappears entirely on a library with nothing new. That is
|
// It therefore disappears entirely on a library with nothing new. That is
|
||||||
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
||||||
// costs on a phone belongs to the list.
|
// costs on a phone belongs to the list.
|
||||||
var recent []Bookmark
|
var recent []store.Bookmark
|
||||||
if tab == "all" {
|
if tab == "all" {
|
||||||
recent = withNew
|
recent = withNew
|
||||||
if len(recent) > recentCount {
|
if len(recent) > RecentCount {
|
||||||
recent = recent[:recentCount]
|
recent = recent[:RecentCount]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui list: %v", err)
|
log.Printf("ui list: %v", err)
|
||||||
@@ -253,7 +246,7 @@ func currentTab(r *http.Request) string {
|
|||||||
// mutation cannot leave them describing the library as it was before the tap.
|
// mutation cannot leave them describing the library as it was before the tap.
|
||||||
// The key is in here because it is tab-shaped too: archived and finished swap
|
// The key is in here because it is tab-shaped too: archived and finished swap
|
||||||
// Archive for Restore.
|
// Archive for Restore.
|
||||||
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||||
view.OOB = true
|
view.OOB = true
|
||||||
for _, name := range []string{"recent", "newcount", "keyrow"} {
|
for _, name := range []string{"recent", "newcount", "keyrow"} {
|
||||||
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
||||||
@@ -266,7 +259,7 @@ func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
|||||||
|
|
||||||
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
||||||
// mutation and appends it to the response.
|
// mutation and appends it to the response.
|
||||||
func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||||
view, err := h.buildListView(currentTab(r))
|
view, err := h.buildListView(currentTab(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui chrome: %v", err)
|
log.Printf("ui chrome: %v", err)
|
||||||
@@ -275,9 +268,9 @@ func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeChromeOOB(w, view)
|
h.writeChromeOOB(w, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := clientIP(r)
|
ip := session.ClientIP(r)
|
||||||
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 {
|
||||||
secs := int(wait.Seconds()) + 1
|
secs := int(wait.Seconds()) + 1
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||||
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
||||||
@@ -293,38 +286,38 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
got := r.PostFormValue("password")
|
got := r.PostFormValue("password")
|
||||||
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
||||||
h.limiter.fail(ip, time.Now())
|
h.limiter.Fail(ip, time.Now())
|
||||||
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.limiter.reset(ip)
|
h.limiter.Reset(ip)
|
||||||
setSessionCookie(w, r, h.key)
|
session.SetCookie(w, r, h.key)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
clearSessionCookie(w, r)
|
session.ClearCookie(w, r)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadForMutation fetches the row a mutation targets, writing the error
|
// loadForMutation fetches the row a mutation targets, writing the error
|
||||||
// response itself when there is nothing to mutate.
|
// response itself when there is nothing to mutate.
|
||||||
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
|
func (h *Handler) loadForMutation(w http.ResponseWriter, r *http.Request) (store.Bookmark, bool) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
return Bookmark{}, false
|
return store.Bookmark{}, false
|
||||||
}
|
}
|
||||||
b, ok, err := h.store.Get(key)
|
b, ok, err := h.store.Get(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui get %q: %v", key, err)
|
log.Printf("ui get %q: %v", key, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return Bookmark{}, false
|
return store.Bookmark{}, false
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return Bookmark{}, false
|
return store.Bookmark{}, false
|
||||||
}
|
}
|
||||||
return b, true
|
return b, true
|
||||||
}
|
}
|
||||||
@@ -338,7 +331,7 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
|
|||||||
// state is the feedback for the tap. The strip and the badge are not: they
|
// state is the feedback for the tap. The strip and the badge are not: they
|
||||||
// describe the whole library, so they are rebuilt out of band on every
|
// describe the whole library, so they are rebuilt out of band on every
|
||||||
// mutation, at the cost of one extra list read per toggle.
|
// mutation, at the cost of one extra list read per toggle.
|
||||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
|
func (h *Handler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b store.Bookmark) {
|
||||||
stored, err := h.store.Upsert(b)
|
stored, err := h.store.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui upsert %q: %v", b.Key, err)
|
log.Printf("ui upsert %q: %v", b.Key, err)
|
||||||
@@ -351,7 +344,7 @@ func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b
|
|||||||
|
|
||||||
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
||||||
// Upsert keeps the stored updated_at and the list does not reorder.
|
// Upsert keeps the stored updated_at and the list does not reorder.
|
||||||
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -367,7 +360,7 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||||
// list does not reorder.
|
// list does not reorder.
|
||||||
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -377,7 +370,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch s := r.PostFormValue("status"); s {
|
switch s := r.PostFormValue("status"); s {
|
||||||
case statusReading, statusArchived, statusFinished:
|
case store.StatusReading, store.StatusArchived, store.StatusFinished:
|
||||||
b.Status = s
|
b.Status = s
|
||||||
default:
|
default:
|
||||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||||
@@ -398,7 +391,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
||||||
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
||||||
// to "45") behind a frozen updated_at.
|
// to "45") behind a frozen updated_at.
|
||||||
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -425,7 +418,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||||
// place of the card — removing it from the page.
|
// place of the card — removing it from the page.
|
||||||
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
+50
-28
@@ -11,6 +11,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/api"
|
||||||
|
"mangabm/backend/internal/httpmw"
|
||||||
|
"mangabm/backend/internal/latest"
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
|
"mangabm/backend/internal/userscript"
|
||||||
|
"mangabm/backend/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all runtime settings, sourced from environment variables.
|
// Config holds all runtime settings, sourced from environment variables.
|
||||||
@@ -149,22 +156,22 @@ func loadConfig() Config {
|
|||||||
// newRouter wires routes and middleware. CORS is the outermost layer so
|
// newRouter wires routes and middleware. CORS is the outermost layer so
|
||||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||||
// /healthz is public.
|
// /healthz is public.
|
||||||
func newRouter(store *Store, cfg Config) http.Handler {
|
func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", healthz)
|
mux.HandleFunc("GET /healthz", api.Healthz)
|
||||||
|
|
||||||
// Outside withAuth (the updater sends no Authorization header) and outside
|
// Outside httpmw.Auth (the updater sends no Authorization header) and
|
||||||
// the WEB_PASSWORD gate (the script must be installable either way). The
|
// outside the WEB_PASSWORD gate (the script must be installable either
|
||||||
// path segment carries the token instead.
|
// way). The path segment carries the token instead.
|
||||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath))
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
|
||||||
|
|
||||||
h := &bookmarkHandler{store: store}
|
h := &api.Handler{Store: s}
|
||||||
protected := http.NewServeMux()
|
protected := http.NewServeMux()
|
||||||
protected.HandleFunc("GET /bookmarks", h.list)
|
protected.HandleFunc("GET /bookmarks", h.List)
|
||||||
protected.HandleFunc("PUT /bookmarks/{key}", h.put)
|
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
|
||||||
protected.HandleFunc("DELETE /bookmarks/{key}", h.delete)
|
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
|
||||||
|
|
||||||
auth := withAuth(cfg.Token, protected)
|
auth := httpmw.Auth(cfg.Token, protected)
|
||||||
mux.Handle("/bookmarks", auth)
|
mux.Handle("/bookmarks", auth)
|
||||||
mux.Handle("/bookmarks/", auth)
|
mux.Handle("/bookmarks/", auth)
|
||||||
|
|
||||||
@@ -172,14 +179,14 @@ func newRouter(store *Store, cfg Config) http.Handler {
|
|||||||
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
||||||
// exposing an unprotected list.
|
// exposing an unprotected list.
|
||||||
if cfg.WebPassword != "" {
|
if cfg.WebPassword != "" {
|
||||||
web, err := newWebHandler(store, cfg)
|
wh, err := web.New(s, cfg.Token, cfg.WebPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web handler: %v", err)
|
log.Fatalf("web handler: %v", err)
|
||||||
}
|
}
|
||||||
web.register(mux)
|
wh.Register(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux)))
|
return httpmw.CORS(cfg.AllowedOrigins, httpmw.Gzip(guardEmptyUserscriptToken(mux)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
||||||
@@ -203,22 +210,22 @@ func main() {
|
|||||||
log.Fatal("API_TOKEN is required")
|
log.Fatal("API_TOKEN is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := OpenStore(cfg.DBPath)
|
s, err := store.Open(cfg.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open store: %v", err)
|
log.Fatalf("open store: %v", err)
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer s.Close()
|
||||||
|
|
||||||
// The poller is off the request path entirely: if it cannot start, the
|
// The poller is off the request path entirely: if it cannot start, the
|
||||||
// service still serves bookmarks and the userscript still captures latest
|
// service still serves bookmarks and the userscript still captures latest
|
||||||
// chapters on its own.
|
// chapters on its own.
|
||||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||||
defer stopPoll()
|
defer stopPoll()
|
||||||
startLatestPoller(pollCtx, store, cfg.LatestPoll)
|
startLatestPoller(pollCtx, s, cfg.LatestPoll)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: newRouter(store, cfg),
|
Handler: newRouter(s, cfg),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,24 +255,39 @@ func main() {
|
|||||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||||
// feature going missing degrades the service to userscript-only latest-chapter
|
// feature going missing degrades the service to userscript-only latest-chapter
|
||||||
// tracking, which is exactly how it behaved before.
|
// tracking, which is exactly how it behaved before.
|
||||||
func startLatestPoller(ctx context.Context, store *Store, cfg LatestPoll) {
|
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
log.Println("latest-chapter poller: disabled by config")
|
log.Println("latest-chapter poller: disabled by config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f, err := newTLSFetcher()
|
f, err := latest.NewTLSFetcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := &latestPoller{
|
p := &latest.Poller{
|
||||||
store: store,
|
Store: s,
|
||||||
fetch: f,
|
Fetch: f,
|
||||||
now: time.Now,
|
Now: time.Now,
|
||||||
cooldown: cfg.Cooldown,
|
Cooldown: cfg.Cooldown,
|
||||||
interval: cfg.Interval,
|
Interval: cfg.Interval,
|
||||||
stagger: cfg.Stagger,
|
Stagger: cfg.Stagger,
|
||||||
batch: cfg.Batch,
|
Batch: cfg.Batch,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional: without it, sites behind a JavaScript challenge are simply not
|
||||||
|
// polled, and their latest_chapter comes from the userscript alone — which
|
||||||
|
// is how the service behaved before the sidecar existed.
|
||||||
|
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
|
||||||
|
bf, err := latest.NewBrowserFetcher(ws)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("latest-chapter poller: browser fetcher disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
p.BrowserFetch = bf
|
||||||
|
context.AfterFunc(ctx, bf.Close)
|
||||||
|
log.Printf("latest-chapter poller: browser fetcher at %s", ws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go p.Run(ctx)
|
go p.Run(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadLatestPollDefaults(t *testing.T) {
|
func TestLoadLatestPollDefaults(t *testing.T) {
|
||||||
@@ -148,7 +150,7 @@ func TestPutStatusValidation(t *testing.T) {
|
|||||||
if tc.want != http.StatusOK {
|
if tc.want != http.StatusOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var got Bookmark
|
var got store.Bookmark
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
@@ -187,7 +189,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var got Bookmark
|
var got store.Bookmark
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-85
@@ -10,6 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/session"
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
|
"mangabm/backend/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testPassword = "hunter2"
|
const testPassword = "hunter2"
|
||||||
@@ -22,22 +26,22 @@ func webConfig() Config {
|
|||||||
|
|
||||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||||
// can seed rows and assert on what the handlers wrote back.
|
// can seed rows and assert on what the handlers wrote back.
|
||||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) {
|
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { store.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
return newRouter(store, cfg), store
|
return newRouter(st, cfg), st
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionCookie returns a cookie a handler will accept for cfg's API token.
|
// sessionCookie returns a cookie a handler will accept for cfg's API token.
|
||||||
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
|
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: session.CookieName,
|
||||||
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
Value: session.Sign(session.Key(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +60,8 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
|||||||
|
|
||||||
func TestIndexWithSessionShowsList(t *testing.T) {
|
func TestIndexWithSessionShowsList(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
if _, err := store.Upsert(Bookmark{
|
if _, err := st.Upsert(store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
@@ -90,8 +94,8 @@ func TestLoginSuccessSetsCookie(t *testing.T) {
|
|||||||
t.Fatalf("POST /login status = %d, want 303", rr.Code)
|
t.Fatalf("POST /login status = %d, want 303", rr.Code)
|
||||||
}
|
}
|
||||||
cookies := rr.Result().Cookies()
|
cookies := rr.Result().Cookies()
|
||||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
|
||||||
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName)
|
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,14 +126,14 @@ func TestLoginRateLimited(t *testing.T) {
|
|||||||
srv.ServeHTTP(rr, req)
|
srv.ServeHTTP(rr, req)
|
||||||
return rr
|
return rr
|
||||||
}
|
}
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < session.MaxFailures; i++ {
|
||||||
if code := post().Code; code != http.StatusUnauthorized {
|
if code := post().Code; code != http.StatusUnauthorized {
|
||||||
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
|
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rr := post()
|
rr := post()
|
||||||
if rr.Code != http.StatusTooManyRequests {
|
if rr.Code != http.StatusTooManyRequests {
|
||||||
t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code)
|
t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
|
||||||
}
|
}
|
||||||
if after := rr.Header().Get("Retry-After"); after == "" {
|
if after := rr.Header().Get("Retry-After"); after == "" {
|
||||||
t.Fatal("429 response has no Retry-After header")
|
t.Fatal("429 response has no Retry-After header")
|
||||||
@@ -202,9 +206,9 @@ func TestStaticAssetsServed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seed inserts one bookmark and returns it as stored.
|
// seed inserts one bookmark and returns it as stored.
|
||||||
func seed(t *testing.T, store *Store, b Bookmark) Bookmark {
|
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
stored, err := store.Upsert(b)
|
stored, err := st.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
@@ -245,8 +249,8 @@ func TestUIRoutesRequireSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
UpdatedAt: 1_000_000,
|
UpdatedAt: 1_000_000,
|
||||||
@@ -258,7 +262,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
after, ok, err := store.Get("asura:solo")
|
after, ok, err := st.Get("asura:solo")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
||||||
}
|
}
|
||||||
@@ -276,7 +280,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
// Toggling again turns it back off.
|
// Toggling again turns it back off.
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
||||||
back, _, _ := store.Get("asura:solo")
|
back, _, _ := st.Get("asura:solo")
|
||||||
if back.Favorite {
|
if back.Favorite {
|
||||||
t.Fatal("Favorite = true after a second toggle, want false")
|
t.Fatal("Favorite = true after a second toggle, want false")
|
||||||
}
|
}
|
||||||
@@ -294,8 +298,8 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
// selector, just a regression guard against reintroducing the bare-id form.
|
// selector, just a regression guard against reintroducing the bare-id form.
|
||||||
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
UpdatedAt: 1_000_000,
|
UpdatedAt: 1_000_000,
|
||||||
@@ -320,8 +324,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||||
@@ -335,7 +339,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
after, ok, err := store.Get("asura:solo")
|
after, ok, err := st.Get("asura:solo")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
||||||
}
|
}
|
||||||
@@ -355,8 +359,8 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
|||||||
|
|
||||||
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
||||||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||||
@@ -375,7 +379,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
after, ok, err := store.Get("asura:solo")
|
after, ok, err := st.Get("asura:solo")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
||||||
}
|
}
|
||||||
@@ -395,8 +399,8 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
|||||||
|
|
||||||
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
@@ -409,7 +413,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
|||||||
if rr.Code != http.StatusBadRequest {
|
if rr.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
}
|
}
|
||||||
after, _, _ := store.Get("asura:solo")
|
after, _, _ := st.Get("asura:solo")
|
||||||
if after.LastChapterNum != 45 {
|
if after.LastChapterNum != 45 {
|
||||||
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
||||||
}
|
}
|
||||||
@@ -440,8 +444,8 @@ func TestMutationsOnMissingKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIDeleteRemovesRow(t *testing.T) {
|
func TestUIDeleteRemovesRow(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
@@ -460,19 +464,19 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
|||||||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
||||||
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
||||||
}
|
}
|
||||||
if _, ok, _ := store.Get("asura:solo"); ok {
|
if _, ok, _ := st.Get("asura:solo"); ok {
|
||||||
t.Fatal("row still present after delete")
|
t.Fatal("row still present after delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIListFavouritesTab(t *testing.T) {
|
func TestUIListFavouritesTab(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
||||||
})
|
})
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||||||
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
@@ -493,14 +497,14 @@ func TestUIListFavouritesTab(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIListNewTab(t *testing.T) {
|
func TestUIListNewTab(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapterNum: 10,
|
Title: "Solo Leveling", LastChapterNum: 10,
|
||||||
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
||||||
UpdatedAt: 2_000_000,
|
UpdatedAt: 2_000_000,
|
||||||
})
|
})
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||||||
Title: "Tower of God", LastChapterNum: 5,
|
Title: "Tower of God", LastChapterNum: 5,
|
||||||
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
||||||
@@ -524,22 +528,22 @@ func TestUIListNewTab(t *testing.T) {
|
|||||||
// seedStatusRows puts one series in each bucket, the archived one also
|
// seedStatusRows puts one series in each bucket, the archived one also
|
||||||
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
||||||
// shows up as a failure rather than passing by accident.
|
// shows up as a failure rather than passing by accident.
|
||||||
func seedStatusRows(t *testing.T, store *Store) {
|
func seedStatusRows(t *testing.T, st *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// floatPtr already exists in store_test.go — same package, reuse it.
|
// floatPtr already exists in store_test.go — same package, reuse it.
|
||||||
rows := []Bookmark{
|
rows := []store.Bookmark{
|
||||||
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
||||||
Status: statusReading, LastChapterNum: 10, Favorite: true,
|
Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
|
||||||
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
||||||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||||
Status: statusArchived, LastChapterNum: 5, Favorite: true,
|
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
||||||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||||||
Status: statusFinished, LastChapterNum: 200, Favorite: true},
|
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
|
||||||
}
|
}
|
||||||
for _, b := range rows {
|
for _, b := range rows {
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
if _, err := store.Upsert(b); err != nil {
|
if _, err := st.Upsert(b); err != nil {
|
||||||
t.Fatalf("seed %s: %v", b.Key, err)
|
t.Fatalf("seed %s: %v", b.Key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,8 +551,8 @@ func seedStatusRows(t *testing.T, store *Store) {
|
|||||||
|
|
||||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
tab string
|
tab string
|
||||||
@@ -604,16 +608,16 @@ func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
|
|||||||
// updated_at-ordered list below it does not already say — and only on All.
|
// updated_at-ordered list below it does not already say — and only on All.
|
||||||
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store) // ReadingOne is at 10 with 11 out; the rest are not reading
|
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
|
||||||
|
|
||||||
// A reading series that is caught up has nothing waiting, so it stays out.
|
// A reading series that is caught up has nothing waiting, so it stays out.
|
||||||
caught := Bookmark{
|
caught := store.Bookmark{
|
||||||
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
||||||
Status: statusReading, LastChapterNum: 40, LatestChapter: "40",
|
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
|
||||||
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(caught); err != nil {
|
if _, err := st.Upsert(caught); err != nil {
|
||||||
t.Fatalf("seed %s: %v", caught.Key, err)
|
t.Fatalf("seed %s: %v", caught.Key, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,12 +637,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nothing new anywhere: the strip has nothing to say and does not render.
|
// Nothing new anywhere: the strip has nothing to say and does not render.
|
||||||
reading, _, err := store.Get("asura:reading")
|
reading, _, err := st.Get("asura:reading")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get: %v", err)
|
t.Fatalf("Get: %v", err)
|
||||||
}
|
}
|
||||||
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
||||||
if _, err := store.Upsert(reading); err != nil {
|
if _, err := st.Upsert(reading); err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
// The section still ships (an out-of-band swap needs the id to exist) but
|
// The section still ships (an out-of-band swap needs the id to exist) but
|
||||||
@@ -652,23 +656,23 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The strip never grows past recentCount, however many series are waiting.
|
// The strip never grows past web.RecentCount, however many series are waiting.
|
||||||
func TestRecentStripCapped(t *testing.T) {
|
func TestRecentStripCapped(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
for i := 0; i <= recentCount; i++ {
|
for i := 0; i <= web.RecentCount; i++ {
|
||||||
b := Bookmark{
|
b := store.Bookmark{
|
||||||
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
||||||
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
||||||
Status: statusReading, LastChapterNum: 1, LatestChapter: "2",
|
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
|
||||||
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(b); err != nil {
|
if _, err := st.Upsert(b); err != nil {
|
||||||
t.Fatalf("seed %s: %v", b.Key, err)
|
t.Fatalf("seed %s: %v", b.Key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount {
|
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
|
||||||
t.Fatalf("strip rendered %d cards, want %d", got, recentCount)
|
t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,14 +690,14 @@ func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string)
|
|||||||
|
|
||||||
func TestUIStatusSetsBucket(t *testing.T) {
|
func TestUIStatusSetsBucket(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
for _, want := range []string{statusArchived, statusFinished, statusReading} {
|
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
|
||||||
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
||||||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
b, ok, err := store.Get("asura:reading")
|
b, ok, err := st.Get("asura:reading")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -705,21 +709,21 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
}
|
}
|
||||||
b, _, _ := store.Get("asura:reading")
|
b, _, _ := st.Get("asura:reading")
|
||||||
if b.Status != statusReading {
|
if b.Status != store.StatusReading {
|
||||||
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIStatusRequiresSession(t *testing.T) {
|
func TestUIStatusRequiresSession(t *testing.T) {
|
||||||
srv, store := newWebTestServer(t, webConfig())
|
srv, st := newWebTestServer(t, webConfig())
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||||||
strings.NewReader("status=archived"))
|
strings.NewReader("status=archived"))
|
||||||
@@ -734,15 +738,15 @@ func TestUIStatusRequiresSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
before, _, _ := store.Get("asura:reading")
|
before, _, _ := st.Get("asura:reading")
|
||||||
time.Sleep(2 * time.Millisecond)
|
time.Sleep(2 * time.Millisecond)
|
||||||
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
|
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d", rr.Code)
|
t.Fatalf("status = %d", rr.Code)
|
||||||
}
|
}
|
||||||
after, _, _ := store.Get("asura:reading")
|
after, _, _ := st.Get("asura:reading")
|
||||||
if after.UpdatedAt != before.UpdatedAt {
|
if after.UpdatedAt != before.UpdatedAt {
|
||||||
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
||||||
}
|
}
|
||||||
@@ -750,8 +754,8 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
|
|||||||
|
|
||||||
func TestCardShowsStatusControls(t *testing.T) {
|
func TestCardShowsStatusControls(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
tab string
|
tab string
|
||||||
@@ -788,8 +792,8 @@ func TestCardShowsStatusControls(t *testing.T) {
|
|||||||
|
|
||||||
func TestAppRendersNewTabs(t *testing.T) {
|
func TestAppRendersNewTabs(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.AddCookie(sessionCookie(t, cfg))
|
req.AddCookie(sessionCookie(t, cfg))
|
||||||
@@ -807,10 +811,10 @@ func TestAppRendersNewTabs(t *testing.T) {
|
|||||||
// outside the swapped card, so nothing else would correct them.
|
// outside the swapped card, so nothing else would correct them.
|
||||||
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
||||||
Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
||||||
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -820,7 +824,7 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
||||||
url.Values{"status": {statusArchived}})
|
url.Values{"status": {store.StatusArchived}})
|
||||||
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, req)
|
srv.ServeHTTP(rr, req)
|
||||||
|
|||||||
@@ -17,8 +17,19 @@ services:
|
|||||||
manga-api:
|
manga-api:
|
||||||
# Traffic arrives over the Traefik network, not a published port.
|
# Traffic arrives over the Traefik network, not a published port.
|
||||||
ports: !reset []
|
ports: !reset []
|
||||||
|
environment:
|
||||||
|
# Must be an IP, not the DNS name — see the base file's comment on this
|
||||||
|
# same key: Chrome's DevTools HTTP handler 500s any Host header that
|
||||||
|
# isn't an IP or "localhost".
|
||||||
|
BROWSER_WS_URL: ${BROWSER_WS_URL:-ws://172.28.0.10:9222}
|
||||||
|
depends_on:
|
||||||
|
- headless-shell
|
||||||
|
# `networks:` here replaces the base file's list entirely, so both must be
|
||||||
|
# named: `proxy` for Traefik routing, `browser` (defined in the base file)
|
||||||
|
# to keep reaching headless-shell without putting it on `proxy` too.
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
- browser
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=${PROXY_NETWORK:-proxy}"
|
- "traefik.docker.network=${PROXY_NETWORK:-proxy}"
|
||||||
@@ -36,6 +47,11 @@ services:
|
|||||||
- "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
- "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
||||||
- "traefik.http.routers.mangaweb.service=mangabm"
|
- "traefik.http.routers.mangaweb.service=mangabm"
|
||||||
|
|
||||||
|
# headless-shell is untouched here: it keeps its `browser` network membership
|
||||||
|
# from the base file and must never join `proxy` — that network is shared
|
||||||
|
# with whatever else sits behind Traefik on this host, and an exposed
|
||||||
|
# CDP endpoint on it would be remote code execution for any of them.
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
+50
-1
@@ -15,7 +15,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# API_TOKEN is required — compose refuses to start without it.
|
# API_TOKEN is required — compose refuses to start without it.
|
||||||
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env}
|
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env}
|
||||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org}
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to}
|
||||||
DB_PATH: /data/bookmarks.db
|
DB_PATH: /data/bookmarks.db
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
# Gates the browser UI. Unset means the web routes are not served at all.
|
# Gates the browser UI. Unset means the web routes are not served at all.
|
||||||
@@ -29,6 +29,17 @@ services:
|
|||||||
LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m}
|
LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m}
|
||||||
LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14}
|
LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14}
|
||||||
LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s}
|
LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s}
|
||||||
|
# CDP endpoint for sites behind a JavaScript challenge (kagane). Unset
|
||||||
|
# disables browser polling for those sites; the userscript still covers them.
|
||||||
|
# Must be an IP, not the "headless-shell" DNS name: Chrome's DevTools HTTP
|
||||||
|
# handler rejects the discovery request (GET /json/version) with a 500
|
||||||
|
# unless the Host header is an IP address or "localhost" — confirmed
|
||||||
|
# 2026-08-03 against chromedp/headless-shell:stable, independent of
|
||||||
|
# chromedp's own dial logic. The sidecar's static address below exists so
|
||||||
|
# this URL survives container recreation.
|
||||||
|
BROWSER_WS_URL: ${BROWSER_WS_URL:-ws://172.28.0.10:9222}
|
||||||
|
depends_on:
|
||||||
|
- headless-shell
|
||||||
volumes:
|
volumes:
|
||||||
- bookmarks-data:/data
|
- bookmarks-data:/data
|
||||||
# The userscript is served from here, read fresh on every request. Editing
|
# The userscript is served from here, read fresh on every request. Editing
|
||||||
@@ -40,6 +51,44 @@ services:
|
|||||||
# the public internet does not.
|
# the public internet does not.
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
|
networks:
|
||||||
|
- browser
|
||||||
|
|
||||||
|
headless-shell:
|
||||||
|
image: chromedp/headless-shell:stable
|
||||||
|
restart: unless-stopped
|
||||||
|
# Chrome allocates shared memory per tab and dies on Docker's 64MB default.
|
||||||
|
shm_size: '1gb'
|
||||||
|
# Reaps zombie renderer processes, which otherwise accumulate for the
|
||||||
|
# container's lifetime.
|
||||||
|
init: true
|
||||||
|
# Deliberately no `ports:` — an exposed CDP endpoint is remote code
|
||||||
|
# execution. Only manga-api, via the `browser` network below, may reach it.
|
||||||
|
# Don't pass --remote-debugging-address/--remote-debugging-port here: the
|
||||||
|
# image's own entrypoint (/headless-shell/run.sh) already starts Chrome on
|
||||||
|
# 127.0.0.1:9223 and fronts it with a socat proxy listening on 0.0.0.0:9222.
|
||||||
|
# Redeclaring the port flag here overrides Chrome's, so it binds 9222
|
||||||
|
# directly (IPv6 loopback only) instead of 9223 — collides with socat's own
|
||||||
|
# bind on 9222 and leaves nothing listening on 9223, so every external
|
||||||
|
# connection to headless-shell:9222 fails with EOF. Only pass flags the
|
||||||
|
# entrypoint doesn't already set.
|
||||||
|
command:
|
||||||
|
- --disable-gpu
|
||||||
|
- --no-sandbox
|
||||||
|
networks:
|
||||||
|
browser:
|
||||||
|
# Pinned so BROWSER_WS_URL can name an IP (required, see above) that
|
||||||
|
# survives `docker compose up` recreating this container.
|
||||||
|
ipv4_address: 172.28.0.10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bookmarks-data:
|
bookmarks-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Not `internal: true`: headless Chrome still needs outbound access to reach
|
||||||
|
# kagane.to. Isolation here comes from membership (only manga-api and
|
||||||
|
# headless-shell join it), not from cutting egress.
|
||||||
|
browser:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.28.0.0/24
|
||||||
|
|||||||
+121
-52
@@ -1,16 +1,16 @@
|
|||||||
# Cinder — mangaBookmark design system
|
# Cinder — mangaBookmark design system
|
||||||
|
|
||||||
Source of truth: the Claude Design doc **Cinder Sheet**
|
Source of truth: the Claude Design project **mangaBookmark Web UI**
|
||||||
(`cfa39183-8874-4f76-987c-afef14dceebb`, files `Cinder Sheet.dc.html` for the
|
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
|
||||||
static spec and `Cinder Sheet App.dc.html` for the interactive one). This file
|
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
|
||||||
records the rules that got implemented so a future agent can extend the UI
|
`style.css`, `filter.js`). This file records the rules that got implemented so
|
||||||
without re-reading the design.
|
a future agent can extend the UI without re-reading the design.
|
||||||
|
|
||||||
Implemented in:
|
Implemented in:
|
||||||
|
|
||||||
| Surface | Files |
|
| Surface | Files |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,icons}.html`, `backend/static/filter.js` |
|
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,chrome,icons}.html`, `backend/static/filter.js` |
|
||||||
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
|
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
|
||||||
|
|
||||||
## 1. The one idea
|
## 1. The one idea
|
||||||
@@ -19,9 +19,12 @@ Implemented in:
|
|||||||
allowed to be crimson: its title turns `--paper-hot` and sits on a 1px ember
|
allowed to be crimson: its title turns `--paper-hot` and sits on a 1px ember
|
||||||
underline sized to the text, its cover gains a 3px ember rule at the foot, and
|
underline sized to the text, its cover gains a 3px ember rule at the foot, and
|
||||||
its `Ch N out` meta and play icon go ember. Everything else — favourites,
|
its `Ch N out` meta and play icon go ember. Everything else — favourites,
|
||||||
status, chrome — stays cool. If a new feature wants to be noticed, it does *not*
|
status, chrome, destruction — stays off that one colour. Destruction gets its
|
||||||
get to borrow the ember; find a typographic answer (weight, italic, a rule) or
|
own token (`--danger`, a duller oxblood) precisely so a remove confirm is
|
||||||
use brass, which is already spoken for by favourites.
|
never mistaken across the room for an unread chapter. If a new feature wants
|
||||||
|
to be noticed, it does *not* get to borrow the ember; find a typographic
|
||||||
|
answer (weight, italic, a rule) or reach for one of the named action accents
|
||||||
|
(§2).
|
||||||
|
|
||||||
Corollaries:
|
Corollaries:
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ Corollaries:
|
|||||||
- **Three type roles, never mixed.** Display serif for anything a human reads as
|
- **Three type roles, never mixed.** Display serif for anything a human reads as
|
||||||
a name (brand, titles, tabs, primary buttons, empty-state headings). Mono
|
a name (brand, titles, tabs, primary buttons, empty-state headings). Mono
|
||||||
small-caps for machine facts (site, chapter numbers, labels, status, badges,
|
small-caps for machine facts (site, chapter numbers, labels, status, badges,
|
||||||
ghost buttons). Sans for prose only (empty-state body, hints).
|
ghost buttons, the action key). Sans for prose only (empty-state body, hints).
|
||||||
|
|
||||||
## 2. Tokens
|
## 2. Tokens
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
|
|||||||
| Token | Dark | Light | Use |
|
| Token | Dark | Light | Use |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `--ink` | `#100f0e` | `#f7f4ef` | page |
|
| `--ink` | `#100f0e` | `#f7f4ef` | page |
|
||||||
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form, toast) |
|
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
|
||||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
|
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
|
||||||
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
|
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
|
||||||
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
|
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
|
||||||
@@ -54,23 +57,34 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
|
|||||||
| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter |
|
| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter |
|
||||||
| `--paper-dim` | `#ddd5cb` | `#191715` | resting title |
|
| `--paper-dim` | `#ddd5cb` | `#191715` | resting title |
|
||||||
| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons |
|
| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons |
|
||||||
| `--mute-2` | `#5a5450` | `#857d75` | eyebrow labels, hints |
|
| `--mute-2` | `#877f76` | `#6c655e` | eyebrow labels, hints (must clear 4.5:1 on both `--ink` and `--ash`) |
|
||||||
| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line |
|
| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line |
|
||||||
| `--faint-2` | `#57504b` | `#a8a098` | cover monogram |
|
| `--faint-2` | `#57504b` | `#a8a098` | cover monogram |
|
||||||
| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 |
|
| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 |
|
||||||
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface (confirm, error) |
|
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface |
|
||||||
| `--ember-ink` | `#150907` | `#fff` | text on solid ember |
|
| `--ember-ink` | `#150907` | `#fff` | text on solid ember |
|
||||||
| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash |
|
| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash |
|
||||||
| `--brass` | `#b8912f` | `#8a681c` | favourites, and only favourites |
|
| `--danger` | `#cf5c4d` | `#97362a` | destruction — remove confirm, never the same as `--ember` |
|
||||||
| `--trash` | `#6b5450` | `#a98276` | remove, at rest |
|
| `--danger-wash` | `#211311` | `#fbe9e5` | remove-confirm surface |
|
||||||
|
| `--danger-ink` | `#150808` | `#fff` | text on solid danger |
|
||||||
|
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
|
||||||
|
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
|
||||||
|
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
|
||||||
|
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
|
||||||
|
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
|
||||||
|
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
|
||||||
|
| `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` |
|
||||||
|
| `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
|
||||||
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
|
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
|
||||||
| `--demonic` | `#a98a78` | `#8a6a55` | site tag |
|
| `--demonic` | `#a98a78` | `#8a6a55` | site tag |
|
||||||
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
|
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
|
||||||
|
|
||||||
Dark is the default (`color-scheme: dark light`); light is a
|
`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
|
||||||
`@media (prefers-color-scheme: light)` override of the same names. **Any new
|
one accent per action, so a press says which lane it belongs to, with none of
|
||||||
colour must be added in both branches** — light is not a filter over dark, the
|
them competing with ember. Dark is the default (`color-scheme: dark light`);
|
||||||
hues are re-tuned.
|
light is a `@media (prefers-color-scheme: light)` override of the same names.
|
||||||
|
**Any new colour must be added in both branches** — light is not a filter over
|
||||||
|
dark, the hues are re-tuned.
|
||||||
|
|
||||||
## 3. Type
|
## 3. Type
|
||||||
|
|
||||||
@@ -83,11 +97,10 @@ hues are re-tuned.
|
|||||||
The web UI **self-hosts** all three: five latin-subset woff2 files in
|
The web UI **self-hosts** all three: five latin-subset woff2 files in
|
||||||
`backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at
|
`backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at
|
||||||
the top of `style.css` and embedded in the binary by the existing
|
the top of `style.css` and embedded in the binary by the existing
|
||||||
`//go:embed static`. There is no request to Google — this UI is read in Bromite,
|
`//go:embed static`. There is no request to Google — this UI needs to survive
|
||||||
where `fonts.googleapis.com` is routinely blocked, and over a LAN with no
|
on a LAN with no internet route. `staticHandler()` in `web.go` registers the
|
||||||
internet route. `staticHandler()` in `web.go` registers the `.woff2` MIME type
|
`.woff2` MIME type because Go's built-in table lacks it and the scratch image
|
||||||
because Go's built-in table lacks it and the scratch image has no
|
has no `/etc/mime.types`.
|
||||||
`/etc/mime.types`.
|
|
||||||
|
|
||||||
Adding a weight means adding a file: grab the *latin* `@font-face` block from
|
Adding a weight means adding a file: grab the *latin* `@font-face` block from
|
||||||
`https://fonts.googleapis.com/css2?...` **with a browser User-Agent** (Google
|
`https://fonts.googleapis.com/css2?...` **with a browser User-Agent** (Google
|
||||||
@@ -102,37 +115,57 @@ root is at the mercy of the host site's CSP.
|
|||||||
|
|
||||||
Recurring specs (copy these rather than inventing sizes):
|
Recurring specs (copy these rather than inventing sizes):
|
||||||
|
|
||||||
- Brand: `400 26px/1 display`, with `<em>` in ember italic — `manga<em>Bookmark</em>`.
|
- Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `<em>` in
|
||||||
- Row title: `400 19px/1.2 display` (21px ≥720px).
|
ember italic — `manga<em>Bookmark</em>`.
|
||||||
- Tab: `400 17px display` (18px ≥720px), active gets `border-bottom: 2px` in
|
- Row title: `400 21px/1.2 display` (`22px` ≥720px).
|
||||||
|
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
|
||||||
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
|
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
|
||||||
the row's own hairline.
|
the row's own hairline.
|
||||||
- Meta / label / badge: `500 10px mono`, `letter-spacing: .12em`,
|
- Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
|
||||||
`text-transform: uppercase`. Eyebrows ("CONTINUE READING") use `.2em`.
|
.04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
|
||||||
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
|
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
|
||||||
- Primary button: `--paper` fill, `--ink` text, `400 17px display`, no border radius.
|
- Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
|
||||||
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
|
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
|
||||||
|
|
||||||
## 4. Components (web UI)
|
## 4. Components (web UI)
|
||||||
|
|
||||||
```
|
```
|
||||||
.sheet
|
.sheet
|
||||||
.topbar .brand + .ghost (log out)
|
.topbar .brand (mark + wordmark) + .ghost (log out)
|
||||||
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
|
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
|
||||||
|
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
|
||||||
.recent h2 eyebrow + .recent-strip > a.recent-card
|
.recent h2 eyebrow + .recent-strip > a.recent-card
|
||||||
main#list article.card … | .empty
|
main#list article.card … | .empty
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Brand mark**: an inline `<svg class="mark">` (`viewBox="0 0 200 172"`),
|
||||||
|
defined once in `chrome.html`'s `mark` template and reused by `app.html` and
|
||||||
|
`login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather
|
||||||
|
than shipping as a static asset. The blade at its centre strokes
|
||||||
|
`var(--logo-blade, var(--ember))` — override that custom property, don't
|
||||||
|
duplicate the SVG, if a surface ever needs a different blade colour. Drawn at
|
||||||
|
a 5px stroke on a 200-unit grid; at brand size that thins out, so `.brand .mark
|
||||||
|
g` nudges `stroke-width` up to `6.5` rather than scaling the artwork down.
|
||||||
|
|
||||||
|
**Action key** (`.keyrow`): one permanent line under the tabs naming what
|
||||||
|
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done /
|
||||||
|
Delete — so the icon strip on a card is never a guess. On a phone each pair
|
||||||
|
stacks icon-over-word (`flex-direction: column`) so the word gets the full
|
||||||
|
cell width and can stay in long form; ≥720px it lays out icon-beside-word and
|
||||||
|
switches the `.short`/`.full` label pair. `.pair.brass` and `.pair.trash`
|
||||||
|
carry their icon's resting accent so the key itself teaches the colour
|
||||||
|
vocabulary in §1/§2.
|
||||||
|
|
||||||
`article.card` — the row, and the only per-series component:
|
`article.card` — the row, and the only per-series component:
|
||||||
|
|
||||||
```
|
```
|
||||||
article.card[.is-new|.is-dim]#card-<key>[data-title]
|
article.card[.is-new|.is-dim]#card-<key>[data-title]
|
||||||
.row
|
.row
|
||||||
a.cover img | span.monogram, + span.foot-rule[.brass]
|
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
|
||||||
.body .title-line (h3.title + svg.fav-mark) , p.meta
|
.body .title-line (h3.title + svg.fav-mark) , p.meta
|
||||||
.actions play, favourite, chapter, archive|restore, finish, remove
|
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
|
||||||
form.chapter-form[hidden] .hint + .field(input + Save)
|
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
|
||||||
.confirm-row[hidden] span + (Remove, Cancel)
|
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
|
||||||
p.error-inline[hidden]
|
p.error-inline[hidden]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,9 +176,29 @@ Rules that are easy to break:
|
|||||||
dim rule is a descendant selector off those two classes, so a new sub-element
|
dim rule is a descendant selector off those two classes, so a new sub-element
|
||||||
inherits the state for free.
|
inherits the state for free.
|
||||||
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
|
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
|
||||||
full-width strip under the row on a phone and a group of 40px squares beside
|
full-width strip under the row on a phone and a group of 44px squares beside
|
||||||
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
|
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
|
||||||
`border-right: 1px var(--rule)`, last child none.
|
`border-right: 1px var(--rule)`, last child none.
|
||||||
|
- Three clusters by consequence, in this order: navigate (`.play`) | organize
|
||||||
|
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`,
|
||||||
|
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
|
||||||
|
`--ash` ground so the thumb reads "this one moves the series" before it
|
||||||
|
reads which icon it landed on; ≥720px they separate by a 10px gap instead of
|
||||||
|
the phone's inset hairline.
|
||||||
|
- Every lifecycle button that moves a series out of the list is
|
||||||
|
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`,
|
||||||
|
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish
|
||||||
|
ask in `.calm` grey since they're reversible; remove alone gets the
|
||||||
|
`--danger-wash` treatment and names the series in its question. Restore
|
||||||
|
fires instantly — no confirm — because it's the reversal.
|
||||||
|
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
|
||||||
|
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember
|
||||||
|
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
|
||||||
|
hover. Desktop cell borders follow the same accent on hover
|
||||||
|
(`border-color: currentColor`); the two coloured *resting* states
|
||||||
|
(`.is-new .play`, `.fav.on`) get their own dim border tokens
|
||||||
|
(`--play-hot-line`, `--fav-line`) instead of the full accent, since a
|
||||||
|
resting border needs less contrast than a hover one.
|
||||||
- Icons are `<use href="#i-…">` against the sprite in `templates/icons.html`,
|
- Icons are `<use href="#i-…">` against the sprite in `templates/icons.html`,
|
||||||
included once by `app.html`. htmx-swapped card fragments reference the
|
included once by `app.html`. htmx-swapped card fragments reference the
|
||||||
page's sprite, so a card never inlines a path. New icon → add a `<symbol>`
|
page's sprite, so a card never inlines a path. New icon → add a `<symbol>`
|
||||||
@@ -155,11 +208,15 @@ Rules that are easy to break:
|
|||||||
- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both.
|
- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both.
|
||||||
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
|
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
|
||||||
panel is a flex container, and `display` beats `hidden`.
|
panel is a flex container, and `display` beats `hidden`.
|
||||||
- Busy state is `.card.htmx-request::before`, a 1px ember bar sliding across the
|
- Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
|
||||||
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
|
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
|
||||||
spinner.
|
spinner, and deliberately `--mute` not `--ember` — on a list screen ember
|
||||||
- `.open` on the pencil / trash cell marks which panel is showing; `filter.js`
|
means "new chapter" and nothing else, so a system state can't borrow it.
|
||||||
`togglePanel()` owns that class alongside `hidden`.
|
- `.open` on the pencil / lifecycle cell marks which panel is showing;
|
||||||
|
`filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside
|
||||||
|
`hidden`. An open lifecycle cell needs the next surface step up from
|
||||||
|
`--hover` (`--rule`) to stay legible as the panel's owner, since the panel
|
||||||
|
itself already sits on `--ash`.
|
||||||
|
|
||||||
## 5. Components (userscript panel)
|
## 5. Components (userscript panel)
|
||||||
|
|
||||||
@@ -167,9 +224,9 @@ Same tokens, same heat rule, structure unchanged from before the revamp
|
|||||||
(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`).
|
(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`).
|
||||||
Cinder-specific: `.item.hot` (new chapter) and `.item.dim` (archived) mirror
|
Cinder-specific: `.item.hot` (new chapter) and `.item.dim` (archived) mirror
|
||||||
`.is-new` / `.is-dim`; chips and `.btn`s are mono small-caps with hairline
|
`.is-new` / `.is-dim`; chips and `.btn`s are mono small-caps with hairline
|
||||||
borders instead of pills; loading is the same sliding ember hairline (`.spinner`
|
borders instead of pills; loading is the same sliding hairline (`.spinner`
|
||||||
is now a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||||
ember-washed when `.err`.
|
`--danger`-washed when `.err`.
|
||||||
|
|
||||||
**Do not touch** the FAB geometry while restyling: `#fab` keeps
|
**Do not touch** the FAB geometry while restyling: `#fab` keeps
|
||||||
`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the
|
`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the
|
||||||
@@ -179,36 +236,48 @@ ember-washed when `.err`.
|
|||||||
## 6. Motion
|
## 6. Motion
|
||||||
|
|
||||||
Three animations, all ≤ 1.15s and all disabled under
|
Three animations, all ≤ 1.15s and all disabled under
|
||||||
`prefers-reduced-motion: reduce`:
|
`prefers-reduced-motion: reduce` (pseudo-elements need naming explicitly in
|
||||||
|
that query — `*` does not match `::before`/`::after`, so the busy bar and
|
||||||
|
error dot are listed by name and fall back to their static drawn form):
|
||||||
|
|
||||||
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
|
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
|
||||||
- `barSlide` — the burning hairline, for any busy state.
|
- `barSlide` — the sliding hairline, for any busy state.
|
||||||
- `emberPulse` — the 5px dot on `.error-inline`.
|
- `mutePulse` — the 5px dot on `.error-inline`.
|
||||||
|
|
||||||
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||||
|
|
||||||
## 7. Accessibility floor (not negotiable)
|
## 7. Accessibility floor (not negotiable)
|
||||||
|
|
||||||
- Touch targets on the phone layout are 44–46px; the 40px desktop cells are
|
- Touch targets on the phone layout are 44–46px; the 44px desktop cells are
|
||||||
pointer-only (≥720px).
|
pointer-only (≥720px).
|
||||||
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
|
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
|
||||||
`aria-hidden`.
|
`aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
|
||||||
|
`aria-controls` pointing at their `.confirm-row`.
|
||||||
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
|
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
|
||||||
and the play cell already reach the same URL — do not make it a third tab stop.
|
and the play cell already reach the same URL — do not make it a third tab stop.
|
||||||
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
|
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
|
||||||
and `setActiveTab()` in `filter.js` maintains it after an htmx swap.
|
and `setActiveTab()` in `filter.js` maintains it after an htmx swap.
|
||||||
|
- `.confirm-row` and `.error-inline` are `role="group"`/`role="status"` with
|
||||||
|
`aria-live="polite"` so a disclosure opening is announced.
|
||||||
- Light and dark are both first-class. Check any new colour in both.
|
- Light and dark are both first-class. Check any new colour in both.
|
||||||
|
|
||||||
## 8. Adding something new — checklist
|
## 8. Adding something new — checklist
|
||||||
|
|
||||||
1. Can it be a hairline, a small-caps label, or a serif line instead of a new
|
1. Can it be a hairline, a small-caps label, or a serif line instead of a new
|
||||||
component? Prefer that.
|
component? Prefer that.
|
||||||
2. Tokens only, both colour branches.
|
2. Tokens only, both colour branches. A new action gets its own named accent
|
||||||
|
(like `--slate`/`--moss`/`--clay`) at the same weight as the existing set —
|
||||||
|
never reuse `--ember` or `--danger` for anything but their one meaning.
|
||||||
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
|
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
|
||||||
third state class.
|
third state class.
|
||||||
4. Icon → `templates/icons.html`; nothing inlines SVG paths.
|
4. If it removes a series from the current view (archive/finish/remove-shaped),
|
||||||
5. Phone first (44px targets, single column), then the ≥720px block.
|
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
|
||||||
6. Verify: `cd backend && go test ./...`, then run the binary and screenshot
|
the only instant action because it's the one that's reversible by nature.
|
||||||
|
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
|
||||||
|
the one exception (`chrome.html`'s `mark` template), since it takes
|
||||||
|
page-level custom properties the sprite can't carry per-instance.
|
||||||
|
6. Phone first (44px targets, single column), then the ≥720px block.
|
||||||
|
7. Verify: `cd backend && go test ./...`, then run the binary and screenshot
|
||||||
both widths and both colour schemes (Playwright: `emulateMedia`,
|
both widths and both colour schemes (Playwright: `emulateMedia`,
|
||||||
`setViewportSize`; disable the browser cache — `/static/*` is served with
|
`setViewportSize`; disable the browser cache — `/static/*` is served with
|
||||||
`max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt
|
`max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @namespace mangabm
|
||||||
// @version 1.5.0
|
// @version 1.6.0
|
||||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
// @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||||
// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
// @match https://asurascans.com/*
|
// @match https://asurascans.com/*
|
||||||
// @match https://demonicscans.org/*
|
// @match https://demonicscans.org/*
|
||||||
|
// @match https://comix.to/*
|
||||||
|
// @match https://kagane.to/*
|
||||||
// @run-at document-idle
|
// @run-at document-idle
|
||||||
// @noframes
|
// @noframes
|
||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
@@ -80,6 +82,14 @@
|
|||||||
return slug.replace(/-[0-9a-f]{8}$/, "");
|
return slug.replace(/-[0-9a-f]{8}$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// comix path segments are "<id>-<slug>", where the slug is a rendering of the
|
||||||
|
// current title and changes when a series is renamed. Only the id is the
|
||||||
|
// identity; seriesUrl keeps the full segment because navigation needs it.
|
||||||
|
function comixSeriesId(segment) {
|
||||||
|
const i = segment.indexOf("-");
|
||||||
|
return i === -1 ? segment : segment.slice(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
const asura = {
|
const asura = {
|
||||||
site: "asura",
|
site: "asura",
|
||||||
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
||||||
@@ -208,7 +218,162 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADAPTERS = [asura, demonic];
|
const comix = {
|
||||||
|
site: "comix",
|
||||||
|
matches: (loc) => /(^|\.)comix\.to$/.test(loc.hostname),
|
||||||
|
detect(loc) {
|
||||||
|
const path = loc.pathname;
|
||||||
|
// /title/<id>-<slug>/<uploadId>-chapter-<n>. Several uploads (different
|
||||||
|
// groups or languages) share one chapter number; the number is the
|
||||||
|
// progress identity, the upload id is not.
|
||||||
|
let m = path.match(/^\/title\/([^/]+)\/[^/]*-chapter-([\d.]+)/);
|
||||||
|
if (m) {
|
||||||
|
const num = parseFloat(m[2]);
|
||||||
|
return {
|
||||||
|
type: "chapter",
|
||||||
|
site: this.site,
|
||||||
|
seriesId: comixSeriesId(m[1]),
|
||||||
|
title: cleanTitle(meta("og:title")),
|
||||||
|
cover: coverFromPage(),
|
||||||
|
seriesUrl: loc.origin + "/title/" + m[1],
|
||||||
|
chapterLabel: "Chapter " + m[2],
|
||||||
|
chapterNum: isNaN(num) ? null : num,
|
||||||
|
chapterUrl: loc.href,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// /title/<id>-<slug>
|
||||||
|
m = path.match(/^\/title\/([^/?#]+)\/?$/);
|
||||||
|
if (m) {
|
||||||
|
return {
|
||||||
|
type: "series",
|
||||||
|
site: this.site,
|
||||||
|
seriesId: comixSeriesId(m[1]),
|
||||||
|
title: cleanTitle(meta("og:title")),
|
||||||
|
cover: coverFromPage(),
|
||||||
|
seriesUrl: loc.origin + "/title/" + m[1],
|
||||||
|
chapterLabel: null,
|
||||||
|
chapterNum: null,
|
||||||
|
chapterUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: "other" };
|
||||||
|
|
||||||
|
// comix chapter og:title is "<Title> · Ch.<n>"; series is clean.
|
||||||
|
function cleanTitle(t) {
|
||||||
|
if (!t) return "";
|
||||||
|
return t.replace(/\s*·\s*Ch\.[\d.]+\s*$/i, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// comix serves no og:image, so this is the one adapter that has to read
|
||||||
|
// the DOM for a cover. Matching on alt rather than a class keeps it off
|
||||||
|
// the site's styling: the cover is the image whose alt is the title.
|
||||||
|
// Do not "simplify" this into meta("og:image") — that returns null.
|
||||||
|
function coverFromPage() {
|
||||||
|
const title = cleanTitle(meta("og:title"));
|
||||||
|
if (!title || !document.querySelectorAll) return "";
|
||||||
|
for (const img of document.querySelectorAll("img[alt]")) {
|
||||||
|
if (img.getAttribute("alt") === title) return img.getAttribute("src") || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Scoped to this series' own id prefix so a recommendation strip's links
|
||||||
|
// cannot win the maximum. seriesId is passed in because the anchors alone
|
||||||
|
// do not say which series the page belongs to.
|
||||||
|
latestChapterFromAnchors(anchors, seriesId) {
|
||||||
|
let best = null;
|
||||||
|
const re = new RegExp("^/title/" + seriesId + "-[^/]*/[^/]*-chapter-([\\d.]+)");
|
||||||
|
for (const a of anchors) {
|
||||||
|
const m = a.href.match(re);
|
||||||
|
if (!m) continue;
|
||||||
|
const num = parseFloat(m[1]);
|
||||||
|
if (isNaN(num)) continue;
|
||||||
|
if (!best || num > best.num) best = { num, label: "Chapter " + m[1] };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const kagane = {
|
||||||
|
site: "kagane",
|
||||||
|
matches: (loc) => /(^|\.)kagane\.to$/.test(loc.hostname),
|
||||||
|
detect(loc) {
|
||||||
|
const path = loc.pathname;
|
||||||
|
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||||
|
// /series/<uuid>/reader/<bookUuid> — no chapter number anywhere in the
|
||||||
|
// URL, so it comes out of og:title instead.
|
||||||
|
let m = path.match(new RegExp("^/series/(" + UUID + ")/reader/" + UUID));
|
||||||
|
if (m) {
|
||||||
|
const num = chapterNumFromTitle(meta("og:title"));
|
||||||
|
return {
|
||||||
|
type: "chapter",
|
||||||
|
site: this.site,
|
||||||
|
seriesId: m[1],
|
||||||
|
title: cleanTitle(meta("og:title")),
|
||||||
|
cover: meta("og:image") || "",
|
||||||
|
seriesUrl: loc.origin + "/series/" + m[1],
|
||||||
|
chapterLabel: num === null ? null : "Chapter " + num,
|
||||||
|
chapterNum: num,
|
||||||
|
chapterUrl: loc.href,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// /series/<uuid>
|
||||||
|
m = path.match(new RegExp("^/series/(" + UUID + ")/?$"));
|
||||||
|
if (m) {
|
||||||
|
return {
|
||||||
|
type: "series",
|
||||||
|
site: this.site,
|
||||||
|
seriesId: m[1],
|
||||||
|
title: cleanTitle(meta("og:title")),
|
||||||
|
cover: meta("og:image") || "",
|
||||||
|
seriesUrl: loc.origin + "/series/" + m[1],
|
||||||
|
chapterLabel: null,
|
||||||
|
chapterNum: null,
|
||||||
|
chapterUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: "other" };
|
||||||
|
|
||||||
|
// Reader og:title is "<Title> - Chapter <n> - <episode name>".
|
||||||
|
function chapterNumFromTitle(t) {
|
||||||
|
const m = t && t.match(/\s-\sChapter\s([\d.]+)\s/);
|
||||||
|
if (!m) return null;
|
||||||
|
const num = parseFloat(m[1]);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitle(t) {
|
||||||
|
if (!t) return "";
|
||||||
|
return t.replace(/\s-\sChapter\s[\d.]+\s-\s.*$/i, "").trim();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Reader hrefs are uuids with no number in them, so no maximum can be taken
|
||||||
|
// from anchors at all. latestChapterFromApi replaces this path entirely.
|
||||||
|
latestChapterFromAnchors() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
// Same-origin only: the request needs the Cloudflare clearance cookie that
|
||||||
|
// this browser already holds for kagane.to. Called cross-origin it would be
|
||||||
|
// challenged and return nothing.
|
||||||
|
async latestChapterFromApi(seriesId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v2/series/" + seriesId);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
let best = null;
|
||||||
|
for (const b of (data && data.series_books) || []) {
|
||||||
|
const num = parseFloat(b.chapter_no);
|
||||||
|
if (isNaN(num)) continue;
|
||||||
|
if (!best || num > best.num) best = { num, label: "Chapter " + b.chapter_no };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADAPTERS = [asura, demonic, comix, kagane];
|
||||||
|
|
||||||
function detect() {
|
function detect() {
|
||||||
const loc = window.location;
|
const loc = window.location;
|
||||||
@@ -227,9 +392,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Highest chapter the site lists, or null when the markup yields nothing.
|
// Highest chapter the site lists, or null when the markup yields nothing.
|
||||||
function computeLatestChapter(site, anchors) {
|
// seriesId is only consulted by adapters whose pages carry other series'
|
||||||
|
// chapter links; the rest ignore it.
|
||||||
|
function computeLatestChapter(site, anchors, seriesId) {
|
||||||
const a = adapterFor(site);
|
const a = adapterFor(site);
|
||||||
return a ? a.latestChapterFromAnchors(anchors) : null;
|
return a ? a.latestChapterFromAnchors(anchors, seriesId) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentSite() {
|
function currentSite() {
|
||||||
@@ -671,8 +838,8 @@
|
|||||||
title: existing.title || p.title || p.seriesId,
|
title: existing.title || p.title || p.seriesId,
|
||||||
series_url: existing.series_url || p.seriesUrl || "",
|
series_url: existing.series_url || p.seriesUrl || "",
|
||||||
cover: existing.cover || p.cover || "",
|
cover: existing.cover || p.cover || "",
|
||||||
last_chapter: p.chapterLabel || "",
|
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
||||||
last_chapter_num: p.chapterNum,
|
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
||||||
last_chapter_url: p.chapterUrl || "",
|
last_chapter_url: p.chapterUrl || "",
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -750,14 +917,15 @@
|
|||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
applyLatestChapterIfChanged(
|
applyLatestChapterIfChanged(
|
||||||
existing,
|
existing,
|
||||||
computeLatestChapter(p.site, anchorsFromDocument(document))
|
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else is only learned by fetching a series page. Same-origin
|
// Everything else is only learned by fetching a series page — or, where the
|
||||||
// only: these requests carry the session that gets us past the site's bot
|
// site offers one, its JSON API. Same-origin only: these requests carry the
|
||||||
// checks, which a request to the other site (or from a server) would not.
|
// session that gets us past the site's bot checks, which a request to the
|
||||||
// One series per navigation keeps it indistinguishable from browsing.
|
// other site (or from a server) would not. A few series per navigation keeps
|
||||||
|
// it indistinguishable from browsing.
|
||||||
async function backgroundRefreshLatest() {
|
async function backgroundRefreshLatest() {
|
||||||
const site = currentSite();
|
const site = currentSite();
|
||||||
if (!site) return;
|
if (!site) return;
|
||||||
@@ -772,15 +940,21 @@
|
|||||||
.slice(0, LATEST_CHECK_BATCH);
|
.slice(0, LATEST_CHECK_BATCH);
|
||||||
if (due.length === 0) return;
|
if (due.length === 0) return;
|
||||||
|
|
||||||
|
const adapter = adapterFor(site);
|
||||||
for (const bm of due) {
|
for (const bm of due) {
|
||||||
// Recorded even when the fetch fails, so a broken series is retried on
|
// Recorded even when the fetch fails, so a broken series is retried on
|
||||||
// the next throttle window rather than on every single page load.
|
// the next throttle window rather than on every single page load.
|
||||||
checked[bm.key] = Date.now();
|
checked[bm.key] = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
let latest;
|
||||||
if (!res.ok) continue;
|
if (adapter && adapter.latestChapterFromApi) {
|
||||||
const html = await res.text();
|
latest = await adapter.latestChapterFromApi(bm.series_id);
|
||||||
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html));
|
} else {
|
||||||
|
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const html = await res.text();
|
||||||
|
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
|
||||||
|
}
|
||||||
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* offline or blocked — try again after the throttle window */
|
/* offline or blocked — try again after the throttle window */
|
||||||
@@ -1414,6 +1588,8 @@
|
|||||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">Web</a>
|
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">Web</a>
|
||||||
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||||
|
<a class="chip" href="https://comix.to" target="_blank" rel="noopener">Comix</a>
|
||||||
|
<a class="chip" href="https://kagane.to" target="_blank" rel="noopener">Kagane</a>
|
||||||
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
||||||
</div>
|
</div>
|
||||||
<section id="context"></section>
|
<section id="context"></section>
|
||||||
@@ -1608,7 +1784,7 @@
|
|||||||
// Exposes pure logic only — see userscript/test/logic.test.js.
|
// Exposes pure logic only — see userscript/test/logic.test.js.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||||
module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
|
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ globalThis.location = {
|
|||||||
|
|
||||||
// og: meta tags the adapters read through meta(). Reassigned per test.
|
// og: meta tags the adapters read through meta(). Reassigned per test.
|
||||||
let metaTags = {};
|
let metaTags = {};
|
||||||
|
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
|
||||||
|
// entry is {alt, src}.
|
||||||
|
let pageImages = [];
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||||
@@ -38,14 +41,23 @@ globalThis.document = {
|
|||||||
const v = metaTags[m[1]];
|
const v = metaTags[m[1]];
|
||||||
return v == null ? null : { getAttribute: () => v };
|
return v == null ? null : { getAttribute: () => v };
|
||||||
},
|
},
|
||||||
|
querySelectorAll(sel) {
|
||||||
|
if (sel !== "img[alt]") return [];
|
||||||
|
return pageImages.map((img) => ({
|
||||||
|
getAttribute: (attr) => img[attr] ?? null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
body: undefined,
|
body: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stripBuildHash,
|
stripBuildHash,
|
||||||
|
comixSeriesId,
|
||||||
asura,
|
asura,
|
||||||
demonic,
|
demonic,
|
||||||
|
comix,
|
||||||
|
kagane,
|
||||||
anchorsFromHTML,
|
anchorsFromHTML,
|
||||||
statusOf,
|
statusOf,
|
||||||
} = require("../manga-bookmark.user.js");
|
} = require("../manga-bookmark.user.js");
|
||||||
@@ -166,6 +178,187 @@ test("demonic.latestChapterFromAnchors parses chaptered.php links, including &am
|
|||||||
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
|
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// comix — the id prefix is the stable identity; the slug tail is a title
|
||||||
|
// rendering that changes when a series is renamed.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test("comixSeriesId keeps only the prefix before the first dash", () => {
|
||||||
|
assert.equal(comixSeriesId("n8we-dungeons-and-crayons"), "n8we");
|
||||||
|
assert.equal(comixSeriesId("20xzd-the-baddest-villainess-is-back"), "20xzd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comixSeriesId leaves a bare id untouched", () => {
|
||||||
|
assert.equal(comixSeriesId("n8we"), "n8we");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix detects a series page", () => {
|
||||||
|
metaTags = { "og:title": "Dungeons and Crayons" };
|
||||||
|
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
|
||||||
|
assert.equal(p.type, "series");
|
||||||
|
assert.equal(p.site, "comix");
|
||||||
|
assert.equal(p.seriesId, "n8we");
|
||||||
|
assert.equal(p.title, "Dungeons and Crayons");
|
||||||
|
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
|
||||||
|
assert.equal(p.chapterNum, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix detects a chapter page and strips the Ch. suffix from the title", () => {
|
||||||
|
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
|
||||||
|
const p = comix.detect(
|
||||||
|
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
|
||||||
|
);
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.seriesId, "n8we");
|
||||||
|
assert.equal(p.title, "Dungeons and Crayons");
|
||||||
|
assert.equal(p.chapterNum, 80);
|
||||||
|
assert.equal(p.chapterLabel, "Chapter 80");
|
||||||
|
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix parses decimal chapter numbers", () => {
|
||||||
|
metaTags = { "og:title": "Dungeons and Crayons · Ch.80.5" };
|
||||||
|
const p = comix.detect(
|
||||||
|
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80.5")
|
||||||
|
);
|
||||||
|
assert.equal(p.chapterNum, 80.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix.detect reads the cover from an img whose alt matches the cleaned title", () => {
|
||||||
|
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
|
||||||
|
pageImages = [
|
||||||
|
{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" },
|
||||||
|
{ alt: "Dungeons and Crayons", src: "https://cdn.example/cover.jpg" },
|
||||||
|
];
|
||||||
|
const p = comix.detect(
|
||||||
|
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
|
||||||
|
);
|
||||||
|
assert.equal(p.cover, "https://cdn.example/cover.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix.detect leaves cover empty when no img alt matches the title", () => {
|
||||||
|
metaTags = { "og:title": "Dungeons and Crayons" };
|
||||||
|
pageImages = [{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" }];
|
||||||
|
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
|
||||||
|
assert.equal(p.cover, "");
|
||||||
|
pageImages = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix ignores unrelated paths", () => {
|
||||||
|
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix takes the max chapter and ignores other series' links", () => {
|
||||||
|
const anchors = anchorsFromHTML(`
|
||||||
|
<a href="/title/n8we-dungeons-and-crayons/11123327-chapter-79">Chapter 79</a>
|
||||||
|
<a href="/title/n8we-dungeons-and-crayons/11139891-chapter-80">Chapter 80</a>
|
||||||
|
<a href="/title/n8we-dungeons-and-crayons/10794753-chapter-78">Chapter 78</a>
|
||||||
|
<a href="/title/qqwrm-full-time-awakening/99999999-chapter-999">Chapter 999</a>
|
||||||
|
`);
|
||||||
|
assert.deepEqual(comix.latestChapterFromAnchors(anchors, "n8we"), {
|
||||||
|
num: 80,
|
||||||
|
label: "Chapter 80",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comix latest returns null when no chapter links are present", () => {
|
||||||
|
assert.equal(comix.latestChapterFromAnchors(anchorsFromHTML("<a href='/browse'>x</a>"), "n8we"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asura and demonic ignore the seriesId argument", () => {
|
||||||
|
const asuraAnchors = anchorsFromHTML(
|
||||||
|
`<a href="/comics/x-aabbccdd/chapter/12"><span>Chapter 12</span></a>`
|
||||||
|
);
|
||||||
|
assert.deepEqual(asura.latestChapterFromAnchors(asuraAnchors, "ignored"), {
|
||||||
|
num: 12,
|
||||||
|
label: "Chapter 12",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// kagane — reader URLs carry uuids and no chapter number, so the number has to
|
||||||
|
// come out of og:title. When that fails, chapterNum is null and the existing
|
||||||
|
// progress logic records the current chapter rather than guessing.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b";
|
||||||
|
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
|
||||||
|
|
||||||
|
test("kagane detects a series page", () => {
|
||||||
|
metaTags = {
|
||||||
|
"og:title": "Infinite Decryption: The Strongest Level 0",
|
||||||
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
||||||
|
};
|
||||||
|
const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES));
|
||||||
|
assert.equal(p.type, "series");
|
||||||
|
assert.equal(p.site, "kagane");
|
||||||
|
assert.equal(p.seriesId, KAGANE_SERIES);
|
||||||
|
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
|
||||||
|
assert.equal(p.cover, "https://kagane.to/api/v2/image/abc/compressed");
|
||||||
|
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kagane reads the chapter number out of og:title", () => {
|
||||||
|
metaTags = {
|
||||||
|
"og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41",
|
||||||
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
||||||
|
};
|
||||||
|
const p = kagane.detect(
|
||||||
|
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
||||||
|
);
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.seriesId, KAGANE_SERIES);
|
||||||
|
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
|
||||||
|
assert.equal(p.chapterNum, 41);
|
||||||
|
assert.equal(p.chapterLabel, "Chapter 41");
|
||||||
|
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kagane yields a null chapterNum when og:title has no chapter", () => {
|
||||||
|
metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
|
||||||
|
const p = kagane.detect(
|
||||||
|
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
||||||
|
);
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.chapterNum, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kagane ignores unrelated paths", () => {
|
||||||
|
assert.equal(kagane.detect(loc("https://kagane.to/search")).type, "other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kagane anchor scanning is structurally impossible and returns null", () => {
|
||||||
|
const anchors = anchorsFromHTML(
|
||||||
|
`<a href="/series/${KAGANE_SERIES}/reader/${KAGANE_BOOK}">Chapter 41</a>`
|
||||||
|
);
|
||||||
|
assert.equal(kagane.latestChapterFromAnchors(anchors, KAGANE_SERIES), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kagane latestChapterFromApi takes the max chapter_no", async () => {
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
assert.equal(url, "/api/v2/series/" + KAGANE_SERIES);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
series_books: [
|
||||||
|
{ chapter_no: "1", title: "Episode 1" },
|
||||||
|
{ chapter_no: "41", title: "Episode 41" },
|
||||||
|
{ chapter_no: "40.5", title: "Episode 40.5" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
assert.deepEqual(await kagane.latestChapterFromApi(KAGANE_SERIES), {
|
||||||
|
num: 41,
|
||||||
|
label: "Chapter 41",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kagane latestChapterFromApi returns null on a challenge or error", async () => {
|
||||||
|
globalThis.fetch = async () => ({ ok: false, status: 403 });
|
||||||
|
assert.equal(await kagane.latestChapterFromApi(KAGANE_SERIES), null);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Shared helpers
|
// Shared helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user