Skip to content

Read a chapter from a source before adding the series - #91

Closed
Squeaks72 wants to merge 2 commits into
AngeloSha:mainfrom
Squeaks72:pr/preview-reader
Closed

Squeaks72 wants to merge 2 commits into
AngeloSha:mainfrom
Squeaks72:pr/preview-reader

Conversation

@Squeaks72

Copy link
Copy Markdown
Contributor

Summary

Deciding whether a title is worth keeping usually takes one chapter. Before this PR, the only way to read that chapter was to add the series, read it, and remove it again. That leaves a folder, a listing, a chapter floor and a row behind. This PR adds a Read a chapter first button to the add dialog. It opens the title straight from the source and writes nothing.

Server (bff/src/routes/sources.ts): two read-only routes in the sources plugin.

  • GET /api/sources/preview/chapters?source=&sourceId= lists the chapters with one copy per number, chosen by the same chooseReleases / effectivePrefsFor rule that /api/sources/detail counts with, so the list matches what an add would land. detail stays count-only on purpose, so the add dialog doesn't pay for rows it never reads.
  • GET /api/sources/preview/pages?source=&chapterId= returns the source's own page URLs.
  • Neither route persists anything: no series row, no files, no progress. Both sit behind the plugin's canDownload preHandler and apply the same by-id age-cap rule as detail: the cap answers 403, and the 18+ reveal does not apply.

No new byte proxy. The web app renders each page through the existing /img/sources/cover proxy. That proxy already fetches a remote image named by a source with the source's referer and Cloudflare cookies, behind the SSRF guard (fetchableCoverUrl and the engine-origin exemption) and the root /img/ authorization hook. A second proxy for pages would have to re-solve all of that, and the note on authorizeImageRequest in routes/images.ts records a downstream proxy that shipped without auth. One consequence: preview pages go into the same LRU image cache as covers (srccover2:1600:<url>).

Things I found and fixed while porting this:

  • Page width. The client asked for w=1400, but the proxy only allows 400, 800 and 1600 and treats anything else as 400. Every page rendered at cover-tile size. It now asks for w=1600 (withoutEnlargement keeps a narrower original as it is).
  • Extension sources can't be previewed through this proxy. Their page URLs are paths on the extension engine's private origin, and the proxy's only engine exemption is the thumbnail shape (engineCoverUrl). Widening that exemption to pages would give any signed-in reader an authenticated GET on the engine. So /preview/pages answers 422 preview_unsupported for a sw: source before it asks the engine anything, and the dialog doesn't offer the button for one. Built-in and add-a-site sources, whose pages are public URLs, work.
  • Doc comments. The route block had an orphaned doc comment (about a sourceBrowsableFor helper that doesn't exist upstream) stacked above the chapters route. detail's own one-line comment had also been separated from its route. Each comment now sits on its own route.

Web (web/components/PreviewReader.tsx, AddSeriesDialog.tsx): this is not the real reader, on purpose. That reader is built on a book id and carries progress, bookmarks, offline copies and the next-chapter chain, and none of those exist for something outside the library. The preview is a chapter list, then a lazy-loaded vertical scroll with Previous and Next buttons, and Add to library at the bottom of the list.

Desktop: the preview is available there too. Upstream keeps every /api/sources/* route on desktop (Discover and adding work the same as on a server; DESKTOP_HIDDEN_ROUTES covers only sign-in, members, sessions, tokens and push), and this is the same kind of feature. desktopRoutes.test.ts passes: its server table is openapi.yaml, which now describes both routes.

Type

  • Fix
  • Feature
  • Docs
  • Refactor / chore

Testing

Re-run after rebasing onto v0.45.0 (91b41aa): bff 1859 tests, 1846 pass, 9 fail, 4 skipped — the same 9 embedded-Postgres entrypoint failures main has in this environment (main: 1857 / 1844 / 9 / 4), nothing new; web 431/431; both typechecks OK. The detail below is from the first run, on v0.44.0.

I ran the upstream CI setup in node:24-alpine against a real postgres:16-alpine (TEST_DATABASE_URL set, so the SQL tests run instead of skipping), first on plain upstream/main (a8bc502) to get a baseline and then on this branch.

upstream/main (baseline) this branch
bff tsc --noEmit OK OK
bff tests 1834 tests, 1821 pass, 9 fail, 4 skipped 1836 tests, 1823 pass, same 9 fail, 4 skipped (2 new)
web tsc --noEmit OK OK
web tests 421/421 421/421

The 9 bff failures are the same on both: they are the embedded-Postgres entrypoint tests (not ok 291-299), which need an initdb/socket setup this sandbox doesn't have. No new failures.

Added to bff/test/sourceAccess.int.test.ts:

  • both preview routes are in its ROUTES table, so canDownload:false has to be refused on them like every other route in the plugin
  • both are in the "every by-id route refuses the adult source for a capped account" list
  • the preview reads a chapter and writes nothing: one copy per number, page URLs returned, and no lib_series row
  • the preview refuses an extension source before asking the engine: 422 preview_unsupported, and the adapter's getPageUrls is never called

Both routes are in bff/openapi.yaml and in the route list in docs/api.md, so openapiCoverage and desktopRoutes both pass.

The six new strings are translated in all eight locale files. The Add to library button reuses the existing key. npm run test:i18n is a Puppeteer walk against a running server, so it could not run in this sandbox. I checked separately that every tr() key this PR adds is present in all eight files.

Checklist

  • Focused scope (one topic)
  • Matches the existing TypeScript style
  • Docs updated if user-facing (docs/USAGE.md)
  • No new default sources hardcoded to specific sites (the engine system reaches sites by URL)

🤖 Generated with Claude Code

Squeaks72 and others added 2 commits September 24, 2026 10:27
Two read-only routes, GET /api/sources/preview/chapters and
/api/sources/preview/pages. Nothing is persisted -- no series row, no files, no
progress -- and page images are rendered through the existing
/img/sources/cover proxy rather than a new byte proxy, so the SSRF guard, the
source's referer and Cloudflare session, and the /img/ authorization hook all
apply unchanged.

Extension sources are refused for pages (422 preview_unsupported) before the
engine is asked: their page URLs are on the engine's private origin, which the
proxy only fetches thumbnails from, so every page would have been a broken
image.

Both routes are in openapi.yaml and docs/api.md, and sourceAccess.int.test.ts
covers them: canDownload and the age cap refuse them like every other route in
the plugin, the list is one copy per number, nothing is written, and the
extension case never reaches the adapter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Whether a title is worth keeping is usually one chapter's worth of question.
Answering it by adding, reading and removing left a folder, a listing, a floor
and a row behind. The preview writes nothing: chapters and page URLs come from
the source on demand and render through the existing source-image proxy, at
its widest whitelisted width (1600).

Not the real reader, deliberately -- that one is built on a book id and carries
progress, bookmarks, offline copies and the next-chapter chain, none of which
exist for something outside the library. Not offered for extension sources,
whose pages the proxy cannot fetch. The new strings are translated in all
eight locales, and USAGE.md describes the button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AngeloSha added a commit that referenced this pull request Sep 25, 2026
…re solver

GET /img/sources/cover takes its URL from the request and is open to any signed-in reader. For a source marked
requiresCloudflare it handed that URL to cfSession -- FlareSolverr's browser, on the same Docker network as the
extension engine and the database -- BEFORE the DNS half of the SSRF guard ran; the check that did run first
knew literal private addresses and four suffixes, not bare container names. So an account could make the solver
open uchiyomi-suwayomi, yomi-db, or any public name pointed at a private address. Blind and time-capped, but a
browser follows redirects and runs the page's scripts.

- assertPublicHost runs before anything touches the network, the solver included (images.ts).
- isBlockedHost refuses a hostname with no dot, and a trailing dot names the same host (ssrfGuard.ts). It was
  deliberately left to DNS; that held only while nothing reached the network first.
- A caller-supplied URL reaches the solver only on a host the source vouched for: its own admin-configured site,
  or a host it has served covers from, learned where every adapter enters (imageHosts.ts, loader.ts). Library
  covers come from series_art and keep the solver: Aqua's covers sit on a Cloudflare CDN that answers 403
  without clearance (measured).

coverSolverGuard.test.ts records every URL a fake FlareSolverr is asked to open; each fix was proved by undoing
it alone and watching its named assertion fail. Full bff suite: 1867/1867. Found while reviewing #91.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AngeloSha added a commit that referenced this pull request Sep 25, 2026
…oading

Read a chapter before adding a series (#91), a source order for new chapters
(#93, first half), swapping in a preferred group's copy once it exists (#93,
second half; closes #81), chapter names borrowed from a source that numbers the
work the same way (#85) and a compact chapter list (#88) -- @Squeaks72's ideas,
rebuilt where the originals could not be merged. Every running download,
including the server's own sweeps, repairs and bulk fetches, now shows with a
Cancel (#82).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AngeloSha added a commit to Squeaks72/uchiyomi that referenced this pull request Sep 25, 2026
@Squeaks72's preview: under Add to library, "Read a chapter first" opens the
title straight from the source, pick a chapter, scroll it, step to the next,
and Add when decided. Nothing is written.

Rebuilt because the first version passed the caller's chapterId to the source's
getPageUrls, which for the site engines is cfGet(chapterId): FlareSolverr's
browser on the Docker network visiting whatever a signed-in account named, the
hole v0.45.1 closed in the cover proxy. Now:
- a chapter is named by its NUMBER in the listing the server fetched for that
  series (the add dialog's cached detail); the page by its INDEX. No URL and no
  chapter id from the client reaches a source, and the list sent to the client
  carries neither;
- pages are fetched by the server through the guarded image fetcher (DNS check
  first, redirects re-checked; an extension's page only from the engine's own
  origin), one at a time per source, and served as the original bytes,
  no-store, only when they are an image, at most 15 MB -- not through
  /img/sources/cover, which cached a year of pages for series nobody added;
- refused for an account with an age limit (a preview reads a site before any
  library's rating applies), without the download permission, and for a
  disabled or cooling-down source; the messages are generic;
- the viewer is portalled to <body> (the dialog's backdrop-filter squeezed a
  fixed viewer into its panel) and takes Escape in the capture phase, so one
  Escape goes back one step instead of closing the add dialog too.

Every rule was proved by reintroducing its bug, the first version's
chapterId route included (bff/test/preview.int.test.ts).

Co-authored-by: Squeaks72 <Squeaks72@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ba4903ddc46209d8740556f9f89bc91a321504a7)
@AngeloSha

Copy link
Copy Markdown
Owner

Thanks for this, previewing a chapter before you add a series is a great idea. I can't merge it as it is, though.

The new pages route passes the caller's chapterId straight to the source engine, and for the add-a-site engines that turns into a request from FlareSolverr's browser, inside the Docker network. Any signed-in account could point it at the extension engine, the database or something on the LAN, and error messages came back word for word, which would also show what's listening where. While checking that I found a quieter version of the same problem in the cover proxy, which is fixed in v0.45.1.

There were some smaller things as well: the age cap never applied to add-a-site sources, /chapters didn't check for disabled sources, there was no cooldown or timeout budget, every preview page got cached for a year, and the viewer ended up squeezed inside the dialog because of backdrop-filter.

I rebuilt it so a page can only be fetched for a chapter that's actually in that series' listing: the client names a chapter by its number and a page by its index, so no address from the browser reaches a source at all. The server fetches each page itself, through the same guard covers go through. One upside of doing it that way: extension sources work too, which yours had to leave out.

Your UI was the starting point. It shipped in v0.47.0 as Read a chapter first in the add dialog, with credit to you.

@AngeloSha AngeloSha closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants