mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 06:20:54 +08:00
两个分支各自演进:一侧引入格式注册表、H5 客户端与 Android 外壳,另一侧 引入漫画目录书与逐页取图。冲突集中在扫描器、metadata、library 路由和 web 客户端,逐项按「两边能力都要保留」的原则合并。 合并后修复了 4 个被冲突掩盖的真实缺陷: - scanner 同时保留了两套目录书实现,类型检查与测试均无法通过;改为统一 走格式注册表,目录书在文件遍历之前发现,其内容不再重复入库。 - /manifest 的 files 对漫画目录书返回的是文件夹路径,客户端无法逐页取图; 改为由 manifest 派生真实页路径。 - 单个文件端点只认 book_files 行,漫画目录书的每一页都 404;改为目录书 额外认领其目录下的路径,并修正路径归一化使穿越尝试仍被拒绝。 - 目录书判定只看图片数量,会把夹着一本书的目录整体吞掉;改为注册表驱动 的判定,含书籍文件的目录始终是书架。 同时修复了 web 端两套测试运行器互相无法执行对方用例的问题(vitest 与 node:test 混跑会静默跳过),以及 web 侧 5 处类型错误。 Ref: #9
104 lines
4.2 KiB
TypeScript
104 lines
4.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it } from 'vitest';
|
|
import { extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts';
|
|
|
|
/**
|
|
* Sanitisation and style extraction.
|
|
*
|
|
* These run against a real DOM (jsdom) rather than a string, because the whole
|
|
* point is to catch what the HTML parser produces — a string-level rewrite cannot
|
|
* see the difference between a `<script>` in markup and one the parser moved.
|
|
*
|
|
* The security property being asserted: a book is data. It may describe how it
|
|
* should look; it may not run code or reach the network.
|
|
*/
|
|
|
|
function parse(html: string): HTMLElement {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = html;
|
|
return container;
|
|
}
|
|
|
|
describe('sanitiseInjectedContent', () => {
|
|
it('removes script elements', () => {
|
|
const node = parse('<p>text</p><script>window.pwned = true</script>');
|
|
sanitiseInjectedContent(node);
|
|
expect(node.querySelector('script')).toBeNull();
|
|
expect(node.textContent).toContain('text');
|
|
});
|
|
|
|
it('strips inline event handlers', () => {
|
|
// These survive an innerHTML round trip, so they must be removed explicitly
|
|
// rather than trusted to the parser.
|
|
const node = parse('<img src="reader-res:a.png" onerror="window.pwned=1"/>');
|
|
sanitiseInjectedContent(node);
|
|
expect(node.querySelector('img')?.getAttribute('onerror')).toBeNull();
|
|
});
|
|
|
|
it('strips a javascript: href but keeps the link text', () => {
|
|
const node = parse('<a href="javascript:alert(1)">click</a>');
|
|
sanitiseInjectedContent(node);
|
|
const anchor = node.querySelector('a');
|
|
expect(anchor?.hasAttribute('href')).toBe(false);
|
|
expect(anchor?.textContent).toBe('click');
|
|
});
|
|
|
|
it('drops a remote src so a book cannot report a reading session', () => {
|
|
const node = parse('<img src="https://tracker.example/pixel.gif"/>');
|
|
sanitiseInjectedContent(node);
|
|
expect(node.querySelector('img')?.hasAttribute('src')).toBe(false);
|
|
});
|
|
|
|
it('keeps the internal resource scheme and blob/data URLs', () => {
|
|
const node = parse(
|
|
'<img src="reader-res:OEBPS/a.png"/><img src="data:image/png;base64,AA"/><img src="blob:xyz"/>',
|
|
);
|
|
sanitiseInjectedContent(node);
|
|
const sources = [...node.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
|
expect(sources).toEqual(['reader-res:OEBPS/a.png', 'data:image/png;base64,AA', 'blob:xyz']);
|
|
});
|
|
|
|
it('removes a base element and a meta refresh, which would navigate away', () => {
|
|
const node = parse('<base href="https://evil.example/"/><meta http-equiv="refresh" content="0;url=https://evil.example"/>');
|
|
sanitiseInjectedContent(node);
|
|
expect(node.querySelector('base')).toBeNull();
|
|
expect(node.querySelector('meta')).toBeNull();
|
|
});
|
|
|
|
it('removes target so a footnote link cannot replace the reader', () => {
|
|
const node = parse('<a href="reader-res:notes.xhtml" target="_blank">note</a>');
|
|
sanitiseInjectedContent(node);
|
|
expect(node.querySelector('a')?.hasAttribute('target')).toBe(false);
|
|
expect(node.querySelector('a')?.getAttribute('href')).toBe('reader-res:notes.xhtml');
|
|
});
|
|
|
|
it('leaves ordinary content untouched', () => {
|
|
const node = parse('<p>正文</p><em>强调</em><ruby>漢<rt>かん</rt></ruby>');
|
|
sanitiseInjectedContent(node);
|
|
expect(node.textContent).toBe('正文强调漢かん');
|
|
expect(node.querySelector('ruby')).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractBody', () => {
|
|
it('returns only the body of a full XHTML document', () => {
|
|
const html = '<html><head><title>t</title></head><body><p>内容</p></body></html>';
|
|
expect(extractBody(html)).toBe('<p>内容</p>');
|
|
});
|
|
|
|
it('returns the input unchanged when there is no body element', () => {
|
|
expect(extractBody('<p>fragment</p>')).toBe('<p>fragment</p>');
|
|
});
|
|
});
|
|
|
|
describe('extractInlineStyles', () => {
|
|
it('collects style blocks in document order', () => {
|
|
const html = '<style>a{color:red}</style><p>x</p><style>b{color:blue}</style>';
|
|
expect(extractInlineStyles(html)).toEqual(['a{color:red}', 'b{color:blue}']);
|
|
});
|
|
|
|
it('returns an empty list when there are none', () => {
|
|
expect(extractInlineStyles('<p>x</p>')).toEqual([]);
|
|
});
|
|
});
|