Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions bff/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2257,6 +2257,94 @@ paths:
description: Not authenticated
"403":
description: "Forbidden: token scope, account permission or age limit"
/api/sources/preview/chapters:
get:
tags:
- sources
summary: A source's chapter list, for reading a chapter before adding the series.
operationId: getApiSourcesPreviewChapters
description: >-
One copy per chapter number under the server-wide release preferences, so the list reads like the
one an add would produce. Nothing is written. Follows the by-id rule of GET /api/sources/detail: the
age cap answers 403, the 18+ reveal does not apply.
parameters:
- name: source
in: query
required: true
schema:
type: string
- name: sourceId
in: query
required: true
schema:
type: string
security:
- bearer: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
required: [title, readingDirection, content]
properties:
title: { type: string }
readingDirection: { type: [string, "null"] }
content:
type: array
items:
type: object
required: [chapterId, number, title, scanlator]
properties:
chapterId: { type: string }
number: { type: number }
title: { type: [string, "null"] }
scanlator: { type: [string, "null"] }
"400": { description: source or sourceId missing, or no such source }
"403": { description: "Forbidden: account permission (canDownload) or the source is above this account's age cap" }
"502": { description: The source could not be read }
/api/sources/preview/pages:
get:
tags:
- sources
summary: The page image URLs of one source chapter, for the preview reader.
operationId: getApiSourcesPreviewPages
description: >-
The URLs are rendered through the existing `/img/sources/cover` proxy, never fetched by the browser
directly, so the proxy's auth and SSRF guard apply to every page. Nothing is written. Extension
sources are refused with 422: their page URLs are on the extension engine, which the proxy does not
fetch pages from.
parameters:
- name: source
in: query
required: true
schema:
type: string
- name: chapterId
in: query
required: true
schema:
type: string
security:
- bearer: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
required: [source, chapterId, pages]
properties:
source: { type: string }
chapterId: { type: string }
pages: { type: array, items: { type: string } }
"400": { description: source or chapterId missing, or no such source }
"403": { description: "Forbidden: account permission (canDownload), the source is above this account's age cap, or disabled by the admin" }
"404": { description: The source served no pages for this chapter }
"422": { description: "`preview_unsupported`: an extension source, whose pages the image proxy cannot fetch" }
"502": { description: The source could not be read }
/api/sources/detail:
get:
tags:
Expand Down
70 changes: 70 additions & 0 deletions bff/src/routes/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1981,6 +1981,76 @@ export default async function sourceRoutes(app: FastifyInstance) {
return { content: found.filter(Boolean) };
});

/**
* A source's chapter list, for the preview reader: read a Discover result WITHOUT adding it.
*
* `/api/sources/detail` deliberately answers with counts and groups rather than the list itself -- the
* add dialog needs "120 chapters", not 120 rows -- so previewing gets its own route rather than making
* every add pay for a payload it does not read. One copy per number, chosen under the server's release
* preferences, so the list reads like the one an add would produce.
*
* Nothing is written by either preview route: no series row, no files, no progress. The age cap is the
* same one `detail` applies: this is a by-id request for a source the person just named, so the cap
* refuses it and the "Show 18+" reveal (which only hides what appears unasked) has no say.
*/
app.get('/api/sources/preview/chapters', async (req, reply) => {
const { source, sourceId } = req.query as { source?: string; sourceId?: string };
const src = source ? getSource(source) : null;
if (!src || !sourceId) return reply.code(400).send({ error: 'bad_request' });
if (!sourceAllowedFor(src, vc(req).maxAgeRating)) return denySource(reply);
try {
const { series, chapters } = await seriesAndChapters(src, sourceId);
const chosen = chooseReleases(chapters, await effectivePrefsFor(null, 0)).releases;
return {
title: series?.title || '',
readingDirection: (series as any)?.readingDirection ?? null,
content: chosen
.filter((c) => c.sourceId)
.map((c) => ({ chapterId: c.sourceId, number: c.number, title: c.title ?? null, scanlator: c.scanlator ?? null })),
};
} catch (e: any) {
return reply.code(502).send({ error: 'unreadable', message: String(e?.message || e).slice(0, 200) });
}
});

/**
* The pages of one chapter, straight from the source, for the preview reader.
*
* It answers the source's own image URLs, and the client renders them through `/img/sources/cover`, which
* is the proxy that already exists for exactly this shape of problem -- a remote image named by a source,
* fetched with that source's referer and Cloudflare session, behind the SSRF guard and the root `/img/`
* authorization hook. Adding a second byte proxy for pages would mean re-solving all of that, and the note
* on `authorizeImageRequest` in routes/images.ts records what happened to one that was shipped without it.
*
* ⚠️ NOT FOR EXTENSION SOURCES. Their page URLs are paths on the extension engine, which is a private
* address; the cover proxy's one engine exemption is the thumbnail shape (`engineCoverUrl`), and widening
* it to pages would hand every signed-in reader an authenticated GET on the engine. So an extension source
* is answered `preview_unsupported` here, before the engine is asked for anything, rather than returning
* URLs every one of which the proxy would then refuse. The add dialog does not offer the preview for them.
*/
app.get('/api/sources/preview/pages', async (req, reply) => {
const { source, chapterId } = req.query as { source?: string; chapterId?: string };
const src = source ? getSource(source) : null;
if (!src || !chapterId) return reply.code(400).send({ error: 'bad_request' });
if (!sourceAllowedFor(src, vc(req).maxAgeRating)) return denySource(reply);
if (isSwAdapterId(src.id)) {
return reply.code(422).send({ error: 'preview_unsupported', message: 'Pages from an extension source cannot be previewed; add the series to read it.' });
}
if (await isDisabled(src.id)) {
return reply.code(403).send({ error: 'disabled', message: `${src.name} is disabled by the admin.` });
}
try {
const urls = await src.getPageUrls(chapterId);
if (!urls?.length) {
return reply.code(404).send({ error: 'no_pages', message: 'That source served no pages for this chapter.' });
}
return { source: src.id, chapterId, pages: urls };
} catch (e: any) {
// Answered, not swallowed: a preview that fails silently looks like an empty chapter.
return reply.code(502).send({ error: 'unreadable', message: String(e?.message || e).slice(0, 200) });
}
});

// Detail for one provider's match: description + chapter count/range (drives the add dialog).
app.get('/api/sources/detail', async (req, reply) => {
const { source, sourceId } = req.query as { source?: string; sourceId?: string };
Expand Down
68 changes: 67 additions & 1 deletion bff/test/sourceAccess.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,36 @@ const CLEAN = 't-clean';
const ADULT = 't-adult';
const SLOW = 't-slow';
const EMPTY = 't-empty';
/** A source with chapters and pages, for the preview routes. */
const PREVIEW = 't-preview';
/** An extension-shaped id: its pages would live on the engine, which the image proxy does not fetch pages from. */
const ENGINE = 'sw:990001';
const USERS = ['sa-admin', 'sa-plain', 'sa-capped', 'sa-nodl', 'sa-emptyperms', 'sa-yesdl', 'sa-adminnodl'];

/** Counts calls so the cache can be shown to be doing something rather than assumed to be. */
const calls = { [CLEAN]: 0, [ADULT]: 0, [SLOW]: 0, [EMPTY]: 0 } as Record<string, number>;
/** How often each source was asked for page URLs; the extension case must never reach its adapter. */
const pageCalls: Record<string, number> = {};

/** A source whose chapters and pages the preview can show: number 2 is listed twice, as two groups' copies. */
function previewable(id: string, name: string) {
return {
id, name,
async search() { return []; },
async getSeries(sid: string) { return { sourceId: sid, source: id, title: `${name} Title` }; },
async listChapters() {
return [
{ sourceId: `${id}-c1`, number: 1, scanlator: 'Group A' },
{ sourceId: `${id}-c2a`, number: 2, scanlator: 'Group A' },
{ sourceId: `${id}-c2b`, number: 2, scanlator: 'Group B' },
];
},
async getPageUrls(chapterId: string) {
pageCalls[id] = (pageCalls[id] ?? 0) + 1;
return [1, 2, 3].map((n) => `https://img.example.test/${chapterId}/${n}.jpg`);
},
};
}

function fake(id: string, name: string, opts: { isNsfw?: boolean; hang?: boolean; empty?: boolean } = {}) {
const series = (n: number) => ({ sourceId: `${id}-${n}`, source: id, title: `${name} Title ${n}` });
Expand Down Expand Up @@ -72,6 +98,8 @@ async function setup() {
registerAdapter(fake(ADULT, 'Adult Source', { isNsfw: true }) as any);
registerAdapter(fake(SLOW, 'Slow Source', { hang: true }) as any);
registerAdapter(fake(EMPTY, 'Empty Source', { empty: true }) as any);
registerAdapter(previewable(PREVIEW, 'Preview Source') as any);
registerAdapter(previewable(ENGINE, 'Engine Source') as any);

const mk = async (username: string, role: string, perms: any, cap: number | null) =>
(await q<{ id: string }>(
Expand Down Expand Up @@ -107,6 +135,8 @@ const ROUTES: Array<{ method: 'GET' | 'POST'; url: string; payload?: any }> = [
{ method: 'GET', url: '/api/sources/search-all?q=title' },
{ method: 'GET', url: '/api/sources/find?q=title' },
{ method: 'GET', url: `/api/sources/detail?source=${CLEAN}&sourceId=${CLEAN}-1` },
{ method: 'GET', url: `/api/sources/preview/chapters?source=${PREVIEW}&sourceId=${PREVIEW}-1` },
{ method: 'GET', url: `/api/sources/preview/pages?source=${PREVIEW}&chapterId=${PREVIEW}-c1` },
{ method: 'GET', url: '/api/sources/jobs' },
{ method: 'GET', url: '/api/discover/trending' },
{ method: 'POST', url: '/api/sources/add', payload: { source: CLEAN, sourceId: `${CLEAN}-1` } },
Expand Down Expand Up @@ -146,6 +176,8 @@ test('sources: who may reach them, and how long they get', { skip }, async (t) =
`/api/sources/latest?source=${ADULT}`,
`/api/sources/search?source=${ADULT}&q=title`,
`/api/sources/detail?source=${ADULT}&sourceId=${ADULT}-1`,
`/api/sources/preview/chapters?source=${ADULT}&sourceId=${ADULT}-1`,
`/api/sources/preview/pages?source=${ADULT}&chapterId=${ADULT}-c1`,
];
for (const url of urls) {
const r = await app.inject({ method: 'GET', url, headers: tok(ids.capped) });
Expand All @@ -172,6 +204,40 @@ test('sources: who may reach them, and how long they get', { skip }, async (t) =
assert.equal(d.statusCode, 200);
});

await t.test('the preview reads a chapter and writes nothing', async () => {
// Read a chapter before adding the series: the list is one copy per number (what an add would land),
// the pages are the source's own URLs for the client to render through /img/sources/cover, and no
// series row appears. Reintroduce the row by persisting from either route; the count below fails.
const before = (await q<{ n: number }>('SELECT count(*)::int AS n FROM lib_series WHERE source_id = $1', [PREVIEW]))[0].n;
const list = await app.inject({
method: 'GET', url: `/api/sources/preview/chapters?source=${PREVIEW}&sourceId=${PREVIEW}-1`, headers: tok(ids.plain),
});
assert.equal(list.statusCode, 200);
const nums = list.json().content.map((c: any) => c.number);
assert.deepEqual([...nums].sort(), [1, 2], 'the preview listed a number twice, unlike the add it previews');
const pages = await app.inject({
method: 'GET', url: `/api/sources/preview/pages?source=${PREVIEW}&chapterId=${PREVIEW}-c1`, headers: tok(ids.plain),
});
assert.equal(pages.statusCode, 200);
assert.equal(pages.json().pages.length, 3);
const after = (await q<{ n: number }>('SELECT count(*)::int AS n FROM lib_series WHERE source_id = $1', [PREVIEW]))[0].n;
assert.equal(after, before, 'a preview wrote a series row');
});

await t.test('the preview refuses an extension source before asking the engine', async () => {
// Its page URLs are paths on the extension engine, a private address the image proxy only fetches
// thumbnails from; answering them would be a chapter of broken images. Refused by id, so the engine is
// never asked. Reintroduce by deleting the `isSwAdapterId` check in the pages route.
pageCalls[ENGINE] = 0;
const r = await app.inject({
method: 'GET', url: `/api/sources/preview/pages?source=${encodeURIComponent(ENGINE)}&chapterId=${encodeURIComponent(ENGINE)}-c1`,
headers: tok(ids.plain),
});
assert.equal(r.statusCode, 422);
assert.equal(r.json().error, 'preview_unsupported');
assert.equal(pageCalls[ENGINE], 0, 'the engine was asked for pages the proxy could never show');
});

await t.test('the fan-outs drop the adult source instead of failing', async () => {
// A fan-out has no single source to refuse, so the right answer is "nobody has it", not a 403.
const r = await app.inject({
Expand Down Expand Up @@ -437,6 +503,6 @@ test('sources: who may reach them, and how long they get', { skip }, async (t) =
} finally {
await app.close();
await q('DELETE FROM users WHERE username = ANY($1)', [USERS]).catch(() => {});
await q('DELETE FROM source_health WHERE source_id = ANY($1)', [[CLEAN, ADULT, SLOW, EMPTY]]).catch(() => {});
await q('DELETE FROM source_health WHERE source_id = ANY($1)', [[CLEAN, ADULT, SLOW, EMPTY, PREVIEW, ENGINE]]).catch(() => {});
}
});
7 changes: 7 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,13 @@ again.
cadence rule calls the group quiet, otherwise *last release {ago}*) — and *{n} chapters have more than one
version*, from the chapter list it already fetched to count them. Sources that name no groups show nothing
there.
- **Read a chapter first:** under **Add to library**, this opens the title straight from the source without
adding it: pick a chapter from the list (one copy per number, the one an add would take), scroll it, and
step to the previous or next one. Nothing is written — no series, no files, no reading progress — and
**Add to library** is at the bottom of the chapter list when you have decided. Pages load through the
same server-side image proxy as Discover's covers, so the source's referer and Cloudflare session apply.
It is not offered for extension sources, whose pages live inside the extension engine; add those to read
them. The preview is a plain vertical scroll: your reader settings apply once the series is in the library.
- **Also check the other sources that carry this title** (admins only — following a source is an admin
act, as it is on the series page, and a member's add goes through as if the switch were off; their done
step says *Other sources: an admin can follow them from Sources & translations.*): when the dialog
Expand Down
8 changes: 8 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,8 +587,16 @@ GET /api/sources/search-all GET /api/sources/latest
GET /api/sources/jobs POST /api/sources/add
GET /api/discover/trending POST /api/sources/fill/scan
POST /api/sources/fill POST /api/sources/fetch
GET /api/sources/preview/chapters
GET /api/sources/preview/pages
```

**Previewing before adding.** `GET /api/sources/preview/chapters?source=&sourceId=` lists a source's
chapters, one copy per number; `GET /api/sources/preview/pages?source=&chapterId=` answers the page URLs, which
the web app renders through `/img/sources/cover` so the proxy's auth and SSRF guard still apply. Neither writes
anything. Extension sources answer `422 preview_unsupported` for pages: their URLs are on the extension engine,
which the proxy fetches thumbnails from and nothing else.

**Filling a series' gaps.** `POST /api/sources/fill/scan` takes `{seriesId, altTitle?}` and answers with what
is missing, a short-lived `planId`, and every source that was checked — including the ones it refused, with
the reason and the measured overlap. `POST /api/sources/fill` then takes
Expand Down
Loading