mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
真机(Chromium,390x844)跑通整条链路后,之前"看起来对"的地方全都露馅了。 这一轮把渲染层按实测重做,四个都是必须修的: 1. **章节必须是真文档,不能是 shadow DOM 里的片段。** 章节里的 <img src> 和 <link rel=stylesheet> 是浏览器自己去请求的, 没法带 Authorization 头。把它们注入宿主文档的结果是每个插图、每个 样式表都 401 —— 书渲染成无样式无图。iframe 是真浏览上下文,它的 子资源请求是普通导航,URL 里带什么就发什么。所以:外壳用 shadow DOM (挡的是客户端样式往书里漏,这是它真正能挡的方向),书本身用 iframe。 2. **子资源的 token 只能走 query。** 服务端新增 `queryTokenAllowed`,白名单式放行:仅 GET、仅 /api/v1/books/*/assets|cover|content。触发扫描、写元数据这类端点一律 不接受 URL 里的 token —— 那才是真正会泄到日志和 Referer 的地方。 新增 5 个测试固定这条边界。 3. **`column-fill: auto` 需要确定高度,否则根本没有分页。** body 不设高度时它会撑到内容高度,整章排成一列,scrollWidth 等于 clientWidth —— 翻页手势完全无效,一章只显示一页。修完 390px 宽的一章 是 3732px / 9 页,翻页步长精确 422px(390 + 32 gutter)。 同时去掉 scroll-snap:吸附目标是列边缘而不是我做出来的页边界, 会导致 scrollLeft 落在两页之间、甚至一次跳两页。 4. **`pageWidth` 是上限不是实际宽度。** 手机上 720px 的列比屏幕还宽,同样退化成"一章一页"。取 min(读者设置, 实际宽度)。 另外发现并按格式分流修好的:PDF(kind=document)之前落进了图片翻页器, 读者看到的是一个破图;现在走 PdfReader。 封面 <img> 也漏了 token,一并改成 api.coverUrl()。 顺带把一个设计缺陷拆开了:目录不再从 manifest 的 groups 取。 groups 是传输分窗,它把目录显示成「第 1 章 – 第 40 章」——服务端的分页 边界漏进了 UI。新增 /books/:id/toc,按格式返回真正的目录 (epub 的 spine 标题、txt 的章节、漫画的卷),与分窗互不干扰。
106 lines
4.5 KiB
TypeScript
106 lines
4.5 KiB
TypeScript
/**
|
|
* H5 hosting tests.
|
|
*
|
|
* Two things here are security-relevant rather than cosmetic: the static handler
|
|
* is a catch-all, so a path traversal would expose the whole filesystem, and the
|
|
* API prefix has to keep answering with JSON so a client never has to parse HTML.
|
|
*/
|
|
import { test, before, after, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import Fastify from 'fastify';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { registerWebRoutes } from '../src/http/routes/web.ts';
|
|
import type { AppContext } from '../src/http/context.ts';
|
|
|
|
let root: string;
|
|
let app: FastifyInstance;
|
|
|
|
before(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'web-'));
|
|
await writeFile(join(root, 'index.html'), '<!doctype html><title>reader</title>');
|
|
await mkdir(join(root, 'assets'));
|
|
await writeFile(join(root, 'assets', 'index-abc123.js'), 'console.log(1)');
|
|
// A file one level above the build, to prove traversal is refused.
|
|
await writeFile(join(root, '..', `secret-${process.pid}.txt`), 'do not serve me');
|
|
|
|
process.env.WEB_DIR = root;
|
|
app = Fastify();
|
|
registerWebRoutes(app, {} as AppContext);
|
|
await app.ready();
|
|
});
|
|
|
|
after(async () => {
|
|
await app.close();
|
|
await rm(join(root, '..', `secret-${process.pid}.txt`), { force: true });
|
|
await rm(root, { recursive: true, force: true });
|
|
delete process.env.WEB_DIR;
|
|
});
|
|
|
|
describe('static client hosting', () => {
|
|
test('the root serves the client', async () => {
|
|
const res = await app.inject({ method: 'GET', url: '/' });
|
|
assert.equal(res.statusCode, 200);
|
|
assert.match(res.headers['content-type'] as string, /text\/html/);
|
|
assert.match(res.body, /reader/);
|
|
// Not cached: a client holding a stale index.html keeps loading a bundle an
|
|
// upgrade already replaced.
|
|
assert.equal(res.headers['cache-control'], 'no-cache');
|
|
});
|
|
|
|
test('hashed assets are cached forever', async () => {
|
|
const res = await app.inject({ method: 'GET', url: '/assets/index-abc123.js' });
|
|
assert.equal(res.statusCode, 200);
|
|
assert.match(res.headers['content-type'] as string, /javascript/);
|
|
assert.match(res.headers['cache-control'] as string, /immutable/);
|
|
});
|
|
|
|
test('an asset is served with its real bytes, not an empty second send', async () => {
|
|
// This was a 200 with `content-length: 0` for every asset. The cause was a
|
|
// `reply.send(stream)` followed by a bare `return` in an async handler:
|
|
// Fastify sends the response twice, the second send is empty, and the
|
|
// client's module never loads — a blank page with no error anywhere except
|
|
// a "stream closed prematurely" log line.
|
|
const res = await app.inject({ method: 'GET', url: '/assets/index-abc123.js' });
|
|
assert.equal(res.statusCode, 200);
|
|
assert.ok(res.rawPayload.byteLength > 0, 'a streamed asset must not be empty');
|
|
assert.equal(res.body, 'console.log(1)');
|
|
});
|
|
|
|
test('index.html is served with its real bytes', async () => {
|
|
const res = await app.inject({ method: 'GET', url: '/' });
|
|
assert.equal(res.statusCode, 200);
|
|
assert.ok(res.rawPayload.byteLength > 0);
|
|
assert.match(res.body, /<title>reader<\/title>/);
|
|
});
|
|
|
|
test('a client-side route falls back to index.html instead of 404', async () => {
|
|
// A single-page app's deep links are not files; returning 404 would break a
|
|
// reload on any screen but the shelf.
|
|
const res = await app.inject({ method: 'GET', url: '/books/abc123' });
|
|
assert.equal(res.statusCode, 200);
|
|
assert.match(res.headers['content-type'] as string, /text\/html/);
|
|
});
|
|
|
|
test('an unknown API path still answers with JSON', async () => {
|
|
// The catch-all must not swallow the API's own 404 shape, or a client that
|
|
// parses `error.code` gets an HTML page and reports "server broken".
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/nope' });
|
|
assert.equal(res.statusCode, 404);
|
|
assert.equal((res.json() as { error: { code: string } }).error.code, 'NOT_FOUND');
|
|
});
|
|
|
|
test('path traversal is refused', async () => {
|
|
const res = await app.inject({ method: 'GET', url: `/../secret-${process.pid}.txt` });
|
|
const leaked = res.body.includes('do not serve me');
|
|
assert.equal(leaked, false, 'a traversal must never read outside the build');
|
|
});
|
|
|
|
test('an encoded traversal is refused too', async () => {
|
|
const res = await app.inject({ method: 'GET', url: `/%2e%2e/secret-${process.pid}.txt` });
|
|
assert.equal(res.body.includes('do not serve me'), false);
|
|
});
|
|
});
|