mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +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 的章节、漫画的卷),与分窗互不干扰。
104 lines
4.5 KiB
TypeScript
104 lines
4.5 KiB
TypeScript
/**
|
|
* Asset URL tests.
|
|
*
|
|
* The behaviour under test is the one that decides whether a chapter renders as
|
|
* a book or as unstyled text with no pictures: the browser fetches a chapter's
|
|
* images and stylesheets itself, so their URLs have to authenticate without a
|
|
* header. The browser test caught this as a wall of 401s.
|
|
*/
|
|
import { test, describe, before } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { JSDOM } from 'jsdom';
|
|
import type { ApiClient } from '../src/net/api.ts';
|
|
|
|
// The module parses XHTML with the DOM, so the DOM has to exist before it loads.
|
|
before(() => {
|
|
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
g.DOMParser = dom.window.DOMParser;
|
|
g.XMLSerializer = dom.window.XMLSerializer;
|
|
g.document = dom.window.document;
|
|
});
|
|
|
|
const { withAssetToken } = await import('../src/net/asset-url.ts');
|
|
|
|
function client(token: string | null, baseUrl = ''): ApiClient {
|
|
return {
|
|
baseUrl,
|
|
currentSession: token ? { accessToken: token } : null,
|
|
} as unknown as ApiClient;
|
|
}
|
|
|
|
const CHAPTER = `<?xml version="1.0" encoding="utf-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml"><head>
|
|
<link rel="stylesheet" href="/api/v1/books/b1/assets?ref=OEBPS%2Fs.css"/>
|
|
</head><body>
|
|
<p>正文</p>
|
|
<img src="/api/v1/books/b1/assets?ref=OEBPS%2Fi.png"/>
|
|
<a href="#note1">脚注</a>
|
|
<img src="https://example.com/remote.png"/>
|
|
</body></html>`;
|
|
|
|
describe('asset URL rewriting', () => {
|
|
test('an image URL gets the token, because an img tag cannot send a header', () => {
|
|
const out = withAssetToken(CHAPTER, client('tok'));
|
|
// Serialising XHTML escapes `&` as `&`, which is what an XML document
|
|
// requires; the browser unescapes it when it parses the attribute.
|
|
assert.match(out, /ref=OEBPS%2Fi\.png&access_token=tok/);
|
|
});
|
|
|
|
test('a stylesheet link gets it too', () => {
|
|
const out = withAssetToken(CHAPTER, client('tok'));
|
|
assert.match(out, /ref=OEBPS%2Fs\.css&access_token=tok/);
|
|
});
|
|
|
|
test('an in-document anchor is left alone', () => {
|
|
// Rewriting `#note1` would break every footnote in the book, because the
|
|
// rewrite would turn a same-document jump into a network request.
|
|
const out = withAssetToken(CHAPTER, client('tok'));
|
|
assert.match(out, /href="#note1"/);
|
|
});
|
|
|
|
test('a third-party URL is left alone', () => {
|
|
// The token is scoped to this instance; sending it to example.com would leak
|
|
// it to a stranger.
|
|
const out = withAssetToken(CHAPTER, client('tok'));
|
|
assert.match(out, /src="https:\/\/example\.com\/remote\.png"/);
|
|
assert.ok(!out.includes('example.com/remote.png?access_token'), 'a foreign URL must not carry our token');
|
|
assert.match(out, /<img src="https:\/\/example\.com\/remote\.png"/);
|
|
});
|
|
|
|
test('a URL that already has a token is not given a second one', () => {
|
|
// The function runs on every chapter, including one served from the cache,
|
|
// so running it twice must not produce two parameters.
|
|
const once = withAssetToken(CHAPTER, client('tok'));
|
|
const twice = withAssetToken(once, client('tok'));
|
|
assert.equal((twice.match(/access_token=tok/g) ?? []).length, 2, 'one per asset URL, not two');
|
|
});
|
|
|
|
test('without a session the document is returned unchanged', () => {
|
|
assert.equal(withAssetToken(CHAPTER, client(null)), CHAPTER);
|
|
});
|
|
|
|
test('a cross-origin deployment rewrites absolute URLs too', () => {
|
|
// The Android shell loads assets from `file://` and talks to a real server
|
|
// origin, so every URL is absolute.
|
|
const html = '<html xmlns="http://www.w3.org/1999/xhtml"><body><img src="http://nas:8080/api/v1/books/b1/assets?ref=a.png"/></body></html>';
|
|
const out = withAssetToken(html, client('tok', 'http://nas:8080'));
|
|
assert.match(out, /access_token=tok/);
|
|
});
|
|
|
|
test('a broken chapter is returned as-is rather than half-rewritten', () => {
|
|
// A parse failure means the document is not well-formed XML. Rewriting part
|
|
// of it risks turning a renderable chapter into an unrenderable one.
|
|
const broken = '<html><body><img src="/api/v1/books/b1/assets?ref=a.png"';
|
|
assert.equal(withAssetToken(broken, client('tok')), broken);
|
|
});
|
|
|
|
test('CSS url() references are rewritten', () => {
|
|
const html = '<html xmlns="http://www.w3.org/1999/xhtml"><head><style>body{background:url("/api/v1/books/b1/assets?ref=bg.png")}</style></head><body/></html>';
|
|
const out = withAssetToken(html, client('tok'));
|
|
assert.match(out, /access_token=tok/);
|
|
});
|
|
});
|