Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fcc4f7e49 | |||
| 9158709dc9 | |||
| e7d308ca87 | |||
| d16e82abf4 | |||
| 43d719c62c | |||
| c9487e5b31 | |||
| c560a61a9a | |||
| 07b075f04a | |||
| bef1469413 | |||
| c36b7326af | |||
| 13789184ff | |||
| f081780d8d | |||
| 8876e5e364 |
+6
-15
@@ -5,13 +5,12 @@
|
||||
API_TOKEN=changeme-generate-a-long-random-token
|
||||
|
||||
# Comma-separated origins allowed to call the API (CORS). Both Asura domains
|
||||
# plus Demonic, Comix, Kagane, and the two novel sites. Add/remove as the
|
||||
# sites' hostnames change.
|
||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net
|
||||
# plus Demonic. Add/remove as the sites' hostnames change.
|
||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
|
||||
|
||||
# --- Prod override (Traefik) only ---
|
||||
# Subdomain Traefik routes to this service (required by the prod override).
|
||||
# BOOKMARK_API_HOST=bookmark-api.example.com
|
||||
# MANGA_API_HOST=manga-api.example.com
|
||||
# Traefik's docker network name, if not "proxy".
|
||||
# PROXY_NETWORK=proxy
|
||||
# Traefik HTTPS entrypoint + cert resolver names, if yours differ from these.
|
||||
@@ -19,7 +18,7 @@ ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicsca
|
||||
# TRAEFIK_CERTRESOLVER=le
|
||||
|
||||
# --- Web UI ---
|
||||
# Password for the browser UI at https://$BOOKMARK_WEB_HOST. Leave unset to
|
||||
# Password for the browser UI at https://$MANGA_WEB_HOST. Leave unset to
|
||||
# disable the web UI entirely (the routes are not registered at all).
|
||||
# Generate one: openssl rand -base64 18
|
||||
WEB_PASSWORD=
|
||||
@@ -28,8 +27,8 @@ WEB_PASSWORD=
|
||||
# whether or not WEB_PASSWORD is set). Left commented on purpose: an example
|
||||
# value here would be a silent wrong-hostname fallback, and Traefik would
|
||||
# publish the UI router on a domain you do not own. The same container also
|
||||
# answers on BOOKMARK_API_HOST for the userscript's API.
|
||||
# BOOKMARK_WEB_HOST=bookmark.example.com
|
||||
# answers on MANGA_API_HOST for the userscript's API.
|
||||
# MANGA_WEB_HOST=manga.example.com
|
||||
|
||||
# --- Latest-chapter poller ---
|
||||
# The backend re-checks each bookmarked series' newest published chapter on its
|
||||
@@ -53,11 +52,3 @@ WEB_PASSWORD=
|
||||
# BATCH x (COOLDOWN / INTERVAL) series hold the cooldown cadence — 84 with these
|
||||
# defaults. Beyond that the cadence stretches uniformly rather than breaking;
|
||||
# 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
|
||||
|
||||
@@ -8,9 +8,6 @@ backend/backend
|
||||
graphify-out/
|
||||
plans/
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# impeccable-ignore-start
|
||||
# Ephemeral output, runtime state, and per-dev overrides.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
description: Code-writer subagent for subagent-driven development. Fast model (ocg/deepseek-v4-flash) for mechanical, well-specified implementation tasks. Escalates complicated tasks so the controller can re-dispatch on minimax-m3.
|
||||
mode: subagent
|
||||
model: 9router/ocg/deepseek-v4-flash
|
||||
---
|
||||
|
||||
You are the implementer subagent for Subagent-Driven Development. You implement one task, exactly as specified, and report back with evidence.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
If you have questions about requirements, acceptance criteria, approach, dependencies, or anything unclear in the task description — ask now. Raise concerns before starting work. Don't guess or make assumptions.
|
||||
|
||||
## Your Job
|
||||
|
||||
1. Implement exactly what the task specifies
|
||||
2. Write tests (follow TDD when the task says to)
|
||||
3. Verify the implementation works (run the focused test while iterating; run the full suite once before committing)
|
||||
4. Commit your work
|
||||
5. Self-review (below)
|
||||
6. Report back
|
||||
|
||||
Follow existing patterns in the codebase. Don't restructure code outside your task. Don't overbuild — only what was requested (YAGNI).
|
||||
|
||||
## When You're in Over Your Head
|
||||
|
||||
It is always OK to stop and say "this is too hard for me." Bad work is worse than no work. STOP and escalate when the task requires architectural judgment, multi-file integration you can't see clearly through, or you're reading file after file without progress.
|
||||
|
||||
**Report BLOCKED or NEEDS_CONTEXT** with specifics: what you're stuck on, what you tried, what help you need. If the task turns out more complicated than mechanical (design judgment, broad codebase understanding), escalate so the controller can re-dispatch you on the more capable minimax-m3 agent.
|
||||
|
||||
## Self-Review Before Reporting
|
||||
|
||||
- **Completeness:** everything in the spec implemented? edge cases handled?
|
||||
- **Quality:** names accurate? code clean and maintainable?
|
||||
- **Discipline:** avoided overbuilding? only what was requested?
|
||||
- **Testing:** do tests verify real behavior? output pristine (no stray warnings)?
|
||||
Fix what you find before reporting.
|
||||
|
||||
## Report Format
|
||||
|
||||
Report back with ONLY (under 15 lines):
|
||||
- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
|
||||
- Commits created (short SHA + subject)
|
||||
- One-line test summary (e.g. "14/14 passing, output pristine")
|
||||
- Concerns, if any
|
||||
- Report file path (if the controller gave you one)
|
||||
|
||||
If BLOCKED or NEEDS_CONTEXT, put the specifics in the final message itself — the controller acts on it directly. Use DONE_WITH_CONCERNS if you completed the work but have doubts. Never silently produce work you're unsure about.
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
description: Reviewer subagent for subagent-driven development. Capable model (ocg/minimax-m3) for task-scoped and whole-branch code review; also the re-dispatch target when implementation tasks are complicated.
|
||||
mode: subagent
|
||||
model: 9router/ocg/minimax-m3
|
||||
---
|
||||
|
||||
You are the reviewer subagent for Subagent-Driven Development. You verify one task's implementation matches its requirements (spec compliance) and is well-built (code quality). You may also be dispatched for whole-branch review.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Task brief file (requirements — use exact values verbatim)
|
||||
- Implementer's report file
|
||||
- Diff file (commit list, stat summary, full diff with context)
|
||||
|
||||
## Method
|
||||
|
||||
Read the diff file once — it is your view of the change. The context lines ARE the changed files: do not read a changed file separately unless a hunk you must judge is cut off mid-function (say so in your report). Do not re-run git commands. Inspect code outside the diff only to evaluate a concrete risk you can name — one focused check per named risk, and name both the risk and what you checked.
|
||||
|
||||
Your review is read-only. Do not mutate the working tree, index, HEAD, or branch state.
|
||||
|
||||
## Do Not Trust the Report
|
||||
|
||||
Treat the implementer's report as unverified claims. It may be incomplete, inaccurate, or optimistic. Verify against the diff. Design rationales in the report ("kept it per YAGNI") are the implementer grading their own work — a stated rationale never downgrades a finding's severity.
|
||||
|
||||
## Tests
|
||||
|
||||
The implementer already ran the tests and reported results. Do not re-run the suite to confirm. Run a test only when reading the code raises a specific doubt no existing run answers — a focused test, never a package-wide suite. If heavy validation seems warranted, recommend it in your report instead. Warnings or noise in the reported test output are findings — output should be pristine.
|
||||
|
||||
## Part 1: Spec Compliance
|
||||
|
||||
Compare the diff against the brief:
|
||||
- **Missing:** requirements skipped, missed, or claimed without implementing
|
||||
- **Extra:** features not requested, over-engineering, nice-to-haves
|
||||
- **Misunderstood:** right feature built the wrong way, wrong problem solved
|
||||
|
||||
If a requirement can't be verified from this diff alone (lives in unchanged code or spans tasks), report it as a ⚠️ item instead of broadening your search.
|
||||
|
||||
## Part 2: Code Quality
|
||||
|
||||
- Clean separation of concerns? proper error handling? DRY without premature abstraction? edge cases?
|
||||
- Do new/changed tests verify real behavior, not mocks? edge cases covered?
|
||||
- Does each file have one clear responsibility? units independently testable? did this change create/significantly grow large files?
|
||||
|
||||
Point at evidence: file:line references for every finding. A tight report that cites lines gives the controller everything it needs.
|
||||
|
||||
## Calibration
|
||||
|
||||
Not everything is Critical. Important = this task can't be trusted until fixed: incorrect or fragile behavior, a missed requirement, maintainability damage you'd block a merge over (verbatim duplication of a logic block, swallowed errors, tests that assert nothing). "Coverage could be broader" and polish suggestions are Minor. If the plan explicitly mandates something this rubric calls a defect, that IS a finding — report Important, labeled plan-mandated. Acknowledge what was done well before listing issues.
|
||||
|
||||
## Output Format
|
||||
|
||||
### Spec Compliance
|
||||
- ✅ Spec compliant | ❌ Issues found: [what's missing/extra/misunderstood, with file:line]
|
||||
- ⚠️ Cannot verify from diff: [requirements you couldn't verify, what the controller should check]
|
||||
|
||||
### Strengths
|
||||
[What's well done? Be specific.]
|
||||
|
||||
### Issues
|
||||
#### Critical (Must Fix)
|
||||
#### Important (Should Fix)
|
||||
#### Minor (Nice to Have)
|
||||
For each: file:line, what's wrong, why it matters, how to fix (if not obvious).
|
||||
|
||||
### Assessment
|
||||
**Task quality:** [Approved | Needs fixes]
|
||||
**Reasoning:** [1-2 sentence technical assessment]
|
||||
|
||||
Your final message is the report itself: begin directly with the spec-compliance verdict. Every line is a verdict, a finding with file:line, or a check you ran — no preamble, no process narration, no closing summary.
|
||||
@@ -2,27 +2,116 @@
|
||||
|
||||
Guidance for OpenCode (and Claude Code) working in this repo.
|
||||
|
||||
## Status
|
||||
|
||||
Active. Backend (`backend/`) and userscript (`userscript/manga-bookmark.user.js`) built. Plan `plans/mangaBookmark.md` = original spec, may drift; trust code + design docs in `docs/superpowers/specs/` over plan.
|
||||
|
||||
## What this is
|
||||
|
||||
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.
|
||||
Manga read-progress tracker for user reading on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** from **Bromite** (mobile Chromium). Userscript injects on-page UI (floating button + slide-in panel), syncs progress to self-hosted Go backend so bookmarks unify across both sites and devices.
|
||||
|
||||
## Hard constraints (drive design — don't violate)
|
||||
## Hard constraints (drive design — do not violate)
|
||||
|
||||
Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
|
||||
- **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()` 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`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional.
|
||||
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
||||
- 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, direct probe) before finalizing, not assumed from single earlier test.
|
||||
Bromite uses Chromium's **native** userscript engine, not Tampermonkey:
|
||||
- **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()`). GM-free script also runs in desktop Tampermonkey/Violentmonkey for faster iteration.
|
||||
- Cross-origin `fetch()` works **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block).
|
||||
- Asura and Demonic = **separate origins, separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional.
|
||||
- Userscript runs in **isolated world**, so embedded API token safe from site's JS.
|
||||
- Cloudflare's block on manga sites is **IP-reputation-based, not universal — not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s w/ 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 would be blocked; wasn't, at least this date. Treat "does curl work now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare bot scoring can flip clean IP without notice. Any backend fetcher still needs graceful-degrade path for when challenged; adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
|
||||
Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
||||
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
|
||||
```
|
||||
|
||||
Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`.
|
||||
- **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-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).
|
||||
- **Web UI:** same binary serves password-gated browser UI on second
|
||||
hostname — `GET /` (list, or login page when no session),
|
||||
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||
under `/ui/*`. Templates/assets `go:embed`-ed, so `backend/Dockerfile`
|
||||
must copy `templates/` and `static/` plus `*.go`. Sessions = stateless
|
||||
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, when empty
|
||||
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`.
|
||||
- **Latest-chapter poller:** ticker goroutine in same binary re-checks
|
||||
each bookmarked series' newest published chapter from backend's own
|
||||
network access, so `latest_chapter` stays fresh when user not
|
||||
browsing. Second, parallel signal — userscript keeps own
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
||||
Two independent clocks: per-bookmark cooldown (`latest_checked_at` column,
|
||||
enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval.
|
||||
Row stamped *before* fetch so broken series waits full
|
||||
cooldown instead of retrying every tick; writes go through
|
||||
`Store.Get` + `Store.Upsert` so new chapter never reorders list.
|
||||
Fetches use `bogdanfinn/tls-client` w/ Chrome profile as defence in depth
|
||||
against fingerprint-based blocking; any failure logs and skips. See
|
||||
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
|
||||
Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so
|
||||
userscript `PUT` committing between the two can be overwritten by
|
||||
poller's stale re-read — reverting read progress and, since stored
|
||||
value now differs, moving `updated_at` and reordering list. Known,
|
||||
accepted limitation for single-user deployment, not bug to fix.
|
||||
- **`updated_at` drives list order, moves only on real reading progress:** server applies timestamp when row new or `last_chapter_num` changes, else keeps stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**; clients must adopt that response over own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
||||
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||
own tab — not All, Updated, Favourites, or recent strip. Poller keeps
|
||||
checking archived series, skips finished ones. `finished` settable only
|
||||
from web UI; `PUT /bookmarks/{key}` rejects it w/ 400.
|
||||
**Empty incoming status means "keep stored one"** — resolved on
|
||||
`VALUES` side of `Store.Upsert`, not conflict clause, since
|
||||
`excluded.*` = post-evaluation row and default applied there'd
|
||||
wipe bucket on every PUT from client predating column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||
(gates browser UI; unset disables it),
|
||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||
default `/userscript/manga-bookmark.user.js`, supplied by a bindmount).
|
||||
|
||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||
|
||||
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. ID 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` w/ 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.
|
||||
4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so
|
||||
failed mutation parked in `localStorage` (`mangabm:queue`) and replayed on
|
||||
next navigation, reconnect, or `refresh()`. Entries are markers
|
||||
(`{key, op, sendStatus, attempts}`), never payloads — body read from
|
||||
cache at send time, so one entry per key gives ordering + coalescing for
|
||||
free. `sendStatus` **sticky**: while archive pending, later writes to
|
||||
that key keep carrying bucket, stops successful
|
||||
in-between write from silently un-archiving series. `refresh()` drains
|
||||
before fetching, overlays anything still pending, so list never
|
||||
flaps. 400 drops entry, 401 aborts pass and keeps queue,
|
||||
transient failures retry to cap of 10. Latest-chapter writes deliberately
|
||||
stay out of queue. See
|
||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
||||
(critical on mobile). Three tabs (All / Favourites / Archived) + row of
|
||||
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
||||
block next to `API_BASE`.
|
||||
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)
|
||||
|
||||
- **asurascans.com**: series `/comics/<slug>` (slug carries a trailing
|
||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
||||
the hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in the userscript,
|
||||
`asuraBuildHash` in the backend); URLs keep the full slug — stale-hash
|
||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
||||
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).
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) are identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -33,78 +122,39 @@ Backend (`cd backend`):
|
||||
|
||||
Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`).
|
||||
|
||||
Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
|
||||
Smoke test: `curl` endpoints w/ `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200.
|
||||
|
||||
## Forge: Gitea, not GitHub
|
||||
|
||||
`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:
|
||||
`origin` = self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` doesn't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones:
|
||||
|
||||
- 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>`
|
||||
- Issues: `tea issue create`, `tea issue list`
|
||||
- Auth lives in `tea login`, not `GH_TOKEN` env var.
|
||||
|
||||
`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 `BookmarkManager 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.
|
||||
`tea` prints output as rendered boxes not plain text; PR URL lands on last line.
|
||||
|
||||
## Security invariants
|
||||
|
||||
- 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`.
|
||||
|
||||
## 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.
|
||||
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` w/ `204`.
|
||||
|
||||
## Relevant skills
|
||||
|
||||
`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
|
||||
|
||||
Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships.
|
||||
Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships.
|
||||
|
||||
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. Return 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, `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 for broad navigation instead of raw source browsing.
|
||||
- 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 graph current (AST-only, no API cost).
|
||||
|
||||
## Notes
|
||||
## OpenCode-specific
|
||||
|
||||
- Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normally.
|
||||
- Caveman mode active by default (`/home/tan/.config/opencode/AGENTS.md`). Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normal.
|
||||
- `.superpowers/` and `.agents/` dirs hold skill definitions. Gitea at `gitea.violetcrown.my.id`.
|
||||
@@ -1,30 +1,141 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code (claude.ai/code) working in this repo.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
## What this is
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Hard constraints (drive design — don't violate)
|
||||
## Hard constraints (these drive the design — do not violate)
|
||||
|
||||
Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
|
||||
- **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()` 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`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional.
|
||||
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
||||
- 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.
|
||||
Bromite uses Chromium's **native** userscript engine, not Tampermonkey:
|
||||
- **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.
|
||||
- Cross-origin `fetch()` works **only** against a CORS-enabled backend. Manga sites are `https://`, so backend **must be HTTPS** (mixed-content block otherwise).
|
||||
- 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.
|
||||
- Userscript runs in an **isolated world**, so the embedded API token is safe from the 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.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
|
||||
Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
||||
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
|
||||
```
|
||||
|
||||
Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/CLAUDE.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/CLAUDE.md`.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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
|
||||
hostname — `GET /` (list, or login page when there is no session),
|
||||
`POST /login`, `POST /logout`, `GET /static/*`, and htmx fragment endpoints
|
||||
under `/ui/*`. Templates and assets are `go:embed`-ed, so `backend/Dockerfile`
|
||||
must copy `templates/` and `static/` as well as `*.go`. Sessions are stateless
|
||||
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them and, when empty,
|
||||
the web routes are not registered at all. UI mutations read-modify-write
|
||||
through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one
|
||||
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
||||
**Design-tool caveat:** the templates link `/static/style.css` root-absolutely
|
||||
(correct — they are served from `/`), but the impeccable detector resolves a
|
||||
stylesheet href with `path.resolve(fileDir, href)`, which drops the directory
|
||||
on a leading `/` and silently skips the file. A relative href does not help
|
||||
either: the template's directory is not its served path. So
|
||||
`detect.mjs backend/templates` reports a **false clean** — always pass
|
||||
`backend/static` too. Its one finding there, `overused-font` on "Instrument
|
||||
Serif", is a deliberate identity choice, not debt.
|
||||
- **Every action that moves a series out of the list is confirm-gated.**
|
||||
Archive, finish, and remove each open their own `.confirm-row` disclosure
|
||||
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
||||
`archive|finish|remove`); restore fires instantly because it is the reversal.
|
||||
Remove's row wears the ember wash, the two reversible ones wear `.calm` grey.
|
||||
`--ember` stays reserved for the new-chapter signal: busy bar and inline
|
||||
error use `--mute`.
|
||||
- **Latest-chapter poller:** a ticker goroutine in the same binary re-checks
|
||||
each bookmarked series' newest published chapter from the backend's own
|
||||
network access, so `latest_chapter` stays fresh when the user is not
|
||||
browsing. It is a *second, parallel* signal — the userscript keeps its own
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
||||
Two independent clocks: a per-bookmark cooldown (`latest_checked_at` column,
|
||||
enforced by `Store.DueForLatestCheck`'s WHERE clause) and a wake interval.
|
||||
The row is stamped *before* the fetch so a broken series waits out a full
|
||||
cooldown instead of retrying every tick, and writes go through
|
||||
`Store.Get` + `Store.Upsert` so a new chapter never reorders the list.
|
||||
Fetches use `bogdanfinn/tls-client` with a Chrome profile as defence in depth
|
||||
against fingerprint-based blocking; any failure logs and skips. See
|
||||
`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
|
||||
a userscript `PUT` that commits between the two can be overwritten by the
|
||||
poller's stale re-read — reverting that read progress and, since the stored
|
||||
value now differs, moving `updated_at` and reordering the list. This is a
|
||||
known, accepted limitation for a single-user deployment, not a 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.
|
||||
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||
their own tab — not in All, Updated, Favourites, or the recent strip. The
|
||||
poller keeps checking archived series and skips finished ones. `finished` is
|
||||
settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400.
|
||||
**An empty incoming status means "keep the stored one"** — resolved on the
|
||||
`VALUES` side of `Store.Upsert`, not in the conflict clause, because
|
||||
`excluded.*` is the post-evaluation row and a default applied there would
|
||||
wipe the bucket on every PUT from a client that predates the column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||
(gates the browser UI; unset disables it),
|
||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||
default `/userscript/manga-bookmark.user.js`, supplied by a bindmount).
|
||||
|
||||
## Commands
|
||||
### 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.
|
||||
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.
|
||||
4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so a
|
||||
failed mutation is parked in `localStorage` (`mangabm:queue`) and replayed on
|
||||
the next navigation, reconnect, or `refresh()`. Entries are markers
|
||||
(`{key, op, sendStatus, attempts}`), never payloads — the body is read from
|
||||
the cache at send time, so one entry per key gives ordering and coalescing for
|
||||
free. `sendStatus` is **sticky**: while an archive is pending, later writes to
|
||||
that key keep carrying the bucket, which is what stops a successful
|
||||
in-between write from silently un-archiving the series. `refresh()` drains
|
||||
before it fetches and overlays anything still pending, so the list never
|
||||
flaps. A 400 drops the entry, a 401 aborts the pass and keeps the queue, and
|
||||
transient failures retry to a cap of 10. Latest-chapter writes deliberately
|
||||
stay out of the queue. See
|
||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||
5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
||||
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
||||
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
||||
block next to `API_BASE`. The FAB is a `7 × 44` edge tab whose *hit* area is
|
||||
widened to `28 × 72` by an invisible `#hit` child; `#fab` must keep
|
||||
`touch-action: none` and must **not** regain `overflow: hidden`. Because
|
||||
`touch-action` is resolved at gesture start, the strip cannot be both
|
||||
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: a
|
||||
swipe from `#hit` scrolls via `window.scrollBy`, a hold of `ARM_MS` arms a
|
||||
reposition drag, and the 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 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).
|
||||
|
||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
||||
|
||||
- **asurascans.com**: series `/comics/<slug>` (slug carries a trailing
|
||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
||||
the hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in the userscript,
|
||||
`asuraBuildHash` in the backend); URLs keep the full slug — stale-hash
|
||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
||||
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).
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) are identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
|
||||
## Commands (once code exists)
|
||||
|
||||
Backend (`cd backend`):
|
||||
- Test all: `go test ./...`
|
||||
@@ -33,74 +144,34 @@ Backend (`cd backend`):
|
||||
|
||||
Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`).
|
||||
|
||||
Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
|
||||
Smoke test: `curl` the endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200.
|
||||
|
||||
## Forge: Gitea, not GitHub
|
||||
|
||||
`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:
|
||||
`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:
|
||||
|
||||
- Open PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
|
||||
- Open a PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
|
||||
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
|
||||
- Issues: `tea issue create`, `tea issue list`
|
||||
- Auth lives in `tea login`, not `GH_TOKEN` env var.
|
||||
- Auth lives in `tea login`, not a `GH_TOKEN` env var.
|
||||
|
||||
`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 `BookmarkManager 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.
|
||||
`tea` prints its output as rendered boxes rather than plain text; the PR URL lands on the last line.
|
||||
|
||||
## Security invariants
|
||||
|
||||
- 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`.
|
||||
|
||||
## 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
|
||||
|
||||
`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.
|
||||
`multi-stage-dockerfile` and `docker-compose-orchestration` for the container work (referenced in the plan).
|
||||
|
||||
## graphify
|
||||
|
||||
Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships.
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
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. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- 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 don't surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost).
|
||||
- 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.
|
||||
- If graphify-out/wiki/index.md exists, use it 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.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
@@ -10,8 +10,8 @@ ACME/cert resolver, and control a domain.
|
||||
|
||||
- Docker + Docker Compose on the server.
|
||||
- A Traefik instance watching a Docker network (default name assumed: `proxy`).
|
||||
- DNS: an `A`/`AAAA` record for `bookmark-api.<yourdomain>` pointing at the server.
|
||||
- The repo copied to the server, e.g. `/opt/bookmarkmanager/` (needs `backend/`,
|
||||
- DNS: an `A`/`AAAA` record for `manga-api.<yourdomain>` pointing at the server.
|
||||
- The repo copied to the server, e.g. `/opt/mangabm/` (needs `backend/`,
|
||||
`docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`).
|
||||
|
||||
Confirm the Traefik network exists (create if not):
|
||||
@@ -25,7 +25,7 @@ docker network ls | grep proxy || docker network create proxy
|
||||
## 1. Configure `.env`
|
||||
|
||||
```bash
|
||||
cd /opt/bookmarkmanager
|
||||
cd /opt/mangabm
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
@@ -36,13 +36,13 @@ Edit `.env`:
|
||||
API_TOKEN=<paste output of: openssl rand -hex 32>
|
||||
|
||||
# CORS allowlist — leave as-is unless a site changes hostname.
|
||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to
|
||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
|
||||
|
||||
# Required for the Traefik override. Both have no fallback — compose refuses
|
||||
# to start without them. BOOKMARK_WEB_HOST is required even if you never set
|
||||
# to start without them. MANGA_WEB_HOST is required even if you never set
|
||||
# WEB_PASSWORD; see 1b.
|
||||
BOOKMARK_API_HOST=bookmark-api.violetcrown.my.id
|
||||
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
|
||||
MANGA_API_HOST=manga-api.violetcrown.my.id
|
||||
MANGA_WEB_HOST=manga.violetcrown.my.id
|
||||
|
||||
# Only if your Traefik setup differs from these defaults:
|
||||
# PROXY_NETWORK=proxy
|
||||
@@ -67,13 +67,13 @@ grep -E '^API_TOKEN=' .env # copy this — the userscript needs the same value
|
||||
|
||||
The browser UI is served by the same container on a second hostname.
|
||||
|
||||
1. Add a DNS `A`/`AAAA` record for `bookmark.<yourdomain>` pointing at the server —
|
||||
the same address as `bookmark-api.<yourdomain>`.
|
||||
1. Add a DNS `A`/`AAAA` record for `manga.<yourdomain>` pointing at the server —
|
||||
the same address as `manga-api.<yourdomain>`.
|
||||
|
||||
2. Set both variables in `.env`:
|
||||
|
||||
```ini
|
||||
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
|
||||
MANGA_WEB_HOST=manga.violetcrown.my.id
|
||||
WEB_PASSWORD=<paste output of: openssl rand -base64 18>
|
||||
```
|
||||
|
||||
@@ -88,16 +88,16 @@ The browser UI is served by the same container on a second hostname.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://bookmark.violetcrown.my.id/
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://manga.violetcrown.my.id/
|
||||
```
|
||||
|
||||
Expected `200`, serving the login page.
|
||||
|
||||
Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/`
|
||||
returns 404. The userscript's API on `BOOKMARK_API_HOST` is unaffected either way.
|
||||
returns 404. The userscript's API on `MANGA_API_HOST` is unaffected either way.
|
||||
|
||||
`BOOKMARK_WEB_HOST` itself is required by the prod override regardless — like
|
||||
`BOOKMARK_API_HOST`, its Traefik label has no fallback, so `docker compose up`
|
||||
`MANGA_WEB_HOST` itself is required by the prod override regardless — like
|
||||
`MANGA_API_HOST`, its Traefik label has no fallback, so `docker compose up`
|
||||
refuses to start without it even if `WEB_PASSWORD` is unset and the web UI is
|
||||
otherwise dormant.
|
||||
|
||||
@@ -116,17 +116,11 @@ This merges the base file (build/image/env/volume) with the prod override
|
||||
(no host port, Traefik network + router labels). Always pass **both** `-f`
|
||||
flags — the prod file is not standalone.
|
||||
|
||||
Two services come up: `bookmark-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 `bookmark-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:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
|
||||
docker logs bookmark-api --tail 20 # expect: "listening on :8080 ..."
|
||||
docker logs manga-api --tail 20 # expect: "listening on :8080 ..."
|
||||
```
|
||||
|
||||
---
|
||||
@@ -137,21 +131,21 @@ Give Traefik a few seconds to issue the cert, then:
|
||||
|
||||
```bash
|
||||
# Health (no auth) — must be valid TLS, no cert warning.
|
||||
curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok
|
||||
curl -s https://manga-api.violetcrown.my.id/healthz # -> ok
|
||||
|
||||
# Auth enforced.
|
||||
curl -s -o /dev/null -w '%{http_code}\n' \
|
||||
https://bookmark-api.violetcrown.my.id/bookmarks # -> 401
|
||||
https://manga-api.violetcrown.my.id/bookmarks # -> 401
|
||||
|
||||
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://bookmark-api.violetcrown.my.id/bookmarks # -> []
|
||||
https://manga-api.violetcrown.my.id/bookmarks # -> []
|
||||
|
||||
# CORS preflight from a real site origin.
|
||||
curl -s -i -X OPTIONS \
|
||||
-H 'Origin: https://asurascans.com' \
|
||||
-H 'Access-Control-Request-Method: PUT' \
|
||||
https://bookmark-api.violetcrown.my.id/bookmarks/x | grep -i access-control
|
||||
https://manga-api.violetcrown.my.id/bookmarks/x | grep -i access-control
|
||||
# -> Access-Control-Allow-Origin: https://asurascans.com (+ Methods/Headers)
|
||||
```
|
||||
|
||||
@@ -165,7 +159,7 @@ a bad cert makes the browser block the userscript's `fetch()` (mixed content).
|
||||
Edit the config block at the top of `userscript/manga-bookmark.user.js`:
|
||||
|
||||
```js
|
||||
const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash
|
||||
const API_BASE = "https://manga-api.yourdomain.com"; // no trailing slash
|
||||
const API_TOKEN = "<same token as .env>";
|
||||
```
|
||||
|
||||
@@ -197,7 +191,7 @@ Tampermonkey/Violentmonkey for quick checks before going mobile.
|
||||
## 6. Smoke-test the full loop
|
||||
|
||||
1. Bookmark a series on Asura.
|
||||
2. `curl -s -H "Authorization: Bearer $TOKEN" https://bookmark-api.yourdomain.com/bookmarks`
|
||||
2. `curl -s -H "Authorization: Bearer $TOKEN" https://manga-api.yourdomain.com/bookmarks`
|
||||
on the server — the series should appear.
|
||||
3. Open a chapter of that series — reopen the panel; last-read updates to that
|
||||
chapter (auto, never regresses on older chapters).
|
||||
@@ -223,7 +217,7 @@ SQLite data persists in the named volume `bookmarks-data` across rebuilds.
|
||||
| Symptom | Likely cause / fix |
|
||||
|---------|--------------------|
|
||||
| No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs <traefik>`. |
|
||||
| 404 from Traefik | Service not on the `proxy` network, or `BOOKMARK_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `bookmark-api`. |
|
||||
| 404 from Traefik | Service not on the `proxy` network, or `MANGA_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `manga-api`. |
|
||||
| `fetch` fails in the userscript, `curl` works | Origin missing from `ALLOWED_ORIGINS`, or mixed content (backend not HTTPS). |
|
||||
| 401 with the right token | Trailing space/newline in `API_TOKEN`; regenerate and restart. |
|
||||
| Panel button absent | URL didn't match an adapter, or user scripts disabled in Bromite. |
|
||||
@@ -243,7 +237,7 @@ auto-updates come from it too.
|
||||
Install once, on the phone (Cromite + Violentmonkey):
|
||||
|
||||
```
|
||||
https://bookmark-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js
|
||||
https://manga-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js
|
||||
```
|
||||
|
||||
Open that URL in Cromite; Violentmonkey offers to install it. The token is in
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
- Name: **BookmarkManager**.
|
||||
- Name: **mangaBookmark**.
|
||||
- **Dark-first is binding**: current dark-by-default / light-follows-system-preference behavior must be preserved as a design constraint, not just a starting default, because reading happens at night.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Manga Bookmark
|
||||
|
||||
Track manga read-progress on **asurascans.com** (a.k.a. asuracomic.net),
|
||||
**demonicscans.org**, **comix.to**, and **kagane.to** from a phone (Bromite /
|
||||
mobile Chromium), synced to a self-hosted Go backend so bookmarks unify across
|
||||
all four sites and all devices.
|
||||
Track manga read-progress on **asurascans.com** (a.k.a. asuracomic.net) and
|
||||
**demonicscans.org** from a phone (Bromite / mobile Chromium), synced to a
|
||||
self-hosted Go backend so bookmarks unify across both sites and all devices.
|
||||
|
||||
Two parts:
|
||||
|
||||
@@ -26,10 +25,9 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
||||
| Var | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. |
|
||||
| `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. |
|
||||
| `ALLOWED_ORIGINS` | Asura + Demonic origins | Comma-separated CORS allowlist. |
|
||||
| `DB_PATH` | `/data/bookmarks.db` | SQLite file location. |
|
||||
| `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
|
||||
|
||||
@@ -41,9 +39,8 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
||||
| `GET` | `/healthz` | none | `200 ok`. |
|
||||
| `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`,
|
||||
`demonic:Infinite-Level-Up-in-Murim`, `comix:12345`, or
|
||||
`kagane:3fa85f64-5717-4562-b3fc-2c963f66afa6`. Sync is last-write-wins.
|
||||
`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.
|
||||
|
||||
`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
|
||||
@@ -86,7 +83,7 @@ curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
|
||||
|
||||
### Deploy behind your reverse proxy
|
||||
|
||||
Route `https://bookmark-api.<domain>` → the service on `:8080` (TLS at the proxy).
|
||||
Route `https://manga-api.<domain>` → the service on `:8080` (TLS at the proxy).
|
||||
|
||||
- **Host proxy** (nginx/Caddy on the host): the base compose already binds
|
||||
`127.0.0.1:8080`; point the proxy `proxy_pass http://127.0.0.1:8080;`.
|
||||
@@ -99,7 +96,7 @@ Route `https://bookmark-api.<domain>` → the service on `:8080` (TLS at the pro
|
||||
```
|
||||
Set `PROXY_NETWORK` in `.env` if your network isn't named `proxy`.
|
||||
|
||||
Verify: `https://bookmark-api.<domain>/healthz` returns `ok` over valid TLS (no
|
||||
Verify: `https://manga-api.<domain>/healthz` returns `ok` over valid TLS (no
|
||||
mixed-content), and an `OPTIONS` preflight from a real site origin returns the
|
||||
CORS headers.
|
||||
|
||||
@@ -112,7 +109,7 @@ CORS headers.
|
||||
Edit the config block at the top of `userscript/manga-bookmark.user.js`:
|
||||
|
||||
```js
|
||||
const API_BASE = "https://bookmark-api.<domain>"; // no trailing slash
|
||||
const API_BASE = "https://manga-api.<domain>"; // no trailing slash
|
||||
const API_TOKEN = "<same token as backend>";
|
||||
```
|
||||
|
||||
@@ -184,7 +181,7 @@ userscript does the looking, from your own browser session:
|
||||
(`LATEST_CHECK_BATCH` / `LATEST_CHECK_THROTTLE_MS`). Failures are silent and
|
||||
simply retried after the window.
|
||||
|
||||
Freshness is tracked per device in `localStorage` under `bmgr:manga:lastchecked`
|
||||
Freshness is tracked per device in `localStorage` under `mangabm:lastchecked`
|
||||
and is deliberately not synced, since each device checks on its own.
|
||||
|
||||
This means a bookmark is as current as its last check — not the moment a
|
||||
@@ -202,8 +199,6 @@ 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>` |
|
||||
| **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:
|
||||
- **`asuracomic.net` deep links are dead (re-checked 2026-07-25).** They 301 to
|
||||
|
||||
+20
-20
@@ -9,16 +9,16 @@ Whole thing is ~5 minutes, most of it waiting on `docker build`. Order matters:
|
||||
**back up before you pull.** A backup taken after a bad migration is a backup of
|
||||
the damage.
|
||||
|
||||
Paths below assume the checkout is at `/opt/bookmarkmanager`; substitute your own. The
|
||||
one absolute rule about paths: **backups live in `../bookmarkmanager-backups/`**, a
|
||||
sibling of the project directory (`/opt/bookmarkmanager-backups`), never inside it. It
|
||||
Paths below assume the checkout is at `/opt/mangabm`; substitute your own. The
|
||||
one absolute rule about paths: **backups live in `../mangabm-backups/`**, a
|
||||
sibling of the project directory (`/opt/mangabm-backups`), never inside it. It
|
||||
sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside
|
||||
the checkout cannot take the backups with them.
|
||||
|
||||
```
|
||||
/opt/
|
||||
├── bookmarkmanager/ <- the checkout (this repo)
|
||||
└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db
|
||||
├── mangabm/ <- the checkout (this repo)
|
||||
└── mangabm-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db
|
||||
```
|
||||
|
||||
---
|
||||
@@ -26,12 +26,12 @@ the checkout cannot take the backups with them.
|
||||
## 0. Preflight
|
||||
|
||||
```bash
|
||||
cd /opt/bookmarkmanager
|
||||
cd /opt/mangabm
|
||||
|
||||
# Both -f flags, every time. The prod override is not standalone.
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
|
||||
$COMPOSE ps # bookmark-api should be Up
|
||||
$COMPOSE ps # manga-api should be Up
|
||||
git status --short # expect empty
|
||||
git log --oneline -1 # note this hash — it is your rollback target
|
||||
df -h /var/lib/docker | tail -1 # a build needs room
|
||||
@@ -44,9 +44,9 @@ dirty tree fails halfway and leaves you in a worse spot than either.
|
||||
Create the backup directory once, and make sure it is a sibling, not a child:
|
||||
|
||||
```bash
|
||||
mkdir -p ../bookmarkmanager-backups
|
||||
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups" # absolute — Docker needs it
|
||||
echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups
|
||||
mkdir -p ../mangabm-backups
|
||||
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups" # absolute — Docker needs it
|
||||
echo "$BACKUP_DIR" # -> /opt/mangabm-backups
|
||||
```
|
||||
|
||||
---
|
||||
@@ -59,7 +59,7 @@ prefixes it with the project directory:
|
||||
|
||||
```bash
|
||||
docker volume ls --filter name=bookmarks-data
|
||||
# -> local bookmarkmanager_bookmarks-data
|
||||
# -> local mangabm_bookmarks-data
|
||||
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||
```
|
||||
|
||||
@@ -172,11 +172,11 @@ bindmounted read-only and read fresh per request.
|
||||
|
||||
```bash
|
||||
$COMPOSE ps # Up, and recently (re)created
|
||||
docker logs bookmark-api --tail 20 # -> "listening on :8080 ..."
|
||||
docker logs manga-api --tail 20 # -> "listening on :8080 ..."
|
||||
```
|
||||
|
||||
Nothing in the log about the database or the poller failing. The image is tagged
|
||||
`bookmarkmanager-backend:latest`, so the previous image is still on disk untagged —
|
||||
`mangabm-backend:latest`, so the previous image is still on disk untagged —
|
||||
that is what makes the rollback in §6 quick.
|
||||
|
||||
---
|
||||
@@ -186,8 +186,8 @@ that is what makes the rollback in §6 quick.
|
||||
Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names once:
|
||||
|
||||
```bash
|
||||
API=https://bookmark-api.violetcrown.my.id
|
||||
WEB=https://bookmark.violetcrown.my.id
|
||||
API=https://manga-api.violetcrown.my.id
|
||||
WEB=https://manga.violetcrown.my.id
|
||||
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
|
||||
|
||||
curl -s $API/healthz # -> ok
|
||||
@@ -281,7 +281,7 @@ docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c '
|
||||
ls -l /data'
|
||||
|
||||
$COMPOSE start
|
||||
docker logs bookmark-api --tail 20
|
||||
docker logs manga-api --tail 20
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||||
```
|
||||
|
||||
@@ -302,9 +302,9 @@ Two steps here are easy to skip and both bite:
|
||||
For a routine redeploy where nothing needs deciding:
|
||||
|
||||
```bash
|
||||
cd /opt/bookmarkmanager
|
||||
cd /opt/mangabm
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups"; mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups"; mkdir -p "$BACKUP_DIR"
|
||||
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||
|
||||
@@ -314,7 +314,7 @@ docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c \
|
||||
git pull --ff-only &&
|
||||
$COMPOSE up -d --build &&
|
||||
sleep 5 &&
|
||||
curl -sf https://bookmark-api.violetcrown.my.id/healthz && echo " deploy ok"
|
||||
curl -sf https://manga-api.violetcrown.my.id/healthz && echo " deploy ok"
|
||||
```
|
||||
|
||||
The `&&` chain is deliberate: if the backup or its integrity check fails,
|
||||
@@ -332,7 +332,7 @@ command can tell you the panel works on the phone.
|
||||
| CSS or template change did not appear | You restarted without `--build`. Assets are `//go:embed`ed. |
|
||||
| Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. |
|
||||
| Everyone logged out of the web UI | `API_TOKEN` or `WEB_PASSWORD` changed; sessions are derived from both. Expected, just log in again. |
|
||||
| `compose` errors about `BOOKMARK_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. |
|
||||
| `compose` errors about `MANGA_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. |
|
||||
| Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. `@version` comes from the file's mtime, so confirm the pull actually touched it. |
|
||||
| `apk add sqlite` fails (no network) | Use the cold-copy fallback in §1 — and copy `bookmarks.db-wal` too. |
|
||||
| Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). |
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Only go source + module files, plus internal/ (which carries the
|
||||
# go:embed'd templates/static directories), are needed in the build context.
|
||||
# Only go source + module files, plus the go:embed'd templates/static
|
||||
# directories, are needed in the build context.
|
||||
*
|
||||
!go.mod
|
||||
!go.sum
|
||||
!*.go
|
||||
!internal/
|
||||
!internal/**
|
||||
!templates/
|
||||
!templates/**
|
||||
!static/
|
||||
!static/**
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system.
|
||||
|
||||
- **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 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).
|
||||
- **Web UI:** same binary serve password-gated browser UI on second
|
||||
hostname — `GET /` (list, or login page when no session),
|
||||
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||
under `/ui/*`. Templates + assets `go:embed`-ed under
|
||||
`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, and when empty,
|
||||
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`.
|
||||
**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 skip file. Relative href 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 fire instantly since it's the reversal.
|
||||
Remove's row wear ember wash, two reversible ones wear `.calm` grey.
|
||||
`--ember` stay reserved for new-chapter signal: busy bar and inline
|
||||
error use `--mute`.
|
||||
- **Latest-chapter poller:** ticker goroutine in same binary re-check
|
||||
each bookmarked series' newest published chapter from backend's own
|
||||
network access, so `latest_chapter` stay fresh when user not
|
||||
browsing. Second, parallel signal — userscript keep own
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
||||
Two independent clocks: per-bookmark cooldown (`latest_checked_at` column,
|
||||
enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval.
|
||||
Row stamped *before* fetch so broken series wait out full
|
||||
cooldown instead of retrying every tick, and writes go through
|
||||
`Store.Get` + `Store.Upsert` so new chapter never reorders list.
|
||||
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
|
||||
against fingerprint-based blocking; any failure log and skip. kagane and
|
||||
novelfull sit behind Cloudflare JavaScript challenges the TLS client can't
|
||||
clear, so they are 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`.
|
||||
Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so
|
||||
userscript `PUT` that commits between the two can get overwritten by
|
||||
poller's stale re-read — reverting that read progress and, since stored
|
||||
value now differs, moving `updated_at` and reordering list. Known,
|
||||
accepted limitation for single-user deployment, not bug to fix.
|
||||
- **`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` |
|
||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||
own tab — not in All, Updated, Favourites, or recent strip. Poller keeps
|
||||
checking archived series and skip finished ones. `finished` settable
|
||||
only from web UI; `PUT /bookmarks/{key}` reject it with 400.
|
||||
**Empty incoming status means "keep stored one"** — resolved on the
|
||||
`VALUES` side of `Store.Upsert`, not conflict clause, since
|
||||
`excluded.*` is post-evaluation row and default applied there would
|
||||
wipe bucket on every PUT from client that predates column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||
(gates browser UI; unset disable it),
|
||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||
default `/userscript/manga-bookmark.user.js`, supplied by bindmount).
|
||||
`BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull;
|
||||
unset disables browser polling and leaves those sites to the userscript
|
||||
alone).
|
||||
@@ -1,81 +0,0 @@
|
||||
Guidance for Claude Code working under `backend/`. See root `CLAUDE.md` for the project-wide architecture diagram, hard constraints, and design system.
|
||||
|
||||
- **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 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).
|
||||
- **Web UI:** same binary serve password-gated browser UI on second
|
||||
hostname — `GET /` (list, or login page when no session),
|
||||
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||
under `/ui/*`. Templates + assets `go:embed`-ed under
|
||||
`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, and when empty,
|
||||
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`.
|
||||
**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 skip file. Relative href 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 fire instantly since it's the reversal.
|
||||
Remove's row wear ember wash, two reversible ones wear `.calm` grey.
|
||||
`--ember` stay reserved for new-chapter signal: busy bar and inline
|
||||
error use `--mute`.
|
||||
- **Latest-chapter poller:** ticker goroutine in same binary re-check
|
||||
each bookmarked series' newest published chapter from backend's own
|
||||
network access, so `latest_chapter` stay fresh when user not
|
||||
browsing. Second, parallel signal — userscript keep own
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
||||
Two independent clocks: per-bookmark cooldown (`latest_checked_at` column,
|
||||
enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval.
|
||||
Row stamped *before* fetch so broken series wait out full
|
||||
cooldown instead of retrying every tick, and writes go through
|
||||
`Store.Get` + `Store.Upsert` so new chapter never reorders list.
|
||||
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
|
||||
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`.
|
||||
Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so
|
||||
userscript `PUT` that commits between the two can get overwritten by
|
||||
poller's stale re-read — reverting that read progress and, since stored
|
||||
value now differs, moving `updated_at` and reordering list. Known,
|
||||
accepted limitation for single-user deployment, not bug to fix.
|
||||
- **`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` |
|
||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||
own tab — not in All, Updated, Favourites, or recent strip. Poller keeps
|
||||
checking archived series and skip finished ones. `finished` settable
|
||||
only from web UI; `PUT /bookmarks/{key}` reject it with 400.
|
||||
**Empty incoming status means "keep stored one"** — resolved on the
|
||||
`VALUES` side of `Store.Upsert`, not conflict clause, since
|
||||
`excluded.*` is post-evaluation row and default applied there would
|
||||
wipe bucket on every PUT from client that predates column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||
(gates browser UI; unset disable it),
|
||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||
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).
|
||||
+6
-5
@@ -1,18 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- build stage: compile a static, CGO-free binary ---
|
||||
FROM golang:1.26-alpine AS build
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Dependencies first for layer caching (changes rarely).
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Then source (changes often). internal/web carries the go:embed'd
|
||||
# templates/static assets — missing them turns the embed directive into a
|
||||
# build error, so the whole tree must land before `go build`.
|
||||
# Then source (changes often).
|
||||
# Source plus the go:embed'd assets. Missing either directory turns the embed
|
||||
# directive into a build error, so both must be copied before `go build`.
|
||||
COPY *.go ./
|
||||
COPY internal/ ./internal/
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
|
||||
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
|
||||
# -trimpath + -ldflags strip paths and debug info for a smaller image.
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/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)
|
||||
}
|
||||
}
|
||||
|
||||
// Both scripts are served from the same handler on the same token, outside the
|
||||
// WEB_PASSWORD gate — a wrong token is a 404, never a 401.
|
||||
func TestNovelUserscriptServed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
|
||||
if err := os.WriteFile(novelPath, []byte("// novel\n"), 0o644); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
s, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.NovelUserscriptPath = novelPath
|
||||
srv := newRouter(s, cfg)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||||
"/u/"+testToken+"/novel-bookmark.user.js", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||
t.Fatalf("Content-Type = %q, want text/javascript", ct)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||||
"/u/wrong-token/novel-bookmark.user.js", nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("wrong token status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -1,12 +1,10 @@
|
||||
module bookmarkmanager/backend
|
||||
module mangabm/backend
|
||||
|
||||
go 1.26
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/bogdanfinn/fhttp v0.6.8
|
||||
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
|
||||
)
|
||||
|
||||
@@ -17,12 +15,7 @@ require (
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
||||
github.com/bogdanfinn/utls v1.7.7-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/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/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
@@ -33,7 +26,7 @@ require (
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
|
||||
+2
-20
@@ -14,24 +14,10 @@ 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/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
|
||||
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/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/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/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -40,14 +26,10 @@ 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/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
@@ -74,8 +56,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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,13 +6,10 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// Handler serves the userscript-facing JSON bookmark API.
|
||||
type Handler struct {
|
||||
Store *store.Store
|
||||
type bookmarkHandler struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
@@ -25,9 +22,9 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all bookmarks. GET /bookmarks
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Store.List()
|
||||
// list returns all bookmarks. GET /bookmarks
|
||||
func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.store.List()
|
||||
if err != nil {
|
||||
log.Printf("list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -36,15 +33,15 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// Put upserts one bookmark. PUT /bookmarks/{key}
|
||||
func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
// put upserts one bookmark. PUT /bookmarks/{key}
|
||||
func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var b store.Bookmark
|
||||
var b Bookmark
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
@@ -68,9 +65,9 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
||||
// rather than trusting every client to leave it alone.
|
||||
switch b.Status {
|
||||
case "", store.StatusReading, store.StatusArchived:
|
||||
case store.StatusFinished:
|
||||
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
|
||||
case "", statusReading, statusArchived:
|
||||
case statusFinished:
|
||||
http.Error(w, "status "+statusFinished+" can only be set from the web UI",
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
default:
|
||||
@@ -78,20 +75,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Same rule as status: empty means "keep the stored value". An unknown
|
||||
// value is a client bug, not something to silently coerce to manga.
|
||||
switch b.Kind {
|
||||
case "", store.KindManga, store.KindNovel:
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid kind"})
|
||||
return
|
||||
}
|
||||
|
||||
// Candidate timestamp, not a decision: Upsert keeps the stored one unless
|
||||
// reading progress actually moved. Any client value is ignored.
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
stored, err := h.Store.Upsert(b)
|
||||
stored, err := h.store.Upsert(b)
|
||||
if err != nil {
|
||||
log.Printf("upsert: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -102,14 +90,14 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, stored)
|
||||
}
|
||||
|
||||
// Delete removes one bookmark. DELETE /bookmarks/{key}
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
// delete removes one bookmark. DELETE /bookmarks/{key}
|
||||
func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.Delete(key); err != nil {
|
||||
if err := h.store.Delete(key); err != nil {
|
||||
log.Printf("delete: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -117,8 +105,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Healthz answers the unauthenticated liveness check. GET /healthz
|
||||
func Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
@@ -1,184 +0,0 @@
|
||||
package latest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"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 and novelfull.com sit behind a
|
||||
// Cloudflare JavaScript challenge. Verified 2026-08-03 (kagane) and 2026-08-05
|
||||
// (novelfull) 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.
|
||||
//
|
||||
// The two sites differ in how the chapter list is read: kagane serves it from
|
||||
// a JSON API that must be called from inside the page (so the request carries
|
||||
// the clearance cookie), while novelfull renders it into the HTML so the
|
||||
// cleared DOM is the payload.
|
||||
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 either the
|
||||
// site's JSON API (kagane) from inside the page so the request carries the
|
||||
// clearance cookie, or the served HTML itself (novelfull) — see
|
||||
// novelfullSeriesURL for the latter case. The returned body is whatever the
|
||||
// site's chapter list lives in, which is what latestChapterFrom's per-site
|
||||
// switch expects.
|
||||
func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int, error) {
|
||||
apiURL, isKagane := kaganeAPIURL(seriesURL)
|
||||
if !isKagane && !novelfullSeriesURL(seriesURL) {
|
||||
return "", 0, fmt.Errorf("not a fetchable browser 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
|
||||
// kagane's chapter list is only in its JSON API, which must be called from
|
||||
// inside the page so the request carries the clearance cookie. novelfull
|
||||
// renders its chapters into the HTML, so the cleared DOM is the answer.
|
||||
// chromedp.OuterHTML returns a QueryAction and chromedp.Evaluate an
|
||||
// EvaluateAction, so the variable has to be the interface both implement.
|
||||
var read chromedp.Action = chromedp.OuterHTML("html", &body, chromedp.ByQuery)
|
||||
if isKagane {
|
||||
read = chromedp.Evaluate(
|
||||
`fetch(`+jsString(apiURL)+`).then(r => r.ok ? r.text() : "")`,
|
||||
&body,
|
||||
awaitPromise,
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
read,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
// novelfullSeriesURL reports whether seriesURL is a novelfull series page this
|
||||
// fetcher will open. novelfull's chapter list is in the served HTML, so unlike
|
||||
// kagane there is no API to call from inside the page — the challenge-cleared
|
||||
// DOM is the payload. The host is pinned here for the same reason kagane's is:
|
||||
// series_url is client-supplied and a headless browser is a strong SSRF
|
||||
// primitive.
|
||||
func novelfullSeriesURL(seriesURL string) bool {
|
||||
u, err := url.Parse(seriesURL)
|
||||
return err == nil && u.Scheme == "https" && u.Hostname() == "novelfull.com" &&
|
||||
strings.HasSuffix(u.Path, ".html")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNovelfullSeriesURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"series page", "https://novelfull.com/reverend-insanity.html", true},
|
||||
{"foreign host", "https://evil.example/reverend-insanity.html", false},
|
||||
{"not https", "http://novelfull.com/reverend-insanity.html", false},
|
||||
{"not a series page", "https://novelfull.com/genre/Fantasy", false},
|
||||
{"garbage", "://nope", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := novelfullSeriesURL(tc.url); got != tc.want {
|
||||
t.Fatalf("novelfullSeriesURL(%q) = %v, want %v", tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB |
@@ -1,23 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 172" role="img" aria-label="BookmarkManager">
|
||||
<title>BookmarkManager</title>
|
||||
<g fill="#100f0e" stroke="#f2ece5" stroke-width="5" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path fill="none" d="M28 36H4v114h192V36h-24"></path>
|
||||
<path fill="none" d="M28 23H17v127h166V23h-11"></path>
|
||||
<g id="mb-half">
|
||||
<path d="M28 7 88 55v97L28 138z"></path>
|
||||
<g fill="#f2ece5" stroke="none">
|
||||
<path d="M37 25 55 39v41L37 66z"></path>
|
||||
<path d="M60 42 79 57v42L60 84z"></path>
|
||||
<path d="M37 75 79 108v13L37 88z"></path>
|
||||
<path d="M37 98 79 129v11L37 131z"></path>
|
||||
</g>
|
||||
</g>
|
||||
<use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use>
|
||||
<g stroke="#e0452c">
|
||||
<path d="M100 4l9 5v11l-9 5-9-5V9z"></path>
|
||||
<path d="M94 24h12v24H94z"></path>
|
||||
<path d="M70 47h60v14H70z"></path>
|
||||
<path d="M91 61h18v87l-9 20-9-20z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 977 B |
@@ -1,87 +0,0 @@
|
||||
{{define "app"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>BookmarkManager</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
{{/* Body text before meta lines: DM Sans is the biggest face and the one
|
||||
most of the page is set in; the mono is small and arrives from CSS. */}}
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "icons" .}}
|
||||
<div class="sheet">
|
||||
<header class="topbar">
|
||||
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
||||
{{/* Plain full-page links, not htmx swaps: switching library replaces the
|
||||
tab row and the chrome, which is a page, not a fragment. */}}
|
||||
<nav class="libswitch" aria-label="Library">
|
||||
<a href="/?tab=all" class="{{if eq .Lib "manga"}}active{{end}}"
|
||||
{{if eq .Lib "manga"}}aria-current="page"{{end}}>Manga</a>
|
||||
<a href="/?lib=novel&tab=all" class="{{if eq .Lib "novel"}}active{{end}}"
|
||||
{{if eq .Lib "novel"}}aria-current="page"{{end}}>Novels</a>
|
||||
</nav>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{/* Search sits above the tabs on a phone and folds into the tab row on a
|
||||
wider screen — one flex container, order swapped in CSS. */}}
|
||||
<div class="chrome">
|
||||
<div class="searchbar">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-search"/></svg>
|
||||
<input id="search" class="search" type="search" placeholder="Find a title"
|
||||
autocomplete="off" aria-label="Search titles">
|
||||
</div>
|
||||
|
||||
{{/* These are real links with real hrefs that change the URL, so they are
|
||||
navigation, not an ARIA tablist — aria-current carries "which bucket am
|
||||
I in" without owing a tabpanel contract we do not implement. */}}
|
||||
<nav class="tabs" aria-label="Bookmark buckets">
|
||||
<a href="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
|
||||
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
{{/* The one bucket novels do not have: without a poller-fed "what is out
|
||||
that I have not read", the tab would only ever restate All. */}}
|
||||
{{if eq .Lib "manga"}}
|
||||
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
|
||||
{{template "newcount" .}}</a>
|
||||
{{end}}
|
||||
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
<main id="list" class="list">
|
||||
{{template "list" .}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,78 +0,0 @@
|
||||
{{/* The regions that live outside the swapped #list: the "Continue reading"
|
||||
strip, the Updated badge and the action key. All are rendered inline by
|
||||
app.html and again, out of band, on every /ui/ response — a mutation must
|
||||
not leave them describing the library as it was before the tap.
|
||||
|
||||
All always render, hidden when they have nothing to say, so an out-of-band
|
||||
swap always has an element with the right id to replace. */}}
|
||||
|
||||
{{define "recent"}}
|
||||
<section class="recent" id="recent"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .Recent}} hidden{{end}}>
|
||||
<h2>Continue reading</h2>
|
||||
<div class="recent-strip">
|
||||
{{range .Recent}}
|
||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<span class="recent-cover">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</span>
|
||||
<span class="recent-title">{{.Title}}</span>
|
||||
<span class="recent-chapter">{{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
|
||||
line under the tabs names every glyph. It follows the tab rather than the
|
||||
row: the archived and finished buckets swap Archive for Restore, and a
|
||||
finished series has no Done to offer. */}}
|
||||
{{define "keyrow"}}
|
||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
||||
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
{{if or (eq .Tab "archived") (eq .Tab "finished")}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||
{{else}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
||||
{{end}}
|
||||
{{if ne .Tab "finished"}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg><span>Done</span></span>
|
||||
{{end}}
|
||||
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* The brand mark, inline so it takes the page's ink and ember rather than the
|
||||
fixed palette of /static/logo.svg (which the favicon needs). */}}
|
||||
{{define "mark"}}
|
||||
<svg class="mark" viewBox="0 0 200 172" aria-hidden="true">
|
||||
<g fill="var(--ink)" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path fill="none" d="M28 36H4v114h192V36h-24"></path>
|
||||
<path fill="none" d="M28 23H17v127h166V23h-11"></path>
|
||||
<g id="mb-half">
|
||||
<path d="M28 7 88 55v97L28 138z"></path>
|
||||
<g fill="currentColor" stroke="none">
|
||||
<path d="M37 25 55 39v41L37 66z"></path>
|
||||
<path d="M60 42 79 57v42L60 84z"></path>
|
||||
<path d="M37 75 79 108v13L37 88z"></path>
|
||||
<path d="M37 98 79 129v11L37 131z"></path>
|
||||
</g>
|
||||
</g>
|
||||
<use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use>
|
||||
<g stroke="var(--ember)">
|
||||
<path d="M100 4l9 5v11l-9 5-9-5V9z"></path>
|
||||
<path d="M94 24h12v24H94z"></path>
|
||||
<path d="M70 47h60v14H70z"></path>
|
||||
<path d="M91 61h18v87l-9 20-9-20z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
{{end}}
|
||||
|
||||
{{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}}
|
||||
@@ -1,59 +1,40 @@
|
||||
package latest
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/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.
|
||||
type Fetcher interface {
|
||||
type fetcher interface {
|
||||
Get(ctx context.Context, url string) (body string, status int, err error)
|
||||
}
|
||||
|
||||
// Poller re-checks each bookmarked series' newest published chapter on a
|
||||
// latestPoller re-checks each bookmarked series' newest published chapter on a
|
||||
// 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
|
||||
// and neither needs to know about the other.
|
||||
//
|
||||
// Two clocks, deliberately independent:
|
||||
//
|
||||
// - Interval is how often this goroutine wakes up and looks.
|
||||
// - Cooldown is how long one bookmark rests since its own last check.
|
||||
// - interval is how often this goroutine wakes up and looks.
|
||||
// - 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
|
||||
// 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
|
||||
// nothing due more often.
|
||||
type Poller struct {
|
||||
Store *store.Store
|
||||
Fetch Fetcher
|
||||
// BrowserFetch handles sites behind a JavaScript challenge that Fetch
|
||||
// cannot clear. Nil disables those sites entirely rather than falling back
|
||||
// to Fetch, which would only ever retrieve a challenge page.
|
||||
BrowserFetch Fetcher
|
||||
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 and novelfull both sit behind a Cloudflare
|
||||
// JavaScript challenge that no TLS fingerprint clears — kagane verified
|
||||
// 2026-08-03, novelfull verified 2026-08-05, both against the same Chrome_133
|
||||
// profile TLSFetcher uses — so they are browser-only or nothing.
|
||||
func (p *Poller) fetcherFor(site string) Fetcher {
|
||||
switch site {
|
||||
case "kagane", "novelfull":
|
||||
return p.BrowserFetch
|
||||
}
|
||||
return p.Fetch
|
||||
type latestPoller struct {
|
||||
store *Store
|
||||
fetch fetcher
|
||||
now func() time.Time // injected so tests can freeze it
|
||||
cooldown time.Duration
|
||||
interval time.Duration
|
||||
stagger time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// Run polls until ctx is cancelled.
|
||||
@@ -62,10 +43,10 @@ func (p *Poller) fetcherFor(site string) Fetcher {
|
||||
// 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
|
||||
// concurrent fetch storms.
|
||||
func (p *Poller) Run(ctx context.Context) {
|
||||
func (p *latestPoller) Run(ctx context.Context) {
|
||||
log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s",
|
||||
p.Interval, p.Cooldown, p.Batch, p.Stagger)
|
||||
t := time.NewTicker(p.Interval)
|
||||
p.interval, p.cooldown, p.batch, p.stagger)
|
||||
t := time.NewTicker(p.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -79,9 +60,9 @@ func (p *Poller) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// runOnce processes one batch of due bookmarks.
|
||||
func (p *Poller) runOnce(ctx context.Context) {
|
||||
cutoff := p.Now().Add(-p.Cooldown).UnixMilli()
|
||||
due, err := p.Store.DueForLatestCheck(cutoff, p.Batch)
|
||||
func (p *latestPoller) runOnce(ctx context.Context) {
|
||||
cutoff := p.now().Add(-p.cooldown).UnixMilli()
|
||||
due, err := p.store.DueForLatestCheck(cutoff, p.batch)
|
||||
if err != nil {
|
||||
log.Printf("latest poll: due query: %v", err)
|
||||
return
|
||||
@@ -97,11 +78,11 @@ func (p *Poller) runOnce(ctx context.Context) {
|
||||
// bot score. This is the server-side analogue of the userscript's "one
|
||||
// series per navigation ... indistinguishable from browsing" (L455-456).
|
||||
stopped := false
|
||||
if i > 0 && p.Stagger > 0 {
|
||||
if i > 0 && p.stagger > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stopped = true
|
||||
case <-time.After(p.Stagger):
|
||||
case <-time.After(p.stagger):
|
||||
}
|
||||
}
|
||||
if stopped {
|
||||
@@ -119,7 +100,7 @@ func (p *Poller) runOnce(ctx context.Context) {
|
||||
// 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
|
||||
// batch or take down the process.
|
||||
func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||
func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("latest poll %q: recovered from panic: %v", b.Key, r)
|
||||
@@ -130,7 +111,7 @@ func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||
// mid-request still consumes the cooldown. Otherwise a renamed or deleted
|
||||
// series would be retried on every single tick forever. The userscript
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
@@ -147,13 +128,7 @@ func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
body, status, err := p.fetch.Get(ctx, b.SeriesURL)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err)
|
||||
return
|
||||
@@ -180,7 +155,7 @@ func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||
// progress or a status change, and moving updated_at because the stored
|
||||
// value now differs. Accepted for a single-user deployment: the window is
|
||||
// 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 {
|
||||
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
||||
return
|
||||
@@ -199,8 +174,8 @@ func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||
cur.LatestChapterNum = &num
|
||||
// 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.
|
||||
cur.UpdatedAt = p.Now().UnixMilli()
|
||||
if _, err := p.Store.Upsert(cur); err != nil {
|
||||
cur.UpdatedAt = p.now().UnixMilli()
|
||||
if _, err := p.store.Upsert(cur); err != nil {
|
||||
log.Printf("latest poll %q: upsert: %v", b.Key, err)
|
||||
return
|
||||
}
|
||||
@@ -208,23 +183,13 @@ func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||
}
|
||||
|
||||
// fetchableSeriesURL reports whether site is a site latestChapterFrom knows how
|
||||
// to parse and seriesURL is safe to hand to a fetcher: an https URL with a
|
||||
// to parse and seriesURL is safe to hand to the fetcher: an https URL with a
|
||||
// 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
|
||||
// server's own network position, not just a check against wasted requests.
|
||||
//
|
||||
// Three sites are held to a stricter rule, each for a different reason:
|
||||
//
|
||||
// - kagane and novelfull are fetched by a headless browser, which executes
|
||||
// JavaScript and carries cookies, and is therefore a far stronger SSRF
|
||||
// primitive than an HTTP GET. Their hosts must match exactly, not merely
|
||||
// be non-empty.
|
||||
// - lightnovelworld's parser regex hardcodes its host, so a URL anywhere
|
||||
// else could never yield a match — reject it here rather than burn the
|
||||
// request.
|
||||
func fetchableSeriesURL(site, seriesURL string) bool {
|
||||
switch site {
|
||||
case "asura", "demonic", "comix", "kagane", "novelfull", "lightnovelworld":
|
||||
case "asura", "demonic":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -232,20 +197,5 @@ func fetchableSeriesURL(site, seriesURL string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if u.Scheme != "https" || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
switch site {
|
||||
case "kagane":
|
||||
return u.Hostname() == "kagane.to"
|
||||
case "novelfull":
|
||||
// Fetched by a real browser, same as kagane, so the host is pinned
|
||||
// rather than merely non-empty.
|
||||
return u.Hostname() == "novelfull.com"
|
||||
case "lightnovelworld":
|
||||
// Its parser regex hardcodes this host, so a URL anywhere else could
|
||||
// never yield a match — reject it here rather than burn the request.
|
||||
return u.Hostname() == "lightnovelworld.net"
|
||||
}
|
||||
return true
|
||||
return u.Scheme == "https" && u.Host != ""
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package latest
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -20,20 +20,20 @@ const maxBodyBytes = 4 << 20
|
||||
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/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
|
||||
// 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.
|
||||
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the
|
||||
// distroless image are all unaffected.
|
||||
type TLSFetcher struct {
|
||||
type tlsFetcher struct {
|
||||
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(),
|
||||
tls_client.WithTimeoutSeconds(30),
|
||||
tls_client.WithClientProfile(profiles.Chrome_133),
|
||||
@@ -41,13 +41,13 @@ func NewTLSFetcher() (*TLSFetcher, error) {
|
||||
if err != nil {
|
||||
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
|
||||
// demonic chapter anchors are a redirect form, and asura has moved domains
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("build request %q: %w", url, err)
|
||||
@@ -1,12 +1,9 @@
|
||||
package latest
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// latestChapter is the newest chapter a series page advertises.
|
||||
@@ -26,26 +23,6 @@ var asuraSlugRe = regexp.MustCompile(`/comics/([^/?#]+)`)
|
||||
// through. Both the raw "&" and the HTML-escaped "&" forms occur.
|
||||
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.]+)"`)
|
||||
|
||||
// novelfullSlugRe pulls the series slug out of a stored series_url. novelfull
|
||||
// series pages are "/<slug>.html"; their chapter anchors are
|
||||
// "/<slug>/chapter-<n>[-<title-slug>].html". Verified live 2026-08-05.
|
||||
var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`)
|
||||
|
||||
// lnwSlugRe does the same for lightnovelworld, whose series pages live under
|
||||
// /novel/<slug>/ while its chapter URLs are flat at the site root:
|
||||
// "/<slug>-chapter-<n>/", absolute in the page's own anchors. Verified live
|
||||
// 2026-08-05.
|
||||
var lnwSlugRe = regexp.MustCompile(`^/novel/([^/?#]+)/?$`)
|
||||
|
||||
// latestChapterFrom returns the highest chapter number body advertises for this
|
||||
// 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,
|
||||
@@ -76,51 +53,12 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
||||
// chapter hrefs in the fetched body carry the current one. Strip to
|
||||
// the stable ID (same rule as migrateAsuraKeys) and make the hash
|
||||
// optional in the pattern, so scoping survives rotations.
|
||||
slug := store.AsuraBuildHash.ReplaceAllString(m[1], "")
|
||||
slug := asuraBuildHash.ReplaceAllString(m[1], "")
|
||||
// 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.
|
||||
re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`)
|
||||
case "demonic":
|
||||
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
|
||||
case "novelfull":
|
||||
u, err := url.Parse(seriesURL)
|
||||
if err != nil {
|
||||
return latestChapter{}, false
|
||||
}
|
||||
m := novelfullSlugRe.FindStringSubmatch(u.Path)
|
||||
if m == nil {
|
||||
return latestChapter{}, false
|
||||
}
|
||||
// Scoped to this series' slug for the same reason asura is: page 1
|
||||
// carries a "latest chapters" widget and a "you may also like" strip,
|
||||
// and neither may contribute to the maximum.
|
||||
re = regexp.MustCompile(`/` + regexp.QuoteMeta(m[1]) + `/chapter-([0-9.]+)`)
|
||||
case "lightnovelworld":
|
||||
u, err := url.Parse(seriesURL)
|
||||
if err != nil {
|
||||
return latestChapter{}, false
|
||||
}
|
||||
m := lnwSlugRe.FindStringSubmatch(u.Path)
|
||||
if m == nil {
|
||||
return latestChapter{}, false
|
||||
}
|
||||
re = regexp.MustCompile(`lightnovelworld\.net/` + regexp.QuoteMeta(m[1]) + `-chapter-([0-9.]+)/`)
|
||||
default:
|
||||
return latestChapter{}, false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package latest
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -34,51 +34,6 @@ 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>
|
||||
<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}]}
|
||||
`
|
||||
|
||||
// Trimmed from https://novelfull.com/reverend-insanity.html fetched 2026-08-05.
|
||||
// The page carries a newest-first "latest chapters" widget above an
|
||||
// oldest-first paginated list, so the newest anchor is deliberately NOT last —
|
||||
// only a maximum finds it. The final anchor belongs to another series and must
|
||||
// be excluded by slug scoping.
|
||||
const novelfullSeriesFixture = `
|
||||
<div class="l-chapters">
|
||||
<a href="/reverend-insanity/chapter-2334-fang-yuan-and-giant-sun.html">Chapter 2334</a>
|
||||
<a href="/reverend-insanity/chapter-2333-three-venerables.html">Chapter 2333</a>
|
||||
</div>
|
||||
<ul class="list-chapter">
|
||||
<li><a href="/reverend-insanity/chapter-1.html">Chapter 1</a></li>
|
||||
<li><a href="/reverend-insanity/chapter-2.html">Chapter 2</a></li>
|
||||
</ul>
|
||||
<a href="/release-that-witch/chapter-9999.html">Chapter 9999</a>
|
||||
`
|
||||
|
||||
// Trimmed from https://lightnovelworld.net/novel/a-will-eternal/ fetched
|
||||
// 2026-08-05. Its chapter anchors are absolute and flat — /<slug>-chapter-<n>/
|
||||
// at the site root, not under /novel/. The last anchor is another series'.
|
||||
const lnwSeriesFixture = `
|
||||
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1/">Chapter 1</a>
|
||||
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1317/">Chapter 1317</a>
|
||||
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1298/">Chapter 1298</a>
|
||||
<a href="https://lightnovelworld.net/overgeared-chapter-9999/">Chapter 9999</a>
|
||||
`
|
||||
|
||||
func TestLatestChapterFrom(t *testing.T) {
|
||||
const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
||||
@@ -145,76 +100,6 @@ func TestLatestChapterFrom(t *testing.T) {
|
||||
site: "mangadex", seriesURL: "https://example.com/x", body: asuraSeriesFixture,
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "novelfull takes the max and ignores another series",
|
||||
site: "novelfull",
|
||||
seriesURL: "https://novelfull.com/reverend-insanity.html",
|
||||
body: novelfullSeriesFixture,
|
||||
wantOK: true, wantNum: 2334, wantLabel: "Chapter 2334",
|
||||
},
|
||||
{
|
||||
name: "novelfull yields nothing on a challenge page",
|
||||
site: "novelfull",
|
||||
seriesURL: "https://novelfull.com/reverend-insanity.html",
|
||||
body: challengeFixture,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull with an unparseable series url",
|
||||
site: "novelfull",
|
||||
seriesURL: "https://novelfull.com/genre/Fantasy",
|
||||
body: novelfullSeriesFixture,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld takes the max and ignores another series",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/",
|
||||
body: lnwSeriesFixture,
|
||||
wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317",
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld tolerates a series url with no trailing slash",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: "https://lightnovelworld.net/novel/a-will-eternal",
|
||||
body: lnwSeriesFixture,
|
||||
wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317",
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld yields nothing on a challenge page",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/",
|
||||
body: challengeFixture,
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1,53 +1,13 @@
|
||||
package latest
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/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
|
||||
// in this file can reach tls-client or a real site.
|
||||
type fakeFetcher struct {
|
||||
@@ -84,16 +44,16 @@ func (f *fakeFetcher) callCount() int {
|
||||
|
||||
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run
|
||||
// instantly and deterministically.
|
||||
func newTestPoller(t *testing.T, s *store.Store, f Fetcher, at time.Time) *Poller {
|
||||
func newTestPoller(t *testing.T, s *Store, f fetcher, at time.Time) *latestPoller {
|
||||
t.Helper()
|
||||
return &Poller{
|
||||
Store: s,
|
||||
Fetch: f,
|
||||
Now: func() time.Time { return at },
|
||||
Cooldown: time.Hour,
|
||||
Interval: 10 * time.Minute,
|
||||
Stagger: 0,
|
||||
Batch: 14,
|
||||
return &latestPoller{
|
||||
store: s,
|
||||
fetch: f,
|
||||
now: func() time.Time { return at },
|
||||
cooldown: time.Hour,
|
||||
interval: 10 * time.Minute,
|
||||
stagger: 0,
|
||||
batch: 14,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +89,7 @@ func TestRunOnceDoesNotReorderList(t *testing.T) {
|
||||
const key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
|
||||
// "other" is the most recently read, so it must stay at the top of List().
|
||||
if _, err := s.Upsert(store.Bookmark{
|
||||
if _, err := s.Upsert(Bookmark{
|
||||
Key: "asura:other", Site: "asura", SeriesID: "other",
|
||||
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
|
||||
}); err != nil {
|
||||
@@ -206,7 +166,7 @@ func TestRunOnceRespectsBatchLimit(t *testing.T) {
|
||||
|
||||
f := &fakeFetcher{body: "", status: 200}
|
||||
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
|
||||
p.Batch = 5
|
||||
p.batch = 5
|
||||
p.runOnce(context.Background())
|
||||
|
||||
if got := f.callCount(); got != 5 {
|
||||
@@ -258,13 +218,13 @@ func TestRunOnceHonoursCooldownAcrossPasses(t *testing.T) {
|
||||
}
|
||||
// Same instant, and again 59 minutes later: both inside the 1h cooldown.
|
||||
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())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("fetched %d times inside the cooldown, want 1", got)
|
||||
}
|
||||
// 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())
|
||||
if got := f.callCount(); got != 2 {
|
||||
t.Fatalf("fetched %d times after the cooldown, want 2", got)
|
||||
@@ -279,7 +239,7 @@ func TestRunOnceCorrectsDownward(t *testing.T) {
|
||||
const key = "demonic:Catastrophic-Necromancer"
|
||||
|
||||
high := 400.0
|
||||
if _, err := s.Upsert(store.Bookmark{
|
||||
if _, err := s.Upsert(Bookmark{
|
||||
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
|
||||
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
|
||||
UpdatedAt: 1000,
|
||||
@@ -318,7 +278,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
key := tt.site + ":x"
|
||||
if _, err := s.Upsert(store.Bookmark{
|
||||
if _, err := s.Upsert(Bookmark{
|
||||
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
||||
UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
@@ -327,7 +287,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
||||
|
||||
now := time.UnixMilli(4_000_000)
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
newTestPoller(t, s, f, now).checkOne(context.Background(), store.Bookmark{
|
||||
newTestPoller(t, s, f, now).checkOne(context.Background(), Bookmark{
|
||||
Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
|
||||
})
|
||||
|
||||
@@ -358,144 +318,3 @@ func TestRunOnceStopsOnCancelledContext(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherForRoutesNovelSites(t *testing.T) {
|
||||
tls := &fakeFetcher{}
|
||||
browser := &fakeFetcher{}
|
||||
p := &Poller{Fetch: tls, BrowserFetch: browser}
|
||||
|
||||
cases := []struct {
|
||||
site string
|
||||
want Fetcher
|
||||
}{
|
||||
{"asura", tls},
|
||||
{"lightnovelworld", tls},
|
||||
{"kagane", browser},
|
||||
{"novelfull", browser},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.site, func(t *testing.T) {
|
||||
if got := p.fetcherFor(tc.site); got != tc.want {
|
||||
t.Fatalf("fetcherFor(%q) = %v, want %v", tc.site, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchableSeriesURLPinsNovelHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
site string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"novelfull on its own host", "novelfull", "https://novelfull.com/reverend-insanity.html", true},
|
||||
{"novelfull on a foreign host", "novelfull", "https://evil.example/x.html", false},
|
||||
{"novelfull over http", "novelfull", "http://novelfull.com/x.html", false},
|
||||
{"lightnovelworld on its own host", "lightnovelworld", "https://lightnovelworld.net/novel/a-will-eternal/", true},
|
||||
{"lightnovelworld on a foreign host", "lightnovelworld", "https://evil.example/novel/x/", false},
|
||||
{"unknown site", "webnovel", "https://webnovel.com/x", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fetchableSeriesURL(tc.site, tc.url); got != tc.want {
|
||||
t.Fatalf("fetchableSeriesURL(%q, %q) = %v, want %v", tc.site, tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+34
-62
@@ -11,13 +11,6 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/api"
|
||||
"bookmarkmanager/backend/internal/httpmw"
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/userscript"
|
||||
"bookmarkmanager/backend/internal/web"
|
||||
)
|
||||
|
||||
// Config holds all runtime settings, sourced from environment variables.
|
||||
@@ -31,10 +24,6 @@ type Config struct {
|
||||
// UserscriptPath is the file served at /u/{token}/manga-bookmark.user.js.
|
||||
// Supplied by a bindmount so the script can be edited without a rebuild.
|
||||
UserscriptPath string
|
||||
// NovelUserscriptPath is the file served at
|
||||
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
|
||||
// two libraries are separate installs.
|
||||
NovelUserscriptPath string
|
||||
// LatestPoll configures the background latest-chapter fetcher.
|
||||
LatestPoll LatestPoll
|
||||
}
|
||||
@@ -142,13 +131,12 @@ func loadLatestPoll() LatestPoll {
|
||||
|
||||
func loadConfig() Config {
|
||||
c := Config{
|
||||
Token: os.Getenv("API_TOKEN"),
|
||||
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
|
||||
Port: envOr("PORT", "8080"),
|
||||
WebPassword: os.Getenv("WEB_PASSWORD"),
|
||||
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
||||
LatestPoll: loadLatestPoll(),
|
||||
Token: os.Getenv("API_TOKEN"),
|
||||
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
|
||||
Port: envOr("PORT", "8080"),
|
||||
WebPassword: os.Getenv("WEB_PASSWORD"),
|
||||
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||
LatestPoll: loadLatestPoll(),
|
||||
}
|
||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
@@ -161,23 +149,22 @@ func loadConfig() Config {
|
||||
// newRouter wires routes and middleware. CORS is the outermost layer so
|
||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||
// /healthz is public.
|
||||
func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
func newRouter(store *Store, cfg Config) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", api.Healthz)
|
||||
mux.HandleFunc("GET /healthz", healthz)
|
||||
|
||||
// Outside httpmw.Auth (the updater sends no Authorization header) and
|
||||
// outside the WEB_PASSWORD gate (the script must be installable either
|
||||
// way). The path segment carries the token instead.
|
||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
|
||||
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", userscript.Handler(cfg.Token, cfg.NovelUserscriptPath))
|
||||
// Outside withAuth (the updater sends no Authorization header) and outside
|
||||
// the WEB_PASSWORD gate (the script must be installable either way). The
|
||||
// path segment carries the token instead.
|
||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath))
|
||||
|
||||
h := &api.Handler{Store: s}
|
||||
h := &bookmarkHandler{store: store}
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /bookmarks", h.List)
|
||||
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
|
||||
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
|
||||
protected.HandleFunc("GET /bookmarks", h.list)
|
||||
protected.HandleFunc("PUT /bookmarks/{key}", h.put)
|
||||
protected.HandleFunc("DELETE /bookmarks/{key}", h.delete)
|
||||
|
||||
auth := httpmw.Auth(cfg.Token, protected)
|
||||
auth := withAuth(cfg.Token, protected)
|
||||
mux.Handle("/bookmarks", auth)
|
||||
mux.Handle("/bookmarks/", auth)
|
||||
|
||||
@@ -185,14 +172,14 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
||||
// exposing an unprotected list.
|
||||
if cfg.WebPassword != "" {
|
||||
wh, err := web.New(s, cfg.Token, cfg.WebPassword)
|
||||
web, err := newWebHandler(store, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("web handler: %v", err)
|
||||
}
|
||||
wh.Register(mux)
|
||||
web.register(mux)
|
||||
}
|
||||
|
||||
return httpmw.CORS(cfg.AllowedOrigins, httpmw.Gzip(guardEmptyUserscriptToken(mux)))
|
||||
return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux)))
|
||||
}
|
||||
|
||||
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
||||
@@ -216,22 +203,22 @@ func main() {
|
||||
log.Fatal("API_TOKEN is required")
|
||||
}
|
||||
|
||||
s, err := store.Open(cfg.DBPath)
|
||||
store, err := OpenStore(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
defer store.Close()
|
||||
|
||||
// The poller is off the request path entirely: if it cannot start, the
|
||||
// service still serves bookmarks and the userscript still captures latest
|
||||
// chapters on its own.
|
||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||
defer stopPoll()
|
||||
startLatestPoller(pollCtx, s, cfg.LatestPoll)
|
||||
startLatestPoller(pollCtx, store, cfg.LatestPoll)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: newRouter(s, cfg),
|
||||
Handler: newRouter(store, cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
@@ -261,39 +248,24 @@ func main() {
|
||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||
// feature going missing degrades the service to userscript-only latest-chapter
|
||||
// tracking, which is exactly how it behaved before.
|
||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
|
||||
func startLatestPoller(ctx context.Context, store *Store, cfg LatestPoll) {
|
||||
if !cfg.Enabled {
|
||||
log.Println("latest-chapter poller: disabled by config")
|
||||
return
|
||||
}
|
||||
f, err := latest.NewTLSFetcher()
|
||||
f, err := newTLSFetcher()
|
||||
if err != nil {
|
||||
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
||||
return
|
||||
}
|
||||
p := &latest.Poller{
|
||||
Store: s,
|
||||
Fetch: f,
|
||||
Now: time.Now,
|
||||
Cooldown: cfg.Cooldown,
|
||||
Interval: cfg.Interval,
|
||||
Stagger: cfg.Stagger,
|
||||
Batch: cfg.Batch,
|
||||
p := &latestPoller{
|
||||
store: store,
|
||||
fetch: f,
|
||||
now: time.Now,
|
||||
cooldown: cfg.Cooldown,
|
||||
interval: cfg.Interval,
|
||||
stagger: cfg.Stagger,
|
||||
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)
|
||||
}
|
||||
|
||||
+2
-77
@@ -10,8 +10,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestLoadLatestPollDefaults(t *testing.T) {
|
||||
@@ -150,7 +148,7 @@ func TestPutStatusValidation(t *testing.T) {
|
||||
if tc.want != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var got store.Bookmark
|
||||
var got Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
@@ -189,7 +187,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var got store.Bookmark
|
||||
var got Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
@@ -243,76 +241,3 @@ func TestGzipCompressesTextNotFonts(t *testing.T) {
|
||||
t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutKindValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
want int
|
||||
}{
|
||||
{"empty is no opinion", "", http.StatusOK},
|
||||
{"manga", "manga", http.StatusOK},
|
||||
{"novel", "novel", http.StatusOK},
|
||||
{"garbage", "comic", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
body := fmt.Sprintf(`{"title":"Solo","kind":%q}`, tc.kind)
|
||||
req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
||||
strings.NewReader(body)))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String())
|
||||
}
|
||||
if tc.want != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var got store.Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
want := tc.kind
|
||||
if want == "" {
|
||||
want = "manga"
|
||||
}
|
||||
if got.Kind != want {
|
||||
t.Fatalf("stored kind = %q, want %q", got.Kind, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The preserve path: a novel row re-PUT by a client that omits the field
|
||||
// entirely must stay a novel and still record the progress it carried.
|
||||
func TestPutOmittedKindPreservesNovelAndAppliesProgress(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
const key = "/bookmarks/lightnovelworld:a-will-eternal"
|
||||
|
||||
seed := auth(httptest.NewRequest(http.MethodPut, key,
|
||||
strings.NewReader(`{"title":"A Will Eternal","kind":"novel","last_chapter_num":10}`)))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, seed)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed status = %d, want 200 (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, key,
|
||||
strings.NewReader(`{"title":"A Will Eternal","last_chapter_num":11}`))))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got store.Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Kind != store.KindNovel {
|
||||
t.Fatalf("Kind = %q, want novel", got.Kind)
|
||||
}
|
||||
if got.LastChapterNum != 11 {
|
||||
t.Fatalf("LastChapterNum = %v, want 11", got.LastChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package httpmw
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
|
||||
// Auth guards a handler with a constant-time bearer-token check.
|
||||
func Auth(token string, next http.Handler) http.Handler {
|
||||
// withAuth guards a handler with a constant-time bearer-token check.
|
||||
func withAuth(token string, next http.Handler) http.Handler {
|
||||
want := []byte(token)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
@@ -71,11 +71,11 @@ func (w *gzipWriter) Write(b []byte) (int, error) {
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Gzip compresses text responses for clients that ask. The templates,
|
||||
// withGzip compresses text responses for clients that ask. The templates,
|
||||
// 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
|
||||
// on mobile data.
|
||||
func Gzip(next http.Handler) http.Handler {
|
||||
func withGzip(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -92,11 +92,11 @@ func Gzip(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// CORS reflects the request Origin only when it is in allowed, answers
|
||||
// withCORS reflects the request Origin only when it is in allowed, answers
|
||||
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
||||
// auth middleware so preflight (which carries no Authorization header) is never
|
||||
// rejected by auth.
|
||||
func CORS(allowed []string, next http.Handler) http.Handler {
|
||||
func withCORS(allowed []string, next http.Handler) http.Handler {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, o := range allowed {
|
||||
set[o] = struct{}{}
|
||||
@@ -1,4 +1,4 @@
|
||||
package session
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
@@ -14,27 +14,27 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "bmgr_session"
|
||||
sessionCookieName = "mangabm_session"
|
||||
// 60 days: long enough that a phone stays logged in between reading spells.
|
||||
sessionTTL = 60 * 24 * time.Hour
|
||||
// Domain separation, so the session key can never collide with any other
|
||||
// use of the secrets it is derived from. Changing this string logs
|
||||
// everyone out.
|
||||
sessionKeyPurpose = "bmgr-web-session-v1"
|
||||
sessionKeyPurpose = "mangabm-web-session-v1"
|
||||
)
|
||||
|
||||
// Key derives the cookie-signing key from both secrets. Sessions are
|
||||
// sessionKey derives the cookie-signing key from both secrets. Sessions are
|
||||
// stateless — there is no session table — so rotating either API_TOKEN or
|
||||
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
|
||||
// separator prevents the concatenation ambiguity a bare apiToken+webPassword
|
||||
// would have (e.g. "ab"+"c" colliding with "a"+"bc").
|
||||
func Key(apiToken, webPassword string) []byte {
|
||||
func sessionKey(apiToken, webPassword string) []byte {
|
||||
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Sign encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
|
||||
func Sign(key []byte, expiryMs int64) string {
|
||||
// signSession encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
|
||||
func signSession(key []byte, expiryMs int64) string {
|
||||
payload := strconv.FormatInt(expiryMs, 10)
|
||||
return payload + "." + sessionMAC(key, payload)
|
||||
}
|
||||
@@ -45,10 +45,10 @@ func sessionMAC(key []byte, payload string) string {
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify checks shape, then expiry, then the signature — in that order.
|
||||
// verifySession checks shape, then expiry, then the signature — in that order.
|
||||
// The signature comparison is constant-time; the checks before it only look at
|
||||
// data the holder already supplied, so their timing leaks nothing.
|
||||
func Verify(key []byte, value string, nowMs int64) bool {
|
||||
func verifySession(key []byte, value string, nowMs int64) bool {
|
||||
payload, sig, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return false
|
||||
@@ -69,10 +69,10 @@ func isHTTPS(r *http.Request) bool {
|
||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
|
||||
func SetCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: Sign(key, time.Now().Add(sessionTTL).UnixMilli()),
|
||||
Name: sessionCookieName,
|
||||
Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()),
|
||||
Path: "/",
|
||||
MaxAge: int(sessionTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
@@ -81,9 +81,9 @@ func SetCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
||||
})
|
||||
}
|
||||
|
||||
func ClearCookie(w http.ResponseWriter, r *http.Request) {
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
@@ -94,11 +94,11 @@ func ClearCookie(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
const (
|
||||
MaxFailures = 10
|
||||
Window = 20 * time.Minute
|
||||
loginMaxFailures = 10
|
||||
loginWindow = 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,
|
||||
// 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.
|
||||
// RemoteAddr is useless behind the proxy — it is always the Traefik container —
|
||||
// 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 {
|
||||
hops := strings.Split(vals[len(vals)-1], ",")
|
||||
if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" {
|
||||
@@ -120,8 +120,8 @@ func ClientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// LoginLimiter throttles password guessing: MaxFailures failures inside a
|
||||
// rolling Window blocks further attempts from that IP until the oldest one
|
||||
// loginLimiter throttles password guessing: loginMaxFailures failures inside a
|
||||
// rolling loginWindow blocks further attempts from that IP until the oldest one
|
||||
// ages out. There is no permanent ban and no unlock step.
|
||||
//
|
||||
// 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
|
||||
// pruned lazily on access; for a single-user deployment the map cannot grow
|
||||
// past the handful of addresses that ever attempt a login.
|
||||
type LoginLimiter struct {
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
failures map[string][]time.Time
|
||||
}
|
||||
|
||||
func NewLoginLimiter() *LoginLimiter {
|
||||
return &LoginLimiter{failures: make(map[string][]time.Time)}
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{failures: make(map[string][]time.Time)}
|
||||
}
|
||||
|
||||
// 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()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
recent := l.pruneLocked(ip, now)
|
||||
if len(recent) < MaxFailures {
|
||||
if len(recent) < loginMaxFailures {
|
||||
return 0
|
||||
}
|
||||
return recent[0].Add(Window).Sub(now)
|
||||
return recent[0].Add(loginWindow).Sub(now)
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) Fail(ip string, now time.Time) {
|
||||
func (l *loginLimiter) fail(ip string, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.failures[ip] = append(l.pruneLocked(ip, now), now)
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) Reset(ip string) {
|
||||
func (l *loginLimiter) reset(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
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.
|
||||
// The caller must hold l.mu.
|
||||
func (l *LoginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
||||
cutoff := now.Add(-Window)
|
||||
func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
||||
cutoff := now.Add(-loginWindow)
|
||||
// In-place filter: kept reuses the backing array of the slice being
|
||||
// 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
|
||||
@@ -1,4 +1,4 @@
|
||||
package session
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -10,18 +10,18 @@ import (
|
||||
)
|
||||
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
key := Key("token-abc", "pw-abc")
|
||||
key := sessionKey("token-abc", "pw-abc")
|
||||
now := time.Now().UnixMilli()
|
||||
value := Sign(key, now+60_000)
|
||||
if !Verify(key, value, now) {
|
||||
t.Fatal("Verify = false for a freshly signed cookie, want true")
|
||||
value := signSession(key, now+60_000)
|
||||
if !verifySession(key, value, now) {
|
||||
t.Fatal("verifySession = false for a freshly signed cookie, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejects(t *testing.T) {
|
||||
key := Key("token-abc", "pw-abc")
|
||||
key := sessionKey("token-abc", "pw-abc")
|
||||
now := time.Now().UnixMilli()
|
||||
valid := Sign(key, now+60_000)
|
||||
valid := signSession(key, now+60_000)
|
||||
payload, sig, _ := strings.Cut(valid, ".")
|
||||
|
||||
cases := []struct {
|
||||
@@ -31,15 +31,15 @@ func TestSessionRejects(t *testing.T) {
|
||||
{"empty", ""},
|
||||
{"no separator", payload + sig},
|
||||
{"unparseable expiry", "notanumber." + sig},
|
||||
{"expired", Sign(key, now-1)},
|
||||
{"expired", signSession(key, now-1)},
|
||||
{"tampered signature", payload + "." + flipLastChar(sig)},
|
||||
{"tampered expiry", "99999999999999." + sig},
|
||||
{"signed with another key", Sign(Key("other-token", "pw-abc"), now+60_000)},
|
||||
{"signed with another key", signSession(sessionKey("other-token", "pw-abc"), now+60_000)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if Verify(key, tc.value, now) {
|
||||
t.Fatalf("Verify(%q) = true, want false", tc.value)
|
||||
if verifySession(key, tc.value, now) {
|
||||
t.Fatalf("verifySession(%q) = true, want false", tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -57,18 +57,18 @@ func flipLastChar(s string) string {
|
||||
}
|
||||
|
||||
func TestSessionKeyDependsOnToken(t *testing.T) {
|
||||
a := Key("token-a", "pw-abc")
|
||||
b := Key("token-b", "pw-abc")
|
||||
a := sessionKey("token-a", "pw-abc")
|
||||
b := sessionKey("token-b", "pw-abc")
|
||||
if string(a) == string(b) {
|
||||
t.Fatal("Key collided for different API tokens")
|
||||
t.Fatal("sessionKey collided for different API tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
||||
a := Key("token-abc", "pw-a")
|
||||
b := Key("token-abc", "pw-b")
|
||||
a := sessionKey("token-abc", "pw-a")
|
||||
b := sessionKey("token-abc", "pw-b")
|
||||
if string(a) == string(b) {
|
||||
t.Fatal("Key collided for different web passwords with the same API token")
|
||||
t.Fatal("sessionKey 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)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
SetCookie(rr, r, Key("token-abc", "pw-abc"))
|
||||
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc"))
|
||||
|
||||
cookies := rr.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("got %d cookies, want 1", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != CookieName {
|
||||
t.Fatalf("cookie name = %q, want %q", c.Name, CookieName)
|
||||
if c.Name != sessionCookieName {
|
||||
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName)
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Fatal("cookie HttpOnly = false, want true")
|
||||
@@ -126,7 +126,7 @@ func TestSetSessionCookieAttributes(t *testing.T) {
|
||||
func TestClearSessionCookie(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
ClearCookie(rr, r)
|
||||
clearSessionCookie(rr, r)
|
||||
|
||||
cookies := rr.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
@@ -168,65 +168,65 @@ func TestClientIP(t *testing.T) {
|
||||
for _, v := range tc.xff {
|
||||
r.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
if got := ClientIP(r); got != tc.want {
|
||||
t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
|
||||
if got := clientIP(r); got != tc.want {
|
||||
t.Fatalf("clientIP() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
||||
l := NewLoginLimiter()
|
||||
l := newLoginLimiter()
|
||||
now := time.Now()
|
||||
for i := 0; i < MaxFailures; i++ {
|
||||
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
||||
t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures)
|
||||
for i := 0; i < loginMaxFailures; i++ {
|
||||
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
||||
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures)
|
||||
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures)
|
||||
}
|
||||
if wait > Window {
|
||||
t.Fatalf("retryAfter = %v, want <= %v", wait, Window)
|
||||
if wait > loginWindow {
|
||||
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterWindowExpires(t *testing.T) {
|
||||
l := NewLoginLimiter()
|
||||
l := newLoginLimiter()
|
||||
start := time.Now()
|
||||
for i := 0; i < MaxFailures; i++ {
|
||||
l.Fail("1.2.3.4", start)
|
||||
for i := 0; i < loginMaxFailures; i++ {
|
||||
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")
|
||||
}
|
||||
later := start.Add(Window + time.Second)
|
||||
if wait := l.RetryAfter("1.2.3.4", later); wait != 0 {
|
||||
later := start.Add(loginWindow + time.Second)
|
||||
if wait := l.retryAfter("1.2.3.4", later); wait != 0 {
|
||||
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
||||
l := NewLoginLimiter()
|
||||
l := newLoginLimiter()
|
||||
now := time.Now()
|
||||
for i := 0; i < MaxFailures; i++ {
|
||||
l.Fail("1.2.3.4", now)
|
||||
for i := 0; i < loginMaxFailures; i++ {
|
||||
l.fail("1.2.3.4", now)
|
||||
}
|
||||
l.Reset("1.2.3.4")
|
||||
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
||||
l.reset("1.2.3.4")
|
||||
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
||||
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterIsPerIP(t *testing.T) {
|
||||
l := NewLoginLimiter()
|
||||
l := newLoginLimiter()
|
||||
now := time.Now()
|
||||
for i := 0; i < MaxFailures; i++ {
|
||||
l.Fail("1.2.3.4", now)
|
||||
for i := 0; i < loginMaxFailures; i++ {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
// htmx replaces the list on a tab switch, so re-apply to the new cards.
|
||||
document.body.addEventListener("htmx:afterSwap", applyFilter);
|
||||
document.addEventListener("bmgr:refilter", applyFilter);
|
||||
document.addEventListener("mangabm:refilter", applyFilter);
|
||||
})();
|
||||
|
||||
function setActiveTab(el) {
|
||||
@@ -58,7 +58,7 @@ function setActiveTab(el) {
|
||||
});
|
||||
// The strip is outside the swapped region, so its visibility is re-decided
|
||||
// here rather than by the server that just answered.
|
||||
document.dispatchEvent(new Event("bmgr:refilter"));
|
||||
document.dispatchEvent(new Event("mangabm:refilter"));
|
||||
}
|
||||
|
||||
// The chapter-edit form and the archive/finish/remove confirm rows are the
|
||||
@@ -81,11 +81,6 @@
|
||||
--danger-ink: #150808;
|
||||
--danger-soft: #e2aaa1;
|
||||
--brass: #b8912f; /* favourite — a second, cooler metal */
|
||||
/* One accent per action, so a press says which lane it belongs to. All three
|
||||
are held at the same weight as --brass: muted, no ember competition. */
|
||||
--slate: #7fa0c0; /* archive */
|
||||
--moss: #7fae86; /* finished */
|
||||
--clay: #b5906f; /* set chapter */
|
||||
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
|
||||
|
||||
/* Desktop cell borders for the two coloured action states. */
|
||||
@@ -94,12 +89,6 @@
|
||||
|
||||
--asura: #7d93a5;
|
||||
--demonic: #a98a78;
|
||||
--comix: #8a9a7d;
|
||||
--kagane: #9a8aa5;
|
||||
|
||||
/* novel sources: same muted family, two hues the manga sites do not use */
|
||||
--novelfull: #a59a7d;
|
||||
--lightnovelworld: #7da59a;
|
||||
|
||||
/* Covers are often missing; the hatch keeps the slot honest instead of
|
||||
faking artwork. */
|
||||
@@ -109,7 +98,7 @@
|
||||
--measure: 760px;
|
||||
/* Cover width + row gap: the disclosure panels indent past the cover on
|
||||
desktop, so both live here rather than as magic numbers. */
|
||||
--cover-w: 93px;
|
||||
--cover-w: 86px;
|
||||
--row-gap: 14px;
|
||||
}
|
||||
|
||||
@@ -142,9 +131,6 @@
|
||||
--danger-ink: #fff;
|
||||
--danger-soft: #7c2c22;
|
||||
--brass: #8a681c;
|
||||
--slate: #3f6689;
|
||||
--moss: #3d6c46;
|
||||
--clay: #7c5533;
|
||||
--trash: #8c6558;
|
||||
|
||||
--play-hot-line: #f0cfc6;
|
||||
@@ -152,10 +138,6 @@
|
||||
|
||||
--asura: #4f6b80;
|
||||
--demonic: #8a6a55;
|
||||
--comix: #5f7250;
|
||||
--kagane: #6f5f7d;
|
||||
--novelfull: #7d6f4f;
|
||||
--lightnovelworld: #4f7d70;
|
||||
|
||||
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
|
||||
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
|
||||
@@ -207,21 +189,14 @@ button { cursor: pointer; }
|
||||
/* ---- brand + chrome ---- */
|
||||
.brand {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font: 400 26px/1 var(--font-display);
|
||||
color: var(--paper);
|
||||
}
|
||||
.brand em { color: var(--ember); font-style: italic; }
|
||||
.brand .mark { width: 30px; height: 26px; flex: none; overflow: visible; }
|
||||
/* The line art is drawn at a 5px stroke on a 200-unit grid; at brand size that
|
||||
thins out, so it is nudged up rather than scaled down blindly. */
|
||||
.brand .mark g { stroke-width: 6.5; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 20px 14px;
|
||||
@@ -310,35 +285,6 @@ button { cursor: pointer; }
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
/* ---- action key: one permanent line under the tabs, so the icon strip below
|
||||
never has to be guessed at. Lean on a phone (28px, edge to edge), a step
|
||||
bigger on desktop where there is room to read it. ---- */
|
||||
.keyrow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 9px 18px 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
/* On a phone each key stacks: icon over word, so the word gets the full cell
|
||||
width and can stay the long form. */
|
||||
.keyrow .pair {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--mute);
|
||||
}
|
||||
.keyrow .pair svg { width: 14px; height: 14px; flex: none; }
|
||||
.keyrow .pair span {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.keyrow .pair.brass svg { color: var(--brass); }
|
||||
.keyrow .pair.trash svg { color: var(--trash); }
|
||||
|
||||
/* ---- continue reading ---- */
|
||||
.recent {
|
||||
display: flex;
|
||||
@@ -365,7 +311,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.recent-strip::-webkit-scrollbar { display: none; }
|
||||
.recent-card {
|
||||
width: var(--cover-w);
|
||||
width: 92px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -373,8 +319,8 @@ button { cursor: pointer; }
|
||||
}
|
||||
.recent-cover {
|
||||
position: relative;
|
||||
width: var(--cover-w);
|
||||
height: calc(var(--cover-w) * 4 / 3);
|
||||
width: 92px;
|
||||
height: 123px;
|
||||
background: var(--hatch);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -382,7 +328,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.recent-title {
|
||||
font: 400 16px/1.25 var(--font-display);
|
||||
font: 400 15px/1.2 var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -390,7 +336,7 @@ button { cursor: pointer; }
|
||||
overflow: hidden;
|
||||
}
|
||||
.recent-chapter {
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .1em;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
@@ -428,7 +374,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.card.is-dim { background: var(--dim); }
|
||||
|
||||
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
|
||||
.row { display: flex; flex-wrap: wrap; gap: var(--row-gap); }
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
@@ -441,7 +387,7 @@ button { cursor: pointer; }
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover .monogram { font-size: 32px; }
|
||||
.cover .monogram { font-size: 30px; }
|
||||
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
|
||||
|
||||
.body {
|
||||
@@ -449,7 +395,6 @@ button { cursor: pointer; }
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -458,7 +403,7 @@ button { cursor: pointer; }
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: 400 21px/1.2 var(--font-display);
|
||||
font: 400 19px/1.2 var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
|
||||
@@ -471,9 +416,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.is-dim .title { font-style: italic; color: var(--mute); }
|
||||
.is-dim .fav-mark { color: var(--mute-2); }
|
||||
/* The mark always sits at the right edge of the measure, not after the last
|
||||
word — a new-chapter title shrink-wraps, so without this it drifts. */
|
||||
.fav-mark { flex: none; margin-left: auto; width: 14px; height: 14px; margin-top: 5px; color: var(--brass); }
|
||||
.fav-mark { flex: none; width: 13px; height: 13px; margin-top: 5px; color: var(--brass); }
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
@@ -481,7 +424,7 @@ button { cursor: pointer; }
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
@@ -489,53 +432,11 @@ button { cursor: pointer; }
|
||||
.meta .sep { color: var(--faint); }
|
||||
.site-asura { color: var(--asura); }
|
||||
.site-demonic { color: var(--demonic); }
|
||||
.site-comix { color: var(--comix); }
|
||||
.site-kagane { color: var(--kagane); }
|
||||
.site-novelfull { color: var(--novelfull); }
|
||||
.site-lightnovelworld { color: var(--lightnovelworld); }
|
||||
.new-chapter { color: var(--ember); }
|
||||
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
|
||||
.state svg { width: 10px; height: 10px; }
|
||||
.state svg { width: 9px; height: 9px; }
|
||||
.is-dim .meta { color: var(--mute-2); }
|
||||
.is-dim .site-asura, .is-dim .site-demonic,
|
||||
.is-dim .site-comix, .is-dim .site-kagane,
|
||||
.is-dim .site-novelfull, .is-dim .site-lightnovelworld { color: var(--mute); filter: grayscale(.6); }
|
||||
|
||||
/* ---- library switch: manga and novels are separate libraries, so the pair
|
||||
sits in the topbar next to the wordmark rather than among the buckets. ---- */
|
||||
.libswitch {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--field-line);
|
||||
}
|
||||
.libswitch a {
|
||||
padding: 7px 13px;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
text-decoration: none;
|
||||
}
|
||||
.libswitch a + a { border-left: 1px solid var(--field-line); }
|
||||
.libswitch a:hover { color: var(--paper-dim); }
|
||||
/* The library you are in carries the ember, the same heat the wordmark and the
|
||||
Updated tab use — it is the one piece of chrome that has to be unmistakable. */
|
||||
.libswitch a.active {
|
||||
background: var(--ember-wash);
|
||||
color: var(--ember);
|
||||
box-shadow: inset 0 -2px 0 var(--ember);
|
||||
}
|
||||
.topbar form { margin-left: 18px; }
|
||||
/* At phone width brand + switch + Log out do not fit on one line, so the
|
||||
switch takes its own row under the wordmark rather than pushing Log out
|
||||
off-screen. */
|
||||
@media (max-width: 719px) {
|
||||
.topbar { flex-wrap: wrap; row-gap: 12px; }
|
||||
.brand { flex: 1 1 auto; min-width: 0; }
|
||||
.libswitch { order: 3; margin-left: 0; }
|
||||
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
|
||||
.topbar form { margin-left: 12px; }
|
||||
}
|
||||
.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); }
|
||||
|
||||
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
||||
.actions {
|
||||
@@ -558,12 +459,6 @@ button { cursor: pointer; }
|
||||
.actions > *:last-child { border-right: none; }
|
||||
.actions svg { width: 17px; height: 17px; }
|
||||
.actions > *:hover { color: var(--paper); }
|
||||
/* Per-action accent on hover and press: gold favourite, slate archive, moss
|
||||
finished, clay chapter. Remove keeps --danger, play keeps paper/ember. */
|
||||
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
|
||||
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
|
||||
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
|
||||
.actions .finish:hover, .actions .finish:active, .actions .finish:focus-visible { color: var(--moss); }
|
||||
.actions .play { color: var(--paper); }
|
||||
.is-new .actions .play { color: var(--ember); }
|
||||
.actions .play:hover { background: var(--hover); }
|
||||
@@ -759,20 +654,6 @@ button { cursor: pointer; }
|
||||
color: var(--paper);
|
||||
}
|
||||
.login-card h1 em { color: var(--ember); font-style: italic; }
|
||||
.login-art {
|
||||
margin: 8px auto 0;
|
||||
width: 240px;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: radial-gradient(circle, var(--ember-wash) 0%, transparent 70%);
|
||||
}
|
||||
.login-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 34px var(--ember-wash)) drop-shadow(0 18px 24px rgba(0,0,0,.5));
|
||||
}
|
||||
.login-card form { display: flex; flex-direction: column; gap: 18px; }
|
||||
.login-card label {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
@@ -813,22 +694,11 @@ button { cursor: pointer; }
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- laptop and up: the whole sheet is drawn 20% larger, which is what
|
||||
reading it at 120% zoom on a 1920-wide screen was doing by hand. Everything
|
||||
in this file is sized in px, so scaling the root is the one adjustment that
|
||||
keeps every proportion — hairlines, cover ratios, hit targets —
|
||||
intact. ---- */
|
||||
@media (min-width: 1280px) {
|
||||
:root { zoom: 1.2; }
|
||||
}
|
||||
|
||||
/* ---- desktop: same measure, actions fold up beside the row ---- */
|
||||
@media (min-width: 720px) {
|
||||
:root { --cover-w: 80px; --row-gap: 20px; }
|
||||
:root { --cover-w: 74px; --row-gap: 20px; }
|
||||
.topbar { padding: 26px 32px 18px; }
|
||||
.brand { font-size: 30px; }
|
||||
.brand .mark { width: 35px; height: 30px; }
|
||||
.libswitch a { padding: 9px 16px; font-size: 11px; }
|
||||
|
||||
.chrome {
|
||||
flex-direction: row;
|
||||
@@ -841,25 +711,13 @@ button { cursor: pointer; }
|
||||
.tabs a { padding: 10px 0 14px; font-size: 18px; }
|
||||
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
|
||||
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
|
||||
.keyrow {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 26px;
|
||||
height: 36px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
.keyrow .pair { flex-direction: row; gap: 7px; }
|
||||
.keyrow .pair svg { width: 14px; height: 14px; }
|
||||
.keyrow .pair span { font-size: 11px; letter-spacing: .1em; }
|
||||
.keyrow .full { display: inline; }
|
||||
.recent-card, .recent-cover { width: var(--cover-w); }
|
||||
.recent-cover { height: calc(var(--cover-w) * 4 / 3); }
|
||||
.recent-title { font-size: 17px; }
|
||||
.recent-card, .recent-cover { width: 100px; }
|
||||
.recent-cover { height: 133px; }
|
||||
|
||||
.card { padding: 18px 32px; }
|
||||
.row { flex-wrap: nowrap; align-items: center; }
|
||||
.cover .monogram { font-size: 28px; }
|
||||
.title { font-size: 22px; }
|
||||
.cover .monogram { font-size: 26px; }
|
||||
.title { font-size: 21px; }
|
||||
|
||||
.actions { flex: none; gap: 4px; border-top: none; }
|
||||
.actions > * {
|
||||
@@ -874,10 +732,6 @@ button { cursor: pointer; }
|
||||
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
|
||||
.is-new .actions .play { border-color: var(--play-hot-line); }
|
||||
.actions .on { border-color: var(--fav-line); }
|
||||
/* The cell border follows the icon on hover, so the accent reads as a state
|
||||
rather than a stray colour. */
|
||||
.actions .fav:hover, .actions .pencil:hover,
|
||||
.actions .box:hover, .actions .finish:hover { border-color: currentColor; }
|
||||
|
||||
/* Panels line up with the body text, i.e. past the cover and its gap. */
|
||||
.chapter-form, .confirm-row, .error-inline {
|
||||
@@ -1,4 +1,4 @@
|
||||
package store
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -33,9 +33,6 @@ type Bookmark struct {
|
||||
// Archived series stay polled for new chapters; finished ones do not.
|
||||
// Empty on the way in means "no opinion" — see Upsert.
|
||||
Status string `json:"status"`
|
||||
// Kind is the library bucket: manga or novel. Empty on the way in means
|
||||
// "no opinion" — see Upsert.
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// HasNewChapter reports whether the site has published past the read point.
|
||||
@@ -89,29 +86,11 @@ func (b Bookmark) ContinueURL() string {
|
||||
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 "?"
|
||||
}
|
||||
|
||||
// Library buckets. A bookmark is in exactly one. This cannot be derived from
|
||||
// Site: asurascans serves manga and novels from the same /comics/ path, so the
|
||||
// userscript that recorded the page is the only party that knows which.
|
||||
const (
|
||||
KindManga = "manga"
|
||||
KindNovel = "novel"
|
||||
)
|
||||
|
||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||
const (
|
||||
StatusReading = "reading"
|
||||
StatusArchived = "archived"
|
||||
StatusFinished = "finished"
|
||||
statusReading = "reading"
|
||||
statusArchived = "archived"
|
||||
statusFinished = "finished"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
@@ -130,7 +109,6 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
latest_chapter_num REAL,
|
||||
latest_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'reading',
|
||||
kind TEXT NOT NULL DEFAULT 'manga',
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
@@ -147,14 +125,11 @@ var addedColumns = []struct{ name, ddl string }{
|
||||
// Lifecycle bucket. The DEFAULT backfills every pre-existing row as
|
||||
// 'reading', so there is no separate migration step.
|
||||
{"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`},
|
||||
// Library bucket. The DEFAULT backfills every pre-existing row as 'manga',
|
||||
// which is what every row written before novels existed actually is.
|
||||
{"kind", `ALTER TABLE bookmarks ADD COLUMN kind TEXT NOT NULL DEFAULT 'manga'`},
|
||||
}
|
||||
|
||||
const bookmarkColumns = `key, site, series_id, title, series_url, cover,
|
||||
last_chapter, last_chapter_num, last_chapter_url,
|
||||
favorite, latest_chapter, latest_chapter_num, updated_at, status, kind`
|
||||
favorite, latest_chapter, latest_chapter_num, updated_at, status`
|
||||
|
||||
// Store is the SQLite-backed bookmark store.
|
||||
type Store struct {
|
||||
@@ -162,7 +137,7 @@ type Store struct {
|
||||
}
|
||||
|
||||
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
||||
func Open(path string) (*Store, error) {
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
||||
// concurrent requests; a single writer connection keeps writes serialized.
|
||||
dsn := path
|
||||
@@ -207,11 +182,11 @@ func migrateColumns(db *sql.DB) error {
|
||||
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
|
||||
// must not be part of series_id. Must stay in sync with stripBuildHash in
|
||||
// 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
|
||||
// the build hash to the stable, hashless ID. Rows keyed with a hash are
|
||||
@@ -243,7 +218,7 @@ func migrateAsuraKeys(db *sql.DB) error {
|
||||
|
||||
groups := map[string][]row{}
|
||||
for _, r := range all {
|
||||
stripped := AsuraBuildHash.ReplaceAllString(r.id, "")
|
||||
stripped := asuraBuildHash.ReplaceAllString(r.id, "")
|
||||
groups[stripped] = append(groups[stripped], r)
|
||||
}
|
||||
for stripped, g := range groups {
|
||||
@@ -311,7 +286,7 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
if err := scan(
|
||||
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
|
||||
&lastChapter, &lastChapterNum, &lastChapterURL,
|
||||
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &b.Kind,
|
||||
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status,
|
||||
); err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
@@ -330,8 +305,8 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
// leave the row in no list at all, so anything outside the three known
|
||||
// buckets reads as the default rather than being passed through.
|
||||
b.Status = status.String
|
||||
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
||||
b.Status = StatusReading
|
||||
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
|
||||
b.Status = statusReading
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -399,20 +374,18 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
// is the stored row and excluded.* is the incoming one; a brand-new key
|
||||
// never reaches this clause, so it keeps the fresh timestamp from VALUES.
|
||||
//
|
||||
// The status and kind columns resolve on the VALUES side, not in the
|
||||
// conflict clause: excluded.* is the row *after* these expressions are
|
||||
// evaluated, so a default applied there would look identical to a real
|
||||
// 'reading' / 'manga' and would overwrite an archived or novel row on
|
||||
// every PUT from a client that knows nothing about the column. Resolved
|
||||
// once here, an empty incoming status or kind means "keep what is
|
||||
// stored", and only a brand-new row falls through to the literal
|
||||
// default. The subquery runs inside this transaction, so it sees the
|
||||
// row this statement is about to conflict with.
|
||||
// The status column resolves on the VALUES side, not in the conflict
|
||||
// clause: excluded.* is the row *after* these expressions are evaluated,
|
||||
// so a default applied there would look identical to a real 'reading' and
|
||||
// would overwrite an archived row on every PUT from a client that knows
|
||||
// nothing about the column. Resolved once here, an empty incoming status
|
||||
// means "keep what is stored", and only a brand-new row falls through to
|
||||
// the literal default. The subquery runs inside this transaction, so it
|
||||
// sees the row this statement is about to conflict with.
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO bookmarks (`+bookmarkColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'),
|
||||
COALESCE(NULLIF(?, ''), (SELECT kind FROM bookmarks WHERE key = ?), 'manga'))
|
||||
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
||||
series_url=excluded.series_url, cover=excluded.cover,
|
||||
@@ -422,7 +395,6 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
latest_chapter=excluded.latest_chapter,
|
||||
latest_chapter_num=excluded.latest_chapter_num,
|
||||
status=excluded.status,
|
||||
kind=excluded.kind,
|
||||
updated_at=CASE
|
||||
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
|
||||
THEN excluded.updated_at
|
||||
@@ -431,8 +403,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
||||
b.LastChapter, b.LastChapterNum, b.LastChapterURL,
|
||||
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt,
|
||||
b.Status, b.Key,
|
||||
b.Kind, b.Key); err != nil {
|
||||
b.Status, b.Key); err != nil {
|
||||
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
|
||||
}
|
||||
|
||||
@@ -510,16 +481,3 @@ func (s *Store) MarkLatestChecked(key string, ts int64) error {
|
||||
}
|
||||
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,15 +1,42 @@
|
||||
package store
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"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 {
|
||||
t.Helper()
|
||||
store, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
@@ -17,6 +44,346 @@ func newTestStore(t *testing.T) *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) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
||||
|
||||
@@ -48,7 +415,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
||||
t.Fatalf("close legacy db: %v", err)
|
||||
}
|
||||
|
||||
store, err := Open(dbPath)
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore on legacy db: %v", err)
|
||||
}
|
||||
@@ -70,7 +437,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
||||
}
|
||||
|
||||
// Reopening an already-migrated database must be a no-op, not an error.
|
||||
store2, err := Open(dbPath)
|
||||
store2, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore is not idempotent: %v", err)
|
||||
}
|
||||
@@ -149,6 +516,19 @@ 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
|
||||
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
|
||||
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
||||
@@ -292,7 +672,7 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
s, err := Open(path)
|
||||
s, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore on pre-existing db: %v", err)
|
||||
}
|
||||
@@ -311,6 +691,38 @@ 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) {
|
||||
store := newTestStore(t)
|
||||
stored, err := store.Upsert(Bookmark{
|
||||
@@ -320,8 +732,8 @@ func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||
if stored.Status != statusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +743,7 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
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 {
|
||||
t.Fatalf("seed: %v", err)
|
||||
@@ -343,8 +755,8 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||
if stored.Status != statusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +768,7 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
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 {
|
||||
t.Fatalf("seed: %v", err)
|
||||
@@ -376,8 +788,8 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||
if stored.Status != statusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,19 +797,19 @@ func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
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 {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
base.Status = StatusReading
|
||||
base.Status = statusReading
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||
if stored.Status != statusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +826,7 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
base.Status = StatusArchived
|
||||
base.Status = statusArchived
|
||||
base.UpdatedAt = first.UpdatedAt + 60_000
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
@@ -445,7 +857,7 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
||||
}
|
||||
db.Close()
|
||||
|
||||
store, err := Open(path)
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
@@ -455,8 +867,8 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if b.Status != StatusReading {
|
||||
t.Fatalf("Status = %q, want %q", b.Status, StatusReading)
|
||||
if b.Status != statusReading {
|
||||
t.Fatalf("Status = %q, want %q", b.Status, statusReading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,9 +877,9 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
for _, tc := range []struct{ key, status string }{
|
||||
{"asura:reading", StatusReading},
|
||||
{"asura:archived", StatusArchived},
|
||||
{"asura:finished", StatusFinished},
|
||||
{"asura:reading", statusReading},
|
||||
{"asura:archived", statusArchived},
|
||||
{"asura:finished", statusFinished},
|
||||
} {
|
||||
if _, err := store.Upsert(Bookmark{
|
||||
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
||||
@@ -500,7 +912,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
||||
|
||||
store, err := Open(dbPath)
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
@@ -526,7 +938,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(dbPath)
|
||||
reopened, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
@@ -565,7 +977,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
// Idempotent: a third open changes nothing.
|
||||
third, err := Open(dbPath)
|
||||
third, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("third open: %v", err)
|
||||
}
|
||||
@@ -578,7 +990,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
||||
|
||||
store, err := Open(dbPath)
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
@@ -598,7 +1010,7 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(dbPath)
|
||||
reopened, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
@@ -660,104 +1072,3 @@ func TestDisplayChapter(t *testing.T) {
|
||||
t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertKindDefaultsToManga(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
got, err := store.Upsert(Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if got.Kind != KindManga {
|
||||
t.Fatalf("Kind = %q, want %q", got.Kind, KindManga)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertKindRoundTrips(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
got, err := store.Upsert(Bookmark{
|
||||
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
||||
SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if got.Kind != KindNovel {
|
||||
t.Fatalf("Kind = %q, want %q", got.Kind, KindNovel)
|
||||
}
|
||||
}
|
||||
|
||||
// The real hazard: a client that predates the column sends no kind at all. That
|
||||
// must keep the stored library, not silently demote a novel to manga.
|
||||
func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if _, err := store.Upsert(Bookmark{
|
||||
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
||||
SeriesID: "a-will-eternal", Kind: KindNovel, LastChapterNum: 10, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Upsert(Bookmark{
|
||||
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
||||
SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if got.Kind != KindNovel {
|
||||
t.Fatalf("Kind = %q, want %q — an empty kind must not reset the library", got.Kind, KindNovel)
|
||||
}
|
||||
if got.LastChapterNum != 11 {
|
||||
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
// A database created before this column exists must gain it, backfilled as
|
||||
// manga, without losing anything.
|
||||
func TestLegacyDatabaseGainsKindAsManga(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
||||
|
||||
legacy, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
if _, err := legacy.Exec(`
|
||||
CREATE TABLE bookmarks (
|
||||
key TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
series_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
series_url TEXT,
|
||||
cover TEXT,
|
||||
last_chapter TEXT,
|
||||
last_chapter_num REAL,
|
||||
last_chapter_url TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("create legacy schema: %v", err)
|
||||
}
|
||||
if _, err := legacy.Exec(`
|
||||
INSERT INTO bookmarks (key, site, series_id, title, updated_at)
|
||||
VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 123)`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
if err := legacy.Close(); err != nil {
|
||||
t.Fatalf("close legacy db: %v", err)
|
||||
}
|
||||
|
||||
store, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Open on legacy db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
list, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Kind != KindManga {
|
||||
t.Fatalf("legacy row should backfill as manga, got %+v", list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{define "app"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>mangaBookmark</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
{{/* Body text before meta lines: DM Sans is the biggest face and the one
|
||||
most of the page is set in; the mono is small and arrives from CSS. */}}
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "icons" .}}
|
||||
<div class="sheet">
|
||||
<header class="topbar">
|
||||
<h1 class="brand">manga<em>Bookmark</em></h1>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{/* Search sits above the tabs on a phone and folds into the tab row on a
|
||||
wider screen — one flex container, order swapped in CSS. */}}
|
||||
<div class="chrome">
|
||||
<div class="searchbar">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-search"/></svg>
|
||||
<input id="search" class="search" type="search" placeholder="Find a title"
|
||||
autocomplete="off" aria-label="Search titles">
|
||||
</div>
|
||||
|
||||
{{/* These are real links with real hrefs that change the URL, so they are
|
||||
navigation, not an ARIA tablist — aria-current carries "which bucket am
|
||||
I in" without owing a tabpanel contract we do not implement. */}}
|
||||
<nav class="tabs" aria-label="Bookmark buckets">
|
||||
<a href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
|
||||
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
|
||||
{{template "newcount" .}}</a>
|
||||
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
<a href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
<main id="list" class="list">
|
||||
{{template "list" .}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{/* The two regions that live outside the swapped #list: the "Continue
|
||||
reading" strip and the Updated badge. Both are rendered inline by app.html
|
||||
and again, out of band, on every /ui/ response — a mutation must not leave
|
||||
them describing the library as it was before the tap.
|
||||
|
||||
Both always render, hidden when they have nothing to say, so an out-of-band
|
||||
swap always has an element with the right id to replace. */}}
|
||||
|
||||
{{define "recent"}}
|
||||
<section class="recent" id="recent"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .Recent}} hidden{{end}}>
|
||||
<h2>Continue reading</h2>
|
||||
<div class="recent-strip">
|
||||
{{range .Recent}}
|
||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<span class="recent-cover">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</span>
|
||||
<span class="recent-title">{{.Title}}</span>
|
||||
<span class="recent-chapter">{{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}}
|
||||
@@ -5,8 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>BookmarkManager</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<title>mangaBookmark</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
</head>
|
||||
@@ -14,11 +13,8 @@
|
||||
<main class="login-card">
|
||||
<div>
|
||||
<span class="eyebrow">Private library</span>
|
||||
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
||||
<h1>manga<em>Bookmark</em></h1>
|
||||
</div>
|
||||
<figure class="login-art" aria-hidden="true">
|
||||
<img src="/static/login-art.png" alt="">
|
||||
</figure>
|
||||
<form method="post" action="/login">
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
@@ -1,4 +1,4 @@
|
||||
package userscript
|
||||
package main
|
||||
|
||||
import (
|
||||
"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
|
||||
// on the host without a restart. It is ~50 KB and polled about once a day.
|
||||
func Handler(token, path string) http.HandlerFunc {
|
||||
func userscriptHandler(token, path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
|
||||
http.NotFound(w, r)
|
||||
@@ -1,4 +1,4 @@
|
||||
package userscript
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const testToken = "s3cret-token"
|
||||
|
||||
// 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.
|
||||
const sampleScript = `// ==UserScript==
|
||||
@@ -37,12 +35,16 @@ func writeScript(t *testing.T, body string) (path, wantVersion string) {
|
||||
return path, "2026.07.28.1642"
|
||||
}
|
||||
|
||||
// newTestMux registers Handler the same way main.go's router does, without
|
||||
// pulling in the store or the rest of the app.
|
||||
func newTestMux(token, path string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
|
||||
return mux
|
||||
func newUserscriptServer(t *testing.T, path string) http.Handler {
|
||||
t.Helper()
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
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 {
|
||||
@@ -54,7 +56,7 @@ func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseR
|
||||
|
||||
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
||||
path, wantVersion := writeScript(t, sampleScript)
|
||||
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||
rr := getScript(t, newUserscriptServer(t, path), testToken)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
@@ -81,13 +83,10 @@ 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) {
|
||||
path, _ := writeScript(t, sampleScript)
|
||||
srv := newTestMux(testToken, path)
|
||||
for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
|
||||
srv := newUserscriptServer(t, path)
|
||||
for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} {
|
||||
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
||||
t.Errorf("token %q: status = %d, want 404", tok, got)
|
||||
}
|
||||
@@ -95,7 +94,7 @@ func TestUserscriptWrongTokenIs404(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUserscriptMissingFileIs404(t *testing.T) {
|
||||
srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
|
||||
srv := newUserscriptServer(t, filepath.Join(t.TempDir(), "absent.user.js"))
|
||||
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", got)
|
||||
}
|
||||
@@ -104,7 +103,7 @@ func TestUserscriptMissingFileIs404(t *testing.T) {
|
||||
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
||||
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
||||
path, _ := writeScript(t, noVersion)
|
||||
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||
rr := getScript(t, newUserscriptServer(t, path), testToken)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
@@ -113,3 +112,21 @@ func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package web
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
@@ -13,9 +13,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/session"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
@@ -24,29 +21,25 @@ var templateFS embed.FS
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
// RecentCount is how many series the "Continue reading" strip shows.
|
||||
const RecentCount = 5
|
||||
// recentCount is how many series the "Continue reading" strip shows.
|
||||
const recentCount = 5
|
||||
|
||||
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||
// It is a separate handler from api.Handler because the two speak different
|
||||
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||
// It is a separate handler from bookmarkHandler because the two speak different
|
||||
// representations (HTML versus JSON) to different clients under different auth.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
type webHandler struct {
|
||||
store *Store
|
||||
tmpl *template.Template
|
||||
key []byte
|
||||
password string
|
||||
limiter *session.LoginLimiter
|
||||
limiter *loginLimiter
|
||||
}
|
||||
|
||||
// listView is what every list-rendering template receives.
|
||||
type listView struct {
|
||||
// Lib is the library this view renders: store.KindManga or store.KindNovel.
|
||||
// Manga is the default and carries no query parameter, so every pre-novel
|
||||
// URL keeps meaning exactly what it did.
|
||||
Lib string
|
||||
Tab string // "all", "fav", or "new"
|
||||
Recent []store.Bookmark
|
||||
Items []store.Bookmark
|
||||
Recent []Bookmark
|
||||
Items []Bookmark
|
||||
// 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
|
||||
// reading set, not the active tab, so the badge does not change meaning as
|
||||
@@ -57,21 +50,14 @@ type listView struct {
|
||||
OOB bool
|
||||
}
|
||||
|
||||
// PageURL and ListURL are the two link shapes every tab needs. Building them
|
||||
// here rather than concatenating in the template is what keeps the library
|
||||
// parameter from being dropped on one link out of ten.
|
||||
func (v listView) PageURL(tab string) string {
|
||||
if v.Lib == store.KindNovel {
|
||||
return "/?lib=novel&tab=" + tab
|
||||
// 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 "/?tab=" + tab
|
||||
}
|
||||
|
||||
func (v listView) ListURL(tab string) string {
|
||||
if v.Lib == store.KindNovel {
|
||||
return "/ui/list?lib=novel&tab=" + tab
|
||||
}
|
||||
return "/ui/list?tab=" + tab
|
||||
return "?"
|
||||
}
|
||||
|
||||
// loginView is what the login template receives.
|
||||
@@ -79,23 +65,23 @@ type loginView struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// New parses every template up front so a broken one kills the process at
|
||||
// startup rather than the first request that touches it.
|
||||
func New(s *store.Store, apiToken, webPassword string) (*Handler, error) {
|
||||
// newWebHandler parses every template up front so a broken one kills the
|
||||
// process at startup rather than the first request that touches it.
|
||||
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{
|
||||
store: s,
|
||||
return &webHandler{
|
||||
store: store,
|
||||
tmpl: tmpl,
|
||||
key: session.Key(apiToken, webPassword),
|
||||
password: webPassword,
|
||||
limiter: session.NewLoginLimiter(),
|
||||
key: sessionKey(cfg.Token, cfg.WebPassword),
|
||||
password: cfg.WebPassword,
|
||||
limiter: newLoginLimiter(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
func (h *webHandler) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /{$}", h.index)
|
||||
mux.HandleFunc("POST /login", h.login)
|
||||
mux.HandleFunc("POST /logout", h.logout)
|
||||
@@ -132,15 +118,15 @@ func staticHandler() http.Handler {
|
||||
}
|
||||
|
||||
// authed reports whether the request carries a valid session cookie.
|
||||
func (h *Handler) authed(r *http.Request) bool {
|
||||
c, err := r.Cookie(session.CookieName)
|
||||
return err == nil && session.Verify(h.key, c.Value, time.Now().UnixMilli())
|
||||
func (h *webHandler) authed(r *http.Request) bool {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// requireSession guards the fragment endpoints. It answers 401 rather than
|
||||
// redirecting, because htmx swaps whatever body it receives into the page and a
|
||||
// redirected login page would be spliced into the card list.
|
||||
func (h *Handler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authed(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -150,7 +136,7 @@ func (h *Handler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) render(w http.ResponseWriter, status int, name string, data any) {
|
||||
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
@@ -162,12 +148,12 @@ func (h *Handler) render(w http.ResponseWriter, status int, name string, data an
|
||||
// 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
|
||||
// URL: one page, no redirect loop to reason about.
|
||||
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authed(r) {
|
||||
h.render(w, http.StatusOK, "login", loginView{})
|
||||
return
|
||||
}
|
||||
view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||
if err != nil {
|
||||
log.Printf("index: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -178,8 +164,8 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 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.
|
||||
func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []store.Bookmark {
|
||||
out := []store.Bookmark{}
|
||||
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
||||
out := []Bookmark{}
|
||||
for _, b := range all {
|
||||
if keep(b) {
|
||||
out = append(out, b)
|
||||
@@ -188,26 +174,6 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
|
||||
return out
|
||||
}
|
||||
|
||||
// kindOf reads a bookmark's library. A row cached or written before the kind
|
||||
// column existed has none; every one of those is manga, which is what the
|
||||
// column default says too.
|
||||
func kindOf(b store.Bookmark) string {
|
||||
if b.Kind == "" {
|
||||
return store.KindManga
|
||||
}
|
||||
return b.Kind
|
||||
}
|
||||
|
||||
// libOf normalises the query parameter. Anything that is not the novel library
|
||||
// is the manga one, so a typo lands on the default page rather than an empty
|
||||
// list.
|
||||
func libOf(q string) string {
|
||||
if q == store.KindNovel {
|
||||
return store.KindNovel
|
||||
}
|
||||
return store.KindManga
|
||||
}
|
||||
|
||||
// buildListView loads the list once and derives both the tab-filtered items and
|
||||
// the recent strip from it.
|
||||
//
|
||||
@@ -215,34 +181,25 @@ func libOf(q string) string {
|
||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
||||
// archived favourite therefore shows only under Archived: Favourites means
|
||||
// "favourites I am currently reading".
|
||||
func (h *Handler) buildListView(lib, tab string) (listView, error) {
|
||||
func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
all, err := h.store.List() // already ordered updated_at DESC
|
||||
if err != nil {
|
||||
return listView{}, err
|
||||
}
|
||||
// Narrow to one library first: reading, withNew and recent all derive from
|
||||
// this slice, so doing it later would let the other library's rows into the
|
||||
// strip and the Updated badge.
|
||||
all = filterBookmarks(all, func(b store.Bookmark) bool { return kindOf(b) == lib })
|
||||
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
||||
|
||||
// Novels do not offer an Updated tab, so a hand-typed one lands on All.
|
||||
if lib == store.KindNovel && tab == "new" {
|
||||
tab = "all"
|
||||
}
|
||||
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 []store.Bookmark
|
||||
var items []Bookmark
|
||||
switch tab {
|
||||
case "fav":
|
||||
items = filterBookmarks(reading, func(b store.Bookmark) bool { return b.Favorite })
|
||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
||||
case "new":
|
||||
items = withNew
|
||||
case "archived":
|
||||
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
||||
case "finished":
|
||||
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
|
||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
||||
default:
|
||||
tab = "all"
|
||||
items = reading
|
||||
@@ -256,18 +213,18 @@ func (h *Handler) buildListView(lib, tab string) (listView, error) {
|
||||
// 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
|
||||
// costs on a phone belongs to the list.
|
||||
var recent []store.Bookmark
|
||||
var recent []Bookmark
|
||||
if tab == "all" {
|
||||
recent = withNew
|
||||
if len(recent) > RecentCount {
|
||||
recent = recent[:RecentCount]
|
||||
if len(recent) > recentCount {
|
||||
recent = recent[:recentCount]
|
||||
}
|
||||
}
|
||||
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
||||
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||
if err != nil {
|
||||
log.Printf("ui list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -291,29 +248,12 @@ func currentTab(r *http.Request) string {
|
||||
return u.Query().Get("tab")
|
||||
}
|
||||
|
||||
// currentLib is the library the reader is looking at, read from htmx's own
|
||||
// header for the same reason currentTab is: out-of-band chrome must be rebuilt
|
||||
// for that view rather than for the default one.
|
||||
func currentLib(r *http.Request) string {
|
||||
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
||||
if err != nil {
|
||||
return store.KindManga
|
||||
}
|
||||
return libOf(u.Query().Get("lib"))
|
||||
}
|
||||
|
||||
// writeChromeOOB appends the regions that live outside #list — the recent
|
||||
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
||||
// 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
|
||||
// Archive for Restore.
|
||||
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||
// writeChromeOOB appends the two regions that live outside #list — the recent
|
||||
// strip and the Updated badge — as out-of-band swaps, so a mutation cannot
|
||||
// leave them describing the library as it was before the tap.
|
||||
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||
view.OOB = true
|
||||
names := []string{"recent", "keyrow"}
|
||||
if view.Lib == store.KindManga {
|
||||
names = append(names, "newcount")
|
||||
}
|
||||
for _, name := range names {
|
||||
for _, name := range []string{"recent", "newcount"} {
|
||||
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
||||
// The card is already written; stale chrome beats a torn response.
|
||||
log.Printf("render %s oob: %v", name, err)
|
||||
@@ -324,8 +264,8 @@ func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||
|
||||
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
||||
// mutation and appends it to the response.
|
||||
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.buildListView(currentLib(r), currentTab(r))
|
||||
func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.buildListView(currentTab(r))
|
||||
if err != nil {
|
||||
log.Printf("ui chrome: %v", err)
|
||||
return
|
||||
@@ -333,9 +273,9 @@ func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeChromeOOB(w, view)
|
||||
}
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := session.ClientIP(r)
|
||||
if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 {
|
||||
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
||||
secs := int(wait.Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
||||
@@ -351,38 +291,38 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
got := r.PostFormValue("password")
|
||||
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."})
|
||||
return
|
||||
}
|
||||
|
||||
h.limiter.Reset(ip)
|
||||
session.SetCookie(w, r, h.key)
|
||||
h.limiter.reset(ip)
|
||||
setSessionCookie(w, r, h.key)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
session.ClearCookie(w, r)
|
||||
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
clearSessionCookie(w, r)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// loadForMutation fetches the row a mutation targets, writing the error
|
||||
// response itself when there is nothing to mutate.
|
||||
func (h *Handler) loadForMutation(w http.ResponseWriter, r *http.Request) (store.Bookmark, bool) {
|
||||
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return store.Bookmark{}, false
|
||||
return Bookmark{}, false
|
||||
}
|
||||
b, ok, err := h.store.Get(key)
|
||||
if err != nil {
|
||||
log.Printf("ui get %q: %v", key, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Bookmark{}, false
|
||||
return Bookmark{}, false
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return store.Bookmark{}, false
|
||||
return Bookmark{}, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
@@ -396,7 +336,7 @@ func (h *Handler) loadForMutation(w http.ResponseWriter, r *http.Request) (store
|
||||
// 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
|
||||
// mutation, at the cost of one extra list read per toggle.
|
||||
func (h *Handler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b store.Bookmark) {
|
||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
|
||||
stored, err := h.store.Upsert(b)
|
||||
if err != nil {
|
||||
log.Printf("ui upsert %q: %v", b.Key, err)
|
||||
@@ -409,7 +349,7 @@ func (h *Handler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b st
|
||||
|
||||
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
||||
// Upsert keeps the stored updated_at and the list does not reorder.
|
||||
func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -425,7 +365,7 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||
// list does not reorder.
|
||||
func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -435,7 +375,7 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
switch s := r.PostFormValue("status"); s {
|
||||
case store.StatusReading, store.StatusArchived, store.StatusFinished:
|
||||
case statusReading, statusArchived, statusFinished:
|
||||
b.Status = s
|
||||
default:
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
@@ -456,7 +396,7 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// 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"
|
||||
// to "45") behind a frozen updated_at.
|
||||
func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -483,7 +423,7 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||
// place of the card — removing it from the page.
|
||||
func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
+86
-214
@@ -10,10 +10,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/session"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/web"
|
||||
)
|
||||
|
||||
const testPassword = "hunter2"
|
||||
@@ -26,22 +22,22 @@ func webConfig() Config {
|
||||
|
||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||
// can seed rows and assert on what the handlers wrote back.
|
||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return newRouter(st, cfg), st
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return newRouter(store, cfg), store
|
||||
}
|
||||
|
||||
// sessionCookie returns a cookie a handler will accept for cfg's API token.
|
||||
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
|
||||
t.Helper()
|
||||
return &http.Cookie{
|
||||
Name: session.CookieName,
|
||||
Value: session.Sign(session.Key(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
||||
Name: sessionCookieName,
|
||||
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +56,8 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
||||
|
||||
func TestIndexWithSessionShowsList(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
if _, err := st.Upsert(store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
if _, err := store.Upsert(Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
@@ -94,8 +90,8 @@ func TestLoginSuccessSetsCookie(t *testing.T) {
|
||||
t.Fatalf("POST /login status = %d, want 303", rr.Code)
|
||||
}
|
||||
cookies := rr.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
|
||||
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
|
||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,14 +122,14 @@ func TestLoginRateLimited(t *testing.T) {
|
||||
srv.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
for i := 0; i < session.MaxFailures; i++ {
|
||||
for i := 0; i < loginMaxFailures; i++ {
|
||||
if code := post().Code; code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
|
||||
}
|
||||
}
|
||||
rr := post()
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
|
||||
t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code)
|
||||
}
|
||||
if after := rr.Header().Get("Retry-After"); after == "" {
|
||||
t.Fatal("429 response has no Retry-After header")
|
||||
@@ -193,7 +189,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) {
|
||||
|
||||
func TestStaticAssetsServed(t *testing.T) {
|
||||
srv, _ := newWebTestServer(t, webConfig())
|
||||
for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg", "/static/login-art.png"} {
|
||||
for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js"} {
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -206,9 +202,9 @@ func TestStaticAssetsServed(t *testing.T) {
|
||||
}
|
||||
|
||||
// seed inserts one bookmark and returns it as stored.
|
||||
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
|
||||
func seed(t *testing.T, store *Store, b Bookmark) Bookmark {
|
||||
t.Helper()
|
||||
stored, err := st.Upsert(b)
|
||||
stored, err := store.Upsert(b)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
@@ -249,8 +245,8 @@ func TestUIRoutesRequireSession(t *testing.T) {
|
||||
|
||||
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
before := seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
before := seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||
UpdatedAt: 1_000_000,
|
||||
@@ -262,7 +258,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
after, ok, err := st.Get("asura:solo")
|
||||
after, ok, err := store.Get("asura:solo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
||||
}
|
||||
@@ -280,7 +276,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
// Toggling again turns it back off.
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
||||
back, _, _ := st.Get("asura:solo")
|
||||
back, _, _ := store.Get("asura:solo")
|
||||
if back.Favorite {
|
||||
t.Fatal("Favorite = true after a second toggle, want false")
|
||||
}
|
||||
@@ -298,8 +294,8 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
// selector, just a regression guard against reintroducing the bare-id form.
|
||||
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||
UpdatedAt: 1_000_000,
|
||||
@@ -324,8 +320,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||
|
||||
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
before := seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
before := seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||
@@ -339,7 +335,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
after, ok, err := st.Get("asura:solo")
|
||||
after, ok, err := store.Get("asura:solo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
||||
}
|
||||
@@ -359,8 +355,8 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||
|
||||
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
before := seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
before := seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
||||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||
@@ -379,7 +375,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
after, ok, err := st.Get("asura:solo")
|
||||
after, ok, err := store.Get("asura:solo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
||||
}
|
||||
@@ -399,8 +395,8 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||
|
||||
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
||||
})
|
||||
@@ -413,7 +409,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
after, _, _ := st.Get("asura:solo")
|
||||
after, _, _ := store.Get("asura:solo")
|
||||
if after.LastChapterNum != 45 {
|
||||
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
||||
}
|
||||
@@ -444,8 +440,8 @@ func TestMutationsOnMissingKey(t *testing.T) {
|
||||
|
||||
func TestUIDeleteRemovesRow(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
||||
})
|
||||
@@ -464,19 +460,19 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
||||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
||||
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
||||
}
|
||||
if _, ok, _ := st.Get("asura:solo"); ok {
|
||||
if _, ok, _ := store.Get("asura:solo"); ok {
|
||||
t.Fatal("row still present after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIListFavouritesTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
||||
})
|
||||
seed(t, st, store.Bookmark{
|
||||
seed(t, store, Bookmark{
|
||||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||||
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
||||
})
|
||||
@@ -497,14 +493,14 @@ func TestUIListFavouritesTab(t *testing.T) {
|
||||
|
||||
func TestUIListNewTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapterNum: 10,
|
||||
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
||||
UpdatedAt: 2_000_000,
|
||||
})
|
||||
seed(t, st, store.Bookmark{
|
||||
seed(t, store, Bookmark{
|
||||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||||
Title: "Tower of God", LastChapterNum: 5,
|
||||
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
||||
@@ -528,22 +524,22 @@ func TestUIListNewTab(t *testing.T) {
|
||||
// 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
|
||||
// shows up as a failure rather than passing by accident.
|
||||
func seedStatusRows(t *testing.T, st *store.Store) {
|
||||
func seedStatusRows(t *testing.T, store *Store) {
|
||||
t.Helper()
|
||||
// floatPtr already exists in store_test.go — same package, reuse it.
|
||||
rows := []store.Bookmark{
|
||||
rows := []Bookmark{
|
||||
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
||||
Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
|
||||
Status: statusReading, LastChapterNum: 10, Favorite: true,
|
||||
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
||||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
||||
Status: statusArchived, LastChapterNum: 5, Favorite: true,
|
||||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||||
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
|
||||
Status: statusFinished, LastChapterNum: 200, Favorite: true},
|
||||
}
|
||||
for _, b := range rows {
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
if _, err := st.Upsert(b); err != nil {
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
@@ -551,8 +547,8 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
||||
|
||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
cases := []struct {
|
||||
tab string
|
||||
@@ -608,16 +604,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.
|
||||
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store) // 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.
|
||||
caught := store.Bookmark{
|
||||
caught := Bookmark{
|
||||
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
||||
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
|
||||
Status: statusReading, LastChapterNum: 40, LatestChapter: "40",
|
||||
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if _, err := st.Upsert(caught); err != nil {
|
||||
if _, err := store.Upsert(caught); err != nil {
|
||||
t.Fatalf("seed %s: %v", caught.Key, err)
|
||||
}
|
||||
|
||||
@@ -637,12 +633,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
}
|
||||
|
||||
// Nothing new anywhere: the strip has nothing to say and does not render.
|
||||
reading, _, err := st.Get("asura:reading")
|
||||
reading, _, err := store.Get("asura:reading")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
||||
if _, err := st.Upsert(reading); err != nil {
|
||||
if _, err := store.Upsert(reading); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
// The section still ships (an out-of-band swap needs the id to exist) but
|
||||
@@ -656,23 +652,23 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The strip never grows past web.RecentCount, however many series are waiting.
|
||||
// The strip never grows past recentCount, however many series are waiting.
|
||||
func TestRecentStripCapped(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
for i := 0; i <= web.RecentCount; i++ {
|
||||
b := store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
for i := 0; i <= recentCount; i++ {
|
||||
b := Bookmark{
|
||||
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
||||
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
||||
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
|
||||
Status: statusReading, LastChapterNum: 1, LatestChapter: "2",
|
||||
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
||||
}
|
||||
if _, err := st.Upsert(b); err != nil {
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
|
||||
t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
|
||||
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount {
|
||||
t.Fatalf("strip rendered %d cards, want %d", got, recentCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,14 +686,14 @@ func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string)
|
||||
|
||||
func TestUIStatusSetsBucket(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
|
||||
for _, want := range []string{statusArchived, statusFinished, statusReading} {
|
||||
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())
|
||||
}
|
||||
b, ok, err := st.Get("asura:reading")
|
||||
b, ok, err := store.Get("asura:reading")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
@@ -709,21 +705,21 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
||||
|
||||
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
b, _, _ := st.Get("asura:reading")
|
||||
if b.Status != store.StatusReading {
|
||||
b, _, _ := store.Get("asura:reading")
|
||||
if b.Status != statusReading {
|
||||
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIStatusRequiresSession(t *testing.T) {
|
||||
srv, st := newWebTestServer(t, webConfig())
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, webConfig())
|
||||
seedStatusRows(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||||
strings.NewReader("status=archived"))
|
||||
@@ -738,15 +734,15 @@ func TestUIStatusRequiresSession(t *testing.T) {
|
||||
|
||||
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
before, _, _ := st.Get("asura:reading")
|
||||
before, _, _ := store.Get("asura:reading")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
after, _, _ := st.Get("asura:reading")
|
||||
after, _, _ := store.Get("asura:reading")
|
||||
if after.UpdatedAt != before.UpdatedAt {
|
||||
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
||||
}
|
||||
@@ -754,8 +750,8 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||
|
||||
func TestCardShowsStatusControls(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
cases := []struct {
|
||||
tab string
|
||||
@@ -792,8 +788,8 @@ func TestCardShowsStatusControls(t *testing.T) {
|
||||
|
||||
func TestAppRendersNewTabs(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
@@ -811,10 +807,10 @@ func TestAppRendersNewTabs(t *testing.T) {
|
||||
// outside the swapped card, so nothing else would correct them.
|
||||
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
||||
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
||||
Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
||||
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
@@ -824,7 +820,7 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||
}
|
||||
|
||||
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
||||
url.Values{"status": {store.StatusArchived}})
|
||||
url.Values{"status": {statusArchived}})
|
||||
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
@@ -840,127 +836,3 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||
t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// seedLibraries puts one manga and one novel row in the store.
|
||||
func seedLibraries(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", Kind: store.KindManga, UpdatedAt: 2_000_000,
|
||||
})
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
||||
SeriesID: "a-will-eternal", Title: "A Will Eternal",
|
||||
Kind: store.KindNovel, UpdatedAt: 1_000_000,
|
||||
})
|
||||
}
|
||||
|
||||
func TestLibrariesAreDisjoint(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
cases := []struct {
|
||||
name, path, want, absent string
|
||||
}{
|
||||
{"manga is the default", "/ui/list?tab=all", "Solo Leveling", "A Will Eternal"},
|
||||
{"novel is opt-in", "/ui/list?lib=novel&tab=all", "A Will Eternal", "Solo Leveling"},
|
||||
{"unknown lib falls back to manga", "/ui/list?lib=comics&tab=all", "Solo Leveling", "A Will Eternal"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, tc.path, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, tc.want) {
|
||||
t.Fatalf("%s missing from %s", tc.want, tc.path)
|
||||
}
|
||||
if strings.Contains(body, tc.absent) {
|
||||
t.Fatalf("%s leaked into %s", tc.absent, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A row written before the kind column existed has none. It is manga.
|
||||
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
|
||||
Title: "Legacy Series", UpdatedAt: 1_000_000,
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=all", nil))
|
||||
if !strings.Contains(rr.Body.String(), "Legacy Series") {
|
||||
t.Fatal("a row with no kind must appear in the manga library")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?lib=novel&tab=all", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "tab=new") {
|
||||
t.Fatal("novel page must not offer the Updated tab")
|
||||
}
|
||||
// html/template escapes & to & inside an attribute value, so that — not
|
||||
// the raw URL — is what lands in the body. htmx and the browser both decode
|
||||
// it on read, so only the assertion has to know.
|
||||
for _, want := range []string{
|
||||
"/?lib=novel&tab=fav",
|
||||
"/?lib=novel&tab=archived",
|
||||
"/?lib=novel&tab=finished",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("novel page missing tab link %s", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `class="libswitch"`) {
|
||||
t.Fatal("novel page missing the library switch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?tab=all", nil))
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "/?tab=new") {
|
||||
t.Fatal("manga page must keep the Updated tab")
|
||||
}
|
||||
if strings.Contains(body, "lib=novel&tab=new") {
|
||||
t.Fatal("the Updated tab must never be emitted for the novel library")
|
||||
}
|
||||
}
|
||||
|
||||
// tab=new is not offered for novels, so a hand-typed one must land on All
|
||||
// rather than an empty page.
|
||||
func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?lib=novel&tab=new", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "A Will Eternal") {
|
||||
t.Fatal("novel tab=new should render the novel All list")
|
||||
}
|
||||
}
|
||||
|
||||
+15
-31
@@ -1,11 +1,11 @@
|
||||
# Production override: join an existing Traefik network and let Traefik route
|
||||
# bookmark-api.<domain> -> this service with TLS. No host port published.
|
||||
# manga-api.<domain> -> this service with TLS. No host port published.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# Set in .env:
|
||||
# BOOKMARK_API_HOST=bookmark-api.example.com # your subdomain (required)
|
||||
# BOOKMARK_WEB_HOST=bookmark.example.com # browser UI subdomain, same container (required)
|
||||
# MANGA_API_HOST=manga-api.example.com # your subdomain (required)
|
||||
# MANGA_WEB_HOST=manga.example.com # browser UI subdomain, same container (required)
|
||||
# PROXY_NETWORK=proxy # Traefik's network name, if not "proxy"
|
||||
# TRAEFIK_ENTRYPOINT=websecure # your HTTPS entrypoint name
|
||||
# TRAEFIK_CERTRESOLVER=le # your ACME/cert resolver name
|
||||
@@ -14,43 +14,27 @@
|
||||
# docker network create proxy # if it doesn't yet
|
||||
|
||||
services:
|
||||
bookmark-api:
|
||||
manga-api:
|
||||
# Traffic arrives over the Traefik network, not a published port.
|
||||
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:
|
||||
- proxy
|
||||
- browser
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${PROXY_NETWORK:-proxy}"
|
||||
- "traefik.http.routers.bmapi.rule=Host(`${BOOKMARK_API_HOST:?set BOOKMARK_API_HOST in .env}`)"
|
||||
- "traefik.http.routers.bmapi.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.bmapi.tls=true"
|
||||
- "traefik.http.routers.bmapi.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
||||
- "traefik.http.services.bmapi.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.mangabm.rule=Host(`${MANGA_API_HOST:?set MANGA_API_HOST in .env}`)"
|
||||
- "traefik.http.routers.mangabm.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.mangabm.tls=true"
|
||||
- "traefik.http.routers.mangabm.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
||||
- "traefik.http.services.mangabm.loadbalancer.server.port=8080"
|
||||
# Second hostname for the browser UI, same container. Traefik needs the
|
||||
# service named explicitly once more than one router targets it.
|
||||
- "traefik.http.routers.bmapi.service=bmapi"
|
||||
- "traefik.http.routers.bmweb.rule=Host(`${BOOKMARK_WEB_HOST:?set BOOKMARK_WEB_HOST in .env}`)"
|
||||
- "traefik.http.routers.bmweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.bmweb.tls=true"
|
||||
- "traefik.http.routers.bmweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
||||
- "traefik.http.routers.bmweb.service=bmapi"
|
||||
|
||||
# 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.
|
||||
- "traefik.http.routers.mangabm.service=mangabm"
|
||||
- "traefik.http.routers.mangaweb.rule=Host(`${MANGA_WEB_HOST:?set MANGA_WEB_HOST in .env}`)"
|
||||
- "traefik.http.routers.mangaweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.mangaweb.tls=true"
|
||||
- "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
|
||||
- "traefik.http.routers.mangaweb.service=mangabm"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
+5
-57
@@ -1,30 +1,27 @@
|
||||
# Base stack — works standalone for local smoke testing (`docker compose up`).
|
||||
# The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik)
|
||||
# terminates TLS for bookmark-api.<domain> and forwards to it.
|
||||
# terminates TLS for manga-api.<domain> and forwards to it.
|
||||
#
|
||||
# If your proxy runs in Docker on its own network, use the prod override which
|
||||
# attaches to that network instead of publishing a port:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
bookmark-api:
|
||||
manga-api:
|
||||
build: ./backend
|
||||
image: bookmarkmanager-backend:latest
|
||||
container_name: bookmark-api
|
||||
image: mangabm-backend:latest
|
||||
container_name: manga-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# API_TOKEN is required — compose refuses to start without it.
|
||||
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org}
|
||||
DB_PATH: /data/bookmarks.db
|
||||
PORT: "8080"
|
||||
# Gates the browser UI. Unset means the web routes are not served at all.
|
||||
WEB_PASSWORD: ${WEB_PASSWORD:-}
|
||||
# Path inside the container; matches the bindmount above.
|
||||
USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js}
|
||||
# Second script from the same bindmount; the novel library is a separate
|
||||
# Violentmonkey install.
|
||||
NOVEL_USERSCRIPT_PATH: ${NOVEL_USERSCRIPT_PATH:-/userscript/novel-bookmark.user.js}
|
||||
# Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill
|
||||
# switch; it only takes effect because these are listed here.
|
||||
LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1}
|
||||
@@ -32,17 +29,6 @@ services:
|
||||
LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m}
|
||||
LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14}
|
||||
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:
|
||||
- bookmarks-data:/data
|
||||
# The userscript is served from here, read fresh on every request. Editing
|
||||
@@ -54,44 +40,6 @@ services:
|
||||
# the public internet does not.
|
||||
ports:
|
||||
- "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 bookmark-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:
|
||||
bookmarks-data:
|
||||
|
||||
networks:
|
||||
# Not `internal: true`: headless Chrome still needs outbound access to reach
|
||||
# kagane.to. Isolation here comes from membership (only bookmark-api and
|
||||
# headless-shell join it), not from cutting egress.
|
||||
browser:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/24
|
||||
|
||||
+55
-127
@@ -1,16 +1,16 @@
|
||||
# Cinder — BookmarkManager design system
|
||||
# Cinder — mangaBookmark design system
|
||||
|
||||
Source of truth: the Claude Design project **BookmarkManager Web UI**
|
||||
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
|
||||
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
|
||||
`style.css`, `filter.js`). This file records the rules that got implemented so
|
||||
a future agent can extend the UI without re-reading the design.
|
||||
Source of truth: the Claude Design doc **Cinder Sheet**
|
||||
(`cfa39183-8874-4f76-987c-afef14dceebb`, files `Cinder Sheet.dc.html` for the
|
||||
static spec and `Cinder Sheet App.dc.html` for the interactive one). This file
|
||||
records the rules that got implemented so a future agent can extend the UI
|
||||
without re-reading the design.
|
||||
|
||||
Implemented in:
|
||||
|
||||
| Surface | Files |
|
||||
| --- | --- |
|
||||
| Web UI (login, list, card, empty, errors) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
|
||||
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,icons}.html`, `backend/static/filter.js` |
|
||||
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
|
||||
|
||||
## 1. The one idea
|
||||
@@ -19,12 +19,9 @@ Implemented in:
|
||||
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
|
||||
its `Ch N out` meta and play icon go ember. Everything else — favourites,
|
||||
status, chrome, destruction — stays off that one colour. Destruction gets its
|
||||
own token (`--danger`, a duller oxblood) precisely so a remove confirm is
|
||||
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).
|
||||
status, chrome — stays cool. 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
|
||||
use brass, which is already spoken for by favourites.
|
||||
|
||||
Corollaries:
|
||||
|
||||
@@ -37,17 +34,17 @@ Corollaries:
|
||||
- **Three type roles, never mixed.** Display serif for anything a human reads as
|
||||
a name (brand, titles, tabs, primary buttons, empty-state headings). Mono
|
||||
small-caps for machine facts (site, chapter numbers, labels, status, badges,
|
||||
ghost buttons, the action key). Sans for prose only (empty-state body, hints).
|
||||
ghost buttons). Sans for prose only (empty-state body, hints).
|
||||
|
||||
## 2. Tokens
|
||||
|
||||
Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the userscript's
|
||||
Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
|
||||
`:host`. **Never hardcode a hex outside those two blocks.**
|
||||
|
||||
| Token | Dark | Light | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ink` | `#100f0e` | `#f7f4ef` | page |
|
||||
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
|
||||
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form, toast) |
|
||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
|
||||
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
|
||||
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
|
||||
@@ -57,36 +54,23 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
|
||||
| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter |
|
||||
| `--paper-dim` | `#ddd5cb` | `#191715` | resting title |
|
||||
| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons |
|
||||
| `--mute-2` | `#877f76` | `#6c655e` | eyebrow labels, hints (must clear 4.5:1 on both `--ink` and `--ash`) |
|
||||
| `--mute-2` | `#5a5450` | `#857d75` | eyebrow labels, hints |
|
||||
| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line |
|
||||
| `--faint-2` | `#57504b` | `#a8a098` | cover monogram |
|
||||
| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 |
|
||||
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface |
|
||||
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface (confirm, error) |
|
||||
| `--ember-ink` | `#150907` | `#fff` | text on solid ember |
|
||||
| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash |
|
||||
| `--danger` | `#cf5c4d` | `#97362a` | destruction — remove confirm, never the same as `--ember` |
|
||||
| `--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 |
|
||||
| `--brass` | `#b8912f` | `#8a681c` | favourites, and only favourites |
|
||||
| `--trash` | `#6b5450` | `#a98276` | remove, at rest |
|
||||
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
|
||||
| `--demonic` | `#a98a78` | `#8a6a55` | site tag |
|
||||
| `--comix` | `#8a9a7d` | `#5f7250` | site tag |
|
||||
| `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag |
|
||||
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
|
||||
|
||||
`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
|
||||
one accent per action, so a press says which lane it belongs to, with none of
|
||||
them competing with ember. Dark is the default (`color-scheme: dark light`);
|
||||
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.
|
||||
Dark is the default (`color-scheme: dark light`); 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
|
||||
|
||||
@@ -97,12 +81,13 @@ dark, the hues are re-tuned.
|
||||
| Sans | `DM Sans` → system UI | system UI |
|
||||
|
||||
The web UI **self-hosts** all three: five latin-subset woff2 files in
|
||||
`backend/internal/web/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
|
||||
`//go:embed static`. There is no request to Google — this UI needs to survive
|
||||
on a LAN with no internet route. `staticHandler()` in `web.go` registers the
|
||||
`.woff2` MIME type because Go's built-in table lacks it and the scratch image
|
||||
has no `/etc/mime.types`.
|
||||
`//go:embed static`. There is no request to Google — this UI is read in Bromite,
|
||||
where `fonts.googleapis.com` is routinely blocked, and over a LAN with no
|
||||
internet route. `staticHandler()` in `web.go` registers the `.woff2` MIME type
|
||||
because Go's built-in table lacks it and the scratch image has no
|
||||
`/etc/mime.types`.
|
||||
|
||||
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
|
||||
@@ -117,58 +102,37 @@ root is at the mercy of the host site's CSP.
|
||||
|
||||
Recurring specs (copy these rather than inventing sizes):
|
||||
|
||||
- Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `<em>` in
|
||||
ember italic — `Bookmark<em>Manager</em>`.
|
||||
- Row title: `400 21px/1.2 display` (`22px` ≥720px).
|
||||
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
|
||||
- Brand: `400 26px/1 display`, with `<em>` in ember italic — `manga<em>Bookmark</em>`.
|
||||
- Row title: `400 19px/1.2 display` (21px ≥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
|
||||
the row's own hairline.
|
||||
- Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
|
||||
.04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
|
||||
- Meta / label / badge: `500 10px mono`, `letter-spacing: .12em`,
|
||||
`text-transform: uppercase`. Eyebrows ("CONTINUE READING") use `.2em`.
|
||||
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
|
||||
- Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
|
||||
- Primary button: `--paper` fill, `--ink` text, `400 17px display`, no border radius.
|
||||
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
|
||||
|
||||
## 4. Components (web UI)
|
||||
|
||||
```
|
||||
.sheet
|
||||
.topbar .brand (mark + wordmark) + .ghost (log out)
|
||||
.topbar .brand + .ghost (log out)
|
||||
.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
|
||||
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(--ember)`,
|
||||
so a surface that needs a different blade colour re-points that token rather
|
||||
than duplicating the SVG. 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. The key follows the tab,
|
||||
not the row: Archive becomes Restore under Archived and Finished, and Finished
|
||||
drops Done. On a phone each pair stacks icon-over-word
|
||||
(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays
|
||||
out icon-beside-word at the same wording. `.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[.is-new|.is-dim]#card-<key>[data-title]
|
||||
.row
|
||||
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
|
||||
a.cover img | span.monogram, + span.foot-rule[.brass]
|
||||
.body .title-line (h3.title + svg.fav-mark) , p.meta
|
||||
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
|
||||
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
|
||||
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
|
||||
.actions play, favourite, chapter, archive|restore, finish, remove
|
||||
form.chapter-form[hidden] .hint + .field(input + Save)
|
||||
.confirm-row[hidden] span + (Remove, Cancel)
|
||||
p.error-inline[hidden]
|
||||
```
|
||||
|
||||
@@ -179,29 +143,9 @@ Rules that are easy to break:
|
||||
dim rule is a descendant selector off those two classes, so a new sub-element
|
||||
inherits the state for free.
|
||||
- `.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 44px squares beside
|
||||
full-width strip under the row on a phone and a group of 40px squares beside
|
||||
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
|
||||
`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`,
|
||||
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>`
|
||||
@@ -211,15 +155,11 @@ Rules that are easy to break:
|
||||
- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both.
|
||||
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
|
||||
panel is a flex container, and `display` beats `hidden`.
|
||||
- Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
|
||||
- Busy state is `.card.htmx-request::before`, a 1px ember bar sliding across the
|
||||
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
|
||||
spinner, and deliberately `--mute` not `--ember` — on a list screen ember
|
||||
means "new chapter" and nothing else, so a system state can't borrow it.
|
||||
- `.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`.
|
||||
spinner.
|
||||
- `.open` on the pencil / trash cell marks which panel is showing; `filter.js`
|
||||
`togglePanel()` owns that class alongside `hidden`.
|
||||
|
||||
## 5. Components (userscript panel)
|
||||
|
||||
@@ -227,9 +167,9 @@ Same tokens, same heat rule, structure unchanged from before the revamp
|
||||
(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`).
|
||||
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
|
||||
borders instead of pills; loading is the same sliding hairline (`.spinner`
|
||||
is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||
`--danger`-washed when `.err`.
|
||||
borders instead of pills; loading is the same sliding ember hairline (`.spinner`
|
||||
is now a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||
ember-washed when `.err`.
|
||||
|
||||
**Do not touch** the FAB geometry while restyling: `#fab` keeps
|
||||
`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the
|
||||
@@ -239,48 +179,36 @@ is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||
## 6. Motion
|
||||
|
||||
Three animations, all ≤ 1.15s and all disabled under
|
||||
`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):
|
||||
`prefers-reduced-motion: reduce`:
|
||||
|
||||
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
|
||||
- `barSlide` — the sliding hairline, for any busy state.
|
||||
- `mutePulse` — the 5px dot on `.error-inline`.
|
||||
- `barSlide` — the burning hairline, for any busy state.
|
||||
- `emberPulse` — the 5px dot on `.error-inline`.
|
||||
|
||||
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
|
||||
## 7. Accessibility floor (not negotiable)
|
||||
|
||||
- Touch targets on the phone layout are 44–46px; the 44px desktop cells are
|
||||
- Touch targets on the phone layout are 44–46px; the 40px desktop cells are
|
||||
pointer-only (≥720px).
|
||||
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
|
||||
`aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
|
||||
`aria-controls` pointing at their `.confirm-row`.
|
||||
`aria-hidden`.
|
||||
- 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.
|
||||
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
|
||||
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.
|
||||
|
||||
## 8. Adding something new — checklist
|
||||
|
||||
1. Can it be a hairline, a small-caps label, or a serif line instead of a new
|
||||
component? Prefer that.
|
||||
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.
|
||||
2. Tokens only, both colour branches.
|
||||
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
|
||||
third state class.
|
||||
4. If it removes a series from the current view (archive/finish/remove-shaped),
|
||||
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
|
||||
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
|
||||
4. Icon → `templates/icons.html`; nothing inlines SVG paths.
|
||||
5. Phone first (44px targets, single column), then the ≥720px block.
|
||||
6. Verify: `cd backend && go test ./...`, then run the binary and screenshot
|
||||
both widths and both colour schemes (Playwright: `emulateMedia`,
|
||||
`setViewportSize`; disable the browser cache — `/static/*` is served with
|
||||
`max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system.
|
||||
|
||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||
|
||||
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 `bmgr:manga: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.
|
||||
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
|
||||
failed mutation park in `localStorage` (`bmgr:manga:queue`) and replayed on
|
||||
next navigation, reconnect, or `refresh()`. Entries are markers
|
||||
(`{key, op, sendStatus, attempts}`), never payloads — body read from
|
||||
cache at send time, so one entry per key give ordering and coalescing for
|
||||
free. `sendStatus` is **sticky**: while archive pending, later writes to
|
||||
that key keep carrying bucket, which stop successful
|
||||
in-between write from silently un-archiving series. `refresh()` drains
|
||||
before it fetches and overlays anything still pending, so list never
|
||||
flaps. 400 drops entry, 401 abort pass and keep queue, and
|
||||
transient failures retry to cap of 10. Latest-chapter writes deliberately
|
||||
stay out of queue. See
|
||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
||||
(critical on mobile). Three tabs (All / Favourites / Archived) and row of
|
||||
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
||||
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 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 trust)
|
||||
|
||||
- **asurascans.com**: series `/comics/<slug>` (slug carries trailing
|
||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
||||
hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript,
|
||||
`asuraBuildHash` in backend); URLs keep full slug — stale-hash
|
||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
||||
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).
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
||||
`h3.title` (series) or `a.truyen-title` (chapter), cover from
|
||||
`meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint
|
||||
clears, so the backend polls it through the headless browser.
|
||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
|
||||
title on a series page and `<Title> Chapter <n>` on a chapter page. Chapter
|
||||
pages carry no `og:image`. Its series page lists every chapter with an
|
||||
absolute href, so the backend polls it with the plain TLS client.
|
||||
|
||||
### Second script: `novel-bookmark.user.js`
|
||||
|
||||
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous
|
||||
installation to carry keys over from). Installed alongside the manga script;
|
||||
both write to the same backend with the same `LIBRARY` column discriminating
|
||||
them.
|
||||
@@ -1,64 +0,0 @@
|
||||
Guidance for Claude Code working under `userscript/`. See root `CLAUDE.md` for the project-wide architecture diagram, hard constraints, and design system.
|
||||
|
||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||
|
||||
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 `bmgr:manga: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.
|
||||
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
|
||||
failed mutation park in `localStorage` (`bmgr:manga:queue`) and replayed on
|
||||
next navigation, reconnect, or `refresh()`. Entries are markers
|
||||
(`{key, op, sendStatus, attempts}`), never payloads — body read from
|
||||
cache at send time, so one entry per key give ordering and coalescing for
|
||||
free. `sendStatus` is **sticky**: while archive pending, later writes to
|
||||
that key keep carrying bucket, which stop successful
|
||||
in-between write from silently un-archiving series. `refresh()` drains
|
||||
before it fetches and overlays anything still pending, so list never
|
||||
flaps. 400 drops entry, 401 abort pass and keep queue, and
|
||||
transient failures retry to cap of 10. Latest-chapter writes deliberately
|
||||
stay out of queue. See
|
||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||
5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
|
||||
(critical on mobile). Three tabs (All / Favourites / Archived) and row of
|
||||
link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
|
||||
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 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 trust)
|
||||
|
||||
- **asurascans.com**: series `/comics/<slug>` (slug carries trailing
|
||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
||||
hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript,
|
||||
`asuraBuildHash` in backend); URLs keep full slug — stale-hash
|
||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
||||
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).
|
||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
||||
2026-07-28.
|
||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
||||
`h3.title` (series) or `a.truyen-title` (chapter), cover from
|
||||
`meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint
|
||||
clears, so the backend polls it through the headless browser.
|
||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
|
||||
title on a series page and `<Title> Chapter <n>` on a chapter page. Chapter
|
||||
pages carry no `og:image`. Its series page lists every chapter with an
|
||||
absolute href, so the backend polls it with the plain TLS client.
|
||||
|
||||
### Second script: `novel-bookmark.user.js`
|
||||
|
||||
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous
|
||||
installation to carry keys over from). Installed alongside the manga script;
|
||||
both write to the same backend with the same `LIBRARY` column discriminating
|
||||
them.
|
||||
@@ -1,16 +1,14 @@
|
||||
// ==UserScript==
|
||||
// @name Manga Bookmark Sync
|
||||
// @namespace mangabm
|
||||
// @version 1.6.0
|
||||
// @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @version 1.5.0
|
||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @author you
|
||||
// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||
// @updateURL https://bookmark-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
|
||||
// @match https://asuracomic.net/*
|
||||
// @match https://asurascans.com/*
|
||||
// @match https://demonicscans.org/*
|
||||
// @match https://comix.to/*
|
||||
// @match https://kagane.to/*
|
||||
// @run-at document-idle
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
@@ -21,38 +19,19 @@
|
||||
// ============================================================
|
||||
// CONFIG — fill these in before installing.
|
||||
// ============================================================
|
||||
const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
||||
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
||||
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
||||
const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
||||
|
||||
// This script owns the manga library; the novel script is a separate install
|
||||
// with its own prefix, so the two never share a cache, a queue or a panel.
|
||||
const STORE_PREFIX = "bmgr:manga:";
|
||||
|
||||
// Which library this script's rows belong to. The novel script is a separate
|
||||
// install that declares "novel"; the backend keeps whichever it is told.
|
||||
const LIBRARY = "manga";
|
||||
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
||||
|
||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||
const CACHE_KEY = STORE_PREFIX + "cache";
|
||||
const CACHE_KEY = "mangabm:cache";
|
||||
|
||||
// Per-device record of when each series was last checked for new chapters.
|
||||
// Deliberately not synced: each device does its own checking.
|
||||
const LASTCHECKED_KEY = STORE_PREFIX + "lastchecked";
|
||||
const LASTCHECKED_KEY = "mangabm:lastchecked";
|
||||
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
||||
const LATEST_CHECK_BATCH = 1; // series fetched per navigation
|
||||
|
||||
// One-time carry-over from the pre-rebrand key names. The cache would rebuild
|
||||
// itself from the server, but the retry queue would not: dropping it loses
|
||||
// writes made while offline.
|
||||
for (const name of ["cache", "lastchecked", "queue", "fabpos"]) {
|
||||
const old = localStorage.getItem("mangabm:" + name);
|
||||
if (old !== null && localStorage.getItem(STORE_PREFIX + name) === null) {
|
||||
localStorage.setItem(STORE_PREFIX + name, old);
|
||||
}
|
||||
localStorage.removeItem("mangabm:" + name);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Site adapters
|
||||
//
|
||||
@@ -101,14 +80,6 @@
|
||||
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 = {
|
||||
site: "asura",
|
||||
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
||||
@@ -237,162 +208,7 @@
|
||||
},
|
||||
};
|
||||
|
||||
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];
|
||||
const ADAPTERS = [asura, demonic];
|
||||
|
||||
function detect() {
|
||||
const loc = window.location;
|
||||
@@ -411,11 +227,9 @@
|
||||
}
|
||||
|
||||
// Highest chapter the site lists, or null when the markup yields nothing.
|
||||
// seriesId is only consulted by adapters whose pages carry other series'
|
||||
// chapter links; the rest ignore it.
|
||||
function computeLatestChapter(site, anchors, seriesId) {
|
||||
function computeLatestChapter(site, anchors) {
|
||||
const a = adapterFor(site);
|
||||
return a ? a.latestChapterFromAnchors(anchors, seriesId) : null;
|
||||
return a ? a.latestChapterFromAnchors(anchors) : null;
|
||||
}
|
||||
|
||||
function currentSite() {
|
||||
@@ -522,9 +336,7 @@
|
||||
}
|
||||
|
||||
function setList(list) {
|
||||
// GET /bookmarks answers with every library; this panel owns one of them,
|
||||
// and the cache must not hold rows it can never show.
|
||||
state.list = (Array.isArray(list) ? list : []).filter((b) => kindOf(b) === LIBRARY);
|
||||
state.list = Array.isArray(list) ? list : [];
|
||||
reindex();
|
||||
saveCache(state.list);
|
||||
}
|
||||
@@ -549,25 +361,19 @@
|
||||
return b.status || "reading";
|
||||
}
|
||||
|
||||
// A list cached by an older version has no kind field, and a row the server
|
||||
// defaulted has "manga" — both mean the same thing here.
|
||||
function kindOf(b) {
|
||||
return b.kind || "manga";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Retry queue
|
||||
//
|
||||
// A failed write is not rolled back and not lost: the key is parked here and
|
||||
// replayed when the backend is next reachable. Entries carry no payload — the
|
||||
// body is read from state.byKey at send time, because state.list *is* the
|
||||
// desired state and is already persisted under CACHE_KEY. One entry per key,
|
||||
// desired state and is already persisted in mangabm:cache. One entry per key,
|
||||
// so two writes to the same series cannot replay out of order, an
|
||||
// archive-then-unarchive collapses to whatever the cache now says, and a
|
||||
// queued DELETE replaces a queued PUT rather than racing it.
|
||||
// ============================================================
|
||||
|
||||
const QUEUE_KEY = STORE_PREFIX + "queue";
|
||||
const QUEUE_KEY = "mangabm:queue";
|
||||
const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count
|
||||
const QUEUE_MAX_ATTEMPTS = 10;
|
||||
|
||||
@@ -839,7 +645,6 @@
|
||||
const bm = {
|
||||
key: key,
|
||||
site: p.site,
|
||||
kind: LIBRARY,
|
||||
series_id: p.seriesId,
|
||||
title: p.title || (existing && existing.title) || p.seriesId,
|
||||
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
||||
@@ -862,13 +667,12 @@
|
||||
const bm = Object.assign({}, existing, {
|
||||
key: key,
|
||||
site: p.site,
|
||||
kind: LIBRARY,
|
||||
series_id: p.seriesId,
|
||||
title: existing.title || p.title || p.seriesId,
|
||||
series_url: existing.series_url || p.seriesUrl || "",
|
||||
cover: existing.cover || p.cover || "",
|
||||
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
||||
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
||||
last_chapter: p.chapterLabel || "",
|
||||
last_chapter_num: p.chapterNum,
|
||||
last_chapter_url: p.chapterUrl || "",
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
@@ -946,15 +750,14 @@
|
||||
if (!existing) return;
|
||||
applyLatestChapterIfChanged(
|
||||
existing,
|
||||
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
|
||||
computeLatestChapter(p.site, anchorsFromDocument(document))
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else is only learned by fetching a series page — or, where the
|
||||
// site offers one, its JSON API. Same-origin only: these requests carry the
|
||||
// session that gets us past the site's bot checks, which a request to the
|
||||
// other site (or from a server) would not. A few series per navigation keeps
|
||||
// it indistinguishable from browsing.
|
||||
// Everything else is only learned by fetching a series page. Same-origin
|
||||
// only: these requests carry the session that gets us past the site's bot
|
||||
// checks, which a request to the other site (or from a server) would not.
|
||||
// One series per navigation keeps it indistinguishable from browsing.
|
||||
async function backgroundRefreshLatest() {
|
||||
const site = currentSite();
|
||||
if (!site) return;
|
||||
@@ -969,21 +772,15 @@
|
||||
.slice(0, LATEST_CHECK_BATCH);
|
||||
if (due.length === 0) return;
|
||||
|
||||
const adapter = adapterFor(site);
|
||||
for (const bm of due) {
|
||||
// Recorded even when the fetch fails, so a broken series is retried on
|
||||
// the next throttle window rather than on every single page load.
|
||||
checked[bm.key] = Date.now();
|
||||
try {
|
||||
let latest;
|
||||
if (adapter && adapter.latestChapterFromApi) {
|
||||
latest = await adapter.latestChapterFromApi(bm.series_id);
|
||||
} 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);
|
||||
}
|
||||
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
||||
if (!res.ok) continue;
|
||||
const html = await res.text();
|
||||
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html));
|
||||
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
||||
} catch (e) {
|
||||
/* offline or blocked — try again after the throttle window */
|
||||
@@ -1104,7 +901,7 @@
|
||||
// FAB placement + dragging (snaps to nearest left/right edge)
|
||||
// ============================================================
|
||||
|
||||
const FAB_KEY = STORE_PREFIX + "fabpos";
|
||||
const FAB_KEY = "mangabm:fabpos";
|
||||
const FAB_MARGIN = 12; // vertical breathing room at the top and bottom
|
||||
const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side
|
||||
const ARM_MS = 400; // hold this long on the invisible strip to arm a drag
|
||||
@@ -1586,39 +1383,13 @@
|
||||
<div id="backdrop"></div>
|
||||
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
||||
<header>
|
||||
<span class="brand">
|
||||
<svg class="mark" viewBox="0 0 200 172" aria-hidden="true">
|
||||
<g fill="var(--ink)" stroke="currentColor" stroke-width="6.5" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path fill="none" d="M28 36H4v114h192V36h-24"></path>
|
||||
<path fill="none" d="M28 23H17v127h166V23h-11"></path>
|
||||
<g id="mb-half">
|
||||
<path d="M28 7 88 55v97L28 138z"></path>
|
||||
<g fill="currentColor" stroke="none">
|
||||
<path d="M37 25 55 39v41L37 66z"></path>
|
||||
<path d="M60 42 79 57v42L60 84z"></path>
|
||||
<path d="M37 75 79 108v13L37 88z"></path>
|
||||
<path d="M37 98 79 129v11L37 131z"></path>
|
||||
</g>
|
||||
</g>
|
||||
<use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use>
|
||||
<g stroke="var(--ember)">
|
||||
<path d="M100 4l9 5v11l-9 5-9-5V9z"></path>
|
||||
<path d="M94 24h12v24H94z"></path>
|
||||
<path d="M70 47h60v14H70z"></path>
|
||||
<path d="M91 61h18v87l-9 20-9-20z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<span>Bookmark<em>Manager</em></span>
|
||||
</span>
|
||||
<span>manga<em>Bookmark</em></span>
|
||||
<button id="closeBtn" aria-label="Close">close</button>
|
||||
</header>
|
||||
<div id="nav">
|
||||
<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://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>
|
||||
</div>
|
||||
<section id="context"></section>
|
||||
@@ -1687,15 +1458,11 @@
|
||||
}
|
||||
#panel.open { transform: translateX(0); }
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding: 16px; border-bottom: 1px solid var(--rule);
|
||||
font: 400 22px/1 var(--font-display); color: var(--paper);
|
||||
}
|
||||
header em { color: var(--ember); font-style: italic; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; }
|
||||
/* 5px stroke on a 200-unit grid thins out at this size: the markup carries
|
||||
6.5 instead. overflow visible keeps the topmost blade pip from clipping. */
|
||||
.mark { width: 26px; height: 22px; flex: none; overflow: visible; }
|
||||
#closeBtn {
|
||||
background: none; border: none; color: var(--mute); cursor: pointer;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
@@ -1813,7 +1580,7 @@
|
||||
// Exposes pure logic only — see userscript/test/logic.test.js.
|
||||
// ============================================================
|
||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf };
|
||||
module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,9 +31,6 @@ globalThis.location = {
|
||||
|
||||
// og: meta tags the adapters read through meta(). Reassigned per test.
|
||||
let metaTags = {};
|
||||
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
|
||||
// entry is {alt, src}.
|
||||
let pageImages = [];
|
||||
globalThis.document = {
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||
@@ -41,26 +38,16 @@ globalThis.document = {
|
||||
const v = metaTags[m[1]];
|
||||
return v == null ? null : { getAttribute: () => v };
|
||||
},
|
||||
querySelectorAll(sel) {
|
||||
if (sel !== "img[alt]") return [];
|
||||
return pageImages.map((img) => ({
|
||||
getAttribute: (attr) => img[attr] ?? null,
|
||||
}));
|
||||
},
|
||||
addEventListener() {},
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
stripBuildHash,
|
||||
comixSeriesId,
|
||||
asura,
|
||||
demonic,
|
||||
comix,
|
||||
kagane,
|
||||
anchorsFromHTML,
|
||||
statusOf,
|
||||
kindOf,
|
||||
} = require("../manga-bookmark.user.js");
|
||||
|
||||
// detect() reads only these four properties off location.
|
||||
@@ -179,187 +166,6 @@ test("demonic.latestChapterFromAnchors parses chaptered.php links, including &am
|
||||
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
|
||||
// ============================================================
|
||||
@@ -378,17 +184,3 @@ test("statusOf defaults a missing status to reading", () => {
|
||||
assert.equal(statusOf({ status: "archived" }), "archived");
|
||||
assert.equal(statusOf({ status: "finished" }), "finished");
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// kindOf — a row written before the kind column existed has none, and every
|
||||
// one of those is manga.
|
||||
// ============================================================
|
||||
|
||||
test("kindOf defaults a missing kind to manga", () => {
|
||||
assert.equal(kindOf({}), "manga");
|
||||
assert.equal(kindOf({ kind: "" }), "manga");
|
||||
});
|
||||
|
||||
test("kindOf passes through an explicit kind", () => {
|
||||
assert.equal(kindOf({ kind: "novel" }), "novel");
|
||||
});
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
|
||||
// ============================================================
|
||||
// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...]
|
||||
// branch: novelfull ships no og: tags, so its cover comes from name="image".
|
||||
// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded
|
||||
// that never fires and no network call is ever made.
|
||||
// ============================================================
|
||||
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
|
||||
|
||||
let metaTags = {};
|
||||
let namedMetas = {};
|
||||
let elements = {};
|
||||
globalThis.document = {
|
||||
querySelector(sel) {
|
||||
let m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||
if (m) {
|
||||
const v = metaTags[m[1]];
|
||||
return v == null ? null : { getAttribute: () => v };
|
||||
}
|
||||
m = sel.match(/^meta\[name="([^"]+)"\]$/);
|
||||
if (m) {
|
||||
const v = namedMetas[m[1]];
|
||||
return v == null ? null : { getAttribute: () => v };
|
||||
}
|
||||
const text = elements[sel];
|
||||
return text == null ? null : { textContent: text };
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
novelfull,
|
||||
lightnovelworld,
|
||||
kindOf,
|
||||
maxChapter,
|
||||
} = require("../novel-bookmark.user.js");
|
||||
|
||||
function loc(href) {
|
||||
const u = new URL(href);
|
||||
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
metaTags = {};
|
||||
namedMetas = {};
|
||||
elements = {};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// novelfull adapter
|
||||
// ============================================================
|
||||
|
||||
test("novelfull.detect reads a series page", () => {
|
||||
reset();
|
||||
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
||||
elements = { "h3.title": "Reverend Insanity" };
|
||||
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
|
||||
assert.equal(p.type, "series");
|
||||
assert.equal(p.site, "novelfull");
|
||||
assert.equal(p.seriesId, "reverend-insanity");
|
||||
assert.equal(p.title, "Reverend Insanity");
|
||||
assert.equal(p.cover, "https://novelfull.com/uploads/thumbs/ri.jpg");
|
||||
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
||||
assert.equal(p.chapterNum, null);
|
||||
});
|
||||
|
||||
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
|
||||
reset();
|
||||
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
||||
elements = { "a.truyen-title": "Reverend Insanity" };
|
||||
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
|
||||
const p = novelfull.detect(loc(url));
|
||||
assert.equal(p.type, "chapter");
|
||||
assert.equal(p.seriesId, "reverend-insanity");
|
||||
assert.equal(p.chapterNum, 2334);
|
||||
assert.equal(p.chapterLabel, "Chapter 2334");
|
||||
assert.equal(p.chapterUrl, url);
|
||||
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
||||
assert.equal(p.title, "Reverend Insanity");
|
||||
});
|
||||
|
||||
test("novelfull.detect returns other for non-series paths", () => {
|
||||
reset();
|
||||
assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other");
|
||||
assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other");
|
||||
});
|
||||
|
||||
test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => {
|
||||
const best = novelfull.latestChapterFromAnchors(
|
||||
[
|
||||
{ href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" },
|
||||
{ href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" },
|
||||
{ href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" },
|
||||
{ href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" },
|
||||
],
|
||||
"reverend-insanity"
|
||||
);
|
||||
assert.deepEqual(best, { num: 2334, label: "Chapter 2334" });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// lightnovelworld adapter
|
||||
// ============================================================
|
||||
|
||||
test("lightnovelworld.detect reads a series page", () => {
|
||||
reset();
|
||||
metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" };
|
||||
elements = { "h1.entry-title": "A Will Eternal" };
|
||||
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
|
||||
assert.equal(p.type, "series");
|
||||
assert.equal(p.site, "lightnovelworld");
|
||||
assert.equal(p.seriesId, "a-will-eternal");
|
||||
assert.equal(p.title, "A Will Eternal");
|
||||
assert.equal(p.cover, "https://lightnovelworld.net/wp-content/uploads/awe.webp");
|
||||
});
|
||||
|
||||
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
||||
reset();
|
||||
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
||||
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
||||
const p = lightnovelworld.detect(loc(url));
|
||||
assert.equal(p.type, "chapter");
|
||||
assert.equal(p.seriesId, "a-will-eternal");
|
||||
assert.equal(p.chapterNum, 1298);
|
||||
assert.equal(p.chapterLabel, "Chapter 1298");
|
||||
assert.equal(p.title, "A Will Eternal");
|
||||
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
||||
// Chapter pages have no cover; the merge in bookmarkCurrent keeps the stored one.
|
||||
assert.equal(p.cover, "");
|
||||
});
|
||||
|
||||
test("lightnovelworld.detect returns other for non-series paths", () => {
|
||||
reset();
|
||||
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
|
||||
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other");
|
||||
});
|
||||
|
||||
test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => {
|
||||
const best = lightnovelworld.latestChapterFromAnchors(
|
||||
[
|
||||
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" },
|
||||
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" },
|
||||
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" },
|
||||
{ href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" },
|
||||
],
|
||||
"a-will-eternal"
|
||||
);
|
||||
assert.deepEqual(best, { num: 1317, label: "Chapter 1317" });
|
||||
});
|
||||
|
||||
test("latestChapterFromAnchors returns null when nothing matches", () => {
|
||||
assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null);
|
||||
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// kindOf
|
||||
// ============================================================
|
||||
|
||||
test("kindOf defaults a missing kind to manga", () => {
|
||||
assert.equal(kindOf({}), "manga");
|
||||
});
|
||||
|
||||
test("kindOf passes through novel", () => {
|
||||
assert.equal(kindOf({ kind: "novel" }), "novel");
|
||||
});
|
||||
Reference in New Issue
Block a user