mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
feat(sources): add configuration pages and chapter updates
This commit is contained in:
1 parent
a6fddab953
commit
c192fc2f12
30 files changed
+1233
-95
No files matched your search
@@ -10,9 +10,6 @@
|
||||
| --- | --- | --- |
|
||||
| 服务端 | `server/` | Node.js + TypeScript,单容器,扫描书库并同步进度 |
|
||||
| 渲染层 | `web/` | TypeScript + Vite,EPUB 精排、TXT、漫画、PDF |
|
||||
| Android 壳 | `android/` | Kotlin + WebView,复用上面那一份渲染层 |
|
||||
|
||||
服务端已提供内置 local / OPDS 来源与受信任进程插件接口。OPDS 下载和插件纯文本章节均接入现有书架与阅读器;章节支持稳定阅读进度、已读缓存和手动刷新目录。插件与来源配置目前使用管理 API,远程富文本、整书预缓存和自动追更仍待实现。见 [插件设计与实施状态](docs/source-plugins.md) 和 [API 与示例插件安装](docs/api.md#来源与插件)。
|
||||
|
||||
---
|
||||
|
||||
@@ -147,13 +144,8 @@ volumes:
|
||||
| 引擎 | 声音来自 | 什么时候用 |
|
||||
| --- | --- | --- |
|
||||
| 系统语音(原生) | Android 外壳的 `TextToSpeech` | Android 客户端默认 |
|
||||
| 系统语音(浏览器) | 浏览器 / WebView 的 `speechSynthesis` | Web 端默认 |
|
||||
| HTTP 朗读 | 服务端 `TTS_URL` 指向的合成服务 | 前两者不可用时 |
|
||||
|
||||
**为什么 Android 要用原生引擎**:WebView 自带的合成器和系统的是**两个引擎**,
|
||||
手机上系统装好的中文语音经常不在 WebView 的语音列表里——于是读者听到英文或听不到声音,
|
||||
而 Web 层拿到的列表本身就是缺的,无从修起。这是「朗读没有中文声音」最常见的原因。
|
||||
|
||||
**HTTP 朗读**:给没有 `speechSynthesis` 的浏览器(比如 Linux 上的 Firefox)、
|
||||
或者没有中文语音的设备用。服务端只做代理,不内置合成模型——容器体积和 NAS 的 CPU 预算
|
||||
都不该为它买单。在 `docker-compose.yml` 里加:
|
||||
@@ -354,22 +346,11 @@ Back 走的是「离开书库」而不是「切回上一个页面」。
|
||||
|
||||
### 两端的关系
|
||||
|
||||
Android 端**不重写渲染**。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
|
||||
渲染复用同一份 `web/` 产物。原因很直接:排版策略一旦出现两份实现,就一定会分叉,
|
||||
而「忠于精排」正是这个产品的差异点。外壳只做 WebView 做不到或做不好的事:
|
||||
|
||||
- **连通性**:WebView 里的 `navigator.onLine` 只要有网卡就说「在线」——
|
||||
连上一个没有出口的 Wi-Fi 时它会骗人,客户端于是转圈而不是读缓存。
|
||||
Android 侧用系统的 `NET_CAPABILITY_VALIDATED` 判断。
|
||||
- **稳定的设备名**:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
|
||||
- **原生提示**:一行 Kotlin 的 Toast,不用在 Web 层造一套通知 UI。
|
||||
|
||||
还有一条刻意的例外:**固定版式的整页图由原生画**。一页漫画或一张扫描图在 WebView 里
|
||||
要走完解码、样式布局、合成三次开销,而屏幕上的结果和 `ImageView` 一模一样;
|
||||
对一章文字这些都是必要的,对「把这张图画满屏」则全是白付的。
|
||||
规则是「只画整页图,且全有或全无」——画不了就交给 WebView,绝不出现空白页。
|
||||
EPUB 永远留在 WebView:文字排版是差异点,只能有一份实现。
|
||||
|
||||
### 离线合并语义
|
||||
|
||||
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
|
||||
@@ -580,13 +561,6 @@ web/src/
|
||||
reader-icons.ttf 图标字体(生成物,见 tools/icons/)
|
||||
tools/icons/ 图标字体的生成器:描边路径 → 字体
|
||||
|
||||
android/app/src/main/java/cool/cnb/reader/
|
||||
MainActivity.kt 单 Activity,只做三件事
|
||||
web/WebHost.kt WebView 配置与资源装载
|
||||
bridge/ReaderBridge.kt 暴露给 JS 的原生方法
|
||||
bridge/ConnectivityMonitor.kt 真正的连通性判断
|
||||
```
|
||||
|
||||
### 技术选型说明
|
||||
|
||||
- **Node.js 22+(`engines: >=22`,内置 `node:sqlite` 与 `scrypt` 所在的最低版本)+ TypeScript + Fastify**:单容器体积可控(约 150MB),迭代最快,
|
||||
@@ -610,13 +584,11 @@ android/app/src/main/java/cool/cnb/reader/
|
||||
`font-size`,因此没有可以漂移的几何——而三十个内联 SVG 组件里,颜色、尺寸、线宽
|
||||
每一项都是单个字形可以漂移的位置。
|
||||
- **界面用 URL 表达**:每个屏幕都有地址——`#/shelf`、`#/book/<id>`、`#/library/<路径>`。
|
||||
用 fragment 而不是路径,是因为同一份产物要被 WebView(`appassets.androidplatform.net`,
|
||||
没有服务端)和 reader 服务端(没有客户端路由回落)同时加载,只有 fragment 在两边都成立。
|
||||
换来的是:一本书可以分享、可以收藏、刷新不丢;Android 返回手势和浏览器返回键
|
||||
走应用自己的屏幕轨迹;走进书库子目录用 `replace`,所以返回是退出管理器而不是逐层退目录。
|
||||
带着链接进来而尚未登录时,链接会等在登录页后面,登录后直接落到那本书。
|
||||
- **单文件 bundle、相对路径**:产物要被两个宿主消费——服务端托管给浏览器、Android 打进
|
||||
assets。`file://` 或 WebView 里加载同源分片会踩平台特异性,所以 `inlineDynamicImports`
|
||||
打成一份,`base: './'`。这条约束也是选 Preact 而不是 React/Vue 的关键:
|
||||
`preact/compat` 不进包、没有 `react-dom`,只引入 `preact` 与 `preact/hooks`,
|
||||
两个模块压缩前合计约 15KB。
|
||||
|
||||
+15
-5
@@ -262,7 +262,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
|
||||
响应带 `Cache-Control: private, max-age=31536000, immutable`:文件资源由内容身份寻址;章节资源引用同时包含目录 revision,所以同一个资源 URL 的内容保持不变。
|
||||
|
||||
章节资源返回 `text/plain; charset=utf-8`,另带 `X-Content-Type-Options: nosniff` 和限制内容执行的 CSP。客户端必须按纯文本转义排版,即使正文包含 HTML 字符也不能直接交给 HTML 渲染器。正文读取后持久缓存,来源禁用或插件卸载不影响缓存命中;未缓存内容仍需要可用来源。旧 revision 的已缓存正文可读,未缓存正文返回 `409 CHAPTER_SNAPSHOT_EXPIRED`,客户端可重新打开当前目录。
|
||||
章节资源按 manifest 的 `mediaType` 返回 `text/plain; charset=utf-8` 或清洗后的 `text/html; charset=utf-8`,带 `nosniff` 和 `default-src 'none'; img-src data:; sandbox` CSP。纯文本必须转义排版;HTML 只保留白名单标签,图片经插件通道获取、校验 PNG/JPEG/GIF/WebP 签名并转为 data URL。脚本、样式、表单、外部链接、SVG 和任意网络图片被移除。正文读取后持久缓存,来源禁用或插件卸载不影响缓存命中;旧 revision 已缓存正文可读,未缓存正文返回 `409 CHAPTER_SNAPSHOT_EXPIRED`。
|
||||
|
||||
### `POST /books/:id/refresh`
|
||||
|
||||
@@ -277,7 +277,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
"mediaType": "text/plain; charset=utf-8", "format": "html" }] }
|
||||
```
|
||||
|
||||
`format: "html"` 表示客户端将纯文本排版为 HTML,不表示资源含可信 HTML。该接口无需请求体;文件书返回 `400 SOURCE_UNSUPPORTED`,其他用户的章节书返回 404。刷新失败保留旧目录;同一目录保持 revision 不变;插件应在正文变化时更新 `ManifestSnapshot.version`。阅读进度和笔记不重写,插章后仍以稳定 href 定位。它是手动刷新接口,没有后台追更或预下载副作用。
|
||||
`format: "html"` 表示客户端将纯文本排版为 HTML,不表示资源含 HTML;富文本条目省略 `format`、声明 `mediaType: text/html`,目录 `kind` 为 `reflowable`。该接口无需请求体;文件书返回 `400 SOURCE_UNSUPPORTED`,其他用户章节书返回 404。刷新失败保留旧目录;同一目录保持 revision 不变;正文变化时插件须更新 `ManifestSnapshot.version`。进度和笔记不重写,插章后以稳定 href 定位。手动刷新会更新检查时间和新增章计数,但不会自动开启订阅或预下载正文。
|
||||
|
||||
### 文件资源的流式与子资源行为
|
||||
|
||||
@@ -837,7 +837,7 @@ Content-Type: application/json
|
||||
|
||||
返回 `201 { "source": {...} }`。可指定 `id`(2~80 位字母、数字、下划线或短横线),省略则自动生成。`sourceType` 和 `pluginId` 从 `/sources/types` 读取。`local` 仅接受空配置,使用宿主 `BOOKS_DIR`。
|
||||
|
||||
`config` 不能包含密码。OPDS 的 `url` 必须是 HTTP(S) 地址;Feed 和下载链接默认限制在该 origin。需要跨 origin 获取文件时,管理员可在 config 中增加 `allowedOrigins`(如 `["https://cdn.example.test"]`)。Basic 凭据不会转发到附加 origin。当前只支持通过 `PATCH /sources/:id` 更新 `enabled`,未提供修改名称/URL/config 或删除来源的接口。
|
||||
`config` 不能包含密码。OPDS 的 `url` 必须是 HTTP(S) 地址;Feed 和下载链接默认限制在该 origin。跨 origin 获取文件须配置 `allowedOrigins`;Basic 凭据不转发到附加 origin。管理员可 `PATCH /sources/:id` 更新 `name/config/enabled`,配置仍由提供者校验,修改 config 会清除该来源所有用户的凭据,防止向新地址发送旧凭据;来源有在途调用时修改 config 返回 `409 SOURCE_BUSY`。`DELETE /sources/:id` 只允许删除没有已获取内容的非内置来源,否则返回 `409 SOURCE_IN_USE`。Web 的 `#/sources` 提供对应表单。
|
||||
|
||||
### 来源浏览、搜索和详情
|
||||
|
||||
@@ -882,9 +882,19 @@ GET /api/v1/sources/:id/publications/:publicationRef/manifest
|
||||
GET /api/v1/sources/:id/publications/:publicationRef/resource?ref=<resource-ref>
|
||||
```
|
||||
|
||||
上述来源级资源接口只返回 JSON:文本用 `text`,二进制小资源用 `base64`,用于插件开发调试。实际入库阅读只接受 UTF-8 `text/plain`;章节目录最多 10,000 项及 2 MiB,单章最多 2 MiB,同时受 RPC 消息上限约束。HTML、图片及其他富文本暂不接入阅读器。
|
||||
上述来源级资源接口只返回 JSON:文本用 `text`,二进制资源用 `base64`。入库阅读接受 UTF-8 `text/plain`、`text/html`、`application/xhtml+xml`;目录最多 10,000 项及 2 MiB,原始正文最多 2 MiB。HTML 插图仅接受 `src="reader-res:<插件资源引用>"`,由同一来源的 `readResource` 返回图片;每章最多 32 个不同图片,单图 2 MiB,清洗与内嵌后的章节总计不超过 8 MiB,整章加载限时 60 秒。所有插件响应仍受单条 RPC 2 MiB 上限约束(含 base64)。
|
||||
|
||||
服务端和设备分别缓存已读正文。Web 客户端保存目录和正文时按服务器、账号及 resourceRef 隔离;断网或服务端 5xx 时可回退本账号缓存,授权失败或资源不存在时不回退。设备断网只能阅读已缓存章节;当前没有整书预下载、缓存总配额或后台追更。
|
||||
服务端和设备分别缓存已读正文(含已内嵌图片)。Web 按服务器、账号及 resourceRef 隔离;断网或 5xx 时目录可回退本账号缓存,授权失败或资源不存在时不回退。没有整书预下载和缓存总配额。后台目录检查由下面的订阅接口控制。
|
||||
|
||||
### 自动追更订阅
|
||||
|
||||
`GET /subscriptions` 返回 `{ "subscriptions": [...] }`,只列当前用户书架中的章节书:`bookId/title/enabled/intervalMinutes/nextCheckAt/lastCheckAt/lastSuccessAt/lastError/failures/newChapters`,时间为毫秒,未检查时间为 null。默认关闭、间隔 60 分钟。
|
||||
|
||||
`PATCH /books/:id/subscription` 接受 `{ "enabled": true, "intervalMinutes": 60 }` 或 `{ "acknowledge": true }`。间隔为 15~10080 分钟的整数;`acknowledge` 只清除新增章提示。管理员不能操作他人的章节书。
|
||||
|
||||
单实例服务每分钟扫描一次,每轮最多顺序处理 20 本;开始时写入 5 分钟执行租约,崩溃重启后到期重试。暂停来源、禁用账号或隐藏书籍时跳过。失败按订阅间隔和指数退避的较大值重试,保留旧目录及上次成功时间;不预下载正文,不发外部通知。状态中的错误是代码,不包含上游消息。浏览器关闭不影响调度;阅读中不会自动替换当前目录,用户刷新或重开时采用新版本。
|
||||
|
||||
插件类型可声明 `credentialKeys: [{ "key": "access-token", "label": "访问令牌" }]`;同时声明 `permissions.credentials: true` 后,宿主仅将该类型声明的当前用户键值放进 RPC `context.credentials`。凭据不出现在来源列表或普通 config 中。`AUTH_REQUIRED` 表示来源凭据问题,客户端不应退出 reader 账号。
|
||||
|
||||
### 插件管理(管理员)
|
||||
|
||||
|
||||
+20
-23
@@ -1,6 +1,6 @@
|
||||
# 来源插件系统设计
|
||||
|
||||
状态:内置来源、进程插件和章节阅读闭环已实现
|
||||
状态:内置来源、插件、配置 UI、章节富文本/图片与自动追更已实现
|
||||
|
||||
适用接口:`/api/v1`,来源插件协议 1
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
本文定义 reader 对本地书库、OPDS 书库和远程书源的统一扩展架构。目标是让核心程序只维护一套书架、阅读、缓存和任务逻辑;内置 `local`、`opds` 两种来源;远程书源以独立插件开发、安装和升级。
|
||||
|
||||
本文区分目标架构和本轮交付。当前已实现 local、OPDS 下载入库、受信任 Node 来源插件及管理接口;外部章节已接入持久化出版物、书架、按章节缓存、手动刷新和既有阅读进度。设备端缓存已读章节,不提供整书预下载或后台自动追更。交付状态与验证见 §13,调用示例见 [API 参考](api.md#来源与插件)。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 目标
|
||||
@@ -169,7 +167,7 @@ interface PublicationContent {
|
||||
}
|
||||
```
|
||||
|
||||
SDK 的 `ResourceResponse` 支持 `mediaType`、`size`(若已知)、`data`、`text` 和 `stream`。内置 OPDS 提供者通过流下载。首个外部进程只支持章节来源,声明 `acquire.file` 会被拒绝;资源通过有大小上限的 `text/base64` JSON 传输。来源级开发预览始终返回 JSON,实际入库阅读只接受 UTF-8 `text/plain`,暂不支持远程 HTML、图片或其他富文本。后续资源通道支持背压和取消后,外部插件才适合传输大文件。
|
||||
SDK 的 `ResourceResponse` 支持 `mediaType`、`size`、`data`、`text` 和 `stream`。内置 OPDS 使用流下载;进程插件暂只支持章节,`acquire.file` 会被拒绝。进程资源通过有大小上限的 `text/base64` JSON 传输;入库章节接受 UTF-8 纯文本或 HTML/XHTML,HTML 内插图通过 `reader-res:<ref>` 引用同一插件的二进制资源。宿主清洗并内嵌图片后才返回给阅读器。来源开发预览始终返回 JSON。
|
||||
|
||||
### 4.3 能力
|
||||
|
||||
@@ -220,7 +218,7 @@ content.update
|
||||
|
||||
### 5.2 宿主能力
|
||||
|
||||
SDK 预留以下能力;首个切片的 `SourceContext` 中能力注入是可选的,独立进程内暂未实现宿主能力的反向 RPC,不能假设以下约束已强制生效:
|
||||
SDK 的目标能力如下。独立进程尚未实现宿主能力的反向 RPC;已实现的凭据注入会在每次调用时,按用户、来源实例、`credentialKeys` 和 `permissions.credentials` 选择允许传递的键,放入 `context.credentials`。这不等于以下全部运行约束都已强制生效:
|
||||
|
||||
| 能力 | 规则 |
|
||||
| --- | --- |
|
||||
@@ -239,7 +237,7 @@ SDK 预留以下能力;首个切片的 `SourceContext` 中能力注入是可
|
||||
|
||||
```text
|
||||
request = { jsonrpc: "2.0", id, method,
|
||||
params: { sourceType, context: { instance, userId }, ... } }
|
||||
params: { sourceType, context: { instance, userId, credentials? }, ... } }
|
||||
response = { jsonrpc: "2.0", id, result }
|
||||
error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
```
|
||||
@@ -270,9 +268,9 @@ error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
|
||||
目标上插件负责搜索、详情、目录快照、正文清洗和图片引用;宿主负责缓存、限流、任务、权限和阅读协议。插件返回 `chapters` 绑定后,宿主获取目录快照、分配稳定 book ID 并加入调用用户书架;HTTP 返回 `ready/publicationId`,阅读器继续使用 `/books/:id/manifest|toc|assets`。
|
||||
|
||||
当前入库阅读只接受 UTF-8 `text/plain`。正文按纯文本下发,客户端负责排版和转义;HTML、图片和其他富文本资源会被拒绝。章节资源第一次读取后持久化,插件禁用或卸载后仍可读取已缓存正文,尚未缓存的章节返回来源不可用。
|
||||
入库阅读支持 UTF-8 `text/plain`、`text/html` 和 `application/xhtml+xml`。纯文本由客户端转义排版;HTML 经 `sanitize-html` 白名单处理,只保留段落、标题、强调、列表、表格等静态标签。删除脚本、样式、表单、外链和 SVG。插图须声明 `reader-res:<插件资源引用>`,宿主调用同一来源 `readResource`,校验 PNG/JPEG/GIF/WebP 类型与文件签名,再内嵌 data URL。整章(含图片)原子缓存,任一资源失败不写半份缓存。禁用或卸载插件后已缓存正文和插图仍可读。
|
||||
|
||||
刷新目录通过 `POST /books/:id/refresh` 显式触发;Web 阅读器目录面板提供“刷新目录”,以稳定章节 href 恢复当前位置。获取或校验失败保留旧目录,界面在目标章节可读后才替换视图。本轮没有定时检查新章、整书预下载或后台追更任务。
|
||||
刷新目录通过 `POST /books/:id/refresh` 或订阅调度触发;阅读器目录面板以稳定 href 恢复当前位置。失败保留旧目录,界面在目标章节可读后才替换视图。`chapter_subscriptions` 持久保存开关、15~10080 分钟间隔、检查时间、失败次数和新增章提示。每分钟调度一次,单轮最多 20 本顺序检查,共用来源并发限制;手动与自动检查同书合并在途调用。失败退避,暂停来源、隐藏书籍、禁用账号时跳过,关闭浏览器不影响检查。新目录不强行替换正在阅读的旧快照,也不预下载正文。
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
@@ -286,7 +284,8 @@ error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
| `source_acquisitions` | `(source_id, user_id, entry_ref, option_id)` 到 `book_id` 的映射。 |
|
||||
| `chapter_publications` | 按用户隔离的章节出版物、来源引用、当前 revision 和目录快照。 |
|
||||
| `chapter_snapshots` | 出版物历次目录快照;刷新后保留旧 revision。 |
|
||||
| `chapter_resources` | 每个 revision 的章节引用、已缓存正文和正文 SHA-256。 |
|
||||
| `chapter_resources` | 每个 revision 的章节引用、已缓存正文、`media_type` 和正文 SHA-256;HTML 正文包含校验后的内嵌图片。旧缓存增量迁移为纯文本。 |
|
||||
| `chapter_subscriptions` | 每书追更开关、间隔、检查时间、失败退避、新增章数及配置 generation。 |
|
||||
| `plugin_storage` | 宿主 KV,按来源和用户命名空间保存;外部进程尚无反向访问接口。 |
|
||||
| `installed_plugins` | 插件 ID、已放置的包目录名及启用状态。 |
|
||||
|
||||
@@ -362,9 +361,9 @@ QUOTA_EXCEEDED 缓存或下载配额不足
|
||||
- 内置 OPDS 的网络请求经过宿主校验:允许 origin、重定向目标、Basic 凭据转发、超时和响应大小;普通 OPDS 链接不能跳到未配置的 origin。
|
||||
- 外部 Node 插件当前是受信任进程,仍拥有本机 Node 的网络、文件和子进程权限;manifest 权限声明和 `SourceContext` 可选能力尚未构成 OS 级网络沙箱。管理员只能安装信任的包。
|
||||
- 插件凭据不出现在普通配置、前端 DTO 和日志中。
|
||||
- 外部插件资源预览只返回 JSON 文本或 base64;入库章节只接受纯文本,并带 `nosniff` 和限制内容执行的 CSP。阅读器自行转义正文,不执行插件 HTML;远程富文本和图片清洗属于后续工作。
|
||||
- 资源预览只返回 JSON;阅读正文纯文本转义或 HTML 白名单清洗,并带 `nosniff`、禁止脚本和外网资源的 CSP。图片经来源通道获取后内嵌,不让浏览器直接访问书源网站。
|
||||
- 本地和已获取文件路径通过宿主 `resolveInside()` 校验;外部插件直接读写路径的能力目前依赖管理员信任,系统沙箱属于后续工作。
|
||||
- 当前硬限制是宿主来源调用 60 秒、插件 RPC 30 秒、单条 JSON 2 MiB、文件获取 256 MiB;章节目录最多 10,000 项及 2 MiB,单章纯文本最多 2 MiB(仍受 RPC 消息上限约束)。CPU/内存和域名权限的强制隔离属于后续工作。
|
||||
- 硬限制:宿主调用及整章加载 60 秒、插件 RPC 30 秒、JSON 2 MiB、文件获取 256 MiB;目录最多 10,000 项及 2 MiB,原始正文 2 MiB;最多 32 个不同图片、每图 2 MiB,内嵌后整章最多 8 MiB。图片仍受 RPC base64 开销限制。CPU/内存和域名权限的系统级隔离属于后续工作。
|
||||
- 插件安装及来源实例配置需要管理员权限;实例配置共享,凭据按用户隔离。
|
||||
|
||||
manifest 中的权限声明用于宿主授权和展示,不能单独视为安全沙箱;运行不可信插件需要容器或操作系统策略。
|
||||
@@ -393,7 +392,7 @@ GET /sources/:id/publications/:publicationRef/resource?ref=...
|
||||
POST /books/:id/refresh 获取并提交新的章节目录快照
|
||||
```
|
||||
|
||||
HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;来源浏览、搜索、获取和当前用户凭据设置需登录。来源的 URL、名称和 config 更新、来源删除、插件包上传及升级尚无接口。请求参数、响应和可运行示例见 [API 参考](api.md#来源与插件)。
|
||||
HTTP 返回宿主 DTO,凭据不回显。插件和来源配置管理需管理员权限;浏览、获取和个人凭据需登录。`PATCH /sources/:id` 更新名称/config/启停,config 变更清除所有用户的该来源凭据;有在途来源调用时拒绝变更 config。`DELETE` 只移除无获取记录的非本地来源。Web `#/sources` 根据类型 schema 绘制配置表单,普通成员只有浏览、凭据和个人追更操作。插件可声明 credentialKeys 与 credentials 权限,宿主只按调用用户向进程传递明确声明的键。包上传和在线升级尚未实现。
|
||||
|
||||
## 11. 分阶段实施
|
||||
|
||||
@@ -439,8 +438,6 @@ HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;
|
||||
|
||||
## 13. 本轮实施状态与后续工作
|
||||
|
||||
本轮交付服务端扩展接口、OPDS 下载阅读闭环和纯文本章节阅读闭环。状态截至 2026-09-20;后续条目不能视为已交付。
|
||||
|
||||
| 工作 | 本轮状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 来源契约 | 已实现并测试 | `server/src/sources/types.ts`,包括目录、获取、章节和资源。 |
|
||||
@@ -448,22 +445,22 @@ HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;
|
||||
| OPDS 解析、浏览、搜索、获取 | 已实现并测试 | OPDS 1.x/2.x、相对链接、OpenSearch、分页、Basic 认证和获取关系。 |
|
||||
| OPDS 文件下载入库 | 已实现并测试 | 同步下载,真实哈希、256 MiB 上限、失败清理、幂等复用、当前用户上架;复用现有阅读接口。 |
|
||||
| SourceHost、来源实例及凭据 | 已实现 | 实例由管理员配置共享,密码按来源/用户加密,调用预算 60 秒;并发限额每实例 2、全宿主 8。 |
|
||||
| 来源管理 HTTP 接口 | 已实现并测试 | 创建、列表、启停和来源调用;仅 `enabled` 可更新,不含前端配置页面。 |
|
||||
| 来源管理与配置 UI | 已实现并测试 | 创建、编辑、启停、删除空来源、浏览/搜索、获取及个人凭据。 |
|
||||
| 内置 local 与本地内容 seam | 已实现并测试 | 保留现有 book ID、书架及阅读接口,集中本地与下载文件解析。 |
|
||||
| 受信任 Node ProcessRunner | 已实现并测试 | JSON-RPC、30 秒调用预算、硬取消、异常退出、2 MiB 消息上限。 |
|
||||
| 插件安装、启停、卸载与启动恢复 | 已实现并测试 | 包由管理员预置到 `DATA_DIR/plugins`;卸载保留数据和包文件。 |
|
||||
| 最小外部来源示例 | 已实现并测试 | `examples/plugins/demo-chapters`,提供静态示例目录和正文,不联网。 |
|
||||
| 插件安装 UI、包上传、在线升级与状态回滚 | 后续 | 本轮不承诺完整插件市场和包管理。 |
|
||||
| 插件安装 UI | 已实现 | 安装预部署目录、启停、重启失败进程、卸载;包上传、在线升级和回滚仍后续。 |
|
||||
| 后台下载任务、断点恢复、JobManager | 后续 | 当前完整文件会持久化,下载过程本身没有后台任务及恢复机制。 |
|
||||
| 外部章节写入书架与阅读 | 已实现并测试 | 私有稳定 book ID、现有 manifest/items/toc/assets、书架、继续阅读和进度。 |
|
||||
| 目录快照与服务端章节缓存 | 已实现并测试 | 稳定 href、revision 资源引用、已读缓存、失败保留旧目录、禁用/卸载/重启后缓存可读。 |
|
||||
| Web 章节阅读与设备离线缓存 | 已实现 | 目录与已读章节按服务器和用户持久缓存;断网或服务端 5xx 时回退;授权及资源不存在错误不回退。 |
|
||||
| 手动刷新目录 | 已实现 | 阅读器目录面板入口;恢复当前章节位置;无后台追更。 |
|
||||
| 远程富文本、图片、整书预缓存、缓存配额与淘汰 | 后续 | 当前只支持 UTF-8 纯文本,设备仅已读章节可离线。 |
|
||||
| 自动检查新章与追更任务 | 后续 | 当前只有显式刷新 API 和阅读器按钮。 |
|
||||
| 反向宿主能力 RPC、凭据和配额强制隔离 | 后续 | 类型预留不等于运行策略已生效。 |
|
||||
| 手动刷新目录 | 已实现 | 阅读器目录面板入口,恢复当前章节位置,记录检查状态。 |
|
||||
| 远程富文本与图片 | 已实现并测试 | 白名单 HTML、受控栅格图片、服务端与设备整章缓存;设备仅已读内容可离线。 |
|
||||
| 整书预缓存、缓存配额与淘汰 | 后续 | 保留历史目录与缓存,无自动清理配额。 |
|
||||
| 自动追更 | 已实现并测试 | 用户按书订阅,服务端定时检查、持久状态、失败退避与新增章提示。 |
|
||||
| 声明凭据注入 | 已实现并测试 | 每次调用仅传递获准声明键,按用户和来源隔离。 |
|
||||
| 反向宿主能力 RPC、系统权限隔离和缓存配额 | 后续 | 类型预留不等于运行策略已生效。 |
|
||||
| 容器/操作系统沙箱 | 后续 | 首个版本仅允许运行管理员信任的插件。 |
|
||||
|
||||
验证覆盖来源、插件进程、管理接口、OPDS 下载、用户隔离、并发限额、参数校验与既有本地阅读回归。章节端到端测试使用真实 Node 进程和可变目录 fixture,覆盖获取幂等、插章定位、版本缓存、旧快照拒绝、刷新失败、禁用/卸载/扫描/重启和 HTML 资源拒绝;核心测试另覆盖迁移、请求与刷新并发及资源边界。测试均使用本地 HTTP fixture 或示例插件,未验证特定公网 OPDS 服务或第三方采集站点。
|
||||
|
||||
本轮验证结果:服务端 `npm test` 274 通过、1 项因 Windows 不支持 Unix 目录权限位而跳过;Web `npm test` 547 通过;两端类型检查和生产构建通过。真实 Chrome 验证了刷新按钮显示、插入章节后保留当前章节及新增章节提示。全页 UI 评审另有两项未通过(沉浸分页顶行截断、书架操作按钮透明度),未纳入本轮修复;Android 安装包未构建。
|
||||
本轮验证结果:服务端全量 `npm test` 281 通过、1 项因 Windows 不支持 Unix 目录权限位而跳过,包含旧缓存媒体类型迁移回归。Web `npm test` 552 通过;两端类型检查和生产构建通过。真实 Chrome 检查了书架入口、配置表单、追更列表及插件信任勾选,在 390×844 手机和 1280×900 桌面布局下没有横向溢出。本轮未重跑全页 UI 评审;上一轮记录的沉浸分页顶行截断、书架操作按钮透明度问题仍未纳入修复。Android 安装包未构建。
|
||||
Generated
+349
-1
@@ -14,10 +14,12 @@
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"fastify": "^5.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pino": "^9.6.0"
|
||||
"pino": "^9.6.0",
|
||||
"sanitize-html": "^2.17.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
@@ -638,6 +640,121 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -775,6 +892,12 @@
|
||||
"ts-type": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.0.0.tgz",
|
||||
@@ -787,6 +910,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -796,6 +928,85 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/epub2": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/epub2/-/epub2-3.0.2.tgz",
|
||||
@@ -852,6 +1063,18 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
@@ -1018,6 +1241,28 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -1039,6 +1284,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -1082,6 +1336,15 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -1134,6 +1397,24 @@
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
@@ -1149,6 +1430,18 @@
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
@@ -1186,6 +1479,34 @@
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -1309,6 +1630,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmmirror.com/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
@@ -1367,6 +1706,15 @@
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -21,10 +21,12 @@
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"fastify": "^5.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pino": "^9.6.0"
|
||||
"pino": "^9.6.0",
|
||||
"sanitize-html": "^2.17.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export const MIGRATIONS_SQL = `
|
||||
ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE books ADD COLUMN content_hash_kind TEXT NOT NULL DEFAULT 'file';
|
||||
ALTER TABLE chapter_resources ADD COLUMN media_type TEXT NOT NULL DEFAULT 'text/plain';
|
||||
`;
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
@@ -162,6 +163,7 @@ CREATE TABLE IF NOT EXISTS chapter_resources (
|
||||
revision TEXT NOT NULL,
|
||||
chapter_id TEXT NOT NULL,
|
||||
provider_ref TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL DEFAULT 'text/plain; charset=utf-8',
|
||||
body TEXT,
|
||||
content_hash TEXT,
|
||||
PRIMARY KEY (book_id, revision, chapter_id),
|
||||
@@ -171,6 +173,19 @@ CREATE TABLE IF NOT EXISTS plugin_storage (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chapter_subscriptions (
|
||||
book_id TEXT PRIMARY KEY REFERENCES chapter_publications(book_id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
interval_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
next_check_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_check_at INTEGER,
|
||||
last_success_at INTEGER,
|
||||
last_error TEXT,
|
||||
failures INTEGER NOT NULL DEFAULT 0,
|
||||
new_chapters INTEGER NOT NULL DEFAULT 0,
|
||||
generation INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapter_due ON chapter_subscriptions(enabled, next_check_at);
|
||||
CREATE TABLE IF NOT EXISTS installed_plugins (
|
||||
plugin_id TEXT PRIMARY KEY,
|
||||
folder TEXT NOT NULL,
|
||||
|
||||
@@ -325,7 +325,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
|
||||
if (ctx.sources?.chapters.has(id)) {
|
||||
const payload = await withSignal(request, reply, (signal) => ctx.sources!.chapters.asset(user.id, id, ref, signal));
|
||||
reply.header('x-content-type-options', 'nosniff');
|
||||
reply.header('content-security-policy', "default-src 'none'; sandbox");
|
||||
reply.header('content-security-policy', "default-src 'none'; img-src data:; sandbox");
|
||||
return sendAssetPayload(request, reply, payload);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,15 @@ function parameter(value: unknown, name: string, required = false): string | und
|
||||
export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
const auth = authenticate(ctx);
|
||||
const host = ctx.sources ??= new SourceHost(ctx.db, ctx.config, () => ctx.shelf, app.log);
|
||||
app.addHook('onReady', () => host.plugins.loadInstalled());
|
||||
app.addHook('onClose', () => host.plugins.close());
|
||||
app.addHook('onReady', async () => { await host.plugins.loadInstalled(); host.updates.start(); });
|
||||
app.addHook('onClose', async () => { await host.updates.stop(); await host.plugins.close(); });
|
||||
|
||||
app.get('/api/v1/subscriptions', { preHandler: auth }, async (request) => ({ subscriptions: host.updates.list(currentUser(request).id) }));
|
||||
app.patch('/api/v1/books/:id/subscription', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request); const { id } = request.params as { id: string };
|
||||
ctx.shelf.get(user.id, id);
|
||||
return { subscription: host.updates.configure(user.id, id, (request.body ?? {}) as Record<string, unknown>) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/sources/types', { preHandler: auth }, async () => ({
|
||||
types: host.registry.list().map(({ pluginId, builtin, provider }) => ({
|
||||
@@ -50,11 +57,16 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
|
||||
app.patch('/api/v1/sources/:id', { preHandler: auth }, async (request) => {
|
||||
requireAdmin(request);
|
||||
const { id } = request.params as { id: string };
|
||||
const enabled = (request.body as Record<string, unknown> | null)?.enabled;
|
||||
if (typeof enabled !== 'boolean') throw badRequest('enabled must be boolean');
|
||||
host.setEnabled(id, enabled);
|
||||
const patch = (request.body ?? {}) as Record<string, unknown>;
|
||||
if (!['name', 'config', 'enabled'].some((key) => key in patch)) throw badRequest('no source changes provided');
|
||||
await host.update(id, patch);
|
||||
return { source: host.list(true).find((source) => source.id === id) };
|
||||
});
|
||||
app.delete('/api/v1/sources/:id', { preHandler: auth }, async (request) => {
|
||||
requireAdmin(request);
|
||||
host.remove((request.params as { id: string }).id);
|
||||
return { ok: true };
|
||||
});
|
||||
app.put('/api/v1/sources/:id/credentials/:key', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
const { id, key } = request.params as { id: string; key: string };
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Db } from '../db/index.ts';
|
||||
import type { AssetPayload, Manifest } from '../indexer/formats/registry.ts';
|
||||
import { AppError, badRequest, conflict, notFound } from '../lib/errors.ts';
|
||||
import { decodeManifest } from '../sources/protocol.ts';
|
||||
import { chapterMedia, richContent } from './rich-content.ts';
|
||||
import type {
|
||||
AcquireRequest, CatalogEntry, ManifestSnapshot, ResourceResponse, SourceContext, SourceProvider,
|
||||
} from '../sources/types.ts';
|
||||
@@ -27,6 +28,7 @@ interface PublicationRow {
|
||||
|
||||
interface ResourceRow {
|
||||
provider_ref: string;
|
||||
media_type: string;
|
||||
body: string | null;
|
||||
content_hash: string | null;
|
||||
}
|
||||
@@ -35,16 +37,7 @@ function hash(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isPlainUtf8(type: string): boolean {
|
||||
const [mediaType, ...parameters] = type.split(';');
|
||||
if (mediaType?.trim().toLowerCase() !== 'text/plain') return false;
|
||||
return parameters.every((parameter) => {
|
||||
const [name, value] = parameter.split('=');
|
||||
return name?.trim().toLowerCase() !== 'charset' || /^(?:utf-8|utf8)$/i.test((value ?? '').trim().replace(/^"|"$/g, ''));
|
||||
});
|
||||
}
|
||||
|
||||
/** Stable reader identities, versioned directories and cached plain-text chapters. */
|
||||
/** Stable reader identities, versioned directories and passive cached chapters. */
|
||||
export class ChapterPublications {
|
||||
private readonly queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -58,7 +51,7 @@ export class ChapterPublications {
|
||||
const publication = this.owned(userId, bookId);
|
||||
const snapshot = JSON.parse(publication.snapshot_json) as ManifestSnapshot;
|
||||
return {
|
||||
kind: 'text', revision: publication.revision, total: snapshot.items.length,
|
||||
kind: snapshot.items.some((item) => chapterMedia(item.mediaType).startsWith('text/html')) ? 'reflowable' : 'text', revision: publication.revision, total: snapshot.items.length,
|
||||
groups: snapshot.items.length ? [{ id: 'chapters', seq: 0, title: '章节', count: snapshot.items.length, offset: 0 }] : [],
|
||||
items: snapshot.items.map((item, seq) => {
|
||||
const id = hash(item.id);
|
||||
@@ -67,7 +60,8 @@ export class ChapterPublications {
|
||||
resourceRef: `chapter-resource:${publication.revision}:${id}`,
|
||||
// The TXT reader typesets plain characters itself; never label a
|
||||
// plugin's characters as HTML or run a markup parser over them.
|
||||
mediaType: 'text/plain; charset=utf-8', format: 'html',
|
||||
mediaType: chapterMedia(item.mediaType),
|
||||
...(chapterMedia(item.mediaType).startsWith('text/plain') ? { format: 'html' as const } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -136,6 +130,7 @@ export class ChapterPublications {
|
||||
}
|
||||
|
||||
asset(userId: string, bookId: string, ref: string, signal?: AbortSignal): Promise<AssetPayload> {
|
||||
signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000);
|
||||
this.owned(userId, bookId);
|
||||
const match = /^chapter-resource:([a-f0-9]{64}):([a-f0-9]{64})$/.exec(ref);
|
||||
if (!match) return Promise.reject(badRequest('chapter assets require the manifest resourceRef', 'INVALID_RESOURCE'));
|
||||
@@ -144,14 +139,25 @@ export class ChapterPublications {
|
||||
signal?.throwIfAborted();
|
||||
const publication = this.owned(userId, bookId);
|
||||
const resource = this.db.get<ResourceRow>(
|
||||
'SELECT provider_ref, body, content_hash FROM chapter_resources WHERE book_id = ? AND revision = ? AND chapter_id = ?',
|
||||
'SELECT provider_ref, media_type, body, content_hash FROM chapter_resources WHERE book_id = ? AND revision = ? AND chapter_id = ?',
|
||||
bookId, revision!, chapterId!,
|
||||
);
|
||||
if (!resource) throw notFound('chapter resource does not exist', 'RESOURCE_GONE');
|
||||
if (resource.body !== null) return this.payload(resource.body, resource.content_hash!);
|
||||
resource.media_type = chapterMedia(resource.media_type);
|
||||
if (resource.body !== null) return this.payload(resource.body, resource.content_hash!, resource.media_type);
|
||||
this.requireCurrent(publication, revision!);
|
||||
const response = await this.loader.resource(userId, publication.source_id, publication.publication_ref, resource.provider_ref, signal);
|
||||
const body = this.readBody(response, signal);
|
||||
if (chapterMedia(response.mediaType) !== resource.media_type) {
|
||||
response.stream?.destroy();
|
||||
throw badRequest('chapter resource type differs from its manifest', 'INVALID_RESOURCE');
|
||||
}
|
||||
let body = this.readBody(response, signal);
|
||||
if (resource.media_type.startsWith('text/html')) {
|
||||
body = await richContent(body, (imageRef) => {
|
||||
signal?.throwIfAborted();
|
||||
return this.loader.resource(userId, publication.source_id, publication.publication_ref, imageRef, signal);
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
// The provider protocol cannot request an old version. If a refresh won
|
||||
// while this request was in flight, never cache new bytes as old content.
|
||||
@@ -161,7 +167,7 @@ export class ChapterPublications {
|
||||
'UPDATE chapter_resources SET body = ?, content_hash = ? WHERE book_id = ? AND revision = ? AND chapter_id = ?',
|
||||
body, contentHash, bookId, revision!, chapterId!,
|
||||
);
|
||||
return this.payload(body, contentHash);
|
||||
return this.payload(body, contentHash, resource.media_type);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,15 +195,14 @@ export class ChapterPublications {
|
||||
throw badRequest('invalid chapter directory version', 'INVALID_MANIFEST');
|
||||
}
|
||||
for (const item of snapshot.items) {
|
||||
if (item.kind !== 'chapter' || !isPlainUtf8(item.mediaType)) {
|
||||
throw badRequest('chapter publications currently support UTF-8 plain text only', 'UNSUPPORTED_FORMAT');
|
||||
}
|
||||
if (item.kind !== 'chapter') throw badRequest('chapter publications require chapter items', 'UNSUPPORTED_FORMAT');
|
||||
chapterMedia(item.mediaType);
|
||||
}
|
||||
return {
|
||||
publicationRef,
|
||||
...(snapshot.version === undefined ? {} : { version: snapshot.version }),
|
||||
items: [...snapshot.items].sort((left, right) => left.seq - right.seq).map((item, seq) => ({
|
||||
id: item.id, seq, title: item.title, kind: 'chapter', mediaType: 'text/plain; charset=utf-8', ref: item.ref,
|
||||
id: item.id, seq, title: item.title, kind: 'chapter', mediaType: chapterMedia(item.mediaType), ref: item.ref,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -209,8 +214,8 @@ export class ChapterPublications {
|
||||
);
|
||||
for (const item of snapshot.items) {
|
||||
this.db.run(
|
||||
'INSERT OR IGNORE INTO chapter_resources (book_id, revision, chapter_id, provider_ref) VALUES (?, ?, ?, ?)',
|
||||
bookId, revision, hash(item.id), item.ref,
|
||||
'INSERT OR IGNORE INTO chapter_resources (book_id, revision, chapter_id, provider_ref, media_type) VALUES (?, ?, ?, ?, ?)',
|
||||
bookId, revision, hash(item.id), item.ref, chapterMedia(item.mediaType),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +237,7 @@ export class ChapterPublications {
|
||||
private readBody(resource: ResourceResponse, signal?: AbortSignal): string {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
if (!isPlainUtf8(resource.mediaType)) throw badRequest('chapter resource must be UTF-8 plain text', 'INVALID_RESOURCE');
|
||||
chapterMedia(resource.mediaType);
|
||||
const bodies = Number(resource.text !== undefined) + Number(resource.data !== undefined) + Number(resource.stream !== undefined);
|
||||
if (bodies !== 1) throw badRequest('chapter resource must contain exactly one body', 'INVALID_RESOURCE');
|
||||
// Chapter RPCs are bounded messages. A stream returned after the host call
|
||||
@@ -254,9 +259,9 @@ export class ChapterPublications {
|
||||
}
|
||||
}
|
||||
|
||||
private payload(body: string, contentHash: string): AssetPayload {
|
||||
private payload(body: string, contentHash: string, contentType = 'text/plain; charset=utf-8'): AssetPayload {
|
||||
const data = Buffer.from(body, 'utf8');
|
||||
return { data, contentType: 'text/plain; charset=utf-8', size: data.byteLength, etag: contentHash };
|
||||
return { data, contentType, size: data.byteLength, etag: contentHash };
|
||||
}
|
||||
|
||||
private serial<T>(key: string, action: () => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import sanitize from 'sanitize-html';
|
||||
import { badRequest, AppError } from '../lib/errors.ts';
|
||||
import type { ResourceResponse } from '../sources/types.ts';
|
||||
|
||||
export const MAX_RICH_BYTES = 8 * 1024 * 1024;
|
||||
const TAGS = ['p', 'div', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote',
|
||||
'strong', 'b', 'em', 'i', 'u', 's', 'sub', 'sup', 'ruby', 'rt', 'rp', 'pre', 'code', 'ul', 'ol', 'li',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'figure', 'figcaption', 'img'];
|
||||
export function chapterMedia(type: string): string {
|
||||
const [media = '', ...params] = type.toLowerCase().split(';');
|
||||
if (!['text/plain', 'text/html', 'application/xhtml+xml'].includes(media.trim()) ||
|
||||
params.some((p) => /^charset\s*=/i.test(p.trim()) && !/^charset\s*=\s*"?utf-?8"?$/i.test(p.trim()))) {
|
||||
throw badRequest('chapter content must be UTF-8 text or HTML', 'UNSUPPORTED_FORMAT');
|
||||
}
|
||||
return media.trim() === 'text/plain' ? 'text/plain; charset=utf-8' : 'text/html; charset=utf-8';
|
||||
}
|
||||
|
||||
/** Only passive raster images are embedded. No URLs from a plugin reach the browser. */
|
||||
export function raster(resource: ResourceResponse): { type: string; bytes: Buffer } {
|
||||
resource.stream?.destroy();
|
||||
if (!resource.data || resource.text !== undefined || resource.stream || resource.data.byteLength > 2 * 1024 * 1024) {
|
||||
throw badRequest('image must contain bounded binary data', 'INVALID_RESOURCE');
|
||||
}
|
||||
const bytes = Buffer.from(resource.data);
|
||||
const type = resource.mediaType.split(';')[0]!.trim().toLowerCase();
|
||||
const valid = (type === 'image/png' && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) ||
|
||||
(type === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) ||
|
||||
(type === 'image/gif' && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString())) ||
|
||||
(type === 'image/webp' && bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP');
|
||||
if (!valid) throw badRequest('only PNG, JPEG, GIF and WebP images with matching bytes are supported', 'INVALID_RESOURCE');
|
||||
return { type, bytes };
|
||||
}
|
||||
|
||||
export async function richContent(html: string, read: (ref: string) => Promise<ResourceResponse>): Promise<string> {
|
||||
const references = new Set<string>();
|
||||
const cleaned = sanitize(html, {
|
||||
allowedTags: TAGS,
|
||||
allowedAttributes: { img: ['src', 'alt'], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'] },
|
||||
allowedSchemes: ['reader-res'], allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
img: (_tag, attrs): sanitize.Tag => {
|
||||
const src = attrs.src ?? '';
|
||||
if (!src.startsWith('reader-res:') || src.length > 16_384 || src.length === 11) {
|
||||
return { tagName: 'span', attribs: {}, text: attrs.alt || '[图片不可用]' };
|
||||
}
|
||||
references.add(src.slice(11));
|
||||
return { tagName: 'img', attribs: { src, alt: attrs.alt ?? '' } };
|
||||
},
|
||||
},
|
||||
});
|
||||
if (references.size > 32) throw new AppError(413, 'CHAPTER_TOO_LARGE', 'chapter has more than 32 images');
|
||||
const images = new Map<string, string>();
|
||||
let size = Buffer.byteLength(cleaned);
|
||||
for (const ref of references) {
|
||||
const { type, bytes } = raster(await read(ref));
|
||||
const url = `data:${type};base64,${bytes.toString('base64')}`;
|
||||
size += url.length;
|
||||
if (size > MAX_RICH_BYTES) throw new AppError(413, 'CHAPTER_TOO_LARGE', 'chapter and images exceed 8 MiB');
|
||||
images.set(`reader-res:${ref}`, url);
|
||||
}
|
||||
const result = sanitize(cleaned, {
|
||||
allowedTags: TAGS, allowedAttributes: { '*': ['colspan', 'rowspan'], img: ['src', 'alt'] },
|
||||
allowedSchemes: ['data'], allowedSchemesByTag: { img: ['data'] },
|
||||
transformTags: { img: (_tag, attrs) => ({ tagName: 'img', attribs: { ...attrs, src: images.get(attrs.src ?? '') ?? '' } }) },
|
||||
});
|
||||
if (Buffer.byteLength(result) > MAX_RICH_BYTES) throw new AppError(413, 'CHAPTER_TOO_LARGE', 'expanded chapter exceeds 8 MiB');
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { ChapterPublications } from '../publications/chapters.ts';
|
||||
import { AppError, badRequest } from '../lib/errors.ts';
|
||||
import type { Manifest } from '../indexer/formats/registry.ts';
|
||||
|
||||
interface Subscription {
|
||||
bookId: string; userId: string; enabled: number; intervalMinutes: number;
|
||||
nextCheckAt: number; lastCheckAt: number | null; lastSuccessAt: number | null;
|
||||
lastError: string | null; failures: number; newChapters: number; generation: number;
|
||||
}
|
||||
const FIELDS = `p.book_id AS bookId, p.user_id AS userId, COALESCE(s.enabled,0) AS enabled,
|
||||
COALESCE(s.interval_minutes,60) AS intervalMinutes, COALESCE(s.next_check_at,0) AS nextCheckAt,
|
||||
s.last_check_at AS lastCheckAt, s.last_success_at AS lastSuccessAt, s.last_error AS lastError,
|
||||
COALESCE(s.failures,0) AS failures, COALESCE(s.new_chapters,0) AS newChapters, COALESCE(s.generation,0) AS generation`;
|
||||
|
||||
/** Durable opt-in subscriptions. One bounded sequential worker shares SourceHost's call budget. */
|
||||
export class ChapterUpdates {
|
||||
private timer?: NodeJS.Timeout;
|
||||
private active?: Promise<void>;
|
||||
private controller = new AbortController();
|
||||
private readonly checks = new Map<string, Promise<Manifest>>();
|
||||
constructor(private readonly db: Db, private readonly chapters: ChapterPublications, private readonly now = Date.now) {}
|
||||
|
||||
list(userId: string) {
|
||||
return this.db.all<Subscription & { title: string }>(`SELECT ${FIELDS}, b.title FROM chapter_publications p
|
||||
JOIN books b ON b.id = p.book_id JOIN user_books u ON u.book_id = p.book_id AND u.user_id = p.user_id AND u.hidden = 0
|
||||
LEFT JOIN chapter_subscriptions s ON s.book_id = p.book_id WHERE p.user_id = ? ORDER BY b.title`, userId)
|
||||
.map(({ userId: _user, generation: _generation, ...row }) => ({ ...row, enabled: row.enabled === 1 }));
|
||||
}
|
||||
|
||||
configure(userId: string, bookId: string, input: { enabled?: unknown; intervalMinutes?: unknown; acknowledge?: unknown }) {
|
||||
this.chapters.manifest(userId, bookId);
|
||||
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw badRequest('enabled must be boolean');
|
||||
if (input.acknowledge !== undefined && typeof input.acknowledge !== 'boolean') throw badRequest('acknowledge must be boolean');
|
||||
if (input.intervalMinutes !== undefined && (!Number.isSafeInteger(input.intervalMinutes) || Number(input.intervalMinutes) < 15 || Number(input.intervalMinutes) > 10080)) {
|
||||
throw badRequest('intervalMinutes must be an integer between 15 and 10080');
|
||||
}
|
||||
this.db.transaction(() => {
|
||||
this.db.run('INSERT OR IGNORE INTO chapter_subscriptions (book_id) VALUES (?)', bookId);
|
||||
if (input.enabled !== undefined || input.intervalMinutes !== undefined) {
|
||||
this.db.run(`UPDATE chapter_subscriptions SET enabled = COALESCE(?,enabled), interval_minutes = COALESCE(?,interval_minutes),
|
||||
next_check_at = ?, failures = 0, last_error = NULL, generation = generation + 1 WHERE book_id = ?`,
|
||||
input.enabled === undefined ? null : Number(input.enabled), input.intervalMinutes === undefined ? null : Number(input.intervalMinutes), this.now(), bookId);
|
||||
}
|
||||
if (input.acknowledge === true) this.db.run('UPDATE chapter_subscriptions SET new_chapters = 0 WHERE book_id = ?', bookId);
|
||||
});
|
||||
return this.list(userId).find((row) => row.bookId === bookId);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.controller = new AbortController();
|
||||
this.timer = setInterval(() => { void this.runDue().catch(() => undefined); }, 60_000);
|
||||
this.timer.unref();
|
||||
void this.runDue().catch(() => undefined);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
clearInterval(this.timer); this.timer = undefined;
|
||||
this.controller.abort();
|
||||
await this.active;
|
||||
}
|
||||
|
||||
runDue(): Promise<void> {
|
||||
if (this.active) return this.active;
|
||||
this.active = this.run().finally(() => { this.active = undefined; });
|
||||
return this.active;
|
||||
}
|
||||
|
||||
check(userId: string, bookId: string, signal?: AbortSignal): Promise<Manifest> {
|
||||
this.chapters.manifest(userId, bookId);
|
||||
const pending = this.checks.get(bookId);
|
||||
if (pending) return pending;
|
||||
const action = this.performCheck(userId, bookId, signal);
|
||||
this.checks.set(bookId, action);
|
||||
void action.finally(() => { if (this.checks.get(bookId) === action) this.checks.delete(bookId); }).catch(() => undefined);
|
||||
return action;
|
||||
}
|
||||
|
||||
private async performCheck(userId: string, bookId: string, signal?: AbortSignal): Promise<Manifest> {
|
||||
this.db.run('INSERT OR IGNORE INTO chapter_subscriptions (book_id) VALUES (?)', bookId);
|
||||
const item = this.db.get<Subscription>(`SELECT ${FIELDS} FROM chapter_publications p
|
||||
JOIN chapter_subscriptions s ON s.book_id = p.book_id WHERE p.book_id = ?`, bookId)!;
|
||||
this.db.run('UPDATE chapter_subscriptions SET next_check_at = ?, last_check_at = ? WHERE book_id = ?', this.now() + 300_000, this.now(), bookId);
|
||||
try {
|
||||
const old = new Set(this.chapters.manifest(userId, bookId).items.map((entry) => entry.href));
|
||||
const fresh = await this.chapters.refresh(userId, bookId, signal);
|
||||
const added = fresh.items.filter((entry) => !old.has(entry.href)).length;
|
||||
this.db.run(`UPDATE chapter_subscriptions SET last_success_at = ?, next_check_at = ?, last_error = NULL,
|
||||
failures = 0, new_chapters = new_chapters + ? WHERE book_id = ? AND generation = ?`,
|
||||
this.now(), this.now() + item.intervalMinutes * 60_000, added, bookId, item.generation);
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
const delay = Math.min(10080, Math.max(item.intervalMinutes, Math.min(1440, 15 * 2 ** Math.min(item.failures, 7))));
|
||||
const code = error instanceof AppError && /^[A-Z_]{1,80}$/.test(error.code) ? error.code : 'UPDATE_FAILED';
|
||||
this.db.run('UPDATE chapter_subscriptions SET next_check_at = ?, last_error = ?, failures = failures + 1 WHERE book_id = ? AND generation = ?',
|
||||
this.now() + delay * 60_000, code, bookId, item.generation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const due = this.db.all<Subscription>(`SELECT ${FIELDS} FROM chapter_publications p
|
||||
JOIN chapter_subscriptions s ON s.book_id = p.book_id
|
||||
JOIN source_instances i ON i.id = p.source_id AND i.enabled = 1
|
||||
JOIN users a ON a.id = p.user_id AND a.disabled = 0
|
||||
JOIN user_books u ON u.book_id = p.book_id AND u.user_id = p.user_id AND u.hidden = 0
|
||||
WHERE s.enabled = 1 AND s.next_check_at <= ? ORDER BY s.next_check_at LIMIT 20`, this.now());
|
||||
for (const item of due) {
|
||||
if (this.controller.signal.aborted) return;
|
||||
const eligible = this.db.get('SELECT book_id FROM chapter_subscriptions WHERE book_id = ? AND enabled = 1 AND generation = ? AND next_check_at <= ?', item.bookId, item.generation, this.now());
|
||||
if (!eligible) continue;
|
||||
try {
|
||||
await this.check(item.userId, item.bookId, this.controller.signal);
|
||||
} catch {
|
||||
if (this.controller.signal.aborted) return;
|
||||
// Failure is recorded per book; other subscriptions still get a turn.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { SourceContext, SourceInstance, SourceProvider, SourceStorage, Cred
|
||||
import { FileAcquisitions } from '../sources/acquisitions.ts';
|
||||
import { PluginManager } from '../sources/plugin-manager.ts';
|
||||
import { ChapterPublications } from '../publications/chapters.ts';
|
||||
import { ChapterUpdates } from './chapter-updates.ts';
|
||||
|
||||
function json(value: unknown): string { return JSON.stringify(value ?? {}); }
|
||||
function parse(value: string): unknown { try { return JSON.parse(value); } catch { return {}; } }
|
||||
@@ -20,6 +21,7 @@ export class SourceHost {
|
||||
readonly registry = new SourceRegistry();
|
||||
readonly plugins: PluginManager;
|
||||
readonly chapters: ChapterPublications;
|
||||
readonly updates: ChapterUpdates;
|
||||
private readonly key: Buffer;
|
||||
private readonly acquisitions: FileAcquisitions;
|
||||
private activeCalls = 0;
|
||||
@@ -31,10 +33,11 @@ export class SourceHost {
|
||||
this.chapters = new ChapterPublications(db, {
|
||||
manifest: (userId, sourceId, ref, signal) => this.manifest(userId, sourceId, ref, signal),
|
||||
resource: (userId, sourceId, publicationRef, ref, signal) => this.call(userId, sourceId, (provider, ctx) => provider.readResource
|
||||
? provider.readResource(ctx, { publicationRef, ref, rendition: 'text' })
|
||||
? provider.readResource(ctx, { publicationRef, ref })
|
||||
: Promise.reject(badRequest('source has no chapter resources', 'SOURCE_UNSUPPORTED')), signal),
|
||||
});
|
||||
this.registry.registerBuiltin('reader.local', createLocalProvider(db, shelf));
|
||||
this.updates = new ChapterUpdates(db, this.chapters);
|
||||
this.registry.registerBuiltin('reader.opds', createOpdsProvider());
|
||||
this.plugins = new PluginManager(db, config.dataDir, this.registry);
|
||||
this.ensureLocal();
|
||||
@@ -84,6 +87,39 @@ export class SourceHost {
|
||||
this.db.run('UPDATE source_instances SET enabled = ? WHERE id = ?', enabled ? 1 : 0, id);
|
||||
}
|
||||
|
||||
async update(id: string, patch: { name?: unknown; config?: unknown; enabled?: unknown }): Promise<void> {
|
||||
const row = this.instance(id);
|
||||
const name = patch.name ?? row.name;
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 128) throw badRequest('invalid source name');
|
||||
if (patch.enabled !== undefined && typeof patch.enabled !== 'boolean') throw badRequest('enabled must be boolean');
|
||||
const configuration = patch.config === undefined ? row.config_json : json(patch.config);
|
||||
if (configuration.length > 64 * 1024) throw badRequest('source configuration is too large');
|
||||
if (patch.config !== undefined) {
|
||||
const registration = this.registry.get(row.plugin_id, row.source_type);
|
||||
if (!registration) throw notFound('source type is unavailable', 'SOURCE_TYPE_NOT_FOUND');
|
||||
await registration.provider.validateConfig?.(patch.config);
|
||||
if (this.registry.get(row.plugin_id, row.source_type) !== registration) throw conflict('plugin changed; retry');
|
||||
}
|
||||
if (this.instance(id).config_json !== row.config_json) throw conflict('configuration changed; reload and retry');
|
||||
if (configuration !== row.config_json && (this.activeSources.get(id) ?? 0) > 0) throw conflict('source is busy; retry configuration when current calls finish', 'SOURCE_BUSY');
|
||||
this.db.transaction(() => {
|
||||
this.db.run('UPDATE source_instances SET name = ?, config_json = ?, enabled = ? WHERE id = ?',
|
||||
name.trim(), configuration, patch.enabled === undefined ? row.enabled : Number(patch.enabled), id);
|
||||
// Changing a destination must not forward existing users' secrets to it.
|
||||
if (configuration !== row.config_json) this.db.run('DELETE FROM source_credentials WHERE source_id = ?', id);
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
const row = this.instance(id);
|
||||
if (row.plugin_id === 'reader.local') throw badRequest('the built-in local source cannot be removed');
|
||||
if (this.db.get('SELECT book_id FROM source_acquisitions WHERE source_id = ? LIMIT 1', id) ||
|
||||
this.db.get('SELECT book_id FROM chapter_publications WHERE source_id = ? LIMIT 1', id)) {
|
||||
throw conflict('source has acquired books; disable it instead', 'SOURCE_IN_USE');
|
||||
}
|
||||
this.db.run('DELETE FROM source_instances WHERE id = ?', id);
|
||||
}
|
||||
|
||||
setCredential(sourceId: string, userId: string, name: string, value: string): void {
|
||||
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(name) || value.length > 16_384) throw badRequest('invalid credential');
|
||||
this.instance(sourceId);
|
||||
@@ -130,7 +166,7 @@ export class SourceHost {
|
||||
}
|
||||
|
||||
refreshPublication(userId: string, bookId: string, signal?: AbortSignal) {
|
||||
return this.chapters.refresh(userId, bookId, signal);
|
||||
return this.updates.check(userId, bookId, signal);
|
||||
}
|
||||
|
||||
async manifest(userId: string, id: string, ref: string, signal?: AbortSignal) {
|
||||
|
||||
@@ -342,6 +342,7 @@ export function createOpdsProvider(options: OpdsProviderOptions = {}): SourcePro
|
||||
descriptor: {
|
||||
id: 'opds', label: 'OPDS', version: '1.0.0',
|
||||
capabilities: ['browse', 'search', 'detail', 'acquire.file'],
|
||||
credentialKeys: [{ key: 'password', label: 'OPDS 密码' }],
|
||||
configSchema: {
|
||||
type: 'object', required: ['url'], additionalProperties: false,
|
||||
properties: {
|
||||
|
||||
@@ -89,9 +89,18 @@ export class ProcessPlugin {
|
||||
|
||||
providers(): readonly SourceProvider[] {
|
||||
return this.manifest.sourceTypes.map((type) => {
|
||||
const context = (ctx: SourceContext) => ({ instance: ctx.instance, userId: ctx.userId });
|
||||
const call = <T>(method: string, ctx: SourceContext, args: Record<string, unknown>): Promise<T> =>
|
||||
this.request(method, { sourceType: type.id, context: context(ctx), ...args }, ctx.signal) as Promise<T>;
|
||||
const call = async <T>(method: string, ctx: SourceContext, args: Record<string, unknown>): Promise<T> => {
|
||||
const credentials: Record<string, string> = Object.create(null);
|
||||
if (this.manifest.permissions?.credentials) {
|
||||
for (const { key } of type.credentialKeys ?? []) {
|
||||
const value = await ctx.credentials?.get(key, ctx.signal);
|
||||
if (value !== undefined) credentials[key] = value;
|
||||
}
|
||||
}
|
||||
return this.request(method, {
|
||||
sourceType: type.id, context: { instance: ctx.instance, userId: ctx.userId, credentials }, ...args,
|
||||
}, ctx.signal) as Promise<T>;
|
||||
};
|
||||
const provider: SourceProvider = {
|
||||
descriptor: { ...type, version: this.manifest.version },
|
||||
validateConfig: async (config) => {
|
||||
|
||||
@@ -20,6 +20,12 @@ const CAPABILITIES = new Set<SourceCapability>([
|
||||
]);
|
||||
|
||||
function assertDescriptor(descriptor: SourceDescriptor): void {
|
||||
if (descriptor.credentialKeys !== undefined && (!Array.isArray(descriptor.credentialKeys) ||
|
||||
descriptor.credentialKeys.length > 16 || descriptor.credentialKeys.some((field) =>
|
||||
!field || !/^[a-z][a-z0-9_.-]{0,63}$/.test(field.key) || typeof field.label !== 'string' || !field.label.trim()) ||
|
||||
new Set(descriptor.credentialKeys.map((field) => field.key)).size !== descriptor.credentialKeys.length)) {
|
||||
throw new SourceRegistryError('invalid credential fields');
|
||||
}
|
||||
if (!ID_RE.test(descriptor.id)) throw new SourceRegistryError(`invalid source id: ${descriptor.id}`);
|
||||
if (!descriptor.label.trim()) throw new SourceRegistryError(`source ${descriptor.id} has an empty label`);
|
||||
if (!descriptor.version.trim()) throw new SourceRegistryError(`source ${descriptor.id} has no version`);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SourceDescriptor {
|
||||
readonly capabilities: readonly SourceCapability[];
|
||||
/** JSON Schema for an instance's public configuration, when applicable. */
|
||||
readonly configSchema?: unknown;
|
||||
readonly credentialKeys?: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface SourceInstance {
|
||||
@@ -213,6 +214,7 @@ export interface PluginSourceType {
|
||||
readonly label: string;
|
||||
readonly capabilities: readonly SourceCapability[];
|
||||
readonly configSchema?: unknown;
|
||||
readonly credentialKeys?: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { Db } from '../src/db/index.ts';
|
||||
import { SCHEMA_SQL } from '../src/db/schema.ts';
|
||||
import { ChapterPublications, MAX_CHAPTER_BYTES } from '../src/publications/chapters.ts';
|
||||
import { ChapterUpdates } from '../src/services/chapter-updates.ts';
|
||||
import type { SourceContext, SourceProvider } from '../src/sources/types.ts';
|
||||
|
||||
async function fixture() {
|
||||
@@ -37,6 +38,58 @@ async function fixture() {
|
||||
return { root, db, context, provider, chapters, setItems(next: typeof items) { items = next; } };
|
||||
}
|
||||
|
||||
test('rich chapter snapshots cache sanitised HTML and images across reloads', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.setItems([{ id: 'rich', seq: 0, title: '图文', kind: 'chapter', mediaType: 'text/html', ref: 'rich' }]);
|
||||
f.provider.readResource = async (_ctx, request) => request.ref === 'pic'
|
||||
? { mediaType: 'image/png', data: Buffer.from('89504e470d0a1a0a', 'hex') }
|
||||
: { mediaType: 'text/html', text: '<p>图文<strong>正文</strong><img src="reader-res:pic"></p><script>evil()</script>' };
|
||||
const id = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '图文' });
|
||||
const manifest = f.chapters.manifest('u1', id);
|
||||
assert.equal(manifest.kind, 'reflowable'); assert.equal(manifest.items[0]!.format, undefined);
|
||||
const resource = await f.chapters.asset('u1', id, manifest.items[0]!.resourceRef!);
|
||||
assert.equal(resource.contentType, 'text/html; charset=utf-8');
|
||||
assert.match(resource.data!.toString(), /data:image\/png;base64,/); assert.doesNotMatch(resource.data!.toString(), /script|evil/);
|
||||
f.provider.readResource = async () => { throw new Error('offline'); };
|
||||
assert.deepEqual((await f.chapters.asset('u1', id, manifest.items[0]!.resourceRef!)).data, resource.data);
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('durable subscriptions update once, accumulate new chapters, retry failures and respect account/shelf state', async () => {
|
||||
const f = await fixture();
|
||||
let now = 1000;
|
||||
try {
|
||||
const id = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '远程书' });
|
||||
let updates = new ChapterUpdates(f.db, f.chapters, () => now);
|
||||
assert.throws(() => updates.configure('other', id, { enabled: true }), { code: 'BOOK_NOT_FOUND' });
|
||||
assert.throws(() => updates.configure('u1', id, { intervalMinutes: 1 }), { code: 'BAD_REQUEST' });
|
||||
updates.configure('u1', id, { enabled: true, intervalMinutes: 15 });
|
||||
f.setItems([{ id: 'three', seq: 0, title: '新章', kind: 'chapter', mediaType: 'text/plain', ref: 'new' }]);
|
||||
await Promise.all([updates.runDue(), updates.runDue()]);
|
||||
assert.equal(updates.list('u1')[0]!.newChapters, 1);
|
||||
assert.equal(updates.list('u1')[0]!.lastSuccessAt, now);
|
||||
updates = new ChapterUpdates(f.db, f.chapters, () => now);
|
||||
await updates.runDue(); assert.equal(updates.list('u1')[0]!.newChapters, 1);
|
||||
now += 15 * 60_000;
|
||||
const original = f.provider.getManifest;
|
||||
f.provider.getManifest = async () => { throw new Error('upstream secret must not leak'); };
|
||||
await updates.runDue();
|
||||
assert.equal(updates.list('u1')[0]!.lastError, 'UPDATE_FAILED');
|
||||
assert.equal(updates.list('u1')[0]!.lastSuccessAt, 1000);
|
||||
assert.ok(updates.list('u1')[0]!.nextCheckAt > now);
|
||||
f.provider.getManifest = original;
|
||||
updates.configure('u1', id, { acknowledge: true, enabled: false });
|
||||
assert.equal(updates.list('u1')[0]!.newChapters, 0);
|
||||
const snapshot = f.chapters.manifest('u1', id).revision;
|
||||
updates.configure('u1', id, { enabled: true });
|
||||
f.db.run('UPDATE user_books SET hidden = 1 WHERE book_id = ?', id);
|
||||
await updates.runDue(); assert.equal(f.chapters.manifest('u1', id).revision, snapshot);
|
||||
assert.equal(updates.list('u1').length, 0);
|
||||
await updates.stop();
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('chapter acquisition creates isolated stable publication and caches plain text assets', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
@@ -77,7 +130,7 @@ test('refresh keeps stable href, versions resource refs, and leaves old cached b
|
||||
test('unsupported manifest media is rejected before creating a publication', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.setItems([{ id: 'bad', seq: 0, title: '坏', kind: 'chapter', mediaType: 'text/html', ref: 'bad' }]);
|
||||
f.setItems([{ id: 'bad', seq: 0, title: '坏', kind: 'chapter', mediaType: 'application/javascript', ref: 'bad' }]);
|
||||
await assert.rejects(() => f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '坏' }), { code: 'UNSUPPORTED_FORMAT' });
|
||||
assert.equal(f.db.get<{ count: number }>('SELECT count(*) AS count FROM books')!.count, 0);
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
@@ -106,6 +159,29 @@ test('old database gains content_hash_kind as an additive migration', async () =
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('old plain chapter caches survive media type migration and repeated restart', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = await f.chapters.acquire(f.provider, f.context, { entryRef: 'book' }, 'book', { ref: 'book', title: '旧缓存' });
|
||||
const ref = f.chapters.manifest('u1', id).items[0]!.resourceRef!;
|
||||
const original = await f.chapters.asset('u1', id, ref);
|
||||
f.db.run('ALTER TABLE chapter_resources DROP COLUMN media_type');
|
||||
for (let restart = 0; restart < 2; restart += 1) {
|
||||
const upgraded = new Db(join(f.root, 'reader.db'));
|
||||
try {
|
||||
const chapters = new ChapterPublications(upgraded, {
|
||||
manifest: async () => { throw new Error('source unavailable'); },
|
||||
resource: async () => { throw new Error('source unavailable'); },
|
||||
});
|
||||
const cached = await chapters.asset('u1', id, ref);
|
||||
assert.equal(cached.contentType, 'text/plain; charset=utf-8');
|
||||
assert.deepEqual(cached.data, original.data);
|
||||
assert.deepEqual(upgraded.all('PRAGMA foreign_key_check'), []);
|
||||
} finally { upgraded.close(); }
|
||||
}
|
||||
} finally { f.db.close(); await rm(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a refresh racing with an uncached resource never labels new bytes with an old revision', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { richContent, chapterMedia } from '../src/publications/rich-content.ts';
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aHf8AAAAASUVORK5CYII=', 'base64');
|
||||
|
||||
test('rich chapters keep semantic markup and embed only declared raster resources', async () => {
|
||||
const calls: string[] = [];
|
||||
const html = await richContent(`<h2 onclick="evil()">标题</h2><p>正文 <strong>强调</strong></p>
|
||||
<script>evil()</script><style>@import 'https://evil.test'</style><iframe srcdoc="bad"></iframe>
|
||||
<img src="reader-res:opaque?x=1&y=2" onerror="evil()"><img src="https://evil.test/pixel">
|
||||
<svg><image href="https://evil.test"></image></svg><form action="https://evil.test"><input></form>
|
||||
<a href="javascript:evil()">链接</a><div style="background:url(https://evil.test)">安全文本</div>`, async (ref) => {
|
||||
calls.push(ref); return { mediaType: 'image/png', data: png };
|
||||
});
|
||||
assert.deepEqual(calls, ['opaque?x=1&y=2']);
|
||||
assert.match(html, /<h2>标题<\/h2>/); assert.match(html, /<strong>强调<\/strong>/);
|
||||
assert.match(html, /src="data:image\/png;base64,/);
|
||||
assert.doesNotMatch(html, /evil|script|style|iframe|svg|form|input|onclick|onerror|https:/);
|
||||
});
|
||||
|
||||
test('rich chapter resource failures, active image formats and quotas fail without partial output', async () => {
|
||||
await assert.rejects(() => richContent('<img src="reader-res:a">', async () => ({ mediaType: 'image/svg+xml', data: Buffer.from('<svg/>') })), { code: 'INVALID_RESOURCE' });
|
||||
await assert.rejects(() => richContent('<img src="reader-res:a">', async () => ({ mediaType: 'image/png', data: Buffer.from('<script>bad</script>') })), { code: 'INVALID_RESOURCE' });
|
||||
await assert.rejects(() => richContent(Array.from({ length: 33 }, (_, n) => `<img src="reader-res:${n}">`).join(''), async () => ({ mediaType: 'image/png', data: png })), { code: 'CHAPTER_TOO_LARGE' });
|
||||
const big = Buffer.alloc(1024 * 1024); png.copy(big);
|
||||
await assert.rejects(() => richContent('<img src="reader-res:repeat">'.repeat(8), async () => ({ mediaType: 'image/png', data: big })), { code: 'CHAPTER_TOO_LARGE' });
|
||||
assert.throws(() => chapterMedia('text/html; charset = gbk'), { code: 'UNSUPPORTED_FORMAT' });
|
||||
});
|
||||
@@ -76,6 +76,26 @@ async function harness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('source editing validates configuration, clears destination credentials and keeps acquired sources', async (t) => {
|
||||
const h = await harness(); t.after(() => h.close());
|
||||
const member = await h.member();
|
||||
const create = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
||||
id: 'editable', pluginId: 'reader.opds', sourceType: 'opds', name: 'Before', config: { url: 'https://one.test/opds' },
|
||||
} });
|
||||
assert.equal(create.statusCode, 201, create.body);
|
||||
await h.app.inject({ method: 'PUT', url: '/api/v1/sources/editable/credentials/password', headers: auth(member), payload: { value: 'private' } });
|
||||
const forbidden = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(member), payload: { name: 'bad' } });
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
const edited = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(h.admin), payload: { name: 'After', config: { url: 'https://two.test/opds' } } });
|
||||
assert.equal(edited.statusCode, 200, edited.body); assert.equal(edited.json().source.name, 'After');
|
||||
assert.equal(h.ctx.db.get<{ count: number }>('SELECT count(*) AS count FROM source_credentials WHERE source_id = ?', 'editable')!.count, 0);
|
||||
const invalid = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/editable', headers: auth(h.admin), payload: { config: { url: 'file:///secret' } } });
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
const removed = await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/editable', headers: auth(h.admin) });
|
||||
assert.equal(removed.statusCode, 200, removed.body);
|
||||
assert.equal((await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/local', headers: auth(h.admin) })).statusCode, 400);
|
||||
});
|
||||
|
||||
async function opdsFixture() {
|
||||
const body = '第一章 远程下载\n\n这是经 OPDS 获取的本地可读正文。\n\n第二章 继续阅读\n\n下载后继续复用现有书架和阅读进度。';
|
||||
let downloads = 0;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
User,
|
||||
} from './types.ts';
|
||||
import type { Platform } from '../core/platform.ts';
|
||||
import type { SourceType, SourceInstance, SourcePlugin, SourceEntry, SourcePage, SourceAcquisition, ChapterSubscription } from './sources.ts';
|
||||
|
||||
export interface SessionStore {
|
||||
load(): Promise<Session | null>;
|
||||
@@ -227,6 +228,39 @@ export class ReaderApi {
|
||||
|
||||
// ---- shelf ----
|
||||
|
||||
async sourceTypes(): Promise<SourceType[]> { return (await this.get<{ types: SourceType[] }>('/api/v1/sources/types')).types; }
|
||||
async sources(): Promise<SourceInstance[]> { return (await this.get<{ sources: SourceInstance[] }>('/api/v1/sources')).sources; }
|
||||
async plugins(): Promise<SourcePlugin[]> { return (await this.get<{ plugins: SourcePlugin[] }>('/api/v1/plugins')).plugins; }
|
||||
async saveSource(id: string | null, input: Record<string, unknown>): Promise<SourceInstance> {
|
||||
return (await this.call<{ source: SourceInstance }>(`/api/v1/sources${id ? `/${encodeURIComponent(id)}` : ''}`, id ? 'PATCH' : 'POST', input)).source;
|
||||
}
|
||||
async removeSource(id: string): Promise<void> { await this.call(`/api/v1/sources/${encodeURIComponent(id)}`, 'DELETE'); }
|
||||
async sourceCredential(id: string, key: string, value: string): Promise<void> {
|
||||
await this.call(`/api/v1/sources/${encodeURIComponent(id)}/credentials/${encodeURIComponent(key)}`, 'PUT', { value });
|
||||
}
|
||||
async installPlugin(folder: string): Promise<void> { await this.call('/api/v1/plugins', 'POST', { folder, trusted: true }); }
|
||||
async enablePlugin(id: string, enabled: boolean): Promise<void> { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'PATCH', { enabled }); }
|
||||
async uninstallPlugin(id: string): Promise<void> { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'DELETE'); }
|
||||
async sourceCatalog(id: string, query: { ref?: string; query?: string; cursor?: string } = {}): Promise<SourcePage> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.ref) params.set('ref', query.ref);
|
||||
if (query.query) params.set('q', query.query);
|
||||
if (query.cursor) params.set('cursor', query.cursor);
|
||||
return this.get(`/api/v1/sources/${encodeURIComponent(id)}/${query.query ? 'search' : 'browse'}?${params}`);
|
||||
}
|
||||
async sourceDetail(id: string, ref: string): Promise<SourceEntry> {
|
||||
return this.get(`/api/v1/sources/${encodeURIComponent(id)}/entries?ref=${encodeURIComponent(ref)}`);
|
||||
}
|
||||
async acquireSource(id: string, entryRef: string, optionId?: string): Promise<SourceAcquisition> {
|
||||
return this.call(`/api/v1/sources/${encodeURIComponent(id)}/acquire`, 'POST', { entryRef, ...(optionId ? { optionId } : {}) });
|
||||
}
|
||||
async subscriptions(): Promise<ChapterSubscription[]> {
|
||||
return (await this.get<{ subscriptions: ChapterSubscription[] }>('/api/v1/subscriptions')).subscriptions;
|
||||
}
|
||||
async configureSubscription(id: string, patch: { enabled?: boolean; intervalMinutes?: number; acknowledge?: boolean }): Promise<void> {
|
||||
await this.call(`/api/v1/books/${encodeURIComponent(id)}/subscription`, 'PATCH', patch);
|
||||
}
|
||||
|
||||
async listBooks(query: ListQuery = {}, options: RequestOptions = {}): Promise<BookListPage> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
|
||||
@@ -51,6 +51,8 @@ export class ApiError extends Error {
|
||||
* the client cannot classify.
|
||||
*/
|
||||
get isAuthFailure(): boolean {
|
||||
// Source credentials are independent of the reader account session.
|
||||
if (this.code === 'AUTH_REQUIRED') return false;
|
||||
if (this.kind === 'unauthorized') return true;
|
||||
if (this.kind !== 'forbidden') return false;
|
||||
return AUTH_FAILURE_CODES.has(this.code);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SourceType {
|
||||
id: string; pluginId: string; label: string; version: string; builtin: boolean; capabilities: string[];
|
||||
configSchema?: { properties?: Record<string, { type?: string; title?: string; default?: unknown }>; required?: string[] };
|
||||
credentialKeys?: Array<{ key: string; label: string }>;
|
||||
}
|
||||
export interface SourceInstance {
|
||||
id: string; pluginId: string; sourceType: string; name: string; enabled: boolean;
|
||||
config?: Record<string, unknown>; descriptor: SourceType | null;
|
||||
}
|
||||
export interface SourceEntry {
|
||||
ref: string; title: string; authors?: string[]; description?: string;
|
||||
options?: Array<{ id: string; label: string; available?: boolean }>;
|
||||
}
|
||||
export interface SourcePage {
|
||||
items: SourceEntry[]; navigation?: Array<{ ref: string; title: string }>; nextCursor?: string; title?: string;
|
||||
}
|
||||
export interface SourceAcquisition {
|
||||
kind: 'ready' | 'action-required'; publicationId?: string;
|
||||
action?: { label: string; url?: string };
|
||||
}
|
||||
export interface SourcePlugin {
|
||||
pluginId: string; builtin: boolean; name?: string; version?: string; folder?: string; enabled: boolean;
|
||||
runtime?: { state: string } | null; error?: { code: string; message: string };
|
||||
}
|
||||
export interface ChapterSubscription {
|
||||
bookId: string; title: string; enabled: boolean; intervalMinutes: number;
|
||||
nextCheckAt: number; lastCheckAt: number | null; lastSuccessAt: number | null;
|
||||
lastError: string | null; newChapters: number;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { OfflineStore } from './store/offline.ts';
|
||||
import { publicationScope } from './store/publications.ts';
|
||||
import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/settings.ts';
|
||||
import { ShelfScreen } from './ui/shelf-screen.tsx';
|
||||
import { SourcesScreen } from './ui/sources-screen.tsx';
|
||||
import { ReaderScreen } from './ui/reader-screen.tsx';
|
||||
import { LoginScreen } from './ui/login-screen.tsx';
|
||||
import { LibraryBrowseScreen, LibraryFilesScreen } from './ui/library-screen.tsx';
|
||||
@@ -81,6 +82,7 @@ export class App {
|
||||
private isAdmin = false;
|
||||
|
||||
private shelf: ShelfScreen | null = null;
|
||||
private sources: SourcesScreen | null = null;
|
||||
private reader: ReaderScreen | null = null;
|
||||
/**
|
||||
* The library, as two screens: the browsing page and the file manager.
|
||||
@@ -261,6 +263,14 @@ export class App {
|
||||
return;
|
||||
}
|
||||
switch (route.name) {
|
||||
case 'sources':
|
||||
this.clearScreens();
|
||||
this.route = route;
|
||||
this.sources = new SourcesScreen({ api: this.api, admin: this.isAdmin, onBack: () => location.back(),
|
||||
onOpen: (book) => this.openBook(book), onSignedOut: () => this.handleSignedOut() });
|
||||
this.root.append(this.sources.element);
|
||||
void this.sources.show();
|
||||
return;
|
||||
case 'shelf':
|
||||
this.showShelf(route);
|
||||
return;
|
||||
@@ -273,6 +283,8 @@ export class App {
|
||||
}
|
||||
|
||||
private clearScreens(): void {
|
||||
this.sources?.dispose();
|
||||
this.sources = null;
|
||||
this.reader?.dispose();
|
||||
this.reader = null;
|
||||
this.shelf?.dispose();
|
||||
@@ -339,6 +351,7 @@ export class App {
|
||||
}
|
||||
this.clearScreens();
|
||||
const shelf = new ShelfScreen({
|
||||
onOpenSources: () => this.router?.navigate({ name: 'sources' }),
|
||||
api: this.api,
|
||||
offline: this.offline,
|
||||
platform: this.platform,
|
||||
|
||||
@@ -3930,3 +3930,23 @@ book-content {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
/* Source management shares the reader's type and colour tokens. */
|
||||
.sources-screen { display:flex; flex-direction:column; min-height:0; height:100%; flex:1; }
|
||||
.sources-header { display:flex; align-items:center; gap:1rem; padding:1rem; border-bottom:1px solid var(--reader-line); }
|
||||
.sources-header h1 { font-size:1.25rem; flex:1; margin:0; }
|
||||
.sources-tabs { display:flex; flex-wrap:wrap; gap:.5rem; padding:.75rem 1rem; }
|
||||
.sources-body { overflow:auto; padding:0 1rem 2rem; flex:1; }
|
||||
.sources-card { max-width:64rem; margin:0 auto 1rem; padding:1rem; border:1px solid var(--reader-line); border-radius:1rem; }
|
||||
.sources-card h2 { margin:.25rem 0 1rem; font-size:1.1rem; }
|
||||
.sources-card label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; }
|
||||
.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:.5rem; background:var(--reader-surface); color:inherit; font:inherit; }
|
||||
.sources-card textarea { min-height:6rem; }
|
||||
.sources-row { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; padding:1rem 0; border-top:1px solid var(--reader-line); }
|
||||
.sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; }
|
||||
.sources-row small { display:block; margin-top:.35rem; opacity:.75; }
|
||||
.sources-actions { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; }
|
||||
.sources-actions label { margin:0; }
|
||||
.sources-card .sources-consent { flex-direction:row; align-items:center; }
|
||||
.sources-search { display:flex; align-items:end; gap:.5rem; margin:1rem 0; }
|
||||
.sources-search label { flex:1; margin:0; }
|
||||
.source-description { max-height:6rem; overflow:auto; font-size:.9rem; white-space:pre-wrap; }
|
||||
@@ -70,6 +70,7 @@ export type LibraryView = 'browse' | 'files';
|
||||
|
||||
/** A parsed location. */
|
||||
export type Route =
|
||||
| { name: 'sources' }
|
||||
| {
|
||||
name: 'shelf';
|
||||
/**
|
||||
@@ -176,6 +177,7 @@ export function parseRoute(hash: string, context?: RouteContext): Route {
|
||||
const parts = pathOf(raw).split('/').filter((part) => part.length > 0);
|
||||
if (parts.length === 0) return shelfRoute(context);
|
||||
const [head, ...rest] = parts;
|
||||
if (head === 'sources') return { name: 'sources' };
|
||||
if (head === 'shelf') {
|
||||
/*
|
||||
* A shelf page is the second segment: `#/shelf/2`.
|
||||
@@ -295,6 +297,7 @@ function shelfRoute(context?: RouteContext, page = 1): Route {
|
||||
*/
|
||||
export function routeHash(route: Route): string {
|
||||
switch (route.name) {
|
||||
case 'sources': return '#/sources';
|
||||
case 'shelf':
|
||||
// `/1` is the absence of a page: one screen, two URLs, is the thing the
|
||||
// router's equality check exists to prevent, and emitting both here is how
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Icon, IconButton, IconTextButton } from './toolkit.tsx';
|
||||
import { type ComponentChildren, type JSX, useEffect, useState } from './vendor/preact.ts';
|
||||
|
||||
export interface ShelfScreenOptions {
|
||||
onOpenSources?(): void;
|
||||
api: ReaderApi;
|
||||
offline: OfflineStore;
|
||||
platform: Platform;
|
||||
@@ -856,6 +857,7 @@ export class ShelfScreen {
|
||||
icon="library"
|
||||
onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}
|
||||
/>
|
||||
{this.options.onOpenSources && <button type="button" className="button" onClick={this.options.onOpenSources}>书源</button>}
|
||||
<IconButton
|
||||
label={`书架设置 · ${DENSITY_LABELS[density]}`}
|
||||
icon="tune"
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ApiError, type ReaderApi } from '../api/client.ts';
|
||||
import type { Book } from '../api/types.ts';
|
||||
import type { ChapterSubscription, SourceEntry, SourceInstance, SourcePage, SourcePlugin, SourceType } from '../api/sources.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { Button, IconButton } from './toolkit.tsx';
|
||||
|
||||
interface Options { api: ReaderApi; admin: boolean; onBack(): void; onOpen(book: Book): void; onSignedOut(): void }
|
||||
interface Editor { id: string | null; typeKey: string; name: string; config: Record<string, unknown>; raw: string }
|
||||
const keyFor = (type: { pluginId: string; id: string }) => `${type.pluginId}/${type.id}`;
|
||||
const date = (value: number | null) => value ? new Date(value).toLocaleString() : '尚未检查';
|
||||
|
||||
/** Source configuration and browsing use the same declared provider capabilities. */
|
||||
export class SourcesScreen {
|
||||
readonly element = document.createElement('div');
|
||||
private readonly ui: ReturnType<typeof mountUI>;
|
||||
private disposed = false;
|
||||
private busy = false;
|
||||
private message = '';
|
||||
private types: SourceType[] = [];
|
||||
private sources: SourceInstance[] = [];
|
||||
private plugins: SourcePlugin[] = [];
|
||||
private subscriptions: ChapterSubscription[] = [];
|
||||
private tab: 'sources' | 'updates' | 'plugins' = 'sources';
|
||||
private editor: Editor | null = null;
|
||||
private selected: SourceInstance | null = null;
|
||||
private page: SourcePage | null = null;
|
||||
private path: Array<{ ref?: string; query?: string; cursor?: string }> = [];
|
||||
private query = '';
|
||||
private credentialValues: Record<string, string> = {};
|
||||
private folder = '';
|
||||
private trusted = false;
|
||||
private acquired: Book | null = null;
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.element.className = 'sources-screen';
|
||||
this.ui = mountUI(this.element, () => this.view(), null);
|
||||
}
|
||||
async show(): Promise<void> { await this.run(() => this.reload()); }
|
||||
dispose(): void { this.disposed = true; this.credentialValues = {}; this.ui.unmount(); }
|
||||
private draw(): void { if (!this.disposed) this.ui.update(null); }
|
||||
private async run(action: () => Promise<void>): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
this.busy = true; this.message = ''; this.draw();
|
||||
try { await action(); }
|
||||
catch (error) {
|
||||
if (this.disposed) return;
|
||||
if (error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut();
|
||||
else this.message = error instanceof Error ? error.message : '操作失败,请重试';
|
||||
} finally { this.busy = false; this.draw(); }
|
||||
}
|
||||
private async reload(): Promise<void> {
|
||||
const [types, sources, subscriptions, plugins] = await Promise.all([
|
||||
this.options.api.sourceTypes(), this.options.api.sources(), this.options.api.subscriptions(),
|
||||
this.options.admin ? this.options.api.plugins() : Promise.resolve([]),
|
||||
]);
|
||||
this.types = types; this.sources = sources; this.subscriptions = subscriptions; this.plugins = plugins;
|
||||
if (this.selected) this.selected = sources.find((source) => source.id === this.selected?.id) ?? null;
|
||||
}
|
||||
private edit(source?: SourceInstance): void {
|
||||
const type = source ? this.types.find((t) => t.pluginId === source.pluginId && t.id === source.sourceType) : this.types.find((t) => t.id === 'opds') ?? this.types[0];
|
||||
if (!type) return;
|
||||
this.editor = { id: source?.id ?? null, typeKey: keyFor(type), name: source?.name ?? '', config: { ...source?.config }, raw: JSON.stringify(source?.config ?? {}, null, 2) };
|
||||
this.draw();
|
||||
}
|
||||
private async save(): Promise<void> {
|
||||
const editor = this.editor; if (!editor) return;
|
||||
const type = this.types.find((t) => keyFor(t) === editor.typeKey)!;
|
||||
const config = type.configSchema?.properties ? editor.config : JSON.parse(editor.raw);
|
||||
await this.options.api.saveSource(editor.id, { name: editor.name, config, ...(!editor.id ? { pluginId: type.pluginId, sourceType: type.id } : {}) });
|
||||
this.editor = null; await this.reload(); this.message = '来源已保存;修改连接配置后请重新保存个人凭据。';
|
||||
}
|
||||
private async catalog(query: { ref?: string; query?: string; cursor?: string }, push = true): Promise<void> {
|
||||
if (!this.selected) return;
|
||||
const page = await this.options.api.sourceCatalog(this.selected.id, query);
|
||||
this.page = page; if (push) this.path.push(query);
|
||||
}
|
||||
private select(source: SourceInstance): void {
|
||||
this.selected = source; this.page = null; this.path = []; this.query = ''; this.credentialValues = {}; this.acquired = null;
|
||||
if (source.descriptor?.capabilities.includes('browse')) void this.run(() => this.catalog({}));
|
||||
else this.draw();
|
||||
}
|
||||
private async acquire(entry: SourceEntry, optionId?: string): Promise<void> {
|
||||
if (!this.selected) return;
|
||||
const result = await this.options.api.acquireSource(this.selected.id, entry.ref, optionId);
|
||||
if (result.kind === 'action-required') { this.message = result.action?.label ?? '请先在来源服务完成授权'; return; }
|
||||
if (!result.publicationId) throw new Error('来源没有返回书籍');
|
||||
this.acquired = (await this.options.api.getBook(result.publicationId)).book;
|
||||
this.subscriptions = await this.options.api.subscriptions();
|
||||
this.message = '已加入书架,可在“自动追更”中开启检查。';
|
||||
}
|
||||
private view() {
|
||||
const type = this.types.find((t) => keyFor(t) === this.editor?.typeKey);
|
||||
return <>
|
||||
<header className="sources-header"><IconButton label="返回" icon="arrow-left" onClick={this.options.onBack} /><h1>书源与追更</h1>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(() => this.reload())}>刷新</Button></header>
|
||||
<nav className="sources-tabs" aria-label="书源功能">
|
||||
<Button onClick={() => { this.tab = 'sources'; this.draw(); }}>书源</Button>
|
||||
<Button onClick={() => { this.tab = 'updates'; this.draw(); }}>自动追更{this.subscriptions.some((s) => s.newChapters > 0) ? ' · 有更新' : ''}</Button>
|
||||
{this.options.admin && <Button onClick={() => { this.tab = 'plugins'; this.draw(); }}>插件管理</Button>}
|
||||
</nav>
|
||||
<div role="status" className="notice">{this.busy ? '正在处理…' : this.message}</div>
|
||||
<main className="sources-body">
|
||||
{this.tab === 'sources' && <>
|
||||
<section className="sources-card"><h2>我的来源</h2>
|
||||
{this.options.admin && <Button disabled={this.busy} onClick={() => this.edit()}>添加来源</Button>}
|
||||
{this.sources.map((source) => <div className="sources-row" key={source.id}>
|
||||
<div><strong>{source.name}</strong><small>{source.descriptor?.label ?? '插件未启用或未安装'} · {source.enabled ? '已启用' : '已暂停'}</small></div>
|
||||
<div className="sources-actions"><Button disabled={this.busy || !source.enabled || !source.descriptor} onClick={() => this.select(source)}>打开</Button>
|
||||
{this.options.admin && <><Button disabled={this.busy || !source.descriptor} onClick={() => this.edit(source)}>配置</Button>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.saveSource(source.id, { enabled: !source.enabled }); await this.reload(); })}>{source.enabled ? '暂停' : '启用'}</Button></>}
|
||||
</div></div>)}
|
||||
</section>
|
||||
{this.editor && <section className="sources-card"><h2>{this.editor.id ? '编辑来源' : '添加来源'}</h2>
|
||||
<form onSubmit={(event) => { event.preventDefault(); void this.run(() => this.save()); }}>
|
||||
<label>来源类型<select disabled={this.busy || !!this.editor.id} value={this.editor.typeKey} onChange={(event) => {
|
||||
this.editor!.typeKey = event.currentTarget.value; this.editor!.config = {}; this.editor!.raw = '{}'; this.draw();
|
||||
}}>{this.types.map((t) => <option value={keyFor(t)}>{t.label}</option>)}</select></label>
|
||||
<label>名称<input required maxLength={128} value={this.editor.name} onInput={(event) => { this.editor!.name = event.currentTarget.value; }} /></label>
|
||||
{type?.configSchema?.properties ? Object.entries(type.configSchema.properties).map(([key, field]) => <label key={key}>{field.title ?? key}
|
||||
{field.type === 'array' || field.type === 'object' ? <textarea aria-label={field.title ?? key} value={JSON.stringify(this.editor!.config[key] ?? (field.type === 'array' ? [] : {}), null, 2)}
|
||||
onChange={(event) => { try { this.editor!.config[key] = JSON.parse(event.currentTarget.value); event.currentTarget.setCustomValidity(''); } catch { event.currentTarget.setCustomValidity('请输入有效 JSON'); } }} />
|
||||
: field.type === 'boolean' ? <input type="checkbox" checked={this.editor!.config[key] === true}
|
||||
onChange={(event) => { this.editor!.config[key] = event.currentTarget.checked; }} />
|
||||
: <input type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'} step={field.type === 'number' ? 'any' : '1'} required={type.configSchema?.required?.includes(key)} value={String(this.editor!.config[key] ?? '')}
|
||||
onInput={(event) => { const value = event.currentTarget.value;
|
||||
if (!value && !type.configSchema?.required?.includes(key)) delete this.editor!.config[key];
|
||||
else this.editor!.config[key] = field.type === 'number' || field.type === 'integer' ? Number(value) : value;
|
||||
}} />}
|
||||
</label>) : <label>配置(JSON)<textarea value={this.editor.raw} onInput={(event) => { this.editor!.raw = event.currentTarget.value; }} /></label>}
|
||||
<p className="notice">配置不包含密码。修改连接配置会清除该来源保存的个人凭据。</p>
|
||||
<div className="sources-actions"><Button type="submit" disabled={this.busy}>保存来源</Button>
|
||||
<Button disabled={this.busy} onClick={() => { this.editor = null; this.draw(); }}>取消</Button>
|
||||
{this.editor.id && <Button disabled={this.busy || type?.id === 'local'} onClick={() => void this.run(async () => {
|
||||
await this.options.api.removeSource(this.editor!.id!); this.editor = null; await this.reload();
|
||||
})}>删除空来源</Button>}</div>
|
||||
</form></section>}
|
||||
{this.selected && <section className="sources-card"><h2>{this.selected.name}</h2>
|
||||
{(this.selected.descriptor?.credentialKeys?.length ?? 0) > 0 && <form onSubmit={(event) => { event.preventDefault(); void this.run(async () => {
|
||||
const source = this.selected!;
|
||||
for (const field of source.descriptor?.credentialKeys ?? []) {
|
||||
if (this.disposed) return;
|
||||
if (field.key in this.credentialValues) await this.options.api.sourceCredential(source.id, field.key, this.credentialValues[field.key]!);
|
||||
}
|
||||
this.credentialValues = {}; this.message = '个人凭据已保存,不会回显。';
|
||||
}); }}><h3>我的登录凭据</h3>{this.selected.descriptor?.credentialKeys?.map((field) => <label key={field.key}>{field.label}
|
||||
<input type="password" autoComplete="off" value={this.credentialValues[field.key] ?? ''} onInput={(event) => { this.credentialValues[field.key] = event.currentTarget.value; }} /></label>)}
|
||||
<Button type="submit" disabled={this.busy}>保存个人凭据</Button></form>}
|
||||
{this.selected.descriptor?.capabilities.includes('search') && <form className="sources-search" onSubmit={(event) => { event.preventDefault(); void this.run(() => this.catalog({ query: this.query.trim() })); }}>
|
||||
<label>搜索书籍<input type="search" required value={this.query} onInput={(event) => { this.query = event.currentTarget.value; }} /></label><Button type="submit" disabled={this.busy}>搜索</Button></form>}
|
||||
{this.path.length > 1 && <Button disabled={this.busy} onClick={() => void this.run(async () => { const previous = this.path[this.path.length - 2]!; await this.catalog(previous, false); this.path.pop(); })}>上一页目录</Button>}
|
||||
{this.page?.title && <h3>{this.page.title}</h3>}
|
||||
{this.page?.navigation?.map((entry) => <Button disabled={this.busy} onClick={() => void this.run(() => this.catalog({ ref: entry.ref }))}>{entry.title}</Button>)}
|
||||
{this.page?.items.length === 0 && <p>这里还没有书籍。可以进入分类或换一个关键词。</p>}
|
||||
{this.page?.items.map((entry) => <article className="sources-row" key={entry.ref}>
|
||||
<div><strong>{entry.title}</strong><small>{entry.authors?.join(' / ')}</small><p className="source-description">{entry.description}</p></div>
|
||||
<div className="sources-actions"><Button disabled={this.busy} onClick={() => void this.run(async () => {
|
||||
const detail = await this.options.api.sourceDetail(this.selected!.id, entry.ref); Object.assign(entry, detail);
|
||||
})}>详情</Button>
|
||||
{(entry.options?.length ? entry.options : [{ id: '', label: '加入书架' }]).map((option) => <Button disabled={this.busy || option.available === false}
|
||||
onClick={() => void this.run(() => this.acquire(entry, option.id))}>{option.label}</Button>)}</div>
|
||||
</article>)}
|
||||
{this.page?.nextCursor && <Button disabled={this.busy} onClick={() => void this.run(() => this.catalog({ ...this.path.at(-1), cursor: this.page!.nextCursor! }))}>下一页</Button>}
|
||||
{this.acquired && <Button onClick={() => this.options.onOpen(this.acquired!)}>阅读《{this.acquired.title}》</Button>}
|
||||
</section>}
|
||||
</>}
|
||||
{this.tab === 'updates' && <section className="sources-card"><h2>自动追更</h2><p className="notice">服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。</p>
|
||||
{!this.subscriptions.length && <p>先从章节书源加入书籍,再开启追更。</p>}
|
||||
{this.subscriptions.map((subscription) => <article className="sources-row" key={subscription.bookId}><div><strong>{subscription.title}</strong>
|
||||
<small>{subscription.newChapters ? `新增 ${subscription.newChapters} 章 · ` : ''}上次成功:{date(subscription.lastSuccessAt)}</small>
|
||||
<small>{subscription.enabled ? `下次检查:${date(subscription.nextCheckAt)}` : '自动追更已关闭'}{subscription.lastError ? ` · 检查失败:${subscription.lastError}` : ''}</small></div>
|
||||
<div className="sources-actions"><label>检查间隔<select aria-label={`${subscription.title}检查间隔`} value={subscription.intervalMinutes} disabled={this.busy}
|
||||
onChange={(event) => { const intervalMinutes = Number(event.currentTarget.value); void this.run(async () => { await this.options.api.configureSubscription(subscription.bookId, { intervalMinutes }); await this.reload(); }); }}>
|
||||
{[...new Set([15, 60, 360, 1440, subscription.intervalMinutes])].sort((a, b) => a - b).map((minutes) => <option value={minutes}>{minutes} 分钟</option>)}</select></label>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.configureSubscription(subscription.bookId, { enabled: !subscription.enabled }); await this.reload(); })}>{subscription.enabled ? '关闭追更' : '开启追更'}</Button>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.refreshPublication(subscription.bookId); await this.reload(); this.message = '目录已更新'; })}>立即检查</Button>
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => {
|
||||
await this.options.api.configureSubscription(subscription.bookId, { acknowledge: true });
|
||||
const book = (await this.options.api.getBook(subscription.bookId)).book; if (!this.disposed) this.options.onOpen(book);
|
||||
})}>阅读</Button>
|
||||
</div></article>)}
|
||||
</section>}
|
||||
{this.tab === 'plugins' && this.options.admin && <section className="sources-card"><h2>插件管理</h2>
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!this.trusted) return; void this.run(async () => { await this.options.api.installPlugin(this.folder); this.folder = ''; this.trusted = false; await this.reload(); }); }}>
|
||||
<label>已部署的插件目录名<input required value={this.folder} placeholder="external-source" onInput={(event) => { this.folder = event.currentTarget.value; }} /></label>
|
||||
<p className="notice">将插件目录放入服务端 DATA_DIR/plugins 后安装。插件以服务端权限运行。</p>
|
||||
<label className="sources-consent"><input type="checkbox" checked={this.trusted} onChange={(event) => { this.trusted = event.currentTarget.checked; this.draw(); }} />我信任这个插件的代码</label>
|
||||
<Button type="submit" disabled={this.busy || !this.trusted}>安装插件</Button>
|
||||
</form>
|
||||
{this.plugins.map((plugin) => <article className="sources-row" key={plugin.pluginId}><div><strong>{plugin.name ?? plugin.pluginId}</strong>
|
||||
<small>{plugin.version} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}{plugin.error ? ` · ${plugin.error.message}` : ''}{plugin.runtime?.state === 'failed' ? ' · 运行失败,请重启插件' : ''}</small></div>
|
||||
{!plugin.builtin && <div className="sources-actions"><Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.enablePlugin(plugin.pluginId, !plugin.enabled); await this.reload(); })}>{plugin.enabled ? '停用' : '启用'}</Button>
|
||||
{plugin.runtime?.state === 'failed' && <Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.enablePlugin(plugin.pluginId, true); await this.reload(); })}>重启插件</Button>}
|
||||
<Button disabled={this.busy} onClick={() => void this.run(async () => { await this.options.api.uninstallPlugin(plugin.pluginId); await this.reload(); this.message = '插件已卸载,书籍及已缓存内容保留。'; })}>卸载</Button></div>}
|
||||
</article>)}
|
||||
</section>}
|
||||
</main>
|
||||
</>;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('requests across an account change', () => {
|
||||
it('keeps the reader signed in when an OPDS source requires separate credentials', async () => {
|
||||
const env = await setup();
|
||||
env.transport.respondWith(() => ({ status: 401, headers: {}, json: { error: { code: 'AUTH_REQUIRED', message: 'OPDS password required' } } }));
|
||||
await expect(env.api.sourceCatalog('private')).rejects.toMatchObject({ code: 'AUTH_REQUIRED', isAuthFailure: false });
|
||||
expect(env.api.currentSession()?.user.id).toBe('a');
|
||||
expect(env.transport.requests).toHaveLength(1);
|
||||
});
|
||||
it('rotates an expired token before loading chapter bytes through the same guarded request path', async () => {
|
||||
const env = await setup();
|
||||
env.transport.respondWith((request) => {
|
||||
|
||||
@@ -102,6 +102,27 @@ async function click(screen: ReaderScreen, label: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('chapter publication reading', () => {
|
||||
it('renders cached rich chapters with images without requesting an external origin, including offline reopening', async () => {
|
||||
const kv = new MemoryKv(); const blobs = new MemoryBlobs();
|
||||
const first = await setup(kv, blobs);
|
||||
const rich = content(['rich']); rich.kind = 'reflowable';
|
||||
delete rich.items[0]!.format; rich.items[0]!.mediaType = 'text/html; charset=utf-8';
|
||||
const html = '<h2>插图章节</h2><p>含有<strong>强调</strong>的正文。</p><img alt="插图" src="data:image/png;base64,iVBORw0KGgo=">';
|
||||
first.transport.respondWith((request) => {
|
||||
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(rich) };
|
||||
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(html) };
|
||||
return { status: 200, headers: {}, json: { progress: null } };
|
||||
});
|
||||
await first.screen.open(book);
|
||||
expect(body(first.screen)?.querySelector('strong')?.textContent).toBe('强调');
|
||||
expect(body(first.screen)?.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/png/);
|
||||
first.screen.dispose(); screens.splice(screens.indexOf(first.screen), 1); await first.offline.flush();
|
||||
const second = await setup(kv, blobs); second.transport.failWith(new ApiError('offline', 'offline'));
|
||||
await second.screen.open(book);
|
||||
expect(body(second.screen)?.querySelector('h2')?.textContent).toBe('插图章节');
|
||||
expect(body(second.screen)?.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/png/);
|
||||
expect(second.transport.requests.some((request) => request.url.includes('/assets?'))).toBe(false);
|
||||
});
|
||||
it('ignores delayed chapter callbacks after disposal and an offline account switch', async () => {
|
||||
const env = await setup();
|
||||
let release!: () => void;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReaderApi } from '../src/api/client.ts';
|
||||
import { SourcesScreen } from '../src/ui/sources-screen.tsx';
|
||||
import { FakeTransport, makePlatform, bodyText } from './helpers/env.ts';
|
||||
import { parseRoute, routeHash } from '../src/ui/router.ts';
|
||||
|
||||
const opds = { id: 'opds', pluginId: 'reader.opds', builtin: true, label: 'OPDS', version: '1', capabilities: ['browse', 'search'],
|
||||
credentialKeys: [{ key: 'password', label: 'OPDS 密码' }], configSchema: { required: ['url'], properties: { url: { type: 'string', title: 'OPDS 地址' } } } };
|
||||
const source = { id: 'source-1', name: '远程书库', pluginId: 'reader.opds', sourceType: 'opds', enabled: true, descriptor: opds, config: { url: 'https://books.test' } };
|
||||
const screens: SourcesScreen[] = [];
|
||||
afterEach(() => { screens.splice(0).forEach((screen) => screen.dispose()); document.body.replaceChildren(); });
|
||||
async function setup(admin = true) {
|
||||
const transport = new FakeTransport();
|
||||
const api = new ReaderApi(makePlatform(transport), { async load() { return null; }, async save() {}, async clear() {} });
|
||||
const onOpen = vi.fn();
|
||||
const screen = new SourcesScreen({ api, admin, onOpen, onBack() {}, onSignedOut() {} });
|
||||
screens.push(screen); document.body.append(screen.element);
|
||||
transport.respondWith((request) => {
|
||||
let json: unknown = {};
|
||||
if (request.url.endsWith('/sources/types')) json = { types: [opds] };
|
||||
else if (request.url.endsWith('/sources')) json = { sources: [source], source };
|
||||
else if (request.url.endsWith('/plugins')) json = { plugins: [{ pluginId: 'remote', name: '远程插件', enabled: true, builtin: false }] };
|
||||
else if (request.url.endsWith('/subscriptions')) json = { subscriptions: [{ bookId: 'book1', title: '追更小说', enabled: false, intervalMinutes: 60, newChapters: 2, lastSuccessAt: 1000, nextCheckAt: 0 }] };
|
||||
else if (request.url.includes('/browse?') || request.url.includes('/search?')) json = { items: [{ ref: 'book-ref', title: '来源小说', options: [{ id: 'epub', label: '下载 EPUB' }] }] };
|
||||
else if (request.url.endsWith('/acquire')) json = { kind: 'ready', publicationId: 'book1' };
|
||||
else if (request.url.endsWith('/books/book1')) json = { book: { id: 'book1', title: '来源小说' } };
|
||||
return { status: 200, headers: {}, json };
|
||||
});
|
||||
await screen.show();
|
||||
return { api, screen, transport, onOpen };
|
||||
}
|
||||
function button(root: HTMLElement, label: string) {
|
||||
const found = [...root.querySelectorAll('button')].find((element) => element.textContent?.trim() === label || element.getAttribute('aria-label') === label);
|
||||
expect(found, label).toBeDefined(); return found!;
|
||||
}
|
||||
async function click(root: HTMLElement, label: string) {
|
||||
button(root, label).click();
|
||||
await vi.waitFor(() => expect(root.querySelector('[role=status]')?.textContent).not.toBe('正在处理…'));
|
||||
}
|
||||
function input(root: HTMLElement, label: string, value: string) {
|
||||
const element = [...root.querySelectorAll('label')].find((item) => item.textContent?.startsWith(label))?.querySelector('input');
|
||||
expect(element, label).toBeDefined(); element!.value = value; element!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('sources and subscriptions UI', () => {
|
||||
it('provides a stable route and separates member browsing from admin configuration', async () => {
|
||||
expect(parseRoute('#/sources')).toEqual({ name: 'sources' }); expect(routeHash({ name: 'sources' })).toBe('#/sources');
|
||||
const { screen, transport } = await setup(false);
|
||||
expect(screen.element.textContent).not.toContain('插件管理'); expect(screen.element.textContent).not.toContain('添加来源');
|
||||
expect(transport.requests.some((request) => request.url.endsWith('/plugins'))).toBe(false);
|
||||
await click(screen.element, '打开'); expect(screen.element.textContent).toContain('来源小说');
|
||||
input(screen.element, 'OPDS 密码', 'private'); await click(screen.element, '保存个人凭据');
|
||||
expect(bodyText(transport.requests.find((request) => request.url.endsWith('/credentials/password')))).toBe('{"value":"private"}');
|
||||
expect(screen.element.querySelector<HTMLInputElement>('input[type=password]')!.value).toBe('');
|
||||
});
|
||||
it('creates OPDS configuration, acquires books and enables automatic updates', async () => {
|
||||
const { screen, transport, onOpen } = await setup();
|
||||
await click(screen.element, '添加来源'); input(screen.element, '名称', '家庭书库'); input(screen.element, 'OPDS 地址', 'https://home.test/opds');
|
||||
await click(screen.element, '保存来源');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/sources') && request.method === 'POST')))).toMatchObject({
|
||||
name: '家庭书库', pluginId: 'reader.opds', config: { url: 'https://home.test/opds' },
|
||||
});
|
||||
await click(screen.element, '打开'); await click(screen.element, '下载 EPUB');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/acquire'))))).toEqual({ entryRef: 'book-ref', optionId: 'epub' });
|
||||
await click(screen.element, '阅读《来源小说》'); expect(onOpen).toHaveBeenCalledWith({ id: 'book1', title: '来源小说' });
|
||||
await click(screen.element, '自动追更 · 有更新'); await click(screen.element, '开启追更');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/subscription'))))).toEqual({ enabled: true });
|
||||
});
|
||||
it('requires the explicit trust control before installing and offers plugin lifecycle actions', async () => {
|
||||
const { screen, transport } = await setup();
|
||||
await click(screen.element, '插件管理'); expect(button(screen.element, '安装插件').disabled).toBe(true);
|
||||
input(screen.element, '已部署的插件目录名', 'external-source');
|
||||
const trusted = screen.element.querySelector<HTMLInputElement>('input[type=checkbox]')!;
|
||||
trusted.checked = true; trusted.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await click(screen.element, '安装插件');
|
||||
expect(JSON.parse(bodyText(transport.requests.find((request) => request.url.endsWith('/plugins') && request.method === 'POST')))).toEqual({ folder: 'external-source', trusted: true });
|
||||
await click(screen.element, '停用'); expect(transport.requests.some((request) => request.url.endsWith('/plugins/remote') && request.method === 'PATCH')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user