diff --git a/README.md b/README.md index f31ff7c..e1537c0 100644 --- a/README.md +++ b/README.md @@ -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/`、`#/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。 diff --git a/docs/api.md b/docs/api.md index 318d7c8..8aac65d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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= ``` -上述来源级资源接口只返回 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 账号。 ### 插件管理(管理员) diff --git a/docs/source-plugins.md b/docs/source-plugins.md index 975bd56..3e9c72a 100644 --- a/docs/source-plugins.md +++ b/docs/source-plugins.md @@ -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:` 引用同一插件的二进制资源。宿主清洗并内嵌图片后才返回给阅读器。来源开发预览始终返回 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 安装包未构建。 diff --git a/server/package-lock.json b/server/package-lock.json index d8b9ef7..869fd0c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 7258553..872b921 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 59704b0..870f920 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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, diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index be996c6..bf11921 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -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); } diff --git a/server/src/http/routes/sources.ts b/server/src/http/routes/sources.ts index e2d3ddf..5e20f5a 100644 --- a/server/src/http/routes/sources.ts +++ b/server/src/http/routes/sources.ts @@ -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) }; + }); 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 | null)?.enabled; - if (typeof enabled !== 'boolean') throw badRequest('enabled must be boolean'); - host.setEnabled(id, enabled); + const patch = (request.body ?? {}) as Record; + 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 }; diff --git a/server/src/publications/chapters.ts b/server/src/publications/chapters.ts index ea13711..c7c6f81 100644 --- a/server/src/publications/chapters.ts +++ b/server/src/publications/chapters.ts @@ -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>(); @@ -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 { + 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( - '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(key: string, action: () => Promise): Promise { diff --git a/server/src/publications/rich-content.ts b/server/src/publications/rich-content.ts new file mode 100644 index 0000000..405dc8d --- /dev/null +++ b/server/src/publications/rich-content.ts @@ -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): Promise { + const references = new Set(); + 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(); + 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; +} diff --git a/server/src/services/chapter-updates.ts b/server/src/services/chapter-updates.ts new file mode 100644 index 0000000..aaa488d --- /dev/null +++ b/server/src/services/chapter-updates.ts @@ -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; + private controller = new AbortController(); + private readonly checks = new Map>(); + constructor(private readonly db: Db, private readonly chapters: ChapterPublications, private readonly now = Date.now) {} + + list(userId: string) { + return this.db.all(`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 { + clearInterval(this.timer); this.timer = undefined; + this.controller.abort(); + await this.active; + } + + runDue(): Promise { + 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 { + 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 { + this.db.run('INSERT OR IGNORE INTO chapter_subscriptions (book_id) VALUES (?)', bookId); + const item = this.db.get(`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 { + const due = this.db.all(`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. + } + } + } +} diff --git a/server/src/services/source-host.ts b/server/src/services/source-host.ts index dbc01f7..062651b 100644 --- a/server/src/services/source-host.ts +++ b/server/src/services/source-host.ts @@ -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 { + 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) { diff --git a/server/src/sources/opds.ts b/server/src/sources/opds.ts index 82840ee..81419ae 100644 --- a/server/src/sources/opds.ts +++ b/server/src/sources/opds.ts @@ -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: { diff --git a/server/src/sources/process-plugin.ts b/server/src/sources/process-plugin.ts index 81cdc1a..ad5805a 100644 --- a/server/src/sources/process-plugin.ts +++ b/server/src/sources/process-plugin.ts @@ -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 = (method: string, ctx: SourceContext, args: Record): Promise => - this.request(method, { sourceType: type.id, context: context(ctx), ...args }, ctx.signal) as Promise; + const call = async (method: string, ctx: SourceContext, args: Record): Promise => { + const credentials: Record = 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; + }; const provider: SourceProvider = { descriptor: { ...type, version: this.manifest.version }, validateConfig: async (config) => { diff --git a/server/src/sources/registry.ts b/server/src/sources/registry.ts index 36d3de6..8b452a9 100644 --- a/server/src/sources/registry.ts +++ b/server/src/sources/registry.ts @@ -20,6 +20,12 @@ const CAPABILITIES = new Set([ ]); 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`); diff --git a/server/src/sources/types.ts b/server/src/sources/types.ts index 710fe2b..cbce251 100644 --- a/server/src/sources/types.ts +++ b/server/src/sources/types.ts @@ -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 { diff --git a/server/test/chapter-publications.test.ts b/server/test/chapter-publications.test.ts index 8845ad1..280b780 100644 --- a/server/test/chapter-publications.test.ts +++ b/server/test/chapter-publications.test.ts @@ -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: '

图文正文

' }; + 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 { diff --git a/server/test/rich-content.test.ts b/server/test/rich-content.test.ts new file mode 100644 index 0000000..e8d1873 --- /dev/null +++ b/server/test/rich-content.test.ts @@ -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(`

标题

正文 强调

+ + +
+ 链接
安全文本
`, async (ref) => { + calls.push(ref); return { mediaType: 'image/png', data: png }; + }); + assert.deepEqual(calls, ['opaque?x=1&y=2']); + assert.match(html, /

标题<\/h2>/); assert.match(html, /强调<\/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('', async () => ({ mediaType: 'image/svg+xml', data: Buffer.from('') })), { code: 'INVALID_RESOURCE' }); + await assert.rejects(() => richContent('', async () => ({ mediaType: 'image/png', data: Buffer.from('') })), { code: 'INVALID_RESOURCE' }); + await assert.rejects(() => richContent(Array.from({ length: 33 }, (_, 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(''.repeat(8), async () => ({ mediaType: 'image/png', data: big })), { code: 'CHAPTER_TOO_LARGE' }); + assert.throws(() => chapterMedia('text/html; charset = gbk'), { code: 'UNSUPPORTED_FORMAT' }); +}); diff --git a/server/test/sources-api.test.ts b/server/test/sources-api.test.ts index fc293d8..2ff2ca1 100644 --- a/server/test/sources-api.test.ts +++ b/server/test/sources-api.test.ts @@ -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; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7fe1501..017b1b5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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; @@ -227,6 +228,39 @@ export class ReaderApi { // ---- shelf ---- + async sourceTypes(): Promise { return (await this.get<{ types: SourceType[] }>('/api/v1/sources/types')).types; } + async sources(): Promise { return (await this.get<{ sources: SourceInstance[] }>('/api/v1/sources')).sources; } + async plugins(): Promise { return (await this.get<{ plugins: SourcePlugin[] }>('/api/v1/plugins')).plugins; } + async saveSource(id: string | null, input: Record): Promise { + return (await this.call<{ source: SourceInstance }>(`/api/v1/sources${id ? `/${encodeURIComponent(id)}` : ''}`, id ? 'PATCH' : 'POST', input)).source; + } + async removeSource(id: string): Promise { await this.call(`/api/v1/sources/${encodeURIComponent(id)}`, 'DELETE'); } + async sourceCredential(id: string, key: string, value: string): Promise { + await this.call(`/api/v1/sources/${encodeURIComponent(id)}/credentials/${encodeURIComponent(key)}`, 'PUT', { value }); + } + async installPlugin(folder: string): Promise { await this.call('/api/v1/plugins', 'POST', { folder, trusted: true }); } + async enablePlugin(id: string, enabled: boolean): Promise { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'PATCH', { enabled }); } + async uninstallPlugin(id: string): Promise { await this.call(`/api/v1/plugins/${encodeURIComponent(id)}`, 'DELETE'); } + async sourceCatalog(id: string, query: { ref?: string; query?: string; cursor?: string } = {}): Promise { + 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 { + return this.get(`/api/v1/sources/${encodeURIComponent(id)}/entries?ref=${encodeURIComponent(ref)}`); + } + async acquireSource(id: string, entryRef: string, optionId?: string): Promise { + return this.call(`/api/v1/sources/${encodeURIComponent(id)}/acquire`, 'POST', { entryRef, ...(optionId ? { optionId } : {}) }); + } + async subscriptions(): Promise { + return (await this.get<{ subscriptions: ChapterSubscription[] }>('/api/v1/subscriptions')).subscriptions; + } + async configureSubscription(id: string, patch: { enabled?: boolean; intervalMinutes?: number; acknowledge?: boolean }): Promise { + await this.call(`/api/v1/books/${encodeURIComponent(id)}/subscription`, 'PATCH', patch); + } + async listBooks(query: ListQuery = {}, options: RequestOptions = {}): Promise { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts index 89d6f74..0ce9d3a 100644 --- a/web/src/api/errors.ts +++ b/web/src/api/errors.ts @@ -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); diff --git a/web/src/api/sources.ts b/web/src/api/sources.ts new file mode 100644 index 0000000..87032ab --- /dev/null +++ b/web/src/api/sources.ts @@ -0,0 +1,29 @@ +export interface SourceType { + id: string; pluginId: string; label: string; version: string; builtin: boolean; capabilities: string[]; + configSchema?: { properties?: Record; required?: string[] }; + credentialKeys?: Array<{ key: string; label: string }>; +} +export interface SourceInstance { + id: string; pluginId: string; sourceType: string; name: string; enabled: boolean; + config?: Record; 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; +} diff --git a/web/src/app.ts b/web/src/app.ts index c474463..474ed74 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -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, diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 72ac42b..45e759d 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/ui/router.ts b/web/src/ui/router.ts index 65c8385..257e78b 100644 --- a/web/src/ui/router.ts +++ b/web/src/ui/router.ts @@ -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 diff --git a/web/src/ui/shelf-screen.tsx b/web/src/ui/shelf-screen.tsx index 937b779..4e9d7a5 100644 --- a/web/src/ui/shelf-screen.tsx +++ b/web/src/ui/shelf-screen.tsx @@ -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 && } ; 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; + 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 = {}; + 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 { 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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 <> +

书源与追更

+
+ +
{this.busy ? '正在处理…' : this.message}
+
+ {this.tab === 'sources' && <> +

我的来源

+ {this.options.admin && } + {this.sources.map((source) =>
+
{source.name}{source.descriptor?.label ?? '插件未启用或未安装'} · {source.enabled ? '已启用' : '已暂停'}
+
+ {this.options.admin && <> + } +
)} +
+ {this.editor &&

{this.editor.id ? '编辑来源' : '添加来源'}

+
{ event.preventDefault(); void this.run(() => this.save()); }}> + + + {type?.configSchema?.properties ? Object.entries(type.configSchema.properties).map(([key, field]) =>