Covers render in the userscript panel, from a public route #60

Closed
opened 2026-08-09 23:12:12 +07:00 by sulthan · 2 comments
Owner

Parent

Spec: #55. Originating bug: #47. Architecture and rejected alternatives: docs/adr/0007-backend-hosts-cover-bytes.md. Domain vocabulary: CONTEXT.md.

Do not close #47 or #55 from this ticket.

What to build

A Reader opens the panel on any Site and sees Covers — the same images the web UI shows, loaded from the deployment's own origin.

Two things have to be true for that, and the first explains the second. An <img> cannot send an Authorization header, and it cannot be handed a token in its URL either: the panel's shadow root is open, so the host page's own JavaScript can read any src attribute the script sets, and a credential in an image URL is a credential handed to a third-party site. The cover route therefore becomes public — no session, no credential. That is defensible rather than a concession: it serves public artwork from public Sites, and its address reveals nothing about which Reader holds what. It does knowingly relax the session gate the kagane proxy carries today, which the ADR records. Responses keep the long-lived immutable cache directive, which content addressing makes safe.

On the client side, both userscripts lose their cover scraping entirely — the DOM scan, the metadata reads, and the cover field in the request body. A scraped address now has no rendering path left, and keeping it would put third-party URLs back into exactly the place this bug came from. This is a deletion, so the corresponding cases in the Node logic tests are deleted with it and the exported symbol lists shrink accordingly.

Both userscripts gain an error handler on the cover image that swaps in the existing placeholder element when a load fails. This is the part worth doing even in isolation: it is the difference between a Reader seeing a designed empty state and a Reader seeing the browser's broken-image glyph, which is half of what #47 actually reported.

The error handler is DOM behaviour and is not testable in the userscript harness by design — that harness covers pure logic under a hand-written stub and must not grow a DOM. Verify it on-device and say so in the report rather than inventing coverage. Run the parse check and the logic tests for both scripts.

Acceptance criteria

  • The cover route serves stored bytes with no session and no credential
  • The route answers not-found for an address that was never stored, and never invents one
  • A malformed or traversal-shaped address gets no bytes
  • Responses keep the immutable cache directive
  • Both userscripts no longer scrape or send a cover
  • Both userscripts fall back to the existing placeholder when a cover image fails to load
  • The deleted scraping's test cases are removed and the export lists updated
  • Parse check and logic tests pass for both userscripts
  • Manually verified on-device: a comix Series bookmarked mid-chapter shows its Cover in the panel
  • go test ./... is green

Blocked by

  • #59 — the panel renders the address that ticket puts on the wire
## Parent Spec: #55. Originating bug: #47. Architecture and rejected alternatives: `docs/adr/0007-backend-hosts-cover-bytes.md`. Domain vocabulary: `CONTEXT.md`. Do not close #47 or #55 from this ticket. ## What to build A Reader opens the panel on any Site and sees Covers — the same images the web UI shows, loaded from the deployment's own origin. Two things have to be true for that, and the first explains the second. An `<img>` cannot send an `Authorization` header, and it cannot be handed a token in its URL either: the panel's shadow root is open, so the host page's own JavaScript can read any `src` attribute the script sets, and a credential in an image URL is a credential handed to a third-party site. The cover route therefore becomes **public** — no session, no credential. That is defensible rather than a concession: it serves public artwork from public Sites, and its address reveals nothing about which Reader holds what. It does knowingly relax the session gate the kagane proxy carries today, which the ADR records. Responses keep the long-lived immutable cache directive, which content addressing makes safe. On the client side, **both** userscripts lose their cover scraping entirely — the DOM scan, the metadata reads, and the `cover` field in the request body. A scraped address now has no rendering path left, and keeping it would put third-party URLs back into exactly the place this bug came from. This is a deletion, so the corresponding cases in the Node logic tests are deleted with it and the exported symbol lists shrink accordingly. Both userscripts gain an error handler on the cover image that swaps in the existing placeholder element when a load fails. This is the part worth doing even in isolation: it is the difference between a Reader seeing a designed empty state and a Reader seeing the browser's broken-image glyph, which is half of what #47 actually reported. The error handler is DOM behaviour and is **not testable** in the userscript harness by design — that harness covers pure logic under a hand-written stub and must not grow a DOM. Verify it on-device and say so in the report rather than inventing coverage. Run the parse check and the logic tests for both scripts. ## Acceptance criteria - [x] The cover route serves stored bytes with no session and no credential - [x] The route answers not-found for an address that was never stored, and never invents one - [x] A malformed or traversal-shaped address gets no bytes - [x] Responses keep the immutable cache directive - [x] Both userscripts no longer scrape or send a cover - [x] Both userscripts fall back to the existing placeholder when a cover image fails to load - [x] The deleted scraping's test cases are removed and the export lists updated - [x] Parse check and logic tests pass for both userscripts - [x] Manually verified on-device: a comix Series bookmarked mid-chapter shows its Cover in the panel - [x] `go test ./...` is green ## Blocked by - #59 — the panel renders the address that ticket puts on the wire
sulthan added the ready-for-agent label 2026-08-09 23:12:12 +07:00
Author
Owner

PR: #69 (feat/60-public-cover-route, closes this issue on merge).

Nine of the ten boxes are ticked. What each rests on:

  • The four route criteria were already satisfied by #59 (92eba07) and were verified here rather than re-implemented: GET /covers/{address} is registered outside httpmw.Auth and outside the Discord session (backend/main.go:210-214), the handler reads no cookie or header (internal/api/handlers.go:142-158), the ^[0-9a-f]{64}$ check runs before the address becomes a path, and the response carries Cache-Control: public, max-age=604800, immutable. Asserted by backend/cover_test.go:231-278.
  • Scraping is gone from both scripts: every adapter cover: field, coverFromPage() (the comix img[alt] scan), metaName() and the orphaned novel meta(). Nothing under userscript/ reads og:image, meta[name=image] or img[alt] any more, and delete body.cover in apiPut keeps a stale pre-upgrade localStorage row from putting a third-party URL back on the wire.
  • The placeholder fallback is onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })) in both card renderers — the same element the no-cover branch already builds, so it inherits the designed empty state instead of the broken-image glyph.
  • Export lists needed no change, and that was checked against origin/main rather than assumed: both deleted helpers were module-private and no cover symbol was ever exported.
  • go test -count=1 ./... green; node --check clean on both scripts; 46/46 logic tests pass.

As the ticket instructs, the onerror handler got no fabricated harness coverage — the Node stub must not grow a DOM. It was exercised ad hoc instead: the el() helper and the exact render expression loaded into a headless Chromium with an unloadable src produced <div class="cover ph"></div>.

The one open box is yours: a comix Series bookmarked mid-chapter, checked on-device against the deployment. That needs a real install and a real panel.

Left alone deliberately: the kagane-specific proxy and its session gate (#63), the poll's blank-Cover fill (#61), browser-backed Sites (#62). #47 and #55 stay open.

PR: https://gitea.violetcrown.my.id/sulthan/mangaBookmark/pulls/69 (`feat/60-public-cover-route`, closes this issue on merge). Nine of the ten boxes are ticked. What each rests on: - The four route criteria were already satisfied by #59 (`92eba07`) and were verified here rather than re-implemented: `GET /covers/{address}` is registered outside `httpmw.Auth` and outside the Discord session (`backend/main.go:210-214`), the handler reads no cookie or header (`internal/api/handlers.go:142-158`), the `^[0-9a-f]{64}$` check runs before the address becomes a path, and the response carries `Cache-Control: public, max-age=604800, immutable`. Asserted by `backend/cover_test.go:231-278`. - Scraping is gone from both scripts: every adapter `cover:` field, `coverFromPage()` (the comix `img[alt]` scan), `metaName()` and the orphaned novel `meta()`. Nothing under `userscript/` reads `og:image`, `meta[name=image]` or `img[alt]` any more, and `delete body.cover` in `apiPut` keeps a stale pre-upgrade `localStorage` row from putting a third-party URL back on the wire. - The placeholder fallback is `onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" }))` in both card renderers — the same element the no-cover branch already builds, so it inherits the designed empty state instead of the broken-image glyph. - Export lists needed no change, and that was checked against `origin/main` rather than assumed: both deleted helpers were module-private and no cover symbol was ever exported. - `go test -count=1 ./...` green; `node --check` clean on both scripts; 46/46 logic tests pass. As the ticket instructs, the `onerror` handler got no fabricated harness coverage — the Node stub must not grow a DOM. It was exercised ad hoc instead: the `el()` helper and the exact render expression loaded into a headless Chromium with an unloadable `src` produced `<div class="cover ph"></div>`. **The one open box is yours:** a comix Series bookmarked mid-chapter, checked on-device against the deployment. That needs a real install and a real panel. Left alone deliberately: the kagane-specific proxy and its session gate (#63), the poll's blank-Cover fill (#61), browser-backed Sites (#62). #47 and #55 stay open.
Author
Owner

Manual verification done in a real Chromium (Playwright), against main at 8b58019.

Setup: local stack (docker compose up, poller off) with PUBLIC_BASE_URL=https://bookmark-api.violetcrown.my.id; the committed manga-bookmark.user.js rendered through /u/<credential>/… so the served copy (credential substituted, version stamped) is what ran, injected as an init script on https://comix.to. The API origin was routed to the local container at the network layer, so the page saw a normal HTTPS cross-origin backend.

Result — comix Series bookmarked mid-chapter (Ch.66 of 70, comix:2031 "Happy Face: From Slave to Arena Legend"):

  • The PUT body carried no cover field; the backend acquired one at creation and stored it.
  • GET /bookmarks returned cover: https://bookmark-api.violetcrown.my.id/covers/f88ea4f3…5ce8.
  • The panel rendered img.cover with that src, naturalWidth×naturalHeight = 280×420, complete: true, no placeholder element — i.e. the image really decoded. The <img> sent no Authorization header, so the public route is what served it.
  • Subtitle read Read: Chapter 66 · Latest: Chapter 70, so the mid-chapter state is the one that rendered.
  • Fallback re-checked in the same session: pointing the same img at /covers/000…0 (never stored → 404) removed the <img> and left div.cover.ph, no broken-image glyph.

Incidental, not a defect in this ticket: the acquisition fetch first failed from this network because the ISP hijacks DNS for comix.to/static.comix.to and answers with an expired MITM certificate. Pinning both hostnames to the real Cloudflare address for the container made the fetch succeed. Deployment egress is unaffected.

Local test state (.env, containers, volumes) has been torn down; nothing in the working tree changed.

Manual verification done in a real Chromium (Playwright), against `main` at 8b58019. Setup: local stack (`docker compose up`, poller off) with `PUBLIC_BASE_URL=https://bookmark-api.violetcrown.my.id`; the committed `manga-bookmark.user.js` rendered through `/u/<credential>/…` so the served copy (credential substituted, version stamped) is what ran, injected as an init script on `https://comix.to`. The API origin was routed to the local container at the network layer, so the page saw a normal HTTPS cross-origin backend. Result — comix Series bookmarked mid-chapter (Ch.66 of 70, `comix:2031` "Happy Face: From Slave to Arena Legend"): - The PUT body carried no `cover` field; the backend acquired one at creation and stored it. - `GET /bookmarks` returned `cover: https://bookmark-api.violetcrown.my.id/covers/f88ea4f3…5ce8`. - The panel rendered `img.cover` with that `src`, `naturalWidth×naturalHeight = 280×420`, `complete: true`, no placeholder element — i.e. the image really decoded. The `<img>` sent no `Authorization` header, so the public route is what served it. - Subtitle read `Read: Chapter 66 · Latest: Chapter 70`, so the mid-chapter state is the one that rendered. - Fallback re-checked in the same session: pointing the same `img` at `/covers/000…0` (never stored → 404) removed the `<img>` and left `div.cover.ph`, no broken-image glyph. Incidental, not a defect in this ticket: the acquisition fetch first failed from this network because the ISP hijacks DNS for `comix.to`/`static.comix.to` and answers with an expired MITM certificate. Pinning both hostnames to the real Cloudflare address for the container made the fetch succeed. Deployment egress is unaffected. Local test state (`.env`, containers, volumes) has been torn down; nothing in the working tree changed.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#60