From 037bdd6880279f8bd6796e99d2e2eed98d3bafda Mon Sep 17 00:00:00 2001 From: hectorqin Date: Sun, 20 Sep 2026 17:51:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(sources):=20=E5=A2=9E=E5=8A=A0=E5=86=85?= =?UTF-8?q?=E7=BD=AE=20local=E3=80=81OPDS=20=E4=B8=8E=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/api.md | 162 ++++++- docs/architecture.md | 2 + docs/source-plugins.md | 465 +++++++++++++++++++++ examples/plugins/demo-chapters/main.mjs | 54 +++ examples/plugins/demo-chapters/plugin.json | 16 + server/src/config/index.ts | 11 +- server/src/db/schema.ts | 42 ++ server/src/http/app.ts | 5 + server/src/http/context.ts | 2 + server/src/http/routes/library.ts | 82 +--- server/src/http/routes/sources.ts | 146 +++++++ server/src/indexer/formats/registry.ts | 2 +- server/src/indexer/scanner.ts | 1 + server/src/main.ts | 1 + server/src/publications/files.ts | 63 +++ server/src/services/browse.ts | 14 +- server/src/services/shelf.ts | 2 +- server/src/services/source-host.ts | 218 ++++++++++ server/src/services/sync.ts | 3 +- server/src/sources/acquisitions.ts | 223 ++++++++++ server/src/sources/local.ts | 55 +++ server/src/sources/opds.ts | 434 +++++++++++++++++++ server/src/sources/plugin-manager.ts | 283 +++++++++++++ server/src/sources/process-plugin.ts | 286 +++++++++++++ server/src/sources/protocol.ts | 100 +++++ server/src/sources/registry.ts | 189 +++++++++ server/src/sources/types.ts | 239 +++++++++++ server/test/acquisitions.test.ts | 226 ++++++++++ server/test/api.test.ts | 10 +- server/test/browse.test.ts | 1 + server/test/config.test.ts | 39 +- server/test/formats.test.ts | 1 + server/test/opds.test.ts | 222 ++++++++++ server/test/plugin-manager.test.ts | 239 +++++++++++ server/test/source-plugins.test.ts | 154 +++++++ server/test/sources-api.test.ts | 389 +++++++++++++++++ server/test/tts.test.ts | 10 + server/test/uploads.test.ts | 1 + 39 files changed, 4303 insertions(+), 91 deletions(-) create mode 100644 docs/source-plugins.md create mode 100644 examples/plugins/demo-chapters/main.mjs create mode 100644 examples/plugins/demo-chapters/plugin.json create mode 100644 server/src/http/routes/sources.ts create mode 100644 server/src/publications/files.ts create mode 100644 server/src/services/source-host.ts create mode 100644 server/src/sources/acquisitions.ts create mode 100644 server/src/sources/local.ts create mode 100644 server/src/sources/opds.ts create mode 100644 server/src/sources/plugin-manager.ts create mode 100644 server/src/sources/process-plugin.ts create mode 100644 server/src/sources/protocol.ts create mode 100644 server/src/sources/registry.ts create mode 100644 server/src/sources/types.ts create mode 100644 server/test/acquisitions.test.ts create mode 100644 server/test/opds.test.ts create mode 100644 server/test/plugin-manager.test.ts create mode 100644 server/test/source-plugins.test.ts create mode 100644 server/test/sources-api.test.ts diff --git a/README.md b/README.md index 8ffe6b5..ffb0adb 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ | 渲染层 | `web/` | TypeScript + Vite,EPUB 精排、TXT、漫画、PDF | | Android 壳 | `android/` | Kotlin + WebView,复用上面那一份渲染层 | +服务端已提供内置 local / OPDS 来源与受信任进程插件接口。OPDS 下载后复用现有书架和阅读器;插件章节目前提供开发接口,管理界面与章节书架接入仍在后续计划中。见 [插件设计与实施状态](docs/source-plugins.md) 和 [API 与示例插件安装](docs/api.md#来源与插件)。 + --- ## 一条命令跑起来 diff --git a/docs/api.md b/docs/api.md index f85b2b0..5c79203 100644 --- a/docs/api.md +++ b/docs/api.md @@ -781,6 +781,142 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** --- +## 来源与插件 + +来源接口把本地书、OPDS 和外部章节插件统一成同一组发现与获取操作。来源实例由管理员创建;来源配置在实例级共享,登录凭据通过用户级凭据接口保存。插件运行在独立的受信任 Node 进程中,安装前必须确认它拥有服务端操作系统权限。 + +### `GET /sources/types` + +返回当前已注册的内置和外部来源类型: + +```json +{ + "types": [ + { "id": "local", "pluginId": "reader.local", "builtin": true, "label": "本地书库", "version": "1.0.0", "capabilities": ["browse", "search", "detail"] }, + { "id": "opds", "pluginId": "reader.opds", "builtin": true, "label": "OPDS", "version": "1.0.0", "capabilities": ["browse", "search", "detail", "acquire.file"] } + ] +} +``` + +### `GET /sources` + +登录用户可以看到来源实例,响应为 `{ "sources": [...] }`;每项有 `id/pluginId/sourceType/name/enabled/descriptor`,只有管理员返回 `config`。服务端启动时自动创建 `local` 实例。 + +### `POST /sources` — 管理员 + +创建来源: + +```http +POST /api/v1/sources +Authorization: Bearer +Content-Type: application/json + +{"pluginId":"reader.opds","sourceType":"opds","name":"家庭 OPDS","config":{"url":"https://books.example.test/opds","username":"reader"}} +``` + +返回 `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 或删除来源的接口。 + +### 来源浏览、搜索和详情 + +```text +GET /api/v1/sources/:id/browse?ref=&cursor=&limit=<1..200> +GET /api/v1/sources/:id/search?q=&cursor=&limit=<1..200> +GET /api/v1/sources/:id/entries?ref= +``` + +目录响应形如 `{ "items": [...], "navigation": [...], "nextCursor": "...", "title": "..." }`;可选字段可省略。`ref` 和 `cursor` 是来源拥有的不透明值,客户端只保存并原样回传。条目返回 `ref`、标题、作者、封面和 `options`;不要从 `ref` 推断 URL 或拼接下一页地址。引用和查询参数必须为非空字符串且不超过 16,384 字符;路径中的 `publicationRef` 需要 URL 编码,路由允许最大 16,384 字符的参数。 + +### 凭据与来源启停 + +```http +PUT /api/v1/sources/:id/credentials/password +Authorization: Bearer +Content-Type: application/json + +{"value":"用户自己的 OPDS 密码"} +``` + +返回 `{ "ok": true }`。`value` 必须为字符串,允许空密码,最大 16,384 字符。密码按 `(sourceId, userId, key)` 隔离并以 AES-256-GCM 加密保存,普通来源列表不会回显。管理员可用 `PATCH /api/v1/sources/:id` 携带 `{ "enabled": false }` 暂停来源;已获取到 `DATA_DIR/acquired` 的书不会被删除。 + +### 获取内容、目录和章节资源 + +```http +POST /api/v1/sources/:id/acquire +Authorization: Bearer +Content-Type: application/json + +{"entryRef":"","optionId":""} +``` + +内置 `local` 直接返回已有书的 `publicationId`。OPDS 文件会同步下载,成功响应为 `{ "kind":"ready", "publicationId":"" }`;单文件上限 256 MiB,下载完成后计算真实 SHA-256,写入 `DATA_DIR/acquired`,复用现有格式解析和阅读端点,并将书加入当前用户书架。取消、超限或解析失败不会留下临时文件。 + +章节插件返回 `{ "kind":"chapters", "publicationRef":"..." }`,随后调用: + +```text +GET /api/v1/sources/:id/publications/:publicationRef/manifest +GET /api/v1/sources/:id/publications/:publicationRef/resource?ref= +``` + +首个进程插件版本的资源接口只返回 JSON:章节文本用 `text`,二进制小资源用 `base64`;不会把插件返回的 HTML 作为应用页面执行。章节 publication 的书架、离线缓存和追更尚未接入。 + +### 插件管理(管理员) + +插件包需要管理员预先放入 `DATA_DIR/plugins/`,当前安装接口只接受目录名,不上传或解压包: + +```http +POST /api/v1/plugins +Authorization: Bearer +Content-Type: application/json + +{"folder":"demo-chapters","trusted":true} +``` + +启停和卸载: + +```text +GET /api/v1/plugins +PATCH /api/v1/plugins/:id body: {"enabled":true|false} +DELETE /api/v1/plugins/:id 保留来源实例、已获取书和数据 +``` + +安装返回 `201 { "plugin": {...} }`,列表返回 `{ "plugins": [...] }`,启停返回 `{ "plugin": {...} }`,卸载返回 `{ "ok": true }`。插件状态包含 `pluginId/builtin/enabled/sourceTypes/runtime`,外部插件还带 `folder/name/version`,加载失败时有 `error`。卸载会移除注册和安装记录,保留包文件、来源实例、凭据和书籍数据。 + +禁用或插件进程故障不会删除已下载文件。当前没有包上传、在线升级或回滚接口;内置 `reader.local` 和 `reader.opds` 不可卸载、禁用或替换。已安装插件失败后可用 `PATCH ... {"enabled":true}` 重新加载。 + +### 可运行示例:demo-chapters + +仓库中的 [`examples/plugins/demo-chapters`](../examples/plugins/demo-chapters) 是一个不联网的静态章节插件。按以下步骤验证完整调用链(服务端已启动且已有管理员令牌): + +```powershell +# 在仓库根目录执行;改为服务端实际使用的 DATA_DIR 和管理员令牌。 +$readerDataDir = 'D:\reader-data' +$readerApi = 'http://localhost:8080/api/v1' +$readerHeaders = @{ Authorization = 'Bearer ' } +New-Item -ItemType Directory -Force (Join-Path $readerDataDir 'plugins') +Copy-Item -Recurse examples/plugins/demo-chapters (Join-Path $readerDataDir 'plugins/demo-chapters') + +Invoke-RestMethod -Method Post -Uri "$readerApi/plugins" -Headers $readerHeaders ` + -ContentType 'application/json' -Body '{"folder":"demo-chapters","trusted":true}' +$createdSource = Invoke-RestMethod -Method Post -Uri "$readerApi/sources" -Headers $readerHeaders ` + -ContentType 'application/json' -Body '{"pluginId":"reader.source.demo","sourceType":"demo-chapters","name":"Demo chapters","config":{}}' +$readerSourceId = $createdSource.source.id + +Invoke-RestMethod -Uri "$readerApi/sources/$readerSourceId/browse" -Headers $readerHeaders +Invoke-RestMethod -Uri "$readerApi/sources/$readerSourceId/entries?ref=demo-book" -Headers $readerHeaders +Invoke-RestMethod -Method Post -Uri "$readerApi/sources/$readerSourceId/acquire" -Headers $readerHeaders ` + -ContentType 'application/json' -Body '{"entryRef":"demo-book"}' +Invoke-RestMethod -Uri "$readerApi/sources/$readerSourceId/publications/demo-book/manifest" -Headers $readerHeaders +Invoke-RestMethod -Uri "$readerApi/sources/$readerSourceId/publications/demo-book/resource?ref=chapter-one" -Headers $readerHeaders +``` + +资源响应中的 `text` 应包含“这是通过独立 Node 进程提供的示例章节”。普通成员也可使用自己的令牌调用浏览与章节端点;只有安装插件、创建和启停来源需要管理员。 + +宿主来源调用预算为 60 秒,同一来源实例最多 2 个并发调用,宿主总计最多 8 个,超限立即返回 `429 RATE_LIMITED`。插件每次 RPC 预算 30 秒、单条 JSON 消息上限 2 MiB(包含 base64 开销);超时、取消或协议错误会终止整个插件进程及其在途请求。外部进程暂不支持 `acquire.file`,大文件获取由内置 OPDS 提供。 + +--- + ## 管理员 ### `GET /admin/users` @@ -807,7 +943,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** → `{ "providers": [ { "id": "none", "displayName": "...", "enabled": true } ] }` -第一版只有 `none`。在线源接入后会自动出现在这里。 +第一版只有 `none`。此处是元数据补全 provider,与 `/sources` 内容来源独立;安装书源插件不会改变该列表。 --- @@ -850,6 +986,30 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** | `TTS_DISABLED` | 400 | 该实例没有配置 `TTS_URL`,无法使用 HTTP 朗读 | | `TTS_UPSTREAM` | 400 | 上游合成服务不可达、超时、报错,或返回的不是音频 | | `TEXT_TOO_LONG` | 400 | 单条语句超过 800 字,请客户端先切句 | +| `INVALID_SOURCE_CONFIG` | 400 | 来源配置不符合内置来源要求 | +| `SOURCE_UNSUPPORTED` | 400 | 来源未声明或未实现请求能力 | +| `SOURCE_DISABLED` | 400 | 来源实例已停用 | +| `SOURCE_NOT_FOUND` | 404 | 来源实例不存在 | +| `SOURCE_TYPE_NOT_FOUND` | 404 | 来源类型未安装 | +| `ENTRY_NOT_FOUND` | 404 | 来源条目不存在 | +| `AUTH_REQUIRED` | 401 | 来源需要凭据或凭据无法解密 | +| `OPDS_UNSAFE_URL` | 400 | OPDS 链接跳转到未允许的 origin 或包含 URL 凭据 | +| `RESOURCE_TOO_LARGE` | 413 | OPDS Feed 或资源超过大小限制 | +| `ACQUISITION_TOO_LARGE` | 413 | 获取文件超过 256 MiB 上限 | +| `INVALID_RESOURCE` | 400 | 获取内容为空、媒体类型不匹配或解析失败 | +| `PLUGIN_TRUST_REQUIRED` | 400 | 安装受信任进程插件时未显式确认权限 | +| `PLUGIN_UNAVAILABLE` | 404 / 503 | 来源插件未注册,或进程不可用 | +| `RATE_LIMITED` | 429 | 上游限流或宿主来源并发达到上限 | +| `PLUGIN_TIMEOUT` | 504 | 插件单次 RPC 超过 30 秒 | +| `PLUGIN_PROTOCOL_ERROR` | 502 | 插件返回了不符合协议的 JSON | +| `PLUGIN_INCOMPATIBLE` | 400 | 安装包的 API 版本或运行时不受支持 | +| `PLUGIN_PACKAGE_NOT_FOUND` | 404 | 插件目录、清单或入口文件不存在 | +| `PLUGIN_INVALID_MANIFEST` | 400 | 插件清单 JSON 或字段不合法 | +| `PLUGIN_PATH_ESCAPE` | 400 | 插件目录不在 `DATA_DIR/plugins` 内 | +| `PLUGIN_ALREADY_INSTALLED` | 409 | 插件 ID 或目录已安装 | +| `BUILTIN_PLUGIN_IMMUTABLE` | 400 | 内置来源不能停用、卸载或替换 | +| `SOURCE_TIMEOUT` | 504 | 宿主来源调用超过 60 秒 | +| `SOURCE_CANCELLED` | 499 | 客户端断开或主动取消来源调用 | | `INTERNAL` | 500 | 服务端错误 | ## 客户端应当遵守的约定 diff --git a/docs/architecture.md b/docs/architecture.md index 4073244..1f2f230 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,6 +3,8 @@ 本文记录**为什么这样设计**,尤其是那些一旦写错就很难回头的决策。 产品设计文档(v0.2)里的约束在这里落成了实现,逐条对应。 +来源扩展与插件系统的设计、接口及实施状态见 [来源插件系统设计](source-plugins.md)。该文档区分目标架构和当前开发切片,本页现有本地书籍身份及兼容约束继续适用。 + --- ## 1. 数据边界 diff --git a/docs/source-plugins.md b/docs/source-plugins.md new file mode 100644 index 0000000..7a9bccf --- /dev/null +++ b/docs/source-plugins.md @@ -0,0 +1,465 @@ +# 来源插件系统设计 + +状态:设计已确定,服务端首个切片已实现 + +适用接口:`/api/v1`,来源插件协议 1 + +更新日期:2026-09-20 + +本文定义 reader 对本地书库、OPDS 书库和远程书源的统一扩展架构。目标是让核心程序只维护一套书架、阅读、缓存和任务逻辑;内置 `local`、`opds` 两种来源;远程书源以独立插件开发、安装和升级。 + +本文区分目标架构和本轮交付。当前已实现 local、OPDS 下载入库、受信任 Node 来源插件及管理接口;外部章节提供目录和资源开发预览,尚未接入书架、离线缓存和追更。交付状态与验证见 §13,调用示例见 [API 参考](api.md#来源与插件)。 + +## 1. 目标与范围 + +### 1.1 目标 + +- 通过同一套来源接口支持本地书、OPDS 目录和远程章节书源。 +- 新增远程站点时只安装或更新插件,不修改核心路由、书架和阅读器。 +- 将来源协议复杂性封装在深模块内:调用方只理解发现、详情和获取内容。 +- 让文件内容和在线章节都进入现有 `manifest / toc / assets` 阅读协议。 +- 插件崩溃、超时或禁用时,不破坏书架、阅读进度和已经缓存的内容。 +- 插件升级保持来源引用和章节身份稳定,并支持可验证的状态迁移。 +- 通过能力声明、凭据隔离、网络权限和独立进程降低插件风险。 + +### 1.2 完整版本目标范围 + +包括: + +- 宿主插件注册表、安装/启用/禁用/升级流程。 +- 内置 `local`:扫描 `BOOKS_DIR`,复用现有格式处理器。 +- 内置 `opds`:支持 OPDS 1.x Atom、OPDS 2 JSON、导航、分页、搜索(若目录提供)和文件获取。 +- 外部 Node.js 进程插件及版本化 RPC。 +- 远程书源的搜索、详情、目录、章节正文和图片资源。 +- 宿主统一缓存、下载任务、限流、错误码和日志。 +- 来源配置 JSON Schema 和凭据引用。 + +不包括: + +- 插件直接替换阅读器 UI 或注入任意前端代码。 +- 不可信插件的完整操作系统级沙箱。首个切片只运行管理员明确安装的受信任插件;需要容器或系统沙箱时另行实现。 +- 自动把不同来源的版本合并为同一作品并迁移进度。先保留独立版本,后续可增加可选 `workId`。 + +## 2. 术语与设计原则 + +| 术语 | 定义 | +| --- | --- | +| 插件包(Plugin package) | 可安装、校验、升级的代码与声明,例如 `reader.source.example`。 | +| 来源实例(Source instance) | 一个具体配置,例如 `/books`、某个 OPDS 地址或一个站点账号。 | +| 来源条目(Source entry) | 来源目录中的书,以 `(sourceInstanceId, externalId)` 标识。 | +| 出版物(Publication) | 可阅读的具体版本,拥有自己的目录、章节/文件和定位体系。 | +| 资源(Resource) | 文件、章节正文、图片、字体等可读取内容。 | +| 宿主(Host) | reader 服务端,负责插件调度和用户数据。 | + +设计遵循 `docs/architecture.md` 的数据边界:`BOOKS_DIR` 是用户书库,`DATA_DIR` 存数据库、下载、缓存和插件状态。插件不直接访问宿主数据库,也不把任意路径或远程 URL 交给客户端。 + +来源接口是外部 seam。`local`、`opds` 和远程插件是该 seam 上的 adapter;宿主将鉴权、任务、缓存、资源流和错误语义放在接口之后,调用方不需要知道每个来源的协议细节。 + +## 3. 总体架构 + +```mermaid +flowchart TB + UI[书库/搜索/书架] --> API[来源 HTTP API] + API --> Host[SourceHost] + Host --> Registry[PluginRegistry] + Registry --> BuiltinRunner[BuiltinRunner] + Registry --> ProcessRunner[ProcessRunner] + BuiltinRunner --> Local[local adapter] + BuiltinRunner --> OPDS[opds adapter] + ProcessRunner --> Remote[外部来源插件] + Host --> Runtime[Runtime:HTTP/凭据/缓存/任务/日志] + Host --> Content[PublicationContent:manifest/toc/assets] + Content --> Reader[现有阅读器] + Local --> Content + OPDS --> Content + Remote --> Content +``` + +### 3.1 模块职责 + +| 模块 | 职责 | 不负责 | +| --- | --- | --- | +| `PluginRegistry` | 发现插件包、校验 manifest、选择版本、记录状态 | 不执行来源业务 | +| `SourceHost` | 将来源调用包装成统一接口;管理实例、能力、超时、任务 | 不解析具体网站规则 | +| `BuiltinRunner` | 在宿主进程内运行受控的 `local` 和 `opds` adapter | 不加载外部任意代码 | +| `ProcessRunner` | 启动和监督外部插件进程,执行 RPC、取消和资源流 | 不拥有书架或用户数据 | +| `Runtime` | HTTP、凭据、命名空间存储、缓存、限流、日志、取消 | 不定义来源协议 | +| `PublicationContent` | 将文件或章节绑定转换为统一阅读协议 | 不负责搜索和目录发现 | +| `JobManager` | 下载、扫描、刷新目录、离线缓存、重试和进度 | 不修改插件内部状态 | +| `Book/Shelf` | 持久化 publication、书架、进度、笔记 | 不根据书名猜测来源身份 | + +## 4. 来源接口 + +接口以 [`server/src/sources/types.ts`](../server/src/sources/types.ts) 为准。以下摘录首个切片的 SDK 契约;可选能力通过 `capabilities` 声明,注册表拒绝声明能力却缺少对应方法的提供者。 + +```ts +interface SourceProvider { + readonly descriptor: SourceDescriptor; + validateConfig?(config: unknown): void | Promise; + browse?(ctx: SourceContext, request: BrowseRequest): Promise; + search?(ctx: SourceContext, request: SearchRequest): Promise; + detail(ctx: SourceContext, entryRef: string): Promise; + acquire(ctx: SourceContext, request: AcquireRequest): Promise; + openFile?(ctx: SourceContext, + acquisition: Extract): Promise; + getManifest?(ctx: SourceContext, publicationRef: string): Promise; + readResource?(ctx: SourceContext, request: ResourceRequest): Promise; +} +``` + +`SourceProvider` 的接口保持小而稳定。适配器内部可以实现规则解析、登录流程、分页协议和站点兼容逻辑;宿主只依赖这些方法的输入、输出、取消、超时和错误不变量。 + +### 4.1 目录和条目 + +```ts +interface SourceDescriptor { + id: string; + label: string; + version: string; + capabilities: SourceCapability[]; + configSchema?: unknown; +} + +interface CatalogPage { + items: CatalogEntry[]; + navigation?: NavigationEntry[]; + nextCursor?: string; + title?: string; +} + +interface NavigationEntry { + ref: string; + title: string; + kind?: 'catalog' | 'search' | 'collection'; +} + +interface CatalogEntry { + ref: string; + title: string; + authors?: string[]; + description?: string; + language?: string; + coverUrl?: string; + options?: AcquisitionOption[]; + metadata?: Record; +} +``` + +`entryRef`、游标和导航引用由来源解释。宿主只做不透明保存和回传,不拼接 URL,也不把它当作数据库主键。 + +### 4.2 内容获取 + +```ts +type Acquisition = + | { kind: 'ready'; publicationId: string } + | { kind: 'file'; acquisitionRef: string; mediaType: string } + | { kind: 'chapters'; publicationRef: string } + | { kind: 'action-required'; action: AcquisitionAction }; +``` + +`Acquisition` 是提供者返回给宿主的内容绑定。`local` 可以返回已经存在的 `publicationId`;远程提供者返回文件或章节引用,由宿主分配稳定身份并处理获取流程。任何 `ready` ID 都必须由宿主验证确实存在且调用用户有权访问,不能直接信任外部插件的声明。后续引入持久化任务时,HTTP 响应可新增 `pending/jobId`,它不是首个切片的插件返回值。 + +文件内容和章节内容由宿主内部统一为: + +```ts +interface PublicationContent { + manifest(ctx: ReadContext): Promise; + toc(ctx: ReadContext): Promise; + asset(ctx: ReadContext, ref: string): Promise; +} +``` + +SDK 的 `ResourceResponse` 支持 `mediaType`、`size`(若已知)、`data`、`text` 和 `stream`。内置 OPDS 提供者通过流下载。首个外部进程只支持章节来源,声明 `acquire.file` 会被拒绝;资源使用有大小上限的 `text/base64` JSON,适合章节和小图片。HTTP 开发预览始终返回 JSON,不在应用同源下直接执行插件 HTML。后续资源通道支持背压和取消后,外部插件才适合传输大文件。 + +### 4.3 能力 + +首个切片的能力枚举: + +```text +browse +search +detail +acquire.file +acquire.chapters +content.manifest +content.resource +content.update +``` + +来源级能力决定 UI 是否显示入口;条目级 `options` 决定本书是否支持某种格式、下载或试读。能力缺失统一返回 `CAPABILITY_UNSUPPORTED`。 + +## 5. 插件包与 SDK + +### 5.1 manifest + +```json +{ + "id": "reader.source.example", + "name": "示例远程书源", + "version": "1.0.0", + "apiVersion": 1, + "runtime": "node", + "entry": "dist/main.js", + "sourceTypes": [ + { + "id": "example", + "label": "示例书源", + "capabilities": ["search", "detail", "acquire.chapters", "content.manifest", "content.resource"], + "configSchema": { "type": "object", "properties": {} } + } + ], + "permissions": { + "network": { "domains": ["example.com", "*.example.com"] }, + "storage": true, + "credentials": true + } +} +``` + +目标安装流程校验插件 ID、版本、宿主 `apiVersion`、入口文件、来源类型唯一性、JSON Schema 和权限。首个切片的 manifest 校验集中在 [`registry.ts`](../server/src/sources/registry.ts),进程启动另行检查支持的协议版本和入口路径。语义化版本范围、包内容哈希、签名和完整权限 schema 校验属于后续包管理工作。 + +### 5.2 宿主能力 + +SDK 预留以下能力;首个切片的 `SourceContext` 中能力注入是可选的,独立进程内暂未实现宿主能力的反向 RPC,不能假设以下约束已强制生效: + +| 能力 | 规则 | +| --- | --- | +| `http` | 超时、重定向检查、Cookie、限流和允许域名均由宿主执行。 | +| `credentials` | 按用户和来源实例隔离;插件只能通过引用读取,日志自动脱敏。 | +| `storage` | 插件/来源实例命名空间 KV,带 schema 版本;不能访问 SQLite。 | +| `cache` | 由宿主控制配额和淘汰;缓存键含来源、账号和资源身份。 | +| `logger` | 结构化日志、级别和脱敏由宿主控制。 | +| `signal` | 每次调用都带取消信号、截止时间和最大响应大小。 | + +宿主不提供前端 DOM 和数据库接口。首个切片中的受信任 Node 进程仍可使用 Node 的网络、文件和子进程能力,独立进程只提供故障隔离;系统沙箱落地后才可以强制限制这些直接调用。 + +### 5.3 RPC + +首个切片采用一行一个 JSON 的 stdio JSON-RPC 2.0;stdout 只输出协议消息,插件日志写 stderr: + +```text +request = { jsonrpc: "2.0", id, method, + params: { sourceType, context: { instance, userId }, ... } } +response = { jsonrpc: "2.0", id, result } +error = { jsonrpc: "2.0", id, error: { code, message, data? } } +``` + +宿主每次来源调用预算为 60 秒,同一来源实例最多并发 2 次,宿主共 8 次,超限返回 `429 RATE_LIMITED`。插件单次 RPC 预算为 30 秒,每条 JSON 消息上限为 2 MiB(包括 base64 和协议字段)。请求取消、超时、协议错误或进程异常退出会终止 worker,全部在途调用失败;取消是硬停止,不是合作式取消。插件进入 `failed` 后可通过管理员重新启用来加载新进程。重试由调用方显式决定,不自动重复 `acquire` 等可能有副作用的调用。后续再加入进度事件、背压流和宿主能力的反向调用。 + +## 6. 内置来源 + +### 6.1 `local` + +- 自动创建 `local` 实例,配置固定为 `{}`,使用宿主 `BOOKS_DIR`;首个切片不支持每个实例覆盖目录。 +- 由宿主扫描器负责增量检测,路径必须通过 `resolveInside()`,不跟随越界符号链接。 +- 文件格式继续复用现有 `server/src/indexer/formats` 处理器。 +- 本地文件作为 `Publication` 的 `file` 绑定;书籍身份继续遵循现有 `content_hash` 规则。 +- 文件读取继续保持原接口的缺失错误语义(如 `FILE_MISSING`);本轮不改已有书架、进度及扫描清理规则。 + +### 6.2 `opds` + +- 支持 OPDS 1.x Atom 和 OPDS 2 JSON;解析导航、分页、搜索入口、封面和 acquisition link。 +- 不假设每个链接都可下载。根据 `rel`、`type` 和条目状态产生 `AcquisitionOption`。 +- 首个切片支持公开或 Basic 认证的 EPUB、PDF、TXT、CBZ 下载;借阅、购买、试读等无法直接获取的关系返回 `action-required`。 +- 来源地址、用户名、附加下载 origin 由管理员配置并共享;密码通过凭据接口按用户和来源隔离,以 AES-256-GCM 加密保存。密码不写在普通配置中。 +- 导航和 Feed 请求限制在配置地址的 origin,附加下载 origin 必须列入 `allowedOrigins`;每次重定向重新校验,跨 origin 不转发 Basic 凭据。此策略允许管理员配置内网 OPDS,不等于系统级网络隔离。 +- 下载同步完成后返回 `ready`,没有后台任务 ID。临时文件写入 `DATA_DIR/acquired`,单文件最大 256 MiB;完成后计算真实 SHA-256、解析格式、原子改名并事务写入 `books`、`acquired_files`、`source_acquisitions`,加入调用用户书架。超限、取消和入库失败清理本次文件;现有书籍元数据保持不变。 +- 下载后的 EPUB/PDF 走与 `local` 相同的格式解析和阅读路径。 + +### 6.3 外部远程书源 + +目标上插件负责搜索、详情、目录快照、正文清洗和图片引用;宿主负责缓存、限流、任务、权限和阅读协议。本轮先实现搜索、详情、获取章节绑定、目录和资源的契约及开发预览;章节 publication 持久化、正文渲染清洗、离线缓存和追更仍待实施。 + +## 7. 数据模型 + +首个切片使用增量表,保留已有 `books`、`book_files`、`user_books` 和 `reading_progress`,没有新建 `publications` 或重写旧书 ID: + +| 表 | 当前用途 | +| --- | --- | +| `source_instances` | 共享来源配置:`id/plugin_id/source_type/name/config_json/enabled/created_at`。 | +| `source_credentials` | `(source_id, user_id, key)` 对应的加密凭据。 | +| `acquired_files` | `book_id` 对应的 `DATA_DIR` 相对文件路径和大小,与扫描目录分离。 | +| `source_acquisitions` | `(source_id, user_id, entry_ref, option_id)` 到 `book_id` 的映射。 | +| `plugin_storage` | 宿主 KV,按来源和用户命名空间保存;外部进程尚无反向访问接口。 | +| `installed_plugins` | 插件 ID、已放置的包目录名及启用状态。 | + +来源实例由管理员配置,对登录用户共享。凭据、获取记录和书架属于当前用户;普通用户的来源列表不返回 `config`。禁用来源不删除已下载文件。 + +下面是后续接入在线章节时的逻辑模型,不是本轮实际 schema: + +```text +source_instances + id, plugin_id, source_type, label, config_json, credential_ref, + enabled, state, state_version, created_at, updated_at + +source_entries + id, source_instance_id, external_ref, metadata_json, + last_seen_at, created_at, updated_at + +publications + id, source_entry_id?, content_kind, media_type, content_version, + availability, created_at, updated_at + +publication_resources + id, publication_id, resource_ref, local_path?, cache_key?, + size, media_type, checksum, state, updated_at + +shelf_items + user_id, publication_id, hidden, created_at, updated_at + +reading_states + user_id, publication_id, locator_json, percent, updated_at +``` + +约束: + +- `source_entries` 的唯一键是 `(source_instance_id, external_ref)`。 +- `external_ref`、OPDS URL、章节序号都不是 `publicationId`。 +- 章节必须有稳定 ID;目录顺序单独保存,插章不会让旧进度指向另一章。 +- 来源失效或插件禁用后,已缓存 publication 仍可读;卸载插件默认保留用户数据。 +- 初期不同来源版本不自动合并进度;未来的作品聚合只提供迁移建议。 +- 插件状态写入自己的 namespace,状态迁移失败时保持旧版本或回滚。 + +## 8. 生命周期与故障语义 + +### 8.1 插件生命周期 + +```text +发现 → 校验 → 安装 → 启用 → 健康检查 → 运行 + ↓ + 禁用 / 故障 +``` + +当前安装流程:管理员将包放在 `DATA_DIR/plugins/`,调用安装接口并显式传 `trusted: true`。宿主校验真实路径、manifest、入口和来源类型,保存安装记录;启动时自动恢复启用的插件。启用、禁用、卸载串行执行,禁用先注销提供者再终止进程;卸载移除安装记录,保留包文件、来源实例和已有用户数据。内置 `reader.local`、`reader.opds` 不允许卸载或替换。 + +后续包上传及升级流程(尚未实现): + +1. 校验包、manifest、API 版本、哈希/签名和权限。 +2. 将包解压到版本目录,不覆盖当前运行版本。 +3. 启动健康检查,读取 `descriptor` 并调用 `validateConfig`。 +4. 停止接收新任务,等待或取消在途调用。 +5. 执行插件声明的状态迁移;失败则保留旧版本。 +6. 原子切换当前版本,重新加载来源实例。 + +插件禁用不删除来源实例、publication、缓存和进度。删除数据是单独的显式操作。 + +### 8.2 统一错误 + +```text +AUTH_REQUIRED 需要登录或凭据过期 +RATE_LIMITED 来源限流 +SOURCE_CHANGED 站点结构或规则失效 +RESOURCE_GONE 远程或本地资源不存在 +CAPABILITY_UNSUPPORTED 当前条目不支持该能力 +PLUGIN_UNAVAILABLE 插件未运行或崩溃 +TIMEOUT 宿主截止时间已到 +QUOTA_EXCEEDED 缓存或下载配额不足 +``` + +该列表是完整版本的错误分类目标。当前 HTTP 响应保持 `{ "error": { "code", "message" } }`,具体已实现错误码以 [API 参考](api.md#错误码) 为准,例如 `SOURCE_TIMEOUT`、`PLUGIN_TIMEOUT`、`RATE_LIMITED`。`retryAfter/details` 未在本轮通用错误响应中实现。 + +## 9. 安全与资源边界 + +以下区分当前强制约束与后续安全工作;不能把 manifest 权限声明当作已生效的系统策略。 + +- 外部插件默认独立进程运行;插件进程崩溃不能让宿主退出。 +- 内置 OPDS 的网络请求经过宿主校验:允许 origin、重定向目标、Basic 凭据转发、超时和响应大小;普通 OPDS 链接不能跳到未配置的 origin。 +- 外部 Node 插件当前是受信任进程,仍拥有本机 Node 的网络、文件和子进程权限;manifest 权限声明和 `SourceContext` 可选能力尚未构成 OS 级网络沙箱。管理员只能安装信任的包。 +- 插件凭据不出现在普通配置、前端 DTO 和日志中。 +- 外部插件资源预览只返回 JSON 文本或 base64,不作为应用同源 HTML 执行;章节 HTML 清洗和受控 `reader-res:` 缓存属于后续阅读接入。 +- 本地和已获取文件路径通过宿主 `resolveInside()` 校验;外部插件直接读写路径的能力目前依赖管理员信任,系统沙箱属于后续工作。 +- 当前硬限制是宿主来源调用 60 秒、插件 RPC 30 秒、单条 JSON 2 MiB、文件获取 256 MiB;CPU/内存和域名权限的强制隔离属于后续工作。 +- 插件安装及来源实例配置需要管理员权限;实例配置共享,凭据按用户隔离。 + +manifest 中的权限声明用于宿主授权和展示,不能单独视为安全沙箱;运行不可信插件需要容器或操作系统策略。 + +## 10. 当前 HTTP 接口 + +均位于 `/api/v1` 并遵循现有认证和错误格式: + +```text +GET /plugins 插件及版本状态 +POST /plugins 安装已放入 plugins 目录的受信任插件 +PATCH /plugins/:id 启用/禁用(enabled) +DELETE /plugins/:id 卸载注册(保留包文件和数据) + +GET /sources/types 当前已注册来源类型 +GET /sources 来源实例列表 +POST /sources 创建来源实例 +PATCH /sources/:id 仅修改 enabled +PUT /sources/:id/credentials/:key 设置当前用户凭据 +GET /sources/:id/browse 浏览目录 +GET /sources/:id/search 搜索 +GET /sources/:id/entries?ref=... 详情 +POST /sources/:id/acquire 获取内容,body 传 entryRef +GET /sources/:id/publications/:publicationRef/manifest +GET /sources/:id/publications/:publicationRef/resource?ref=... +``` + +HTTP 返回宿主 DTO,凭据不回显。插件管理均需管理员权限;来源浏览、搜索、获取和当前用户凭据设置需登录。来源的 URL、名称和 config 更新、来源删除、插件包上传及升级尚无接口。请求参数、响应和可运行示例见 [API 参考](api.md#来源与插件)。 + +## 11. 分阶段实施 + +### 阶段一:内容 seam 与内置 local + +- 抽取 `PublicationContent`,让现有 `/manifest`、`/toc`、`/assets` 不再直接依赖 `book_files → absPath`。 +- 将现有扫描和格式处理包装为 `local` adapter。 +- 保留旧 `bookId`、进度、笔记和书架语义,补充 publication 映射。 +- 验收:已有 EPUB/TXT/PDF/漫画书籍、阅读进度和离线缓存行为不变。 + +### 阶段二:SourceHost 与内置 OPDS + +- 实现来源注册、实例配置、能力 DTO 和 `JobManager`。 +- 实现 OPDS 1.x/2.x 解析、分页、详情和 EPUB/PDF 下载。 +- 下载文件进入 `DATA_DIR`,复用 local 的内容解析链路。 +- 验收:OPDS 书可浏览、搜索(若服务支持)、下载、阅读和重启恢复;失败任务可重试。 + +### 阶段三:插件管理与 ProcessRunner + +- 定义 manifest、SDK、RPC、插件目录和生命周期。 +- 增加一个最小章节插件,验证搜索→详情→目录→正文→图片完整链路。 +- 增加超时、取消、进程崩溃恢复、错误映射和插件状态迁移。 +- 验收:插件无需修改核心代码即可新增来源;禁用插件不影响已缓存内容。 + +### 阶段四:远程书源生态 + +- 实现配置表单、凭据动作、章节缓存和目录刷新。 +- 建立契约测试样例:分页、登录、限流、规则失效、插章和图片资源。 +- 验收:导入一个书源配置即可创建来源实例;更新规则只升级插件/配置,不改阅读器。 + +## 12. 验证与兼容策略 + +每个 adapter 和插件都必须通过宿主契约测试: + +- 能力声明与实际方法一致。 +- 所有调用支持取消、超时和稳定错误码。 +- 外部引用保持可重复解析;章节 ID 在刷新后稳定。 +- 资源流不会被 JSON 编码截断或超出大小限制。 +- 凭据和缓存按用户/来源实例隔离。 +- 插件崩溃、禁用、升级失败时,书架和已缓存 publication 仍可访问。 + +宿主 API 继续遵循现有长期兼容策略:只增加字段和端点,不改变已有字段语义。数据库迁移按可回滚的小步骤提交;旧书先映射为 `local` publication,远程来源不伪造 `content_hash`。 + +## 13. 本轮实施状态与后续工作 + +本轮交付服务端扩展接口和 OPDS 下载阅读闭环。状态截至 2026-09-20;后续条目不能视为已交付。 + +| 工作 | 本轮状态 | 说明 | +| --- | --- | --- | +| 来源契约 | 已实现并测试 | `server/src/sources/types.ts`,包括目录、获取、章节和资源。 | +| 来源注册表及 manifest 校验 | 已实现并测试 | 验证能力与方法一致,校验插件 DTO、章节 ID 和顺序。 | +| OPDS 解析、浏览、搜索、获取 | 已实现并测试 | OPDS 1.x/2.x、相对链接、OpenSearch、分页、Basic 认证和获取关系。 | +| OPDS 文件下载入库 | 已实现并测试 | 同步下载,真实哈希、256 MiB 上限、失败清理、幂等复用、当前用户上架;复用现有阅读接口。 | +| SourceHost、来源实例及凭据 | 已实现 | 实例由管理员配置共享,密码按来源/用户加密,调用预算 60 秒;并发限额每实例 2、全宿主 8。 | +| 来源管理 HTTP 接口 | 已实现并测试 | 创建、列表、启停和来源调用;仅 `enabled` 可更新,不含前端配置页面。 | +| 内置 local 与本地内容 seam | 已实现并测试 | 保留现有 book ID、书架及阅读接口,集中本地与下载文件解析。 | +| 受信任 Node ProcessRunner | 已实现并测试 | JSON-RPC、30 秒调用预算、硬取消、异常退出、2 MiB 消息上限。 | +| 插件安装、启停、卸载与启动恢复 | 已实现并测试 | 包由管理员预置到 `DATA_DIR/plugins`;卸载保留数据和包文件。 | +| 最小外部来源示例 | 已实现并测试 | `examples/plugins/demo-chapters`,提供静态示例目录和正文,不联网。 | +| 插件安装 UI、包上传、在线升级与状态回滚 | 后续 | 本轮不承诺完整插件市场和包管理。 | +| 后台下载任务、断点恢复、JobManager | 后续 | 当前完整文件会持久化,下载过程本身没有后台任务及恢复机制。 | +| 外部章节写入书架、离线缓存与追更 | 后续 | 先可调用插件目录/资源,再接入持久化出版物。 | +| 反向宿主能力 RPC、凭据和配额强制隔离 | 后续 | 类型预留不等于运行策略已生效。 | +| 容器/操作系统沙箱 | 后续 | 首个版本仅允许运行管理员信任的插件。 | + +验证结果:在 `server` 目录运行 `npm test`,共 264 项,263 通过、0 失败、1 项因 Windows 不支持 Unix 目录权限位而明确跳过;`npm run typecheck` 与生产 `npm run build` 通过。覆盖新增来源、插件进程、管理接口、OPDS 下载、用户隔离、并发限额、参数校验与既有本地阅读回归。测试均使用本地 HTTP fixture 或示例插件,未验证特定公网 OPDS 服务或第三方采集站点。 diff --git a/examples/plugins/demo-chapters/main.mjs b/examples/plugins/demo-chapters/main.mjs new file mode 100644 index 0000000..7ff8801 --- /dev/null +++ b/examples/plugins/demo-chapters/main.mjs @@ -0,0 +1,54 @@ +import { createInterface } from 'node:readline'; + +const entry = { + ref: 'demo-book', + title: '插件示例书', + authors: ['Reader'], + description: '用于验证外部插件的发现、获取和章节阅读协议。', +}; + +const methods = { + validateConfig: () => null, + browse: () => ({ title: '示例书库', items: [entry] }), + search: ({ request }) => ({ items: entry.title.includes(request.query) ? [entry] : [] }), + detail: ({ entryRef }) => { + if (entryRef !== entry.ref) throw Object.assign(new Error('书籍不存在'), { code: 'RESOURCE_GONE' }); + return entry; + }, + acquire: ({ request }) => { + if (request.entryRef !== entry.ref) throw Object.assign(new Error('书籍不存在'), { code: 'RESOURCE_GONE' }); + return { kind: 'chapters', publicationRef: entry.ref }; + }, + getManifest: ({ publicationRef }) => { + if (publicationRef !== entry.ref) throw Object.assign(new Error('书籍不存在'), { code: 'RESOURCE_GONE' }); + return { + publicationRef, + version: '1', + items: [{ id: 'chapter-one', seq: 0, title: '第一章', kind: 'chapter', mediaType: 'text/plain', ref: 'chapter-one' }], + }; + }, + readResource: ({ request }) => { + if (request.publicationRef !== entry.ref || request.ref !== 'chapter-one') { + throw Object.assign(new Error('章节不存在'), { code: 'RESOURCE_GONE' }); + } + return { mediaType: 'text/plain; charset=utf-8', text: '第一章\n\n这是通过独立 Node 进程提供的示例章节。' }; + }, +}; + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +for await (const line of input) { + let request; + try { + request = JSON.parse(line); + if (request.method === '$/cancelRequest') continue; + const method = methods[request.method]; + if (!method) throw Object.assign(new Error('不支持的方法'), { code: 'UNSUPPORTED_OPERATION' }); + const result = await method(request.params); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); + } catch (error) { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: request?.id, + error: { code: error.code ?? 'PLUGIN_ERROR', message: error.message }, + }) + '\n'); + } +} diff --git a/examples/plugins/demo-chapters/plugin.json b/examples/plugins/demo-chapters/plugin.json new file mode 100644 index 0000000..7a82d0e --- /dev/null +++ b/examples/plugins/demo-chapters/plugin.json @@ -0,0 +1,16 @@ +{ + "id": "reader.source.demo", + "name": "Demo chapter source", + "version": "0.1.0", + "apiVersion": 1, + "runtime": "node", + "entry": "main.mjs", + "sourceTypes": [ + { + "id": "demo-chapters", + "label": "示例章节书源", + "capabilities": ["browse", "search", "detail", "acquire.chapters", "content.manifest", "content.resource"], + "configSchema": { "type": "object", "properties": {}, "additionalProperties": false } + } + ] +} diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 425527d..8b3fec4 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; /** * Runtime configuration. @@ -98,13 +98,16 @@ function loadOrCreateSecret(dataDir: string, file = 'token.secret'): string { export function loadConfig(): AppConfig { const booksDir = resolve(process.env.BOOKS_DIR ?? '/books'); const dataDir = resolve(process.env.DATA_DIR ?? join(process.cwd(), DATA_DIR_DEFAULT_NAME)); - mkdirSync(dataDir, { recursive: true }); - - if (booksDir === dataDir || dataDir.startsWith(`${booksDir}/`)) { + const dataRelativeToBooks = relative(booksDir, dataDir); + const insideBooks = dataRelativeToBooks === '' || ( + dataRelativeToBooks !== '..' && !dataRelativeToBooks.startsWith(`..${sep}`) && !isAbsolute(dataRelativeToBooks) + ); + if (insideBooks) { throw new Error( `DATA_DIR (${dataDir}) must not live inside BOOKS_DIR (${booksDir}); the library mount is read-only.`, ); } + mkdirSync(dataDir, { recursive: true }); return { booksDir, diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7f9485e..022ecbe 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -100,6 +100,48 @@ CREATE TABLE IF NOT EXISTS book_files ( ); CREATE INDEX IF NOT EXISTS idx_files_book ON book_files(book_id); +-- Configurations contain no credentials. Built-in and installed source types +-- share this table; disabling a source never removes acquired content. +CREATE TABLE IF NOT EXISTS source_instances ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + source_type TEXT NOT NULL, + name TEXT NOT NULL, + config_json TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS source_credentials ( + source_id TEXT NOT NULL REFERENCES source_instances(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + encrypted_value TEXT NOT NULL, + PRIMARY KEY (source_id, user_id, key) +); +-- Host-managed files are deliberately separate from the scanned books mount. +CREATE TABLE IF NOT EXISTS acquired_files ( + book_id TEXT PRIMARY KEY REFERENCES books(id) ON DELETE CASCADE, + rel_path TEXT NOT NULL UNIQUE, + size INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS source_acquisitions ( + source_id TEXT NOT NULL REFERENCES source_instances(id), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entry_ref TEXT NOT NULL, + option_id TEXT NOT NULL DEFAULT '', + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + PRIMARY KEY (source_id, user_id, entry_ref, option_id) +); +CREATE TABLE IF NOT EXISTS plugin_storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS installed_plugins ( + plugin_id TEXT PRIMARY KEY, + folder TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 +); + -- Per-user library state. Kept separate from books so that a rescan can never -- clobber a user's own edits. CREATE TABLE IF NOT EXISTS user_books ( diff --git a/server/src/http/app.ts b/server/src/http/app.ts index d85b1d5..914cfe7 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -7,6 +7,7 @@ import { registerLibraryRoutes } from './routes/library.ts'; import { registerSyncRoutes } from './routes/sync.ts'; import { registerTtsRoutes } from './routes/tts.ts'; import { registerWebRoutes } from './routes/web.ts'; +import { registerSourceRoutes } from './routes/sources.ts'; import { isOriginAllowed, resolveCorsOrigin } from './cors.ts'; export function buildApp(ctx: AppContext): FastifyInstance { @@ -20,6 +21,9 @@ export function buildApp(ctx: AppContext): FastifyInstance { // JSON request (a sync batch, a batch metadata edit) hold 400MB of heap. bodyLimit: 8 * 1024 * 1024, trustProxy: true, + // Source publications use opaque plugin-owned references, often longer than + // the router's default 100-character parameter limit. + routerOptions: { maxParamLength: 16_384 }, }); registerErrorHandler(app); @@ -58,6 +62,7 @@ export function buildApp(ctx: AppContext): FastifyInstance { registerLibraryRoutes(app, ctx); registerSyncRoutes(app, ctx); registerTtsRoutes(app, ctx); + registerSourceRoutes(app, ctx); // Registered last: the SPA fallback must not shadow an API route. registerWebRoutes(app, ctx); diff --git a/server/src/http/context.ts b/server/src/http/context.ts index 748a50b..b10df6f 100644 --- a/server/src/http/context.ts +++ b/server/src/http/context.ts @@ -8,6 +8,7 @@ import type { SyncService } from '../services/sync.ts'; import type { TtsService } from '../services/tts.ts'; import type { BrowseService } from '../services/browse.ts'; import type { UploadService } from '../services/uploads.ts'; +import type { SourceHost } from '../services/source-host.ts'; export interface AppContext { config: AppConfig; @@ -36,6 +37,7 @@ export interface AppContext { * is to write. */ uploads: UploadService; + sources?: SourceHost; log: FastifyBaseLogger; } diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 3c23d6a..d5c451a 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -12,32 +12,10 @@ import { capabilities, contentTypeFor, directoryHandlerForFormat, - fileHandlerForFormat, type ContentItem, } from '../../indexer/formats/index.ts'; import { sendAssetPayload } from '../assets.ts'; - -/** - * The single live path backing a book, if it is file-backed. - * - * A directory book has no file to stream, so the caller is told which of the two - * shapes it is rather than being handed a path that may not exist. - */ -function resolveBookSource( - ctx: AppContext, - bookId: string, - format: string, -): { relPath: string; isDirectory: boolean } | null { - const file = ctx.db.get<{ rel_path: string }>( - 'SELECT rel_path FROM book_files WHERE book_id = ? AND missing = 0 ORDER BY rel_path LIMIT 1', - bookId, - ); - if (!file) return null; - // Directory books are recorded under a path with no extension; a file handler - // for the format means the path is a real file. - const isDirectory = format === 'comic-dir' || fileHandlerForFormat(format) === null; - return { relPath: file.rel_path, isDirectory }; -} +import { FilePublications } from '../../publications/files.ts'; /** * The handler that owns a book, resolved from its recorded format. @@ -45,20 +23,13 @@ function resolveBookSource( * A missing file still has a handler; only the manifest and asset calls fail. */ function resolveHandler(ctx: AppContext, bookId: string, format: string) { - void ctx; void bookId; - return fileHandlerForFormat(format) ?? directoryHandlerForFormat(format) ?? null; + return new FilePublications(ctx.db, ctx.config).handler(format); } -/** Absolute path plus library-relative path for a book's backing store. */ +/** Validated storage context, independent of a book's discovery source. */ function sourceContext(ctx: AppContext, bookId: string) { - const file = ctx.db.get<{ rel_path: string }>( - 'SELECT rel_path FROM book_files WHERE book_id = ? AND missing = 0 ORDER BY rel_path LIMIT 1', - bookId, - ); - if (!file) throw notFound('no available file for this book', 'FILE_MISSING'); - const relPath = assertSafeRel(file.rel_path); - return { relPath, absPath: resolveInside(ctx.config.booksDir, relPath) }; + return new FilePublications(ctx.db, ctx.config).context(bookId); } /** @@ -230,21 +201,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo // // This is a contract, not an inventory: `/books/:id/file` serves exactly the // paths named here, and nothing else. - files: content && handler && 'files' in handler && handler.files - ? (await handler.files({ ...sourceContext(ctx, id), bookId: id })).map((file) => ({ - rel_path: file.relPath, - // The handler's own reference, passed through untouched. It is what - // makes the listed path fetchable: a directory book addresses a page - // as `page::`, and only its handler knows where the - // volumes begin. - ref: file.ref, - size: file.size, - missing: file.missing, - })) - : ctx.db.all<{ rel_path: string; size: number; missing: number }>( - 'SELECT rel_path, size, missing FROM book_files WHERE book_id = ? ORDER BY rel_path', - id, - ), + files: await new FilePublications(ctx.db, ctx.config).files(id, book.format), // `content` is null only for a book whose format exposes no addressable // structure at all; the client then falls back to the raw file. content: windowed, @@ -263,29 +220,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo const user = currentUser(request); const { id } = request.params as { id: string }; const book = ctx.shelf.get(user.id, id); - const source = resolveBookSource(ctx, id, book.format); - if (!source) throw notFound('no available file for this book', 'FILE_MISSING'); - - // Directory books have no file to stream; the client caches page by page. - if (source.isDirectory) { - throw badRequest( - 'this book is backed by a directory; fetch pages from the manifest instead', - 'DIRECTORY_BOOK', - ); - } - - const abs = resolveInside(ctx.config.booksDir, assertSafeRel(source.relPath)); - if (!existsSync(abs)) throw notFound('file is no longer on disk', 'FILE_MISSING'); - - return sendAssetPayload(request, reply, { - stream: createReadStream(abs), - contentType: contentTypeFor(source.relPath), - filename: source.relPath.split('/').pop() ?? 'book', - size: (await stat(abs)).size, - seekable: true, - etag: id, - lastModified: (await stat(abs)).mtimeMs, - }); + return sendAssetPayload(request, reply, await new FilePublications(ctx.db, ctx.config).content(id, book.format)); }); /** @@ -399,6 +334,11 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo // legitimate one. Without this a traversal inside a directory book passes the // "is it under the folder" test. const wanted = normalizeRel(query.path); + const publications = new FilePublications(ctx.db, ctx.config); + const managed = publications.managed(id); + if (managed && managed.rel_path === wanted) { + return sendAssetPayload(request, reply, await publications.content(id, book.format)); + } // Two shapes of book, and each owns a different set of paths. // diff --git a/server/src/http/routes/sources.ts b/server/src/http/routes/sources.ts new file mode 100644 index 0000000..6de16bd --- /dev/null +++ b/server/src/http/routes/sources.ts @@ -0,0 +1,146 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { AppContext } from '../context.ts'; +import { authenticate, currentUser, requireAdmin } from '../auth.ts'; +import { badRequest } from '../../lib/errors.ts'; +import { SourceHost } from '../../services/source-host.ts'; + +function textBody(body: unknown, name: string): string { + const value = (body as Record | null)?.[name]; + return parameter(value, name, true)!; +} + +function parameter(value: unknown, name: string, required = false): string | undefined { + if (value === undefined && !required) return undefined; + if (typeof value !== 'string' || !value.trim() || value.length > 16_384) { + throw badRequest(`${name} must be a nonempty string up to 16384 characters`); + } + return value; +} + +/** Source discovery and plugin management. All provider-specific behaviour stays in SourceHost. */ +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.get('/api/v1/sources/types', { preHandler: auth }, async () => ({ + types: host.registry.list().map(({ pluginId, builtin, provider }) => ({ + ...provider.descriptor, pluginId, builtin: builtin === true, + })), + })); + app.get('/api/v1/sources', { preHandler: auth }, async (request) => { + const user = currentUser(request); + return { sources: host.list(user.role === 'admin') }; + }); + app.post('/api/v1/sources', { preHandler: auth }, async (request, reply) => { + requireAdmin(request); + const body = (request.body ?? {}) as Record; + const pluginId = textBody(body, 'pluginId'); + const sourceType = textBody(body, 'sourceType'); + const name = textBody(body, 'name'); + const id = await host.create({ + id: parameter(body.id, 'id'), pluginId, sourceType, name, + config: body.config ?? {}, + }); + reply.status(201); + return { source: host.list(true).find((source) => source.id === id) }; + }); + 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); + return { source: host.list(true).find((source) => source.id === id) }; + }); + 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 }; + const value = (request.body as Record | null)?.value; + if (typeof value !== 'string') throw badRequest('credential value must be a string'); + host.setCredential(id, user.id, key, value); + return { ok: true }; + }); + app.get('/api/v1/sources/:id/browse', { preHandler: auth }, async (request, reply) => { + const user = currentUser(request); const { id } = request.params as { id: string }; + const q = request.query as { ref?: string; cursor?: string; limit?: string }; + return withSignal(request, reply, (signal) => host.browse(user.id, id, { + ref: parameter(q.ref, 'ref'), cursor: parameter(q.cursor, 'cursor'), limit: pageLimit(q.limit), + }, signal)); + }); + app.get('/api/v1/sources/:id/search', { preHandler: auth }, async (request, reply) => { + const user = currentUser(request); const { id } = request.params as { id: string }; + const q = request.query as { q?: string; cursor?: string; limit?: string }; + const query = parameter(q.q, 'q', true)!; + return withSignal(request, reply, (signal) => host.search(user.id, id, { + query, cursor: parameter(q.cursor, 'cursor'), limit: pageLimit(q.limit), + }, signal)); + }); + app.get('/api/v1/sources/:id/entries', { preHandler: auth }, async (request, reply) => { + const user = currentUser(request); const { id } = request.params as { id: string }; + const q = request.query as { ref?: string }; + const ref = parameter(q.ref, 'ref', true)!; + return withSignal(request, reply, (signal) => host.detail(user.id, id, ref, signal)); + }); + app.post('/api/v1/sources/:id/acquire', { preHandler: auth }, async (request, reply) => { + const user = currentUser(request); const { id } = request.params as { id: string }; + const body = request.body as Record | null; + const entryRef = textBody(body, 'entryRef'); + const optionId = parameter(body?.optionId, 'optionId'); + return withSignal(request, reply, (signal) => host.acquire(user.id, id, entryRef, optionId, signal)); + }); + app.get('/api/v1/sources/:id/publications/:publicationRef/manifest', { preHandler: auth }, async (request, reply) => { + const user = currentUser(request); const params = request.params as { id: string; publicationRef: string }; + return withSignal(request, reply, (signal) => host.manifest(user.id, params.id, params.publicationRef, signal)); + }); + app.get('/api/v1/sources/:id/publications/:publicationRef/resource', { preHandler: auth }, async (request, reply) => { + const user = currentUser(request); const params = request.params as { id: string; publicationRef: string }; + const ref = parameter((request.query as { ref?: string }).ref, 'ref', true)!; + const resource = await withSignal(request, reply, (signal) => host.resource(user.id, params.id, params.publicationRef, ref, signal)); + // Development endpoint: JSON only. Untrusted HTML is never served as an app-origin document. + if (resource.stream) { resource.stream.destroy(); throw badRequest('streaming preview is not supported'); } + return { mediaType: resource.mediaType, ...(resource.text !== undefined ? { text: resource.text } : {}), + ...(resource.data ? { base64: Buffer.from(resource.data).toString('base64') } : {}) }; + }); + app.get('/api/v1/plugins', { preHandler: auth }, async (request) => { + requireAdmin(request); + return { plugins: host.plugins.list() }; + }); + app.post('/api/v1/plugins', { preHandler: auth }, async (request, reply) => { + requireAdmin(request); + const body = request.body as Record | null; + if (body?.trusted !== true) throw badRequest('trusted must be true: installed plugins execute with server OS privileges', 'PLUGIN_TRUST_REQUIRED'); + const plugin = await host.plugins.install(textBody(body, 'folder')); + reply.status(201); + return { plugin }; + }); + app.patch('/api/v1/plugins/:id', { preHandler: auth }, async (request) => { + requireAdmin(request); + const enabled = (request.body as Record | null)?.enabled; + if (typeof enabled !== 'boolean') throw badRequest('enabled must be boolean'); + return { plugin: await host.plugins.setEnabled((request.params as { id: string }).id, enabled) }; + }); + app.delete('/api/v1/plugins/:id', { preHandler: auth }, async (request) => { + requireAdmin(request); + await host.plugins.uninstall((request.params as { id: string }).id); + return { ok: true }; + }); +} + +function pageLimit(value?: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !/^[1-9]\d{0,2}$/.test(value)) throw badRequest('invalid limit'); + const number = Number(value); + if (!Number.isSafeInteger(number) || number < 1 || number > 200) throw badRequest('limit must be an integer between 1 and 200'); + return number; +} + +async function withSignal(request: FastifyRequest, reply: FastifyReply, action: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + request.raw.once('aborted', abort); + reply.raw.once('close', abort); + try { return await action(controller.signal); } + finally { request.raw.removeListener('aborted', abort); reply.raw.removeListener('close', abort); } +} diff --git a/server/src/indexer/formats/registry.ts b/server/src/indexer/formats/registry.ts index bc2b393..c33f698 100644 --- a/server/src/indexer/formats/registry.ts +++ b/server/src/indexer/formats/registry.ts @@ -182,7 +182,7 @@ export type ProgressReporter = (scanned: number) => void; export interface HandlerContext { /** Library-relative path, forward slashes. */ relPath: string; - /** Absolute path, guaranteed inside the read-only books root. */ + /** Absolute path validated by the host: the books mount or managed DATA_DIR content. */ absPath: string; /** * Book id, available once the book exists. Handlers that emit links back into diff --git a/server/src/indexer/scanner.ts b/server/src/indexer/scanner.ts index dcc2109..d002ab8 100644 --- a/server/src/indexer/scanner.ts +++ b/server/src/indexer/scanner.ts @@ -234,6 +234,7 @@ export class Scanner { this.db.run( `DELETE FROM books WHERE id NOT IN (SELECT DISTINCT book_id FROM book_files WHERE missing = 0) + AND id NOT IN (SELECT book_id FROM acquired_files) AND id NOT IN (SELECT DISTINCT book_id FROM user_books)`, ); diff --git a/server/src/main.ts b/server/src/main.ts index 4ca2ebd..8eab518 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -30,6 +30,7 @@ async function main(): Promise { shelf: undefined, sync: undefined, log: undefined, + sources: undefined, } as unknown as AppContext; const app = buildApp(ctx); diff --git a/server/src/publications/files.ts b/server/src/publications/files.ts new file mode 100644 index 0000000..8785345 --- /dev/null +++ b/server/src/publications/files.ts @@ -0,0 +1,63 @@ +import { createReadStream, existsSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import type { AppConfig } from '../config/index.ts'; +import type { Db } from '../db/index.ts'; +import { assertSafeRel, resolveInside } from '../lib/paths.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; +import { + contentTypeFor, directoryHandlerForFormat, fileHandlerForFormat, + type AssetPayload, type HandlerContext, +} from '../indexer/formats/index.ts'; + +/** Resolves stored content independently of where a book was discovered. */ +export class FilePublications { + constructor(private readonly db: Db, private readonly config: AppConfig) {} + + handler(format: string) { + return fileHandlerForFormat(format) ?? directoryHandlerForFormat(format); + } + + managed(bookId: string): { rel_path: string; size: number } | undefined { + return this.db.get('SELECT rel_path, size FROM acquired_files WHERE book_id = ?', bookId); + } + + context(bookId: string): HandlerContext { + const local = this.db.get<{ rel_path: string }>( + 'SELECT rel_path FROM book_files WHERE book_id = ? AND missing = 0 ORDER BY rel_path LIMIT 1', bookId, + ); + const managed = this.managed(bookId); + const useLocal = local && (!managed || existsSync(resolveInside(this.config.booksDir, assertSafeRel(local.rel_path)))); + const file = useLocal ? local : managed ?? local; + if (!file) throw notFound('no available file for this book', 'FILE_MISSING'); + const relPath = assertSafeRel(file.rel_path); + return { bookId, relPath, absPath: resolveInside(useLocal ? this.config.booksDir : this.config.dataDir, relPath) }; + } + + async files(bookId: string, format: string) { + const handler = this.handler(format); + if (handler && 'files' in handler && handler.files) { + return (await handler.files(this.context(bookId))).map((file) => ({ + rel_path: file.relPath, ref: file.ref, size: file.size, missing: file.missing, + })); + } + const local = this.db.all<{ rel_path: string; size: number; missing: number }>( + 'SELECT rel_path, size, missing FROM book_files WHERE book_id = ? ORDER BY rel_path', bookId, + ); + const managed = this.managed(bookId); + return managed ? [...local, { ...managed, missing: 0 }] : local; + } + + async content(bookId: string, format: string): Promise { + if (!fileHandlerForFormat(format)) { + throw badRequest('this book is backed by a directory; fetch pages from the manifest instead', 'DIRECTORY_BOOK'); + } + const context = this.context(bookId); + if (!existsSync(context.absPath)) throw notFound('file is no longer on disk', 'FILE_MISSING'); + const info = await stat(context.absPath); + return { + stream: createReadStream(context.absPath), contentType: contentTypeFor(context.relPath), + filename: context.relPath.split('/').pop() ?? 'book', size: info.size, + seekable: true, etag: bookId, lastModified: info.mtimeMs, + }; + } +} diff --git a/server/src/services/browse.ts b/server/src/services/browse.ts index 00fa076..d9b57dc 100644 --- a/server/src/services/browse.ts +++ b/server/src/services/browse.ts @@ -544,14 +544,16 @@ export class BrowseService { throw badRequest('bookIds must contain strings', 'BAD_BOOK_ID'); } /* - * `book_files` rather than `books`: the path is a property of the *file*, and a - * book whose last file is missing has a row in `books` and none to point at. - * Reporting that one as a failure is the honest answer — the shelf's own - * predicate already says a book with no live file is not on the shelf. + * Local files belong to the shared library. Managed downloads can only be + * added or removed by a user with an acquisition record, even if another + * account supplies their book id. Neither path grants access from an id alone. */ const file = this.db.get<{ book_id: string }>( - 'SELECT book_id FROM book_files WHERE book_id = ? AND missing = 0 LIMIT 1', - input, + `SELECT book_id FROM book_files WHERE book_id = ? AND missing = 0 + UNION SELECT f.book_id FROM acquired_files f + JOIN source_acquisitions a ON a.book_id = f.book_id + WHERE f.book_id = ? AND a.user_id = ? LIMIT 1`, + input, input, userId, ); if (!file) { result.failed.push({ path: input, reason: 'NO_LIVE_FILE' }); diff --git a/server/src/services/shelf.ts b/server/src/services/shelf.ts index 1a36265..7b5a586 100644 --- a/server/src/services/shelf.ts +++ b/server/src/services/shelf.ts @@ -167,7 +167,7 @@ export class ShelfService { const library = scope === 'library'; const where: string[] = library ? ['EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)'] - : ['ub.user_id = ?', 'ub.hidden = 0', 'EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)']; + : ['ub.user_id = ?', 'ub.hidden = 0', '(EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0) OR EXISTS (SELECT 1 FROM acquired_files a WHERE a.book_id = b.id))']; const params: Array = library ? [] : [userId]; if (options.search) { diff --git a/server/src/services/source-host.ts b/server/src/services/source-host.ts new file mode 100644 index 0000000..98f4238 --- /dev/null +++ b/server/src/services/source-host.ts @@ -0,0 +1,218 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import type { FastifyBaseLogger } from 'fastify'; +import type { AppConfig } from '../config/index.ts'; +import type { Db } from '../db/index.ts'; +import type { ShelfService } from './shelf.ts'; +import { AppError, badRequest, conflict, notFound } from '../lib/errors.ts'; +import { SourceRegistry } from '../sources/registry.ts'; +import { createLocalProvider } from '../sources/local.ts'; +import { createOpdsProvider } from '../sources/opds.ts'; +import type { SourceContext, SourceInstance, SourceProvider, SourceStorage, CredentialStore } from '../sources/types.ts'; +import { FileAcquisitions } from '../sources/acquisitions.ts'; +import { PluginManager } from '../sources/plugin-manager.ts'; + +function json(value: unknown): string { return JSON.stringify(value ?? {}); } +function parse(value: string): unknown { try { return JSON.parse(value); } catch { return {}; } } + +/** Host-owned registry, instance lifecycle and credential boundary. */ +export class SourceHost { + readonly registry = new SourceRegistry(); + readonly plugins: PluginManager; + private readonly key: Buffer; + private readonly acquisitions: FileAcquisitions; + private activeCalls = 0; + private readonly activeSources = new Map(); + + constructor(private readonly db: Db, private readonly config: AppConfig, shelf: () => ShelfService, private readonly log?: FastifyBaseLogger) { + this.key = createHash('sha256').update('reader/source-credentials/v1\0').update(config.jwtSecret).digest(); + this.acquisitions = new FileAcquisitions(db, config); + this.registry.registerBuiltin('reader.local', createLocalProvider(db, shelf)); + this.registry.registerBuiltin('reader.opds', createOpdsProvider()); + this.plugins = new PluginManager(db, config.dataDir, this.registry); + this.ensureLocal(); + } + + private ensureLocal(): void { + const found = this.db.get<{ id: string }>("SELECT id FROM source_instances WHERE plugin_id = 'reader.local' AND source_type = 'local' LIMIT 1"); + if (!found) this.db.run( + `INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at) + VALUES ('local', 'reader.local', 'local', '本地书库', '{}', 1, ?)`, Date.now(), + ); + } + + list(includeConfig = false) { + return this.db.all<{ id: string; plugin_id: string; source_type: string; name: string; config_json: string; enabled: number }>( + 'SELECT id, plugin_id, source_type, name, config_json, enabled FROM source_instances ORDER BY name', + ).map((row) => ({ + id: row.id, pluginId: row.plugin_id, sourceType: row.source_type, name: row.name, + enabled: row.enabled === 1, + ...(includeConfig ? { config: parse(row.config_json) } : {}), + descriptor: this.registry.get(row.plugin_id, row.source_type)?.provider.descriptor ?? null, + })); + } + + async create(input: { id?: string; pluginId: string; sourceType: string; name: string; config: unknown }): Promise { + const id = input.id?.trim() || randomBytes(12).toString('hex'); + if (!/^[a-zA-Z0-9_-]{2,80}$/.test(id)) throw badRequest('invalid source instance id'); + if (!input.name.trim() || input.name.length > 128) throw badRequest('invalid source name'); + if (json(input.config).length > 64 * 1024) throw badRequest('source configuration is too large'); + const registration = this.registry.get(input.pluginId, input.sourceType); + if (!registration) throw notFound('source type is not installed', 'SOURCE_TYPE_NOT_FOUND'); + await registration.provider.validateConfig?.(input.config); + if (this.registry.get(input.pluginId, input.sourceType) !== registration) { + throw conflict('source plugin changed during configuration; retry', 'PLUGIN_UNAVAILABLE'); + } + if (this.db.get('SELECT id FROM source_instances WHERE id = ?', id)) throw conflict('source instance already exists'); + this.db.run( + `INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at) + VALUES (?, ?, ?, ?, ?, 1, ?)`, id, input.pluginId, input.sourceType, input.name.trim(), json(input.config), Date.now(), + ); + return id; + } + + setEnabled(id: string, enabled: boolean): void { + const result = this.db.get<{ id: string }>('SELECT id FROM source_instances WHERE id = ?', id); + if (!result) throw notFound('source instance not found', 'SOURCE_NOT_FOUND'); + this.db.run('UPDATE source_instances SET enabled = ? WHERE id = ?', enabled ? 1 : 0, 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); + this.db.run( + `INSERT INTO source_credentials (source_id, user_id, key, encrypted_value) VALUES (?, ?, ?, ?) + ON CONFLICT(source_id, user_id, key) DO UPDATE SET encrypted_value = excluded.encrypted_value`, + sourceId, userId, name, this.encrypt(value), + ); + } + + async browse(userId: string, id: string, request: { ref?: string; cursor?: string; limit?: number }, signal?: AbortSignal) { + return this.call(userId, id, (provider, ctx) => provider.browse + ? provider.browse(ctx, request) : Promise.reject(badRequest('source does not support browse', 'SOURCE_UNSUPPORTED')), signal); + } + async search(userId: string, id: string, request: { query: string; cursor?: string; limit?: number }, signal?: AbortSignal) { + return this.call(userId, id, (provider, ctx) => provider.search + ? provider.search(ctx, request) : Promise.reject(badRequest('source does not support search', 'SOURCE_UNSUPPORTED')), signal); + } + async detail(userId: string, id: string, ref: string, signal?: AbortSignal) { + return this.call(userId, id, (provider, ctx) => provider.detail(ctx, ref), signal); + } + async acquire(userId: string, id: string, entryRef: string, optionId?: string, signal?: AbortSignal) { + return this.call(userId, id, async (provider, ctx) => { + const acquisition = await provider.acquire(ctx, { entryRef, optionId }); + if (acquisition.kind === 'ready') { + if (ctx.instance.pluginId !== 'reader.local' || ctx.instance.sourceType !== 'local') { + throw badRequest('external plugins cannot reference host book identities', 'INVALID_ACQUISITION'); + } + this.db.run( + `INSERT INTO user_books (user_id, book_id, added_at, hidden) VALUES (?, ?, ?, 0) + ON CONFLICT(user_id, book_id) DO UPDATE SET hidden = 0, + added_at = CASE WHEN user_books.hidden = 1 THEN excluded.added_at ELSE user_books.added_at END`, + userId, acquisition.publicationId, Date.now(), + ); + } + if (acquisition.kind !== 'file') return acquisition; + const entry = await provider.detail(ctx, entryRef); + const bookId = await this.acquisitions.acquire(provider, ctx, { entryRef, optionId }, acquisition, entry); + this.log?.info({ sourceId: id, bookId }, 'source file acquired'); + return { kind: 'ready' as const, publicationId: bookId }; + }, signal); + } + + async manifest(userId: string, id: string, ref: string, signal?: AbortSignal) { + return this.call(userId, id, (provider, ctx) => provider.getManifest + ? provider.getManifest(ctx, ref) : Promise.reject(badRequest('source has no chapter manifest', 'SOURCE_UNSUPPORTED')), signal); + } + async resource(userId: string, id: string, publicationRef: string, ref: string, signal?: AbortSignal) { + return this.call(userId, id, (provider, ctx) => provider.readResource + ? provider.readResource(ctx, { publicationRef, ref }) : Promise.reject(badRequest('source has no chapter resources', 'SOURCE_UNSUPPORTED')), signal); + } + + private async call(userId: string, id: string, action: (provider: SourceProvider, ctx: SourceContext) => Promise, externalSignal?: AbortSignal): Promise { + const row = this.instance(id); + if (!row.enabled) throw badRequest('source is disabled', 'SOURCE_DISABLED'); + const registration = this.registry.get(row.plugin_id, row.source_type); + if (!registration) throw notFound('source plugin is not installed', 'PLUGIN_UNAVAILABLE'); + const active = this.activeSources.get(id) ?? 0; + if (active >= 2 || this.activeCalls >= 8) { + throw new AppError(429, 'RATE_LIMITED', 'source is busy; retry after current requests complete'); + } + this.activeSources.set(id, active + 1); + this.activeCalls += 1; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 60_000); timer.unref(); + const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal; + try { + signal.throwIfAborted(); + return await action(registration.provider, this.context(row, userId, signal)); + } catch (error) { + if (controller.signal.aborted) throw new AppError(504, 'SOURCE_TIMEOUT', 'source request exceeded its execution deadline'); + if (externalSignal?.aborted) throw new AppError(499, 'SOURCE_CANCELLED', 'source request cancelled'); + throw error; + } finally { + clearTimeout(timer); + this.activeCalls -= 1; + const remaining = (this.activeSources.get(id) ?? 1) - 1; + if (remaining) this.activeSources.set(id, remaining); + else this.activeSources.delete(id); + } + } + + private context(row: SourceRow, userId: string, signal: AbortSignal): SourceContext { + const instance: SourceInstance = { + id: row.id, pluginId: row.plugin_id, sourceType: row.source_type, name: row.name, + config: parse(row.config_json), enabled: row.enabled === 1, + }; + return { + instance, userId, signal, + credentials: this.credentials(row.id, userId), + storage: this.storage(row.id, userId), + log: this.log ? { + debug: (message, fields) => this.log?.debug(fields ?? {}, message), + info: (message, fields) => this.log?.info(fields ?? {}, message), + warn: (message, fields) => this.log?.warn(fields ?? {}, message), + error: (message, fields) => this.log?.error(fields ?? {}, message), + } : undefined, + }; + } + private credentials(sourceId: string, userId: string): CredentialStore { + return { + get: async (key) => { + const row = this.db.get<{ encrypted_value: string }>('SELECT encrypted_value FROM source_credentials WHERE source_id = ? AND user_id = ? AND key = ?', sourceId, userId, key); + return row ? this.decrypt(row.encrypted_value) : undefined; + }, + set: async (key, value) => this.setCredential(sourceId, userId, key, value), + delete: async (key) => this.db.run('DELETE FROM source_credentials WHERE source_id = ? AND user_id = ? AND key = ?', sourceId, userId, key), + }; + } + private storage(sourceId: string, userId: string): SourceStorage { + const prefix = `source:${sourceId}:${userId}:`; + return { + get: async (key) => { const row = this.db.get<{ value: string }>('SELECT value FROM plugin_storage WHERE key = ?', prefix + key); return row ? parse(row.value) : undefined; }, + set: async (key, value) => this.db.run('INSERT INTO plugin_storage (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', prefix + key, json(value)), + delete: async (key) => this.db.run('DELETE FROM plugin_storage WHERE key = ?', prefix + key), + }; + } + private instance(id: string): SourceRow { + const row = this.db.get('SELECT id, plugin_id, source_type, name, config_json, enabled FROM source_instances WHERE id = ?', id); + if (!row) throw notFound('source instance not found', 'SOURCE_NOT_FOUND'); + return row; + } + private encrypt(value: string): string { + const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + return `${iv.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}.${encrypted.toString('base64url')}`; + } + private decrypt(value: string): string { + try { + const [iv, tag, body] = value.split('.'); + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(iv!, 'base64url')); + decipher.setAuthTag(Buffer.from(tag!, 'base64url')); + return Buffer.concat([decipher.update(Buffer.from(body!, 'base64url')), decipher.final()]).toString('utf8'); + } catch { + throw new AppError(401, 'AUTH_REQUIRED', 'stored credentials could not be decrypted; save them again'); + } + } +} + +interface SourceRow { id: string; plugin_id: string; source_type: string; name: string; config_json: string; enabled: number } diff --git a/server/src/services/sync.ts b/server/src/services/sync.ts index d3fd0b7..a324cf3 100644 --- a/server/src/services/sync.ts +++ b/server/src/services/sync.ts @@ -250,7 +250,8 @@ export class SyncService { FROM reading_progress p JOIN user_books ub ON ub.book_id = p.book_id AND ub.user_id = p.user_id WHERE p.user_id = ? AND ub.hidden = 0 - AND EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = p.book_id AND f.missing = 0) + AND (EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = p.book_id AND f.missing = 0) + OR EXISTS (SELECT 1 FROM acquired_files a WHERE a.book_id = p.book_id)) ORDER BY p.updated_at DESC LIMIT ?`, userId, limit, ); diff --git a/server/src/sources/acquisitions.ts b/server/src/sources/acquisitions.ts new file mode 100644 index 0000000..7ced719 --- /dev/null +++ b/server/src/sources/acquisitions.ts @@ -0,0 +1,223 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createWriteStream, existsSync } from 'node:fs'; +import { mkdir, readFile, rename, rm } from 'node:fs/promises'; +import { basename, extname, join } from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { AppConfig } from '../config/index.ts'; +import type { Db } from '../db/index.ts'; +import { fileHandlerForExtension, type ParsedSource } from '../indexer/formats/index.ts'; +import { computeBookId } from '../indexer/identity.ts'; +import { saveCover } from '../indexer/metadata.ts'; +import { AppError, badRequest } from '../lib/errors.ts'; +import { resolveInside } from '../lib/paths.ts'; +import type { + Acquisition, AcquireRequest, CatalogEntry, ResourceResponse, SourceContext, SourceProvider, +} from './types.ts'; + +export const MAX_ACQUISITION_BYTES = 256 * 1024 * 1024; +type FileAcquisition = Extract; + +const EXTENSIONS: Readonly> = { + 'application/epub+zip': 'epub', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'text/markdown': 'md', + 'application/vnd.comicbook+zip': 'cbz', + 'application/x-cbz': 'cbz', + 'application/zip': 'zip', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', +}; + +function mediaType(value: string): string { + return value.split(';', 1)[0]!.trim().toLowerCase(); +} + +function tooLarge(): AppError { + return new AppError(413, 'ACQUISITION_TOO_LARGE', 'download exceeds the acquisition size limit'); +} + +/** Imports complete files into DATA_DIR without changing the scanned library. */ +export class FileAcquisitions { + private readonly queues = new Map>(); + private readonly maxBytes: number; + + constructor( + private readonly db: Db, + private readonly config: Pick, + options: { maxBytes?: number } = {}, + ) { + this.maxBytes = options.maxBytes ?? MAX_ACQUISITION_BYTES; + if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes <= 0 || this.maxBytes > MAX_ACQUISITION_BYTES) { + throw new Error('acquisition maxBytes must be between 1 and 256 MiB'); + } + } + + acquire( + provider: SourceProvider, + context: SourceContext, + request: AcquireRequest, + acquisition: FileAcquisition, + entry: CatalogEntry, + ): Promise { + const key = JSON.stringify([context.instance.id, context.userId, request.entryRef, request.optionId ?? '']); + // Serialize instead of sharing promises: one caller's cancellation must not + // cancel another caller's download. The successor reuses a committed file. + const previous = this.queues.get(key) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(() => this.importFile(provider, context, request, acquisition, entry)); + this.queues.set(key, result); + void result.finally(() => { + if (this.queues.get(key) === result) this.queues.delete(key); + }).catch(() => undefined); + return result; + } + + private async importFile( + provider: SourceProvider, + context: SourceContext, + request: AcquireRequest, + acquisition: FileAcquisition, + entry: CatalogEntry, + ): Promise { + const { signal } = context; + signal.throwIfAborted(); + const existing = this.db.get<{ book_id: string; rel_path: string }>( + `SELECT a.book_id, f.rel_path FROM source_acquisitions a + JOIN acquired_files f ON f.book_id = a.book_id + WHERE a.source_id = ? AND a.user_id = ? AND a.entry_ref = ? AND a.option_id = ?`, + context.instance.id, context.userId, request.entryRef, request.optionId ?? '', + ); + if (existing && existsSync(resolveInside(this.config.dataDir, existing.rel_path))) { + this.addToShelf(context.userId, existing.book_id, Date.now()); + return existing.book_id; + } + if (!provider.openFile) throw badRequest('source does not support file downloads', 'CAPABILITY_UNSUPPORTED'); + const type = mediaType(acquisition.mediaType); + const extension = EXTENSIONS[type]; + const handler = extension ? fileHandlerForExtension(extension) : null; + if (!extension || !handler) throw badRequest('unsupported acquisition media type', 'UNSUPPORTED_FORMAT'); + if (acquisition.size !== undefined && acquisition.size > this.maxBytes) throw tooLarge(); + + await mkdir(join(this.config.dataDir, 'acquired'), { recursive: true }); + const token = randomUUID(); + const temporaryPath = resolveInside(this.config.dataDir, `acquired/${token}.part`); + let finalPath: string | undefined; + let coverPath: string | undefined; + let keepFile = false; + let keepCover = false; + let resource: ResourceResponse | undefined; + try { + resource = await provider.openFile(context, acquisition); + signal.throwIfAborted(); + const responseType = mediaType(resource.mediaType); + if (responseType !== 'application/octet-stream' && responseType !== type) { + throw badRequest('download media type does not match its acquisition', 'INVALID_RESOURCE'); + } + if (resource.size !== undefined && resource.size > this.maxBytes) throw tooLarge(); + const bodies = Number(resource.data !== undefined) + Number(resource.text !== undefined) + Number(resource.stream !== undefined); + if (bodies !== 1) throw badRequest('download must contain exactly one body', 'INVALID_RESOURCE'); + const input = resource.stream ?? Readable.from([ + resource.data !== undefined ? resource.data : Buffer.from(resource.text!, 'utf8'), + ]); + let size = 0; + const hash = createHash('sha256'); + const maximum = this.maxBytes; + const limit = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + size += chunk.byteLength; + if (size > maximum) { callback(tooLarge()); return; } + hash.update(chunk); + callback(null, chunk); + }, + }); + await pipeline(input, limit, createWriteStream(temporaryPath, { flags: 'wx' }), { signal }); + signal.throwIfAborted(); + if (size === 0) throw badRequest('download is empty', 'INVALID_RESOURCE'); + const bytes = await readFile(temporaryPath); + const filename = basename((acquisition.filename ?? resource.filename ?? `${entry.title}.${extension}`).replaceAll('\\', '/')); + const displayName = `${filename.slice(0, filename.length - extname(filename).length)}.${extension}`; + const parseContext = { relPath: displayName, absPath: temporaryPath }; + if (type === 'application/pdf' && !bytes.subarray(0, 1024).includes(Buffer.from('%PDF-'))) { + throw badRequest('download is not a PDF', 'INVALID_RESOURCE'); + } + if (handler.matches && !await handler.matches(parseContext, bytes.subarray(0, 4096))) { + throw badRequest('download does not match its format', 'INVALID_RESOURCE'); + } + const parsed = await handler.parse(parseContext, bytes); + if (parsed.metadata.raw.parseError) { + throw badRequest('download could not be parsed as its declared format', 'INVALID_RESOURCE'); + } + const contentHash = hash.digest('hex'); + const bookId = computeBookId(parsed.metadata.identifier, contentHash); + signal.throwIfAborted(); + const relPath = `acquired/${bookId}-${token}.${extension}`; + finalPath = resolveInside(this.config.dataDir, relPath); + await rename(temporaryPath, finalPath); + + if (parsed.cover && !this.db.get('SELECT id FROM books WHERE id = ?', bookId)) { + // A unique cover name lets rollback remove only this import's bytes, + // even when another source imports the same publication concurrently. + coverPath = await saveCover(this.config.dataDir, `${bookId}-${token}`, parsed.cover); + } + signal.throwIfAborted(); + const now = Date.now(); + this.db.transaction(() => { + this.insertBook(bookId, parsed, contentHash, size, coverPath, entry, context, now); + const file = this.db.get<{ rel_path: string }>('SELECT rel_path FROM acquired_files WHERE book_id = ?', bookId); + if (!file || !existsSync(resolveInside(this.config.dataDir, file.rel_path))) { + this.db.run( + `INSERT INTO acquired_files (book_id, rel_path, size) VALUES (?, ?, ?) + ON CONFLICT(book_id) DO UPDATE SET rel_path = excluded.rel_path, size = excluded.size`, + bookId, relPath, size, + ); + } + this.db.run( + `INSERT INTO source_acquisitions (source_id, user_id, entry_ref, option_id, book_id) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, user_id, entry_ref, option_id) DO UPDATE SET book_id = excluded.book_id`, + context.instance.id, context.userId, request.entryRef, request.optionId ?? '', bookId, + ); + this.addToShelf(context.userId, bookId, now); + }); + keepFile = this.db.get<{ rel_path: string }>('SELECT rel_path FROM acquired_files WHERE book_id = ?', bookId)?.rel_path === relPath; + keepCover = coverPath !== undefined && this.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE id = ?', bookId)?.cover_path === coverPath; + return bookId; + } finally { + resource?.stream?.destroy(); + await rm(temporaryPath, { force: true }); + if (finalPath && !keepFile) await rm(finalPath, { force: true }); + if (coverPath && !keepCover) await rm(resolveInside(this.config.dataDir, coverPath), { force: true }); + } + } + + private addToShelf(userId: string, bookId: string, now: number): void { + this.db.run( + `INSERT INTO user_books (user_id, book_id, added_at, hidden) VALUES (?, ?, ?, 0) + ON CONFLICT(user_id, book_id) DO UPDATE SET hidden = 0`, + userId, bookId, now, + ); + } + + private insertBook( + bookId: string, parsed: ParsedSource, contentHash: string, size: number, + coverPath: string | undefined, entry: CatalogEntry, context: SourceContext, now: number, + ): void { + const m = parsed.metadata; + const embedded = m.source === 'embedded'; + // Importing another copy must not refresh or overwrite existing metadata. + this.db.run( + `INSERT OR IGNORE INTO books (id, identifier, content_hash, format, title, author, publisher, language, + isbn, description, series, series_index, tags, pubdate, cover_path, file_size, page_count, meta_json, + source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + bookId, m.identifier, contentHash, parsed.format, + (embedded ? m.title : entry.title) || m.title || entry.title, + m.author || entry.authors?.join(', ') || '', m.publisher, m.language || entry.language || '', + m.isbn, m.description || entry.description || '', m.series, m.seriesIndex, + JSON.stringify(m.tags), m.pubdate || entry.publishedAt || '', coverPath ?? null, size, + parsed.pageCount, JSON.stringify({ ...m.raw, kind: parsed.kind }), + embedded ? m.source : `provider:${context.instance.pluginId}`, now, now, + ); + } +} diff --git a/server/src/sources/local.ts b/server/src/sources/local.ts new file mode 100644 index 0000000..c0b9b56 --- /dev/null +++ b/server/src/sources/local.ts @@ -0,0 +1,55 @@ +import type { Db } from '../db/index.ts'; +import type { ShelfService, BookDto } from '../services/shelf.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; +import type { SourceProvider, CatalogEntry } from './types.ts'; + +function entry(book: Pick): CatalogEntry { + return { + ref: book.id, title: book.title, authors: book.author ? [book.author] : [], + description: book.description, + options: [{ id: 'read', label: '加入书架', available: true }], metadata: { format: book.format }, + }; +} + +export function createLocalProvider(db: Db, shelf: () => ShelfService): SourceProvider { + const detail = async (_ctx: unknown, ref: string): Promise => { + const row = db.get( + `SELECT id, title, author, description, format FROM books b + WHERE b.id = ? AND EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)`, ref, + ); + if (!row) throw notFound('local book not found', 'ENTRY_NOT_FOUND'); + return entry(row); + }; + function page(cursor?: string): number { + if (cursor && !/^[1-9]\d{0,5}$/.test(cursor)) throw badRequest('invalid catalog cursor'); + return Number(cursor ?? 1); + } + return { + descriptor: { id: 'local', label: '本地书库', version: '1.0.0', capabilities: ['browse', 'search', 'detail'] }, + validateConfig(config) { + if (!config || typeof config !== 'object' || Array.isArray(config) || Object.keys(config).length) { + throw badRequest('local uses the configured BOOKS_DIR and accepts no instance settings'); + } + }, + async browse(ctx, request) { + const result = shelf().list(ctx.userId, { + scope: 'library', path: request.ref, page: page(request.cursor), pageSize: request.limit ?? 40, + }); + return { items: result.items.map(entry), ...(result.page * result.pageSize < result.total + ? { nextCursor: String(result.page + 1) } : {}) }; + }, + async search(ctx, request) { + const result = shelf().list(ctx.userId, { + scope: 'library', search: request.query, page: page(request.cursor), pageSize: request.limit ?? 40, + }); + return { items: result.items.map(entry), ...(result.page * result.pageSize < result.total + ? { nextCursor: String(result.page + 1) } : {}) }; + }, + detail, + async acquire(ctx, request) { + if (request.optionId && request.optionId !== 'read') throw badRequest('unknown acquisition option'); + await detail(ctx, request.entryRef); + return { kind: 'ready', publicationId: request.entryRef }; + }, + }; +} diff --git a/server/src/sources/opds.ts b/server/src/sources/opds.ts new file mode 100644 index 0000000..82840ee --- /dev/null +++ b/server/src/sources/opds.ts @@ -0,0 +1,434 @@ +import { XMLParser, XMLValidator } from 'fast-xml-parser'; +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { AppError } from '../lib/errors.ts'; +import type { + Acquisition, CatalogEntry, CatalogPage, SourceContext, SourceProvider, + ResourceResponse, SourceHttpResponse, +} from './types.ts'; + +const ACQUISITION = 'http://opds-spec.org/acquisition'; +const FILE_TYPES = new Set([ + 'application/epub+zip', 'application/pdf', 'text/plain', + 'application/vnd.comicbook+zip', 'application/x-cbz', +]); + +type ObjectValue = Record; +interface OpdsLink { href: string; rel: string[]; type: string; title: string } +interface OpdsBook { + id: string; + title: string; + authors: string[]; + description?: string; + coverUrl?: string; + detailUrl?: string; + acquisitions: OpdsLink[]; +} +interface OpdsDocument { + title: string; + books: OpdsBook[]; + navigation: Array<{ title: string; url: string }>; + next?: string; + search?: OpdsLink; +} + +function object(value: unknown): ObjectValue { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as ObjectValue : {}; +} + +function list(value: unknown): unknown[] { + return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]; +} + +function text(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + const record = object(value); + return typeof record['#text'] === 'string' ? record['#text'] : ''; +} + +function url(value: string, base: string): string | undefined { + try { + const parsed = new URL(value, base); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return undefined; + return parsed.href; + } catch { return undefined; } +} + +function links(value: unknown, base: string, atom = false): OpdsLink[] { + return list(value).flatMap((item) => { + const link = object(item); + const prefix = atom ? '@_' : ''; + const href = url(text(link[`${prefix}href`]), base); + if (!text(link[`${prefix}href`]) || !href) return []; + const rel = list(link[`${prefix}rel`]).flatMap((value) => text(value).split(/\s+/)).filter(Boolean); + return [{ href, rel, type: text(link[`${prefix}type`]), title: text(link[`${prefix}title`]) }]; + }); +} + +function plainType(type: string): string { return type.split(';')[0]!.trim().toLowerCase(); } +function hasRel(link: OpdsLink, rel: string): boolean { return link.rel.includes(rel); } +function isAcquisition(link: OpdsLink): boolean { return link.rel.some((rel) => rel === ACQUISITION || rel.startsWith(`${ACQUISITION}/`)); } +function downloadable(link: OpdsLink): boolean { + return (hasRel(link, ACQUISITION) || hasRel(link, `${ACQUISITION}/open-access`)) && FILE_TYPES.has(plainType(link.type)); +} + +function documentLinks(parsed: OpdsDocument, all: OpdsLink[]): void { + parsed.next = all.find((link) => hasRel(link, 'next'))?.href; + parsed.search = all.find((link) => hasRel(link, 'search')); +} + +function parseJsonPublication(raw: unknown, base: string): OpdsBook | undefined { + const item = object(raw); + const metadata = object(item.metadata); + const all = links(item.links, base); + const id = text(metadata.identifier) || all.find((link) => hasRel(link, 'self'))?.href || all.find(isAcquisition)?.href; + const title = text(metadata.title); + if (!id || !title) return undefined; + return { + id, title, + authors: list(metadata.author).map((author) => text(author) || text(object(author).name)).filter(Boolean), + description: text(metadata.description) || undefined, + coverUrl: links(item.images, base)[0]?.href, + detailUrl: all.find((link) => hasRel(link, 'self') && plainType(link.type) === 'application/opds-publication+json')?.href, + acquisitions: all.filter(isAcquisition), + }; +} + +function parseJson(raw: unknown, base: string): OpdsDocument { + const feed = object(raw); + if (!('metadata' in feed) || !text(object(feed.metadata).title)) throw new Error('Invalid OPDS JSON document'); + const parsed: OpdsDocument = { title: text(object(feed.metadata).title), books: [], navigation: [] }; + documentLinks(parsed, links(feed.links, base)); + const sections = [feed, ...list(feed.groups).map(object)]; + for (const section of sections) { + for (const item of list(section.publications)) { + const book = parseJsonPublication(item, base); + if (book) parsed.books.push(book); + } + for (const link of links(section.navigation, base)) { + parsed.navigation.push({ title: link.title || link.href, url: link.href }); + } + } + // A detail URL can return a single OPDS publication rather than a feed. + if (!('publications' in feed) && !('navigation' in feed) && !('groups' in feed)) { + const book = parseJsonPublication(feed, base); + if (book) parsed.books.push(book); + } + return parsed; +} + +function parseXml(body: string): ObjectValue { + if (/ (!link.rel.length || hasRel(link, 'subsection') || hasRel(link, 'alternate')) && /atom\+xml|opds\+json/i.test(link.type)); + if (navigation) parsed.navigation.push({ title: title || navigation.title || navigation.href, url: navigation.href }); + continue; + } + const id = text(entry.id) || all.find((link) => hasRel(link, 'self'))?.href || acquisitions[0]!.href; + if (!title) continue; + parsed.books.push({ + id, title, + authors: list(entry.author).map((author) => text(object(author).name)).filter(Boolean), + description: text(entry.summary) || text(entry.content) || undefined, + coverUrl: all.find((link) => hasRel(link, 'http://opds-spec.org/image/thumbnail'))?.href + ?? all.find((link) => hasRel(link, 'http://opds-spec.org/image'))?.href, + detailUrl: all.find((link) => hasRel(link, 'alternate') && /atom\+xml/i.test(link.type))?.href, + acquisitions, + }); + } + return parsed; +} + +function parseDocument(body: string, base: string): OpdsDocument { + return body.trimStart().startsWith('<') ? parseAtom(body, base) : parseJson(JSON.parse(body), base); +} + +interface OpdsConfig { + url: string; + username?: string; + allowedOrigins?: string[]; +} + +export interface OpdsProviderOptions { + fetch?: typeof globalThis.fetch; + maxFeedBytes?: number; + maxFileBytes?: number; +} + +function fail(message: string, code = 'OPDS_INVALID_RESPONSE', status = 502): never { + throw new AppError(status, code, message); +} + +function configuration(raw: unknown): OpdsConfig { + const value = object(raw); + if (!text(value.url) || !url(text(value.url), text(value.url))) { + fail('OPDS url must be an absolute HTTP(S) URL without credentials', 'INVALID_SOURCE_CONFIG', 400); + } + if ('password' in value) fail('Store the OPDS password in credentials, not public configuration', 'INVALID_SOURCE_CONFIG', 400); + if (value.username !== undefined && typeof value.username !== 'string') { + fail('OPDS username must be a string', 'INVALID_SOURCE_CONFIG', 400); + } + if (value.allowedOrigins !== undefined && (!Array.isArray(value.allowedOrigins) || value.allowedOrigins.some((origin) => { + if (typeof origin !== 'string') return true; + const normalized = url(origin, origin); + return !normalized || new URL(normalized).origin !== origin; + }))) fail('allowedOrigins must contain HTTP(S) origins without paths', 'INVALID_SOURCE_CONFIG', 400); + return { url: new URL(text(value.url)).href, username: value.username as string | undefined, allowedOrigins: value.allowedOrigins as string[] | undefined }; +} + +function checkedUrl(candidate: string, config: OpdsConfig, file = false): string { + const normalized = url(candidate, config.url); + if (!normalized) fail('OPDS link must use HTTP(S) without URL credentials', 'OPDS_UNSAFE_URL', 400); + const target = new URL(normalized); + const allowed = [new URL(config.url).origin, ...(file ? config.allowedOrigins ?? [] : [])]; + if (!allowed.includes(target.origin)) fail('OPDS link origin is not allowed by this source', 'OPDS_UNSAFE_URL', 400); + return target.href; +} + +function encodeRef(prefix: string, value: unknown): string { + return `${prefix}${Buffer.from(JSON.stringify(value)).toString('base64url')}`; +} + +function decodeRef(ref: string, prefix: string): ObjectValue { + if (!ref.startsWith(prefix) || ref.length > 16_384) fail('Invalid OPDS reference', 'INVALID_SOURCE_REF', 400); + try { return object(JSON.parse(Buffer.from(ref.slice(prefix.length), 'base64url').toString('utf8'))); } + catch { return fail('Invalid OPDS reference', 'INVALID_SOURCE_REF', 400); } +} + +function optionId(link: OpdsLink): string { + return createHash('sha256').update(`${link.href}\n${link.type}\n${link.rel.join(' ')}`).digest('hex').slice(0, 24); +} + +function catalogEntry(book: OpdsBook, feedUrl: string): CatalogEntry { + return { + ref: encodeRef('opds:', { url: feedUrl, id: book.id }), + title: book.title, authors: book.authors, description: book.description, coverUrl: book.coverUrl, + options: book.acquisitions.map((link) => ({ + id: optionId(link), mediaType: link.type, + label: link.title || (downloadable(link) ? plainType(link.type) : `Unsupported acquisition: ${link.rel.join(', ')}`), + available: downloadable(link), + })), + }; +} + +function catalogPage(document: OpdsDocument, feedUrl: string): CatalogPage { + return { + title: document.title, + items: document.books.map((book) => catalogEntry(book, feedUrl)), + navigation: document.navigation.map((entry) => ({ ref: entry.url, title: entry.title, kind: 'catalog' })), + nextCursor: document.next, + }; +} + +function expandSearch(template: string, query: string, limit: number): string { + const normalized = template.replace(/%7B/gi, '{').replace(/%7D/gi, '}'); + if (!normalized.includes('{searchTerms}')) fail('OPDS search template has no searchTerms parameter', 'SOURCE_UNSUPPORTED', 400); + return normalized.replace(/\{([^}]+)\}/g, (_match, name: string) => { + if (name === 'searchTerms') return encodeURIComponent(query); + if (name === 'count' || name === 'count?') return String(limit); + if (name === 'startIndex' || name === 'startPage') return '1'; + if (name.endsWith('?')) return ''; + return fail(`Unsupported OPDS search parameter: ${name}`, 'SOURCE_UNSUPPORTED', 400); + }); +} + +/** Built-in OPDS 1/2 source. Credentials and downloads stay inside the provider. */ +export function createOpdsProvider(options: OpdsProviderOptions = {}): SourceProvider { + const fetcher = options.fetch ?? globalThis.fetch; + const maxFeedBytes = options.maxFeedBytes ?? 4 * 1024 * 1024; + const maxFileBytes = options.maxFileBytes ?? 256 * 1024 * 1024; + + async function request(ctx: SourceContext, candidate: string, file = false): Promise { + const config = configuration(ctx.instance.config); + let target = checkedUrl(candidate, config, file); + for (let redirects = 0; redirects <= 5; redirects += 1) { + ctx.signal.throwIfAborted(); + const headers: Record = { accept: file ? '*/*' : 'application/opds+json, application/atom+xml, application/opds-publication+json, application/opensearchdescription+xml' }; + if (config.username && new URL(target).origin === new URL(config.url).origin) { + const password = await ctx.credentials?.get('password', ctx.signal); + if (password === undefined) fail('OPDS credentials are required', 'AUTH_REQUIRED', 401); + headers.authorization = `Basic ${Buffer.from(`${config.username}:${password}`).toString('base64')}`; + } + const response = ctx.http + ? await ctx.http.request({ url: target, headers }, ctx.signal) + : await fetcher(target, { headers, signal: ctx.signal, redirect: 'manual' }); + const responseUrl = response.url || target; + checkedUrl(responseUrl, config, file); + const header = (name: string) => response.headers instanceof Headers ? response.headers.get(name) : response.headers[name]; + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = header('location'); + if (response instanceof Response) await response.body?.cancel(); + if (!location || redirects === 5) fail('OPDS redirect limit exceeded or Location missing'); + target = checkedUrl(new URL(location, target).href, config, file); + continue; + } + if (response.status < 200 || response.status >= 300) { + if (response instanceof Response) await response.body?.cancel(); + if (response.status === 401 || response.status === 403) fail('OPDS authentication is required', 'AUTH_REQUIRED', 401); + if (response.status === 404) fail('OPDS resource no longer exists', 'RESOURCE_GONE', 404); + if (response.status === 429) fail('OPDS request rate is limited', 'RATE_LIMITED', 429); + fail(`OPDS server returned HTTP ${response.status}`); + } + const size = Number(header('content-length')); + if (Number.isFinite(size) && size > (file ? maxFileBytes : maxFeedBytes)) { + if (response instanceof Response) await response.body?.cancel(); + fail('OPDS resource exceeds the configured size limit', 'RESOURCE_TOO_LARGE', 413); + } + return response; + } + return fail('OPDS redirect limit exceeded'); + } + + async function* bytes(response: Response | SourceHttpResponse, maxBytes: number): AsyncGenerator { + let total = 0; + const stream = response instanceof Response + ? response.body ? Readable.fromWeb(response.body as never) : [] + : [response.body]; + for await (const chunk of stream) { + total += chunk.byteLength; + if (total > maxBytes) fail('OPDS resource exceeds the configured size limit', 'RESOURCE_TOO_LARGE', 413); + yield chunk; + } + } + + async function document(ctx: SourceContext, target: string): Promise<{ parsed: OpdsDocument; url: string }> { + const response = await request(ctx, target); + const chunks: Uint8Array[] = []; + for await (const chunk of bytes(response, maxFeedBytes)) chunks.push(chunk); + const finalUrl = response.url || target; + try { return { parsed: parseDocument(Buffer.concat(chunks).toString('utf8'), finalUrl), url: finalUrl }; } + catch (error) { + if (error instanceof AppError) throw error; + return fail('OPDS response is not a valid Atom or JSON catalog'); + } + } + + async function lookup(ctx: SourceContext, ref: string): Promise<{ book: OpdsBook; url: string }> { + const stored = decodeRef(ref, 'opds:'); + if (!text(stored.url) || !text(stored.id)) fail('Invalid OPDS entry reference', 'INVALID_SOURCE_REF', 400); + const feed = await document(ctx, text(stored.url)); + let book = feed.parsed.books.find((entry) => entry.id === stored.id); + if (!book) fail('OPDS entry no longer exists in its catalog', 'RESOURCE_GONE', 404); + if (book.detailUrl && book.detailUrl !== feed.url) { + const full = await document(ctx, book.detailUrl); + book = full.parsed.books.find((entry) => entry.id === stored.id) ?? book; + } + return { book, url: feed.url }; + } + + return { + descriptor: { + id: 'opds', label: 'OPDS', version: '1.0.0', + capabilities: ['browse', 'search', 'detail', 'acquire.file'], + configSchema: { + type: 'object', required: ['url'], additionalProperties: false, + properties: { + url: { type: 'string', format: 'uri', title: 'OPDS catalog URL' }, + username: { type: 'string', title: 'Basic authentication username (password is stored separately)' }, + allowedOrigins: { type: 'array', items: { type: 'string', format: 'uri' }, title: 'Additional download origins' }, + }, + }, + }, + validateConfig: (config) => { configuration(config); }, + async browse(ctx, request) { + const config = configuration(ctx.instance.config); + const feed = await document(ctx, request.cursor || request.ref || config.url); + return catalogPage(feed.parsed, feed.url); + }, + async search(ctx, search) { + const config = configuration(ctx.instance.config); + if (search.cursor) { + const feed = await document(ctx, search.cursor); + return catalogPage(feed.parsed, feed.url); + } + if (!search.query.trim()) fail('Search query must not be empty', 'BAD_REQUEST', 400); + const root = await document(ctx, config.url); + const link = root.parsed.search; + if (!link) fail('This OPDS catalog does not advertise search', 'SOURCE_UNSUPPORTED', 400); + let template = link.href; + if (plainType(link.type) === 'application/opensearchdescription+xml') { + const response = await request(ctx, link.href); + const chunks: Uint8Array[] = []; + for await (const chunk of bytes(response, maxFeedBytes)) chunks.push(chunk); + let description: ObjectValue; + try { description = object(parseXml(Buffer.concat(chunks).toString('utf8')).OpenSearchDescription); } + catch { return fail('Invalid OPDS OpenSearch description'); } + const candidates = list(description.Url).map(object); + const candidate = candidates.find((item) => /atom\+xml|opds\+json/i.test(text(item['@_type'])) && (!item['@_method'] || text(item['@_method']).toUpperCase() === 'GET')); + const rawTemplate = candidate ? text(candidate['@_template']) : ''; + if (!rawTemplate) fail('OPDS search has no supported GET template', 'SOURCE_UNSUPPORTED', 400); + template = new URL(rawTemplate, response.url || link.href).href; + } + const target = expandSearch(template, search.query, Math.max(1, Math.min(search.limit ?? 50, 200))); + const feed = await document(ctx, target); + return catalogPage(feed.parsed, feed.url); + }, + async detail(ctx, entryRef) { + const entry = await lookup(ctx, entryRef); + return { ...catalogEntry(entry.book, entry.url), ref: entryRef }; + }, + async acquire(ctx, request): Promise { + const { book } = await lookup(ctx, request.entryRef); + const selected = request.optionId + ? book.acquisitions.find((link) => optionId(link) === request.optionId) + : book.acquisitions.find(downloadable) ?? book.acquisitions[0]; + if (!selected) fail('OPDS acquisition option no longer exists', 'RESOURCE_GONE', 404); + if (!downloadable(selected)) { + return { kind: 'action-required', action: { + type: hasRel(selected, `${ACQUISITION}/borrow`) ? 'borrow' : 'external', + label: 'This release supports direct EPUB, PDF, TXT and CBZ downloads; borrowing, purchases and DRM require an external application.', + url: selected.href, + } }; + } + checkedUrl(selected.href, configuration(ctx.instance.config), true); + return { + kind: 'file', mediaType: plainType(selected.type), + acquisitionRef: encodeRef('opds-file:', { entryRef: request.entryRef, optionId: optionId(selected) }), + }; + }, + async openFile(ctx, acquisition): Promise { + const ref = decodeRef(acquisition.acquisitionRef, 'opds-file:'); + if (!text(ref.entryRef) || !text(ref.optionId)) fail('Invalid OPDS acquisition reference', 'INVALID_SOURCE_REF', 400); + const { book } = await lookup(ctx, text(ref.entryRef)); + const selected = book.acquisitions.find((link) => optionId(link) === ref.optionId && downloadable(link)); + if (!selected) fail('OPDS download no longer exists', 'RESOURCE_GONE', 404); + const response = await request(ctx, selected.href, true); + const header = (name: string) => response.headers instanceof Headers ? response.headers.get(name) : response.headers[name]; + const actualType = plainType(header('content-type') ?? ''); + if (actualType && actualType !== 'application/octet-stream' && actualType !== plainType(selected.type)) { + if (response instanceof Response) await response.body?.cancel(); + fail('OPDS download media type differs from its catalog entry'); + } + const length = header('content-length'); + return { + mediaType: plainType(selected.type), stream: Readable.from(bytes(response, maxFileBytes)), + size: length && Number.isFinite(Number(length)) ? Number(length) : undefined, + etag: header('etag') ?? undefined, + }; + }, + }; +} + +export const opdsProvider = createOpdsProvider(); diff --git a/server/src/sources/plugin-manager.ts b/server/src/sources/plugin-manager.ts new file mode 100644 index 0000000..7c15572 --- /dev/null +++ b/server/src/sources/plugin-manager.ts @@ -0,0 +1,283 @@ +import { realpath, stat } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import type { Db } from '../db/index.ts'; +import { AppError } from '../lib/errors.ts'; +import { ProcessPlugin } from './process-plugin.ts'; +import { SourceRegistry, SourceRegistryError } from './registry.ts'; +import type { PluginRuntimeStatus, SourceDescriptor, SourceProvider } from './types.ts'; + +interface InstalledPlugin { plugin_id: string; folder: string; enabled: number } +interface ManagedPlugin { + plugin?: ProcessPlugin; + providers: readonly SourceProvider[]; + active: boolean; + error?: { code: string; message: string }; +} + +export interface PluginInfo { + pluginId: string; + folder?: string; + builtin: boolean; + enabled: boolean; + name?: string; + version?: string; + sourceTypes: readonly SourceDescriptor[]; + runtime: PluginRuntimeStatus | null; + error?: { code: string; message: string }; +} + +function pluginError(status: number, code: string, message: string): AppError { + return new AppError(status, code, message); +} + +function inside(root: string, target: string): boolean { + const rel = relative(root, target); + return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); +} + +/** + * Installs trusted packages already present under DATA_DIR/plugins. All lifecycle + * mutations are serialized; unloading removes providers before awaiting workers. + */ +export class PluginManager { + private readonly managed = new Map(); + private pending: Promise = Promise.resolve(); + private closed = false; + + constructor(private readonly db: Db, private readonly dataDir: string, private readonly registry: SourceRegistry) {} + + loadInstalled(): Promise { + return this.enqueue(async () => { + for (const row of this.rows()) { + if (row.enabled !== 1 || this.managed.get(row.plugin_id)?.active) continue; + try { + this.assertExternal(row.plugin_id); + await this.activate(row); + } catch (error) { + // One bad package must not prevent the library or other plugins starting. + this.managed.set(row.plugin_id, { active: false, providers: [], error: this.errorInfo(error) }); + } + } + }); + } + + install(folder: string): Promise { + return this.enqueue(async () => { + const plugin = await this.loadPackage(folder); + let managed: ManagedPlugin | undefined; + try { + this.assertExternal(plugin.manifest.id); + if (this.rows().some((row) => row.plugin_id === plugin.manifest.id || row.folder === folder)) { + throw pluginError(409, 'PLUGIN_ALREADY_INSTALLED', 'This plugin id or package folder is already installed'); + } + managed = this.prepare(plugin); + this.register(managed); + this.db.run('INSERT INTO installed_plugins (plugin_id, folder, enabled) VALUES (?, ?, 1)', plugin.manifest.id, folder); + this.managed.set(plugin.manifest.id, managed); + return this.info({ plugin_id: plugin.manifest.id, folder, enabled: 1 }); + } catch (error) { + if (managed) this.unregister(managed); + await plugin.close(); + throw this.packageInputError(error); + } + }); + } + + setEnabled(pluginId: string, enabled: boolean): Promise { + return this.enqueue(async () => { + this.assertExternal(pluginId); + const row = this.row(pluginId); + const previous = this.managed.get(pluginId); + if (!enabled) { + this.db.run('UPDATE installed_plugins SET enabled = 0 WHERE plugin_id = ?', pluginId); + if (previous) await this.stop(previous); + return this.info({ ...row, enabled: 0 }); + } + if (row.enabled === 1 && previous?.active && previous.plugin?.status().state !== 'failed') return this.info(row); + if (previous) await this.stop(previous); + try { + await this.activate(row); + this.db.run('UPDATE installed_plugins SET enabled = 1 WHERE plugin_id = ?', pluginId); + } catch (error) { + const failed = this.managed.get(pluginId); + if (failed) await this.stop(failed); + this.managed.set(pluginId, { active: false, providers: [], error: this.errorInfo(error) }); + throw error; + } + return this.info({ ...row, enabled: 1 }); + }); + } + + uninstall(pluginId: string): Promise { + return this.enqueue(async () => { + this.assertExternal(pluginId); + this.row(pluginId); + this.db.run('DELETE FROM installed_plugins WHERE plugin_id = ?', pluginId); + const managed = this.managed.get(pluginId); + if (managed) await this.stop(managed); + this.managed.delete(pluginId); + // Package files, source instances, credentials and reading state are retained. + }); + } + + list(): PluginInfo[] { + const builtins = new Map(); + for (const registration of this.registry.list()) { + if (!registration.builtin) continue; + const item = builtins.get(registration.pluginId) ?? { + pluginId: registration.pluginId, builtin: true, enabled: true, + name: registration.provider.descriptor.label, version: registration.provider.descriptor.version, + sourceTypes: [], runtime: null, + }; + item.sourceTypes = [...item.sourceTypes, registration.provider.descriptor]; + builtins.set(registration.pluginId, item); + } + return [...builtins.values(), ...this.rows().filter((row) => !builtins.has(row.plugin_id)).map((row) => this.info(row))]; + } + + close(): Promise { + return this.enqueue(async () => { + if (this.closed) return; + this.closed = true; + const outcomes = await Promise.allSettled([...this.managed.values()].map((managed) => this.stop(managed))); + const failure = outcomes.find((outcome) => outcome.status === 'rejected'); + if (failure?.status === 'rejected') throw failure.reason; + }, true); + } + + private enqueue(action: () => Promise, allowClosed = false): Promise { + const work = this.pending.then(() => { + if (this.closed && !allowClosed) throw pluginError(503, 'PLUGIN_MANAGER_CLOSED', 'Plugin manager is closed'); + return action(); + }); + this.pending = work.then(() => {}, () => {}); + return work; + } + + private rows(): InstalledPlugin[] { + return this.db.all('SELECT plugin_id, folder, enabled FROM installed_plugins ORDER BY plugin_id'); + } + + private row(pluginId: string): InstalledPlugin { + const row = this.db.get('SELECT plugin_id, folder, enabled FROM installed_plugins WHERE plugin_id = ?', pluginId); + if (!row) throw pluginError(404, 'PLUGIN_NOT_FOUND', 'Plugin is not installed'); + return row; + } + + private assertExternal(pluginId: string): void { + if (this.registry.list().some((registration) => registration.pluginId === pluginId && registration.builtin)) { + throw pluginError(400, 'BUILTIN_PLUGIN_IMMUTABLE', 'Built-in source providers cannot be replaced, disabled or uninstalled'); + } + } + + private async packageDirectory(folder: string): Promise { + if (typeof folder !== 'string' || !/^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,126}[a-zA-Z0-9_-])?$/.test(folder)) { + throw pluginError(400, 'PLUGIN_INVALID_FOLDER', 'Plugin folder must be a single directory name'); + } + const data = await realpath(this.dataDir); + const root = await realpath(resolve(data, 'plugins')); + if (!inside(data, root)) throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin root must remain within DATA_DIR'); + const directory = await realpath(join(root, folder)); + if (!inside(root, directory)) throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin package must remain within DATA_DIR/plugins'); + const manifest = await realpath(join(directory, 'plugin.json')); + if (!inside(directory, manifest)) { + throw pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin package must remain within DATA_DIR/plugins'); + } + if (!(await stat(directory)).isDirectory()) throw pluginError(400, 'PLUGIN_INVALID_FOLDER', 'Plugin package must be a directory'); + return directory; + } + + private async loadPackage(folder: string): Promise { + try { return await ProcessPlugin.load(await this.packageDirectory(folder)); } + catch (error) { throw this.packageInputError(error); } + } + + private packageInputError(error: unknown): unknown { + if (error instanceof AppError) { + if (error.code === 'PLUGIN_INVALID' || error.code === 'PLUGIN_INCOMPATIBLE') { + return pluginError(400, error.code, error.message); + } + return error; + } + if (error instanceof SyntaxError) return pluginError(400, 'PLUGIN_INVALID_MANIFEST', 'Plugin manifest must contain valid JSON'); + if (error instanceof SourceRegistryError) return pluginError(400, 'PLUGIN_INVALID_MANIFEST', error.message); + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') return pluginError(404, 'PLUGIN_PACKAGE_NOT_FOUND', 'Plugin folder, manifest or entry file does not exist'); + if (code === 'ENOTDIR' || code === 'EISDIR') return pluginError(400, 'PLUGIN_INVALID_PACKAGE', 'Plugin package must contain a manifest and an executable entry file'); + if (code === 'EACCES' || code === 'EPERM') return pluginError(403, 'PLUGIN_PACKAGE_UNREADABLE', 'The server cannot read this plugin package'); + if (code === 'ELOOP') return pluginError(400, 'PLUGIN_PATH_ESCAPE', 'Plugin package contains a symbolic link loop'); + return error; + } + + private prepare(plugin: ProcessPlugin): ManagedPlugin { + if (this.registry.list().some((registration) => registration.pluginId === plugin.manifest.id)) { + throw pluginError(409, 'PLUGIN_SOURCE_CONFLICT', 'The plugin already owns a registered source type'); + } + const managed: ManagedPlugin = { plugin, providers: [], active: true }; + managed.providers = plugin.providers().map((provider) => new Proxy(provider, { + get(target, key, receiver) { + const value = Reflect.get(target, key, receiver) as unknown; + if (typeof value !== 'function') return value; + return async (...args: unknown[]) => { + // Existing callers may retain providers after registry removal. Do not + // let those references restart a worker after disable or uninstall. + if (!managed.active) throw pluginError(503, 'PLUGIN_UNAVAILABLE', 'Plugin is disabled or uninstalled'); + return value.apply(target, args); + }; + }, + })); + const validation = new SourceRegistry(); + for (const provider of managed.providers) validation.registerPluginManifest(plugin.manifest, provider); + return managed; + } + + private register(managed: ManagedPlugin): void { + for (const provider of managed.providers) this.registry.registerPluginManifest(managed.plugin!.manifest, provider); + } + + private unregister(managed: ManagedPlugin): void { + managed.active = false; + for (const provider of managed.providers) { + const pluginId = managed.plugin!.manifest.id; + if (this.registry.get(pluginId, provider.descriptor.id)?.provider === provider) { + this.registry.unregister(pluginId, provider.descriptor.id); + } + } + } + + private async stop(managed: ManagedPlugin): Promise { + this.unregister(managed); + await managed.plugin?.close(); + } + + private async activate(row: InstalledPlugin): Promise { + const plugin = await this.loadPackage(row.folder); + let managed: ManagedPlugin | undefined; + try { + if (plugin.manifest.id !== row.plugin_id) throw pluginError(409, 'PLUGIN_ID_CHANGED', 'Installed package plugin id no longer matches its installation record'); + managed = this.prepare(plugin); + this.register(managed); + this.managed.set(row.plugin_id, managed); + } catch (error) { + if (managed) this.unregister(managed); + await plugin.close(); + throw this.packageInputError(error); + } + } + + private info(row: InstalledPlugin): PluginInfo { + const managed = this.managed.get(row.plugin_id); + return { + pluginId: row.plugin_id, folder: row.folder, builtin: false, enabled: row.enabled === 1, + name: managed?.plugin?.manifest.name, version: managed?.plugin?.manifest.version, + sourceTypes: managed?.providers.map((provider) => provider.descriptor) ?? [], + runtime: managed?.plugin?.status() ?? null, error: managed?.error, + }; + } + + private errorInfo(error: unknown): { code: string; message: string } { + return error instanceof AppError + ? { code: error.code, message: error.message } + : { code: 'PLUGIN_LOAD_FAILED', message: error instanceof Error ? error.message : 'Plugin failed to load' }; + } +} diff --git a/server/src/sources/process-plugin.ts b/server/src/sources/process-plugin.ts new file mode 100644 index 0000000..81cdc1a --- /dev/null +++ b/server/src/sources/process-plugin.ts @@ -0,0 +1,286 @@ +import { spawn } from 'node:child_process'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { readFile, realpath, stat } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { AppError } from '../lib/errors.ts'; +import { validatePluginManifest } from './registry.ts'; +import { decodeCatalogEntry, decodeCatalogPage, decodeChapterAcquisition, decodeManifest } from './protocol.ts'; +import type { + PluginManifest, + PluginRuntimeState, + PluginRuntimeStatus, + ResourceResponse, + SourceContext, + SourceProvider, +} from './types.ts'; + +export class PluginError extends AppError { + constructor(code: string, message: string) { + super(code === 'PLUGIN_TIMEOUT' ? 504 : code === 'PLUGIN_UNAVAILABLE' ? 503 : 502, code, message); + this.name = 'PluginError'; + } +} + +export interface ProcessPluginOptions { + readonly timeoutMs?: number; + /** Applies to each JSON line and limits the first version's inline resource payloads. */ + readonly maxMessageBytes?: number; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(reason: unknown): void; + cleanup(): void; +} + +/** + * Trusted Node plugins speak newline-delimited JSON-RPC over stdio. This is + * fault isolation, not a sandbox: installed code has this OS user's privileges. + */ +export class ProcessPlugin { + private process: ChildProcessWithoutNullStreams | undefined; + private state: PluginRuntimeState = 'stopped'; + private sequence = 0; + private buffer = Buffer.alloc(0); + private readonly pending = new Map(); + private readonly exits = new Set>(); + private readonly timeoutMs: number; + private readonly maxMessageBytes: number; + + private constructor( + readonly manifest: PluginManifest, + private readonly directory: string, + private readonly entry: string, + options: ProcessPluginOptions, + ) { + this.timeoutMs = options.timeoutMs ?? 30_000; + this.maxMessageBytes = options.maxMessageBytes ?? 2 * 1024 * 1024; + if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) { + throw new Error('plugin timeoutMs must be a positive integer'); + } + if (!Number.isSafeInteger(this.maxMessageBytes) || this.maxMessageBytes < 256) { + throw new Error('plugin maxMessageBytes must be at least 256'); + } + } + + static async load(directory: string, options: ProcessPluginOptions = {}): Promise { + const root = await realpath(directory); + const manifest = validatePluginManifest(JSON.parse(await readFile(resolve(root, 'plugin.json'), 'utf8'))); + if (manifest.apiVersion !== 1) { + throw new PluginError('PLUGIN_INCOMPATIBLE', `unsupported plugin API version: ${manifest.apiVersion}`); + } + if (isAbsolute(manifest.entry)) { + throw new PluginError('PLUGIN_INVALID', 'plugin entry must be relative to its directory'); + } + const entry = await realpath(resolve(root, manifest.entry)); + const rel = relative(root, entry); + if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new PluginError('PLUGIN_INVALID', 'plugin entry must be inside its directory'); + } + if (!(await stat(entry)).isFile()) { + throw new PluginError('PLUGIN_INVALID', 'plugin entry must be a file'); + } + return new ProcessPlugin(manifest, root, entry, options); + } + + status(): PluginRuntimeStatus { + return { pluginId: this.manifest.id, state: this.state, pendingRequests: this.pending.size }; + } + + 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 provider: SourceProvider = { + descriptor: { ...type, version: this.manifest.version }, + validateConfig: async (config) => { + await this.request('validateConfig', { sourceType: type.id, config }); + }, + detail: async (ctx, entryRef) => decodeCatalogEntry(await call('detail', ctx, { entryRef })), + acquire: async (ctx, request) => decodeChapterAcquisition(await call('acquire', ctx, { request })), + }; + if (type.capabilities.includes('browse')) { + provider.browse = async (ctx, request) => decodeCatalogPage(await call('browse', ctx, { request })); + } + if (type.capabilities.includes('search')) { + provider.search = async (ctx, request) => decodeCatalogPage(await call('search', ctx, { request })); + } + if (type.capabilities.includes('content.manifest')) { + provider.getManifest = async (ctx, publicationRef) => decodeManifest(await call('getManifest', ctx, { publicationRef }), publicationRef); + } + if (type.capabilities.includes('content.resource')) { + provider.readResource = async (ctx, request) => decodeResource(await call('readResource', ctx, { request })); + } + // File transfer through this bounded JSON channel is intentionally unsupported. + if (type.capabilities.includes('acquire.file')) { + throw new PluginError('PLUGIN_INCOMPATIBLE', 'process plugins currently support chapter resources only'); + } + return provider; + }); + } + + async close(): Promise { + const child = this.process; + this.process = undefined; + this.state = 'stopped'; + this.buffer = Buffer.alloc(0); + this.rejectAll(new PluginError('PLUGIN_UNAVAILABLE', 'plugin stopped')); + child?.kill('SIGKILL'); + await Promise.all([...this.exits]); + } + + private start(): ChildProcessWithoutNullStreams { + if (this.process) return this.process; + if (this.state === 'failed') throw new PluginError('PLUGIN_UNAVAILABLE', 'plugin failed; reload it to retry'); + const child = spawn(process.execPath, [this.entry], { + cwd: this.directory, + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + // Do not automatically copy server API tokens and other configuration. + env: pluginEnvironment(), + }); + this.process = child; + this.state = 'running'; + const exit = new Promise((done) => child.once('close', () => done())); + this.exits.add(exit); + void exit.then(() => this.exits.delete(exit)); + child.stdout.on('data', (chunk: Buffer) => { + if (this.process === child) this.onData(chunk); + }); + child.stderr.resume(); + child.stdin.on('error', () => { + if (this.process === child) this.fail(new PluginError('PLUGIN_UNAVAILABLE', 'plugin input pipe failed')); + }); + child.on('error', () => { + if (this.process === child) this.fail(new PluginError('PLUGIN_UNAVAILABLE', 'plugin could not start')); + }); + child.on('close', () => { + if (this.process === child) this.fail(new PluginError('PLUGIN_UNAVAILABLE', 'plugin process exited')); + }); + return child; + } + + private request(method: string, params: Record, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(new PluginError('PLUGIN_CANCELLED', 'plugin request cancelled')); + let child: ChildProcessWithoutNullStreams; + try { + child = this.start(); + } catch (error) { + return Promise.reject(error); + } + const id = ++this.sequence; + const line = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'; + if (Buffer.byteLength(line) > this.maxMessageBytes) { + return Promise.reject(new PluginError('PLUGIN_MESSAGE_TOO_LARGE', 'plugin request exceeds the message limit')); + } + return new Promise((resolveRequest, reject) => { + const cancel = () => { + if (!this.pending.has(id)) return; + // Killing the worker is the only hard cancellation available for trusted + // stdio plugins. Other calls in the same process fail with it. + this.fail(new PluginError('PLUGIN_CANCELLED', 'plugin process stopped because a request was cancelled')); + }; + const timer = setTimeout(() => { + this.fail(new PluginError('PLUGIN_TIMEOUT', 'plugin request exceeded its execution deadline')); + }, this.timeoutMs); + timer.unref(); + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', cancel); + }; + this.pending.set(id, { resolve: resolveRequest, reject, cleanup }); + signal?.addEventListener('abort', cancel, { once: true }); + child.stdin.write(line); + }); + } + + private onData(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (true) { + const end = this.buffer.indexOf(10); + if (end < 0) { + if (this.buffer.length > this.maxMessageBytes) { + this.fail(new PluginError('PLUGIN_MESSAGE_TOO_LARGE', 'plugin response exceeds the message limit')); + } + return; + } + if (end > this.maxMessageBytes) { + this.fail(new PluginError('PLUGIN_MESSAGE_TOO_LARGE', 'plugin response exceeds the message limit')); + return; + } + const line = this.buffer.subarray(0, end).toString('utf8'); + this.buffer = this.buffer.subarray(end + 1); + try { + this.onMessage(JSON.parse(line)); + } catch { + this.fail(new PluginError('PLUGIN_PROTOCOL_ERROR', 'plugin returned an invalid JSON-RPC message')); + return; + } + } + } + + private onMessage(input: unknown): void { + if (!input || typeof input !== 'object') throw new Error('invalid response'); + const value = input as Record; + if (value.jsonrpc !== '2.0' || !Number.isSafeInteger(value.id)) throw new Error('invalid response'); + if (('result' in value) === ('error' in value)) throw new Error('invalid response'); + const pending = this.pending.get(value.id as number); + if (!pending) return; // A cooperative cancellation can race an already written response. + this.pending.delete(value.id as number); + pending.cleanup(); + if ('error' in value) { + const error = value.error as { code?: unknown; message?: unknown } | null; + const code = typeof error?.code === 'string' ? error.code : 'PLUGIN_ERROR'; + pending.reject(new PluginError(code, typeof error?.message === 'string' ? error.message : 'plugin request failed')); + } else { + pending.resolve(value.result); + } + } + + private rejectAll(error: Error): void { + for (const pending of this.pending.values()) { + pending.cleanup(); + pending.reject(error); + } + this.pending.clear(); + } + + private fail(error: PluginError): void { + const child = this.process; + this.process = undefined; + this.state = 'failed'; + this.buffer = Buffer.alloc(0); + this.rejectAll(error); + child?.kill('SIGKILL'); + } +} + +function pluginEnvironment(): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of ['PATH', 'Path', 'SystemRoot', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'TMPDIR', 'LANG']) { + if (process.env[key] !== undefined) result[key] = process.env[key]; + } + return result; +} + +function decodeResource(input: unknown): ResourceResponse { + if (!input || typeof input !== 'object') throw new PluginError('PLUGIN_PROTOCOL_ERROR', 'invalid plugin resource'); + const value = input as Record; + if (typeof value.mediaType !== 'string' || !value.mediaType.trim()) { + throw new PluginError('PLUGIN_PROTOCOL_ERROR', 'plugin resource has no media type'); + } + const hasText = typeof value.text === 'string'; + const hasBase64 = typeof value.base64 === 'string'; + if (hasText === hasBase64) { + throw new PluginError('PLUGIN_PROTOCOL_ERROR', 'plugin resource must contain exactly one of text or base64'); + } + if (hasText) return { mediaType: value.mediaType, text: value.text as string }; + const encoded = value.base64 as string; + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { + throw new PluginError('PLUGIN_PROTOCOL_ERROR', 'plugin resource contains invalid base64'); + } + const data = Buffer.from(encoded, 'base64'); + return { mediaType: value.mediaType, data, size: data.length }; +} diff --git a/server/src/sources/protocol.ts b/server/src/sources/protocol.ts new file mode 100644 index 0000000..88c10e9 --- /dev/null +++ b/server/src/sources/protocol.ts @@ -0,0 +1,100 @@ +import { AppError } from '../lib/errors.ts'; +import type { Acquisition, CatalogEntry, CatalogPage, ManifestSnapshot } from './types.ts'; + +function invalid(message: string): never { + throw new AppError(502, 'PLUGIN_PROTOCOL_ERROR', message); +} + +function object(input: unknown, label: string): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) invalid(`invalid plugin ${label}`); + return input as Record; +} + +function string(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !value.trim()) invalid(`plugin ${label} must be a nonempty string`); +} + +/** Validate wire data before it can become host-owned catalog or reading state. */ +export function decodeCatalogEntry(input: unknown): CatalogEntry { + const value = object(input, 'catalog entry'); + string(value.ref, 'entry ref'); + string(value.title, 'entry title'); + for (const key of ['description', 'coverUrl', 'language', 'publishedAt']) { + if (value[key] !== undefined && typeof value[key] !== 'string') invalid(`invalid plugin entry ${key}`); + } + if (value.authors !== undefined && + (!Array.isArray(value.authors) || value.authors.some((author) => typeof author !== 'string'))) { + invalid('plugin authors must be strings'); + } + if (value.options !== undefined) { + if (!Array.isArray(value.options)) invalid('plugin acquisition options must be an array'); + const ids = new Set(); + for (const option of value.options) { + const acquisition = object(option, 'acquisition option'); + string(acquisition.id, 'acquisition option id'); + string(acquisition.label, 'acquisition option label'); + if (ids.has(acquisition.id)) invalid('duplicate plugin acquisition option id'); + ids.add(acquisition.id); + } + } + return input as CatalogEntry; +} + +export function decodeCatalogPage(input: unknown): CatalogPage { + const value = object(input, 'catalog page'); + if (!Array.isArray(value.items)) invalid('plugin catalog items must be an array'); + value.items.forEach(decodeCatalogEntry); + for (const key of ['title', 'nextCursor']) { + if (value[key] !== undefined && typeof value[key] !== 'string') invalid(`invalid plugin catalog ${key}`); + } + if (value.navigation !== undefined) { + if (!Array.isArray(value.navigation)) invalid('plugin navigation must be an array'); + for (const entry of value.navigation) { + const navigation = object(entry, 'navigation'); + string(navigation.ref, 'navigation ref'); + string(navigation.title, 'navigation title'); + } + } + return input as CatalogPage; +} + +export function decodeChapterAcquisition(input: unknown): Acquisition { + const value = object(input, 'acquisition'); + if (value.kind === 'chapters') { + string(value.publicationRef, 'publication ref'); + } else if (value.kind === 'action-required') { + const action = object(value.action, 'acquisition action'); + if (!['login', 'borrow', 'external'].includes(action.type as string)) invalid('invalid plugin acquisition action type'); + string(action.label, 'action label'); + if (action.url !== undefined) { + string(action.url, 'action URL'); + let url: URL; + try { url = new URL(action.url); } catch { invalid('invalid plugin action URL'); } + if (!['http:', 'https:'].includes(url.protocol)) invalid('plugin action URL must use HTTP or HTTPS'); + } + } else { + invalid('process plugins must acquire chapter content or request an action'); + } + return input as Acquisition; +} + +export function decodeManifest(input: unknown, publicationRef: string): ManifestSnapshot { + const value = object(input, 'manifest'); + if (value.publicationRef !== publicationRef) invalid('plugin manifest publication ref does not match the request'); + if (!Array.isArray(value.items)) invalid('plugin manifest items must be an array'); + const ids = new Set(); + const sequences = new Set(); + for (const entry of value.items) { + const item = object(entry, 'manifest item'); + string(item.id, 'chapter id'); + string(item.ref, 'chapter ref'); + string(item.title, 'chapter title'); + string(item.mediaType, 'chapter media type'); + if (item.kind !== 'chapter' && item.kind !== 'page') invalid('invalid plugin chapter kind'); + if (typeof item.seq !== 'number' || !Number.isSafeInteger(item.seq) || item.seq < 0) invalid('invalid plugin chapter order'); + if (ids.has(item.id) || sequences.has(item.seq)) invalid('plugin manifest contains duplicate chapter ids or order'); + ids.add(item.id); + sequences.add(item.seq); + } + return input as ManifestSnapshot; +} diff --git a/server/src/sources/registry.ts b/server/src/sources/registry.ts new file mode 100644 index 0000000..36d3de6 --- /dev/null +++ b/server/src/sources/registry.ts @@ -0,0 +1,189 @@ +import type { + PluginManifest, + SourceCapability, + SourceDescriptor, + SourceProvider, + SourceRegistration, +} from './types.ts'; + +export class SourceRegistryError extends Error { + constructor(message: string) { + super(message); + this.name = 'SourceRegistryError'; + } +} + +const ID_RE = /^[a-z][a-z0-9._-]{0,127}$/; +const CAPABILITIES = new Set([ + 'browse', 'search', 'detail', 'acquire.file', 'acquire.chapters', + 'content.manifest', 'content.resource', 'content.update', +]); + +function assertDescriptor(descriptor: SourceDescriptor): void { + 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`); + const capabilities = new Set(descriptor.capabilities); + for (const capability of capabilities) { + if (!CAPABILITIES.has(capability)) throw new SourceRegistryError(`unsupported capability: ${capability}`); + } + if (capabilities.size !== descriptor.capabilities.length) { + throw new SourceRegistryError(`source ${descriptor.id} declares duplicate capabilities`); + } +} + +function assertProvider(provider: SourceProvider): void { + assertDescriptor(provider.descriptor); + if (typeof provider.detail !== 'function') { + throw new SourceRegistryError(`source ${provider.descriptor.id} must implement detail()`); + } + if (typeof provider.acquire !== 'function') { + throw new SourceRegistryError(`source ${provider.descriptor.id} must implement acquire()`); + } + const caps = new Set(provider.descriptor.capabilities); + const requireMethod = (capability: SourceCapability, method: keyof SourceProvider): void => { + if (caps.has(capability) && typeof provider[method] !== 'function') { + throw new SourceRegistryError( + `source ${provider.descriptor.id} declares ${capability} but does not implement ${String(method)}()`, + ); + } + }; + requireMethod('browse', 'browse'); + requireMethod('search', 'search'); + requireMethod('content.manifest', 'getManifest'); + requireMethod('content.resource', 'readResource'); + requireMethod('content.update', 'getManifest'); + if (caps.has('acquire.chapters')) { + if (typeof provider.getManifest !== 'function' || typeof provider.readResource !== 'function') { + throw new SourceRegistryError(`source ${provider.descriptor.id} must implement chapter manifest and resources`); + } + } + if (caps.has('acquire.file') && typeof provider.openFile !== 'function') { + throw new SourceRegistryError( + `source ${provider.descriptor.id} declares acquire.file but does not implement openFile()`, + ); + } +} + +/** Registry for built-in and installed source providers. */ +export class SourceRegistry { + private readonly registrations = new Map(); + + register(registration: SourceRegistration): SourceRegistration { + assertProvider(registration.provider); + if (!ID_RE.test(registration.pluginId)) { + throw new SourceRegistryError(`invalid plugin id: ${registration.pluginId}`); + } + const key = this.key(registration.pluginId, registration.provider.descriptor.id); + if (this.registrations.has(key)) { + throw new SourceRegistryError(`source already registered: ${key}`); + } + this.registrations.set(key, registration); + return registration; + } + + registerBuiltin(pluginId: string, provider: SourceProvider): SourceRegistration { + return this.register({ pluginId, provider, builtin: true }); + } + + unregister(pluginId: string, sourceType: string): boolean { + return this.registrations.delete(this.key(pluginId, sourceType)); + } + + get(pluginId: string, sourceType: string): SourceRegistration | undefined { + return this.registrations.get(this.key(pluginId, sourceType)); + } + + require(pluginId: string, sourceType: string): SourceRegistration { + const registration = this.get(pluginId, sourceType); + if (!registration) throw new SourceRegistryError(`source not found: ${pluginId}/${sourceType}`); + return registration; + } + + list(): readonly SourceRegistration[] { + return [...this.registrations.values()]; + } + + listDescriptors(): readonly SourceDescriptor[] { + return this.list().map(({ provider }) => provider.descriptor); + } + + registerPluginManifest(manifest: PluginManifest, provider: SourceProvider): SourceRegistration { + validatePluginManifest(manifest); + const sourceType = manifest.sourceTypes.find((type) => type.id === provider.descriptor.id); + if (!sourceType) { + throw new SourceRegistryError( + `plugin ${manifest.id} does not declare source type ${provider.descriptor.id}`, + ); + } + if (sourceType.capabilities.length !== provider.descriptor.capabilities.length || + sourceType.capabilities.some((capability) => !provider.descriptor.capabilities.includes(capability))) { + throw new SourceRegistryError(`provider capabilities do not match plugin manifest: ${provider.descriptor.id}`); + } + return this.register({ pluginId: manifest.id, provider }); + } + + private key(pluginId: string, sourceType: string): string { + return `${pluginId}/${sourceType}`; + } +} + +export function validatePluginManifest(input: unknown): PluginManifest { + if (!input || typeof input !== 'object') throw new SourceRegistryError('plugin manifest must be an object'); + const value = input as Record; + const requiredString = (name: string): string => { + const v = value[name]; + if (typeof v !== 'string' || !v.trim()) throw new SourceRegistryError(`plugin manifest ${name} is required`); + return v; + }; + const id = requiredString('id'); + if (!ID_RE.test(id)) throw new SourceRegistryError(`invalid plugin id: ${id}`); + const manifest: PluginManifest = { + id, + name: requiredString('name'), + version: requiredString('version'), + apiVersion: value.apiVersion as number, + runtime: value.runtime as 'node', + entry: requiredString('entry'), + sourceTypes: Array.isArray(value.sourceTypes) ? (value.sourceTypes as PluginManifest['sourceTypes']) : [], + permissions: value.permissions as PluginManifest['permissions'], + }; + if (manifest.apiVersion !== 1) { + throw new SourceRegistryError('unsupported plugin API version; expected apiVersion: 1'); + } + if (manifest.runtime !== 'node') throw new SourceRegistryError(`unsupported plugin runtime: ${manifest.runtime}`); + if (manifest.sourceTypes.length === 0) throw new SourceRegistryError('plugin manifest must declare sourceTypes'); + const ids = new Set(); + for (const type of manifest.sourceTypes) { + if (!type || typeof type.id !== 'string' || !ID_RE.test(type.id)) { + throw new SourceRegistryError('plugin source type has an invalid id'); + } + if (ids.has(type.id)) throw new SourceRegistryError(`duplicate plugin source type: ${type.id}`); + ids.add(type.id); + if (typeof type.label !== 'string' || !type.label.trim() || !Array.isArray(type.capabilities)) { + throw new SourceRegistryError(`invalid plugin source type: ${type.id}`); + } + assertDescriptor({ ...type, version: manifest.version }); + if (type.capabilities.includes('acquire.chapters') && + (!type.capabilities.includes('content.manifest') || !type.capabilities.includes('content.resource'))) { + throw new SourceRegistryError(`chapter source ${type.id} must declare manifest and resource capabilities`); + } + } + return manifest; +} + +/** Descriptors reserved for the two providers shipped by the host. */ +export const BUILTIN_SOURCE_TYPES = { + local: { + id: 'local', + label: 'Local library', + version: '1', + capabilities: ['browse', 'search', 'detail'], + } as const satisfies SourceDescriptor, + opds: { + id: 'opds', + label: 'OPDS catalog', + version: '1', + capabilities: ['browse', 'search', 'detail', 'acquire.file'], + } as const satisfies SourceDescriptor, +}; diff --git a/server/src/sources/types.ts b/server/src/sources/types.ts new file mode 100644 index 0000000..710fe2b --- /dev/null +++ b/server/src/sources/types.ts @@ -0,0 +1,239 @@ +import type { Readable } from 'node:stream'; + +/** Stable source capabilities exposed to the host and clients. */ +export type SourceCapability = + | 'browse' + | 'search' + | 'detail' + | 'acquire.file' + | 'acquire.chapters' + | 'content.manifest' + | 'content.resource' + | 'content.update'; + +export interface SourceDescriptor { + /** Stable source type id, e.g. `local`, `opds`, or a plugin-defined id. */ + readonly id: string; + readonly label: string; + readonly version: string; + readonly capabilities: readonly SourceCapability[]; + /** JSON Schema for an instance's public configuration, when applicable. */ + readonly configSchema?: unknown; +} + +export interface SourceInstance { + readonly id: string; + readonly pluginId: string; + readonly sourceType: string; + readonly name: string; + /** Configuration is owned by the host and never persisted by a provider. */ + readonly config: unknown; + readonly enabled: boolean; +} + +export interface SourceContext { + readonly instance: SourceInstance; + /** User id scopes cache and credential access. */ + readonly userId: string; + readonly signal: AbortSignal; + readonly http?: SourceHttpClient; + readonly credentials?: CredentialStore; + readonly storage?: SourceStorage; + readonly log?: SourceLogger; +} + +export interface SourceHttpRequest { + readonly url: string; + readonly method?: string; + readonly headers?: Record; + readonly body?: string | Uint8Array; +} + +export interface SourceHttpResponse { + readonly status: number; + readonly headers: Record; + readonly body: Uint8Array; + readonly url: string; +} + +export interface SourceHttpClient { + request(request: SourceHttpRequest, signal: AbortSignal): Promise; +} + +export interface CredentialStore { + get(key: string, signal?: AbortSignal): Promise; + set(key: string, value: string, signal?: AbortSignal): Promise; + delete(key: string, signal?: AbortSignal): Promise; +} + +export interface SourceStorage { + get(key: string): Promise; + set(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + +export interface SourceLogger { + debug(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + error(message: string, fields?: Record): void; +} + +export interface BrowseRequest { + readonly ref?: string; + readonly cursor?: string; + readonly limit?: number; +} + +export interface SearchRequest { + readonly query: string; + readonly cursor?: string; + readonly limit?: number; +} + +export interface CatalogPage { + readonly items: readonly CatalogEntry[]; + readonly navigation?: readonly NavigationEntry[]; + readonly nextCursor?: string; + readonly title?: string; +} + +export interface NavigationEntry { + readonly ref: string; + readonly title: string; + readonly kind?: 'catalog' | 'search' | 'collection'; +} + +export interface CatalogEntry { + readonly ref: string; + readonly title: string; + readonly authors?: readonly string[]; + readonly description?: string; + readonly coverUrl?: string; + readonly language?: string; + readonly publishedAt?: string; + readonly options?: readonly AcquisitionOption[]; + readonly metadata?: Readonly>; +} + +export interface AcquisitionOption { + readonly id: string; + readonly label: string; + readonly mediaType?: string; + readonly size?: number; + readonly available?: boolean; +} + +export interface AcquireRequest { + readonly entryRef: string; + readonly optionId?: string; +} + +export type Acquisition = + | { + readonly kind: 'ready'; + readonly publicationId: string; + } + | { + readonly kind: 'file'; + /** Provider-owned reference; host must call openFile rather than fetch arbitrary URLs. */ + readonly acquisitionRef: string; + readonly mediaType: string; + readonly filename?: string; + readonly size?: number; + } + | { + readonly kind: 'chapters'; + readonly publicationRef: string; + } + | { + readonly kind: 'action-required'; + readonly action: AcquisitionAction; + }; + +export interface AcquisitionAction { + readonly type: 'login' | 'borrow' | 'external'; + readonly label: string; + readonly url?: string; + readonly fields?: readonly { name: string; label: string; secret?: boolean }[]; +} + +export interface ManifestSnapshot { + readonly publicationRef: string; + readonly version?: string; + readonly items: readonly ManifestItem[]; +} + +export interface ManifestItem { + readonly id: string; + readonly seq: number; + readonly title: string; + readonly kind: 'chapter' | 'page'; + readonly mediaType: string; + readonly ref: string; +} + +export interface ResourceRequest { + readonly publicationRef: string; + readonly ref: string; + readonly rendition?: 'text' | 'html' | 'binary'; +} + +export interface ResourceResponse { + readonly mediaType: string; + readonly data?: Uint8Array; + readonly text?: string; + readonly stream?: Readable; + readonly size?: number; + readonly filename?: string; + readonly etag?: string; +} + +export interface SourceProvider { + readonly descriptor: SourceDescriptor; + validateConfig?(config: unknown): void | Promise; + browse?(ctx: SourceContext, request: BrowseRequest): Promise; + search?(ctx: SourceContext, request: SearchRequest): Promise; + detail(ctx: SourceContext, entryRef: string): Promise; + acquire(ctx: SourceContext, request: AcquireRequest): Promise; + /** Resolve a file acquisition without exposing arbitrary network access to the host. */ + openFile?(ctx: SourceContext, acquisition: Extract): Promise; + getManifest?(ctx: SourceContext, publicationRef: string): Promise; + readResource?(ctx: SourceContext, request: ResourceRequest): Promise; +} + +export interface SourceRegistration { + readonly pluginId: string; + readonly provider: SourceProvider; + readonly builtin?: boolean; +} + +export interface PluginSourceType { + readonly id: string; + readonly label: string; + readonly capabilities: readonly SourceCapability[]; + readonly configSchema?: unknown; +} + +export interface PluginManifest { + readonly id: string; + readonly name: string; + readonly version: string; + readonly apiVersion: number; + readonly runtime: 'node'; + readonly entry: string; + readonly sourceTypes: readonly PluginSourceType[]; + readonly permissions?: { + readonly network?: { readonly domains?: readonly string[] }; + readonly storage?: boolean; + readonly credentials?: boolean; + }; +} + +export type PluginRuntimeState = 'stopped' | 'running' | 'failed'; + +export interface PluginRuntimeStatus { + readonly pluginId: string; + readonly state: PluginRuntimeState; + readonly pendingRequests: number; +} diff --git a/server/test/acquisitions.test.ts b/server/test/acquisitions.test.ts new file mode 100644 index 0000000..6c93f62 --- /dev/null +++ b/server/test/acquisitions.test.ts @@ -0,0 +1,226 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import JSZip from 'jszip'; +import { Db } from '../src/db/index.ts'; +import { FileAcquisitions } from '../src/sources/acquisitions.ts'; +import type { SourceContext, SourceInstance, SourceProvider } from '../src/sources/types.ts'; + +function fixture(root: string): { db: Db; config: { dataDir: string }; context: SourceContext } { + const db = new Db(join(root, 'reader.db')); + db.run( + `INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at) + VALUES ('u1', 'u1', '', 'x', 'member', 0, 0, 0)`, + ); + db.run( + `INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at) + VALUES ('s1', 'demo', 'demo', 'Demo', '{}', 1, 0)`, + ); + const instance: SourceInstance = { + id: 's1', pluginId: 'demo', sourceType: 'demo', name: 'Demo', config: {}, enabled: true, + }; + return { db, config: { dataDir: root }, context: { instance, userId: 'u1', signal: new AbortController().signal } }; +} + +function provider(body: Buffer | Readable, mediaType = 'text/plain'): SourceProvider { + return { + descriptor: { id: 'demo', label: 'Demo', version: '1', capabilities: ['detail', 'acquire.file'] }, + async detail() { return { ref: 'entry-1', title: 'Remote title', authors: ['Author'] }; }, + async acquire() { return { kind: 'file', acquisitionRef: 'file-1', mediaType, filename: 'remote.txt' }; }, + async openFile() { return { mediaType, data: Buffer.isBuffer(body) ? body : undefined, stream: Buffer.isBuffer(body) ? undefined : body }; }, + }; +} + +describe('FileAcquisitions', () => { + test('downloads, parses, persists, and shelves a file', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const importer = new FileAcquisitions(db, config); + const bytes = Buffer.from('第一章\nhello\n', 'utf8'); + const bookId = await importer.acquire( + provider(bytes), context, { entryRef: 'entry-1', optionId: 'txt' }, + { kind: 'file', acquisitionRef: 'file-1', mediaType: 'text/plain', filename: 'remote.txt' }, + { ref: 'entry-1', title: 'Remote title', authors: ['Author'] }, + ); + const hash = createHash('sha256').update(bytes).digest('hex'); + const book = db.get<{ title: string; content_hash: string; source: string }>('SELECT title, content_hash, source FROM books WHERE id = ?', bookId); + assert.deepEqual({ ...book }, { title: 'Remote title', content_hash: hash, source: 'provider:demo' }); + assert.equal(db.get('SELECT book_id FROM user_books WHERE user_id = ? AND book_id = ? AND hidden = 0', 'u1', bookId)?.book_id, bookId); + const acquired = db.get<{ rel_path: string; size: number }>('SELECT rel_path, size FROM acquired_files WHERE book_id = ?', bookId); + assert.equal(acquired?.size, bytes.byteLength); + assert.deepEqual(await readFile(join(config.dataDir, acquired!.rel_path)), bytes); + assert.equal(db.get('SELECT book_id FROM source_acquisitions WHERE source_id = ? AND user_id = ? AND entry_ref = ? AND option_id = ?', 's1', 'u1', 'entry-1', 'txt')?.book_id, bookId); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('serial repeat reuses the committed file and does not create another copy', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const importer = new FileAcquisitions(db, config); + const body = Buffer.from('same bytes'); + const args = [provider(body), context, { entryRef: 'entry-1', optionId: '' }, + { kind: 'file', acquisitionRef: 'file-1', mediaType: 'text/plain', filename: 'same.txt' }, + { ref: 'entry-1', title: 'Same' }] as const; + const first = await importer.acquire(...args); + const second = await importer.acquire(...args); + assert.equal(second, first); + assert.equal((await readdir(join(root, 'acquired'))).length, 1); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects an oversized response and cleans temporary files', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const importer = new FileAcquisitions(db, config, { maxBytes: 4 }); + await assert.rejects( + importer.acquire( + provider(Buffer.from('12345')), context, { entryRef: 'large' }, + { kind: 'file', acquisitionRef: 'large', mediaType: 'text/plain' }, { ref: 'large', title: 'Large' }, + ), + (error: unknown) => (error as { code?: string }).code === 'ACQUISITION_TOO_LARGE', + ); + assert.equal((await readdir(join(root, 'acquired'))).length, 0); + assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('cancellation after provider response leaves no acquired file', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context: original } = fixture(root); + try { + const controller = new AbortController(); + const context = { ...original, signal: controller.signal }; + const source: SourceProvider = { + ...provider(Buffer.from('cancel me')), + async openFile() { + controller.abort(); + return { mediaType: 'text/plain', stream: Readable.from([Buffer.from('cancel me')]) }; + }, + }; + const importer = new FileAcquisitions(db, config); + await assert.rejects(importer.acquire( + source, context, { entryRef: 'cancel' }, + { kind: 'file', acquisitionRef: 'cancel', mediaType: 'text/plain' }, { ref: 'cancel', title: 'Cancel' }, + ), { name: 'AbortError' }); + assert.equal((await readdir(join(root, 'acquired'))).length, 0); + assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('concurrent requests for one acquisition download once and restore a hidden shelf entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const importer = new FileAcquisitions(db, config); + let calls = 0; + const source = provider(Buffer.from('same bytes')); + source.openFile = async () => { + calls += 1; + return { mediaType: 'text/plain', stream: Readable.from([Buffer.from('same bytes')]) }; + }; + const acquire = () => importer.acquire(source, context, { entryRef: 'same' }, + { kind: 'file', acquisitionRef: 'same', mediaType: 'text/plain' }, { ref: 'same', title: 'Same' }); + const [first, second] = await Promise.all([acquire(), acquire()]); + assert.equal(first, second); + assert.equal(calls, 1); + db.run('UPDATE user_books SET hidden = 1 WHERE user_id = ? AND book_id = ?', context.userId, first); + assert.equal(await acquire(), first); + assert.equal(db.get('SELECT hidden FROM user_books WHERE user_id = ? AND book_id = ?', context.userId, first)?.hidden, 0); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('concurrent sources with identical bytes share one file and preserve existing metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const importer = new FileAcquisitions(db, config); + const body = Buffer.from('the same publication'); + const acquire = (entryRef: string, title: string) => importer.acquire( + provider(body), context, { entryRef }, { kind: 'file', acquisitionRef: entryRef, mediaType: 'text/plain' }, + { ref: entryRef, title }, + ); + const [first, second] = await Promise.all([acquire('first', 'First'), acquire('second', 'Second')]); + assert.equal(first, second); + assert.equal((await readdir(join(root, 'acquired'))).length, 1); + assert.equal(db.get('SELECT COUNT(*) AS n FROM source_acquisitions')?.n, 2); + db.run('UPDATE books SET title = ?, author = ?, source = ? WHERE id = ?', 'Edited title', 'Edited author', 'embedded', first); + await acquire('third', 'Different catalog title'); + assert.deepEqual({ ...db.get('SELECT title, author, source FROM books WHERE id = ?', first) }, + { title: 'Edited title', author: 'Edited author', source: 'embedded' }); + assert.equal((await readdir(join(root, 'acquired'))).length, 1); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects HTML pretending to be a PDF without adding a book', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const importer = new FileAcquisitions(db, config); + await assert.rejects(importer.acquire( + provider(Buffer.from('login required'), 'application/pdf'), context, { entryRef: 'pdf' }, + { kind: 'file', acquisitionRef: 'pdf', mediaType: 'application/pdf' }, { ref: 'pdf', title: 'PDF' }, + ), (error: unknown) => (error as { code?: string }).code === 'INVALID_RESOURCE'); + assert.equal((await readdir(join(root, 'acquired'))).length, 0); + assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a database failure rolls back rows and removes downloaded file and extracted cover', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-acquisition-')); + const { db, config, context } = fixture(root); + try { + const zip = new JSZip(); + zip.file('mimetype', 'application/epub+zip'); + zip.file('META-INF/container.xml', ''); + zip.file('book.opf', `test-bookEmbedded + + + + `); + zip.file('chapter.xhtml', 'Text'); + zip.file('cover.png', Buffer.from([137, 80, 78, 71])); + const bytes = await zip.generateAsync({ type: 'nodebuffer' }); + const importer = new FileAcquisitions(db, config); + const missingUser = { ...context, userId: 'missing' }; + await assert.rejects(importer.acquire( + provider(bytes, 'application/epub+zip'), missingUser, { entryRef: 'epub' }, + { kind: 'file', acquisitionRef: 'epub', mediaType: 'application/epub+zip' }, { ref: 'epub', title: 'Catalog' }, + ), /FOREIGN KEY constraint failed/); + assert.equal(db.get('SELECT COUNT(*) AS n FROM books')?.n, 0); + assert.equal(db.get('SELECT COUNT(*) AS n FROM acquired_files')?.n, 0); + assert.equal((await readdir(join(root, 'acquired'))).length, 0); + assert.equal((await readdir(join(root, 'covers'))).length, 0); + } finally { + db.close(); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/test/api.test.ts b/server/test/api.test.ts index dc5d193..3f2f827 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -86,6 +86,7 @@ before(async () => { after(async () => { await app.close(); + ctx.db.close(); await rm(root, { recursive: true, force: true }); }); @@ -187,10 +188,13 @@ test('progress and notes survive a library re-organisation', async () => { }); test('duplicate copies collapse into one book', async () => { + // Generate once: ZIP headers include timestamps, so re-generating an EPUB + // is not a byte-identical copy when this suite crosses a clock boundary. + const bytes = await makeEpub({ id: 'urn:test:duplicate-copy', title: '副本测试', creator: '测试作者' }); + await writeFile(join(booksDir, '副本原书.epub'), bytes); + await ctx.scanner.scan(); const before = ctx.db.all<{ id: string }>('SELECT id FROM books'); - await writeFile(join(booksDir, '备份 三体.epub'), await makeEpub({ - id: 'urn:isbn:9787536692930', title: '三体', creator: '刘慈欣', series: '地球往事', index: '1', - })); + await writeFile(join(booksDir, '副本备份.epub'), bytes); await ctx.scanner.scan(); const after = ctx.db.all<{ id: string }>('SELECT id FROM books'); assert.equal(after.length, before.length, 'a byte-identical copy must not create a new book'); diff --git a/server/test/browse.test.ts b/server/test/browse.test.ts index 6746e7e..989ad82 100644 --- a/server/test/browse.test.ts +++ b/server/test/browse.test.ts @@ -113,6 +113,7 @@ before(async () => { after(async () => { await app.close(); + ctx.db.close(); await rm(root, { recursive: true, force: true }); }); diff --git a/server/test/config.test.ts b/server/test/config.test.ts index 74db85d..ea4a9f0 100644 --- a/server/test/config.test.ts +++ b/server/test/config.test.ts @@ -10,7 +10,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, readFile, chmod, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, chmod, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { loadConfig } from '../src/config/index.ts'; @@ -84,6 +84,10 @@ test('READER_TOKEN_SECRET wins and skips the file entirely', async () => { }); test('an unwritable DATA_DIR fails with the fix, not a bare EACCES', async (t) => { + if (process.platform === 'win32') { + t.skip('Windows does not enforce Unix directory permission bits'); + return; + } if (process.getuid?.() === 0) { // root bypasses the permission bits, so the failure cannot be reproduced. t.skip('running as root; permission bits are not enforced'); @@ -117,14 +121,43 @@ test('an unwritable DATA_DIR fails with the fix, not a bare EACCES', async (t) = } }); -test('DATA_DIR inside BOOKS_DIR is refused at boot', async () => { +test('DATA_DIR inside BOOKS_DIR is refused before creating the directory', async () => { const root = await mkdtemp(join(tmpdir(), 'reader-config-')); const booksDir = join(root, 'books'); + const dataDir = join(booksDir, 'nested', 'data'); await mkdir(booksDir, { recursive: true }); try { - await withEnv({ DATA_DIR: join(booksDir, 'data'), BOOKS_DIR: booksDir }, () => { + await withEnv({ DATA_DIR: dataDir, BOOKS_DIR: booksDir }, async () => { assert.throws(() => loadConfig(), /must not live inside BOOKS_DIR/); + await assert.rejects(stat(join(booksDir, 'nested')), { code: 'ENOENT' }); + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('DATA_DIR equal to BOOKS_DIR is refused before creating either directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-config-')); + const booksDir = join(root, 'not-created'); + try { + await withEnv({ DATA_DIR: booksDir, BOOKS_DIR: booksDir }, async () => { + assert.throws(() => loadConfig(), /must not live inside BOOKS_DIR/); + await assert.rejects(stat(booksDir), { code: 'ENOENT' }); + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a sibling DATA_DIR sharing the BOOKS_DIR prefix is permitted', async () => { + const root = await mkdtemp(join(tmpdir(), 'reader-config-')); + const booksDir = join(root, 'books'); + const dataDir = join(root, 'books-data'); + try { + await withEnv({ DATA_DIR: dataDir, BOOKS_DIR: booksDir }, async () => { + assert.equal(loadConfig().dataDir, dataDir); + assert.ok((await stat(dataDir)).isDirectory()); }); } finally { await rm(root, { recursive: true, force: true }); diff --git a/server/test/formats.test.ts b/server/test/formats.test.ts index 1b51a56..127ff0b 100644 --- a/server/test/formats.test.ts +++ b/server/test/formats.test.ts @@ -226,6 +226,7 @@ before(async () => { after(async () => { await app.close(); + ctx.db.close(); await rm(root, { recursive: true, force: true }); }); diff --git a/server/test/opds.test.ts b/server/test/opds.test.ts new file mode 100644 index 0000000..7b66414 --- /dev/null +++ b/server/test/opds.test.ts @@ -0,0 +1,222 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createOpdsProvider } from '../src/sources/opds.ts'; +import { AppError } from '../src/lib/errors.ts'; +import type { Acquisition, SourceContext } from '../src/sources/types.ts'; + +const ROOT = 'https://library.example/catalog'; +const ACQUIRE = 'http://opds-spec.org/acquisition'; + +function context(config: Record = { url: ROOT }): SourceContext { + return { + instance: { id: 'library', pluginId: 'builtin.opds', sourceType: 'opds', name: 'Library', config, enabled: true }, + userId: 'reader', signal: new AbortController().signal, + }; +} + +function transport(routes: Record) { + const calls: Array<{ url: string; headers: Headers; redirect?: RequestRedirect }> = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = String(input); + calls.push({ url, headers: new Headers(init?.headers), redirect: init?.redirect }); + const route = routes[url]; + assert.notEqual(route, undefined, `Unexpected network request: ${url}`); + const response = route instanceof Response + ? route + : new Response(typeof route === 'string' ? route : JSON.stringify(route), { status: 200 }); + Object.defineProperty(response, 'url', { value: url }); + return response; + }; + return { fetch, calls }; +} + +function jsonFeed(extra: Record = {}) { + return { + metadata: { title: 'Library' }, + publications: [{ + metadata: { identifier: 'urn:book:one', title: 'One', author: [{ name: 'Alice' }, { name: 'Bob' }] }, + images: [{ href: '/covers/one.jpg', type: 'image/jpeg' }], + links: [{ href: '/files/one.epub', type: 'application/epub+zip', rel: ACQUIRE }], + }], + ...extra, + }; +} + +function code(expected: string) { + return (error: unknown) => error instanceof AppError && error.code === expected; +} + +test('OPDS 1 Atom resolves namespaces, xml:base, navigation, pagination and direct acquisitions', async () => { + const xml = ` + + A & B + `; + const network = transport({ [ROOT]: xml }); + const provider = createOpdsProvider(network); + const page = await provider.browse!(context(), {}); + assert.equal(page.title, 'A & B'); + assert.deepEqual(page.navigation, [{ title: 'Fiction', ref: 'https://library.example/books/fiction', kind: 'catalog' }]); + assert.equal(page.nextCursor, 'https://library.example/books/?page=2&count=10'); + assert.equal(page.items[0]?.title, '书籍一'); + assert.deepEqual(page.items[0]?.authors, ['作者']); + assert.equal(page.items[0]?.coverUrl, 'https://library.example/books/one/cover.jpg'); + assert.equal(page.items[0]?.options?.[0]?.available, true); +}); + +test('OPDS 2 groups and navigation remain separate from publication entries', async () => { + const network = transport({ [ROOT]: jsonFeed({ + links: [{ rel: 'next', href: '?page=2' }], + navigation: [{ title: 'Recent', href: '/recent', type: 'application/opds+json' }], + groups: [{ publications: [{ metadata: { identifier: 'urn:book:two', title: 'Two' }, links: [{ rel: ACQUIRE, href: '/two.pdf', type: 'application/pdf' }] }] }], + }) }); + const page = await createOpdsProvider(network).browse!(context(), {}); + assert.deepEqual(page.items.map((item) => item.title), ['One', 'Two']); + assert.deepEqual(page.items[0]?.authors, ['Alice', 'Bob']); + assert.equal(page.items[0]?.coverUrl, 'https://library.example/covers/one.jpg'); + assert.equal(page.navigation?.[0]?.title, 'Recent'); + assert.equal(page.nextCursor, `${ROOT}?page=2`); +}); + +test('acquisition revalidates provider references and streams the selected file', async () => { + const network = transport({ [ROOT]: jsonFeed(), 'https://library.example/files/one.epub': new Response('epub-bytes', { headers: { 'content-type': 'application/epub+zip', etag: '"one"' } }) }); + const provider = createOpdsProvider(network); + const ctx = context(); + const entry = (await provider.browse!(ctx, {})).items[0]!; + const acquisition = await provider.acquire(ctx, { entryRef: entry.ref }); + assert.equal(acquisition.kind, 'file'); + assert.equal('url' in acquisition, false, 'host must resolve a provider reference, not fetch a client-supplied URL'); + const file = await provider.openFile!(ctx, acquisition as Extract); + const chunks: Buffer[] = []; + for await (const chunk of file.stream!) chunks.push(Buffer.from(chunk)); + assert.equal(Buffer.concat(chunks).toString(), 'epub-bytes'); + assert.equal(file.mediaType, 'application/epub+zip'); + assert.equal(file.etag, '"one"'); + await assert.rejects(() => provider.acquire(ctx, { entryRef: entry.ref, optionId: 'invented' }), code('RESOURCE_GONE')); + await assert.rejects(() => provider.detail(ctx, 'not-a-ref'), code('INVALID_SOURCE_REF')); +}); + +test('borrowing, purchases and DRM are explicit external actions rather than file downloads', async () => { + for (const [rel, mediaType, expected] of [ + [`${ACQUIRE}/borrow`, 'application/epub+zip', 'borrow'], + [`${ACQUIRE}/buy`, 'application/epub+zip', 'external'], + [ACQUIRE, 'application/vnd.adobe.adept+xml', 'external'], + ]) { + const network = transport({ [ROOT]: jsonFeed({ publications: [{ metadata: { identifier: 'one', title: 'One' }, links: [{ rel, type: mediaType, href: '/action' }] }] }) }); + const provider = createOpdsProvider(network); + const entry = (await provider.browse!(context(), {})).items[0]!; + assert.equal(entry.options?.[0]?.available, false); + const acquisition = await provider.acquire(context(), { entryRef: entry.ref }); + assert.equal(acquisition.kind, 'action-required'); + if (acquisition.kind === 'action-required') assert.equal(acquisition.action.type, expected); + } +}); + +test('JSON search templates encode user input and pagination follows advertised links', async () => { + const searchUrl = 'https://library.example/search?q=%E4%B8%89%E4%BD%93%20%26%20A&limit=20'; + const network = transport({ + [ROOT]: jsonFeed({ links: [{ rel: 'search', href: '/search?q={searchTerms}&limit={count}', type: 'application/opds+json', templated: true }] }), + [searchUrl]: jsonFeed({ links: [{ rel: 'next', href: '/search?page=2' }] }), + 'https://library.example/search?page=2': jsonFeed(), + }); + const provider = createOpdsProvider(network); + const first = await provider.search!(context(), { query: '三体 & A', limit: 20 }); + assert.equal(first.items.length, 1); + await provider.search!(context(), { query: '三体 & A', cursor: first.nextCursor }); + assert.equal(network.calls.at(-1)?.url, 'https://library.example/search?page=2'); +}); + +test('OPDS 1 OpenSearch descriptions resolve their relative GET templates', async () => { + const network = transport({ + [ROOT]: 'Search', + 'https://library.example/search/description.xml': '', + 'https://library.example/search/results?q=Hello%20World&page=1&language=': 'Results', + }); + const result = await createOpdsProvider(network).search!(context(), { query: 'Hello World' }); + assert.equal(result.title, 'Results'); + assert.equal(network.calls.length, 3); +}); + +test('missing search capability produces a stable unsupported error', async () => { + const provider = createOpdsProvider(transport({ [ROOT]: jsonFeed() })); + await assert.rejects(() => provider.search!(context(), { query: 'book' }), code('SOURCE_UNSUPPORTED')); +}); + +test('basic credentials stay scoped to the configured origin on allowed CDN redirects', async () => { + const network = transport({ + [ROOT]: jsonFeed(), + 'https://library.example/files/one.epub': new Response(null, { status: 302, headers: { location: 'https://cdn.example/one.epub' } }), + 'https://cdn.example/one.epub': new Response('epub', { headers: { 'content-type': 'application/epub+zip' } }), + }); + const ctx: SourceContext = { + ...context({ url: ROOT, username: 'reader', allowedOrigins: ['https://cdn.example'] }), + credentials: { get: async () => 'secret', set: async () => {}, delete: async () => {} }, + }; + const provider = createOpdsProvider(network); + const entry = (await provider.browse!(ctx, {})).items[0]!; + const acquisition = await provider.acquire(ctx, { entryRef: entry.ref }); + assert.equal(acquisition.kind, 'file'); + const resource = await provider.openFile!(ctx, acquisition as Extract); + resource.stream!.resume(); + assert.equal(network.calls[0]?.headers.get('authorization'), `Basic ${Buffer.from('reader:secret').toString('base64')}`); + assert.equal(network.calls.at(-1)?.headers.get('authorization'), null); + assert.ok(network.calls.every((call) => call.redirect === 'manual')); +}); + +test('forged origins and redirects are rejected before reaching an unrelated host', async () => { + const network = transport({ [ROOT]: new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/private' } }) }); + const provider = createOpdsProvider(network); + await assert.rejects(() => provider.browse!(context(), { cursor: 'https://other.example/private' }), code('OPDS_UNSAFE_URL')); + assert.equal(network.calls.length, 0); + await assert.rejects(() => provider.browse!(context(), {}), code('OPDS_UNSAFE_URL')); + assert.equal(network.calls.length, 1); + assert.throws(() => provider.validateConfig!({ url: 'https://user:password@example.com/opds' }), code('INVALID_SOURCE_CONFIG')); + assert.throws(() => provider.validateConfig!({ url: ROOT, password: 'secret' }), code('INVALID_SOURCE_CONFIG')); +}); + +test('malformed XML, entity declarations, oversized feeds and authentication failures are bounded errors', async () => { + for (const xml of ['broken</feed>', '<html><title>Login', '{}', ']>&secret;']) { + await assert.rejects(() => createOpdsProvider(transport({ [ROOT]: xml })).browse!(context(), {}), code('OPDS_INVALID_RESPONSE')); + } + await assert.rejects(() => createOpdsProvider({ ...transport({ [ROOT]: jsonFeed() }), maxFeedBytes: 20 }).browse!(context(), {}), code('RESOURCE_TOO_LARGE')); + await assert.rejects(() => createOpdsProvider(transport({ [ROOT]: new Response('', { status: 401 }) })).browse!(context(), {}), code('AUTH_REQUIRED')); + await assert.rejects(() => createOpdsProvider(transport({ [ROOT]: jsonFeed() })).browse!(context({ url: ROOT, username: 'reader' }), {}), code('AUTH_REQUIRED')); +}); + +test('a successful HTML login page cannot masquerade as an EPUB download', async () => { + const network = transport({ + [ROOT]: jsonFeed(), + 'https://library.example/files/one.epub': new Response('login', { headers: { 'content-type': 'text/html' } }), + }); + const provider = createOpdsProvider(network); + const entry = (await provider.browse!(context(), {})).items[0]!; + const acquisition = await provider.acquire(context(), { entryRef: entry.ref }); + await assert.rejects(() => provider.openFile!(context(), acquisition as Extract), code('OPDS_INVALID_RESPONSE')); +}); + +test('download streams enforce the byte budget even without Content-Length', async () => { + const network = transport({ + [ROOT]: jsonFeed(), + 'https://library.example/files/one.epub': new Response('a'.repeat(64), { headers: { 'content-type': 'application/epub+zip' } }), + }); + const provider = createOpdsProvider({ ...network, maxFileBytes: 32 }); + const entry = (await provider.browse!(context(), {})).items[0]!; + const acquisition = await provider.acquire(context(), { entryRef: entry.ref }); + const file = await provider.openFile!(context(), acquisition as Extract); + await assert.rejects(async () => { for await (const _chunk of file.stream!) { /* Drain until the provider rejects the oversized body. */ } }, code('RESOURCE_TOO_LARGE')); +}); + +test('cancelled operations do not start a network request', async () => { + const network = transport({ [ROOT]: jsonFeed() }); + const ctx = { ...context(), signal: AbortSignal.abort() }; + await assert.rejects(() => createOpdsProvider(network).browse!(ctx, {}), { name: 'AbortError' }); + assert.equal(network.calls.length, 0); +}); diff --git a/server/test/plugin-manager.test.ts b/server/test/plugin-manager.test.ts new file mode 100644 index 0000000..baa7ca6 --- /dev/null +++ b/server/test/plugin-manager.test.ts @@ -0,0 +1,239 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { access, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Db } from '../src/db/index.ts'; +import { PluginManager } from '../src/sources/plugin-manager.ts'; +import { SourceRegistry } from '../src/sources/registry.ts'; +import type { SourceContext, SourceProvider } from '../src/sources/types.ts'; + +const WORKER = `import { createInterface } from 'node:readline'; +createInterface({ input: process.stdin }).on('line', (line) => { + const request = JSON.parse(line); + if (request.params.entryRef === 'hang') return; + if (request.params.entryRef === 'crash') process.exit(1); + const result = request.method === 'detail' ? { ref: 'book', title: 'Book' } : null; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'); +});`; + +const builtin: SourceProvider = { + descriptor: { id: 'local', label: 'Local', version: '1', capabilities: ['detail'] }, + async detail() { return { ref: 'book', title: 'Local book' }; }, + async acquire() { return { kind: 'ready', publicationId: 'book' }; }, +}; + +function context(): SourceContext { + return { + userId: 'reader', signal: new AbortController().signal, + instance: { id: 'test', pluginId: 'test.plugin', sourceType: 'test', name: 'Test', config: {}, enabled: true }, + }; +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'reader-plugin-manager-')); + const dataDir = join(root, 'data'); + await mkdir(join(dataDir, 'plugins'), { recursive: true }); + const db = new Db(':memory:'); + const registry = new SourceRegistry(); + registry.registerBuiltin('reader.local', builtin); + const managers = [new PluginManager(db, dataDir, registry)]; + return { + root, dataDir, db, registry, manager: managers[0]!, + async package(folder = 'example', pluginId = 'test.plugin', sourceTypes: unknown[] = [{ id: 'test', label: 'Test', capabilities: ['detail'] }]) { + const directory = join(dataDir, 'plugins', folder); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'plugin.json'), JSON.stringify({ + id: pluginId, name: 'Test plugin', version: '1.0.0', apiVersion: 1, runtime: 'node', entry: 'main.mjs', sourceTypes, + })); + await writeFile(join(directory, 'main.mjs'), WORKER); + return directory; + }, + restart() { + const manager = new PluginManager(db, dataDir, registry); + managers.push(manager); + return manager; + }, + async dispose() { + for (const manager of managers) await manager.close(); + db.close(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +test('installation registers a real provider and lists builtins alongside the persisted package', async () => { + const f = await fixture(); + try { + await f.package(); + const installed = await f.manager.install('example'); + assert.equal(installed.pluginId, 'test.plugin'); + assert.equal(installed.folder, 'example'); + assert.equal(installed.enabled, true); + assert.equal(installed.runtime?.state, 'stopped', 'processes start lazily on their first operation'); + const provider = f.registry.require('test.plugin', 'test').provider; + assert.equal((await provider.detail(context(), 'book')).title, 'Book'); + const plugins = f.manager.list(); + assert.equal(plugins.find((plugin) => plugin.pluginId === 'reader.local')?.builtin, true); + assert.equal(plugins.find((plugin) => plugin.pluginId === 'test.plugin')?.runtime?.state, 'running'); + assert.equal(f.db.get<{ enabled: number }>('SELECT enabled FROM installed_plugins WHERE plugin_id = ?', 'test.plugin')?.enabled, 1); + } finally { await f.dispose(); } +}); + +test('disable stops in-flight work, rejects retained providers, and persists across restart', async () => { + const f = await fixture(); + try { + await f.package(); + await f.manager.install('example'); + const provider = f.registry.require('test.plugin', 'test').provider; + const pending = assert.rejects(provider.detail(context(), 'hang'), { code: 'PLUGIN_UNAVAILABLE' }); + const disabled = await f.manager.setEnabled('test.plugin', false); + await pending; + assert.equal(disabled.enabled, false); + assert.equal(disabled.runtime?.state, 'stopped'); + assert.equal(f.registry.get('test.plugin', 'test'), undefined); + await assert.rejects(provider.detail(context(), 'book'), { code: 'PLUGIN_UNAVAILABLE' }); + await f.manager.close(); + const restarted = f.restart(); + await restarted.loadInstalled(); + assert.equal(restarted.list().find((plugin) => plugin.pluginId === 'test.plugin')?.enabled, false); + assert.equal(f.registry.get('test.plugin', 'test'), undefined); + await restarted.setEnabled('test.plugin', true); + assert.equal((await f.registry.require('test.plugin', 'test').provider.detail(context(), 'book')).title, 'Book'); + } finally { await f.dispose(); } +}); + +test('enabled packages reload after shutdown and failed package loads do not block healthy packages', async () => { + const f = await fixture(); + try { + await f.package(); + await f.manager.install('example'); + f.db.run('INSERT INTO installed_plugins (plugin_id, folder, enabled) VALUES (?, ?, 1)', 'broken.plugin', 'missing'); + await f.manager.close(); + const restarted = f.restart(); + await restarted.loadInstalled(); + await restarted.loadInstalled(); + assert.ok(f.registry.get('test.plugin', 'test')); + assert.equal(restarted.list().find((plugin) => plugin.pluginId === 'broken.plugin')?.error?.code, 'PLUGIN_PACKAGE_NOT_FOUND'); + assert.equal(f.registry.list().length, 2, 'reloading is idempotent'); + assert.equal((await f.registry.require('test.plugin', 'test').provider.detail(context(), 'book')).title, 'Book'); + } finally { await f.dispose(); } +}); + +test('a crashed worker remains isolated and can be reloaded through enable', async () => { + const f = await fixture(); + try { + await f.package(); + await f.package('healthy', 'healthy.plugin'); + await f.manager.install('example'); + await f.manager.install('healthy'); + await assert.rejects(f.registry.require('test.plugin', 'test').provider.detail(context(), 'crash'), { code: 'PLUGIN_UNAVAILABLE' }); + assert.equal(f.manager.list().find((plugin) => plugin.pluginId === 'test.plugin')?.runtime?.state, 'failed'); + assert.equal((await f.registry.require('healthy.plugin', 'test').provider.detail(context(), 'book')).title, 'Book'); + await f.manager.setEnabled('test.plugin', true); + assert.equal((await f.registry.require('test.plugin', 'test').provider.detail(context(), 'book')).title, 'Book'); + } finally { await f.dispose(); } +}); + +test('uninstall retains package files and source data while removing registrations and install records', async () => { + const f = await fixture(); + try { + const directory = await f.package(); + await f.manager.install('example'); + f.db.run("INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at) VALUES ('keep', 'test.plugin', 'test', 'Keep', '{}', 1, 0)"); + f.db.run("INSERT INTO plugin_storage (key, value) VALUES ('keep', 'data')"); + await f.manager.uninstall('test.plugin'); + assert.equal(f.registry.get('test.plugin', 'test'), undefined); + assert.equal(f.db.get('SELECT plugin_id FROM installed_plugins WHERE plugin_id = ?', 'test.plugin'), undefined); + assert.ok(f.db.get("SELECT id FROM source_instances WHERE id = 'keep'")); + assert.ok(f.db.get("SELECT key FROM plugin_storage WHERE key = 'keep'")); + await access(join(directory, 'main.mjs')); + await f.manager.install('example'); + assert.ok(f.registry.get('test.plugin', 'test')); + } finally { await f.dispose(); } +}); + +test('builtins, duplicate installations and source conflicts cannot be replaced', async () => { + const f = await fixture(); + try { + await f.package(); + await f.package('builtin', 'reader.local'); + await assert.rejects(f.manager.install('builtin'), { code: 'BUILTIN_PLUGIN_IMMUTABLE' }); + await assert.rejects(f.manager.setEnabled('reader.local', false), { code: 'BUILTIN_PLUGIN_IMMUTABLE' }); + await assert.rejects(f.manager.uninstall('reader.local'), { code: 'BUILTIN_PLUGIN_IMMUTABLE' }); + f.registry.register({ pluginId: 'test.plugin', provider: builtin }); + await assert.rejects(f.manager.install('example'), { code: 'PLUGIN_SOURCE_CONFLICT' }); + assert.equal(f.db.all('SELECT plugin_id FROM installed_plugins').length, 0); + f.registry.unregister('test.plugin', 'local'); + await f.manager.install('example'); + await assert.rejects(f.manager.install('example'), { code: 'PLUGIN_ALREADY_INSTALLED' }); + assert.equal(f.registry.require('reader.local', 'local').provider, builtin); + } finally { await f.dispose(); } +}); + +test('invalid multi-source packages leave no partially registered providers or persisted installation', async () => { + const f = await fixture(); + try { + await f.package('invalid', 'invalid.plugin', [ + { id: 'valid', label: 'Valid', capabilities: ['detail'] }, + { id: 'unsupported-file', label: 'Unsupported file', capabilities: ['detail', 'acquire.file'] }, + ]); + await assert.rejects(f.manager.install('invalid'), { code: 'PLUGIN_INCOMPATIBLE', statusCode: 400 }); + assert.equal(f.registry.list().length, 1); + assert.equal(f.db.all('SELECT plugin_id FROM installed_plugins').length, 0); + } finally { await f.dispose(); } +}); + +test('missing packages, invalid JSON and invalid manifests return stable client errors', async () => { + const f = await fixture(); + try { + await assert.rejects(f.manager.install('missing'), { code: 'PLUGIN_PACKAGE_NOT_FOUND', statusCode: 404 }); + const directory = await f.package(); + await writeFile(join(directory, 'plugin.json'), '{ invalid JSON'); + await assert.rejects(f.manager.install('example'), { code: 'PLUGIN_INVALID_MANIFEST', statusCode: 400 }); + await writeFile(join(directory, 'plugin.json'), JSON.stringify({ id: 'test.plugin' })); + await assert.rejects(f.manager.install('example'), { code: 'PLUGIN_INVALID_MANIFEST', statusCode: 400 }); + await f.package(); + await rm(join(directory, 'main.mjs')); + await assert.rejects(f.manager.install('example'), { code: 'PLUGIN_PACKAGE_NOT_FOUND', statusCode: 404 }); + assert.equal(f.db.all('SELECT plugin_id FROM installed_plugins').length, 0); + assert.equal(f.registry.list().length, 1); + } finally { await f.dispose(); } +}); + +test('install accepts only one package folder and rejects symlink escapes', async () => { + const f = await fixture(); + try { + const directory = await f.package(); + for (const folder of ['../example', './example', '/example', 'example/child', 'example\\child', '.', '', 'example.']) { + await assert.rejects(f.manager.install(folder), { code: 'PLUGIN_INVALID_FOLDER' }); + } + const outside = join(f.root, 'outside'); + await mkdir(outside); + await writeFile(join(outside, 'plugin.json'), '{}'); + await symlink(outside, join(f.dataDir, 'plugins', 'escape'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(f.manager.install('escape'), { code: 'PLUGIN_PATH_ESCAPE' }); + await access(join(directory, 'plugin.json')); + } finally { await f.dispose(); } +}); + +test('changed package identity is refused on restart and lifecycle mutations remain ordered', async () => { + const f = await fixture(); + try { + await f.package(); + await f.manager.install('example'); + const disabled = f.manager.setEnabled('test.plugin', false); + const enabled = f.manager.setEnabled('test.plugin', true); + await Promise.all([disabled, enabled]); + assert.equal(f.manager.list().find((plugin) => plugin.pluginId === 'test.plugin')?.enabled, true); + assert.ok(f.registry.get('test.plugin', 'test')); + await f.manager.close(); + await f.package('example', 'changed.plugin'); + const restarted = f.restart(); + await restarted.loadInstalled(); + assert.equal(restarted.list().find((plugin) => plugin.pluginId === 'test.plugin')?.error?.code, 'PLUGIN_ID_CHANGED'); + assert.equal(f.registry.get('changed.plugin', 'test'), undefined); + await restarted.close(); + await assert.rejects(restarted.setEnabled('test.plugin', true), { code: 'PLUGIN_MANAGER_CLOSED' }); + } finally { await f.dispose(); } +}); diff --git a/server/test/source-plugins.test.ts b/server/test/source-plugins.test.ts new file mode 100644 index 0000000..19a16ec --- /dev/null +++ b/server/test/source-plugins.test.ts @@ -0,0 +1,154 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { ProcessPlugin } from '../src/sources/process-plugin.ts'; +import { SourceRegistry, validatePluginManifest } from '../src/sources/registry.ts'; +import { decodeChapterAcquisition, decodeManifest } from '../src/sources/protocol.ts'; +import type { PluginManifest, SourceContext, SourceProvider } from '../src/sources/types.ts'; + +function context(signal = new AbortController().signal): SourceContext { + return { + userId: 'user-one', + signal, + instance: { + id: 'instance-one', pluginId: 'reader.source.demo', sourceType: 'demo-chapters', + name: 'Demo', config: {}, enabled: true, + }, + }; +} + +const manifest: PluginManifest = { + id: 'test.source', name: 'Test', version: '1.0.0', apiVersion: 1, runtime: 'node', entry: 'main.mjs', + sourceTypes: [{ id: 'test', label: 'Test', capabilities: ['detail'] }], +}; + +async function fixture(code: string): Promise<{ directory: string; dispose(): Promise }> { + const directory = await mkdtemp(join(tmpdir(), 'reader-plugin-')); + await writeFile(join(directory, 'plugin.json'), JSON.stringify(manifest)); + await writeFile(join(directory, 'main.mjs'), code); + return { directory, dispose: () => rm(directory, { recursive: true, force: true }) }; +} + +test('registry separates plugin namespaces and rejects unsupported advertised capabilities', () => { + const registry = new SourceRegistry(); + const provider: SourceProvider = { + descriptor: { id: 'test', label: 'Test', version: '1', capabilities: ['detail'] }, + async detail() { return { ref: 'book', title: 'Book' }; }, + async acquire() { return { kind: 'ready', publicationId: 'book' }; }, + }; + registry.registerBuiltin('reader.builtin', provider); + registry.register({ pluginId: 'reader.external', provider }); + assert.equal(registry.list().length, 2); + assert.equal(registry.require('reader.builtin', 'test').builtin, true); + assert.throws(() => registry.register({ pluginId: 'reader.external', provider }), /already registered/); + assert.throws(() => registry.register({ + pluginId: 'reader.invalid', + provider: { ...provider, descriptor: { ...provider.descriptor, capabilities: ['search'] } }, + }), /does not implement search/); + assert.throws(() => registry.register({ + pluginId: 'reader.invalid', + provider: { ...provider, descriptor: { ...provider.descriptor, capabilities: ['acquire.chapters'] } }, + }), /chapter manifest and resources/); +}); + +test('plugin manifest rejects API version mismatch and duplicate or incomplete source types', () => { + assert.equal(validatePluginManifest(manifest).apiVersion, 1); + assert.throws(() => validatePluginManifest({ ...manifest, apiVersion: 2 }), /API version/); + assert.throws(() => validatePluginManifest({ ...manifest, apiVersion: undefined }), /API version/); + assert.throws(() => validatePluginManifest({ ...manifest, sourceTypes: [manifest.sourceTypes[0], manifest.sourceTypes[0]] }), /duplicate/); + assert.throws(() => validatePluginManifest({ + ...manifest, sourceTypes: [{ id: 'test', label: 'Test', capabilities: ['acquire.chapters'] }], + }), /must declare manifest and resource/); +}); + +test('real example process supports discovery, acquisition and stable chapter resources', async () => { + const plugin = await ProcessPlugin.load(resolve(import.meta.dirname, '../../examples/plugins/demo-chapters')); + try { + const registry = new SourceRegistry(); + const provider = plugin.providers()[0]!; + registry.registerPluginManifest(plugin.manifest, provider); + await provider.validateConfig?.({}); + const page = await provider.browse!(context(), {}); + assert.equal(page.items[0]?.ref, 'demo-book'); + const acquisition = await provider.acquire(context(), { entryRef: page.items[0]!.ref }); + assert.deepEqual(acquisition, { kind: 'chapters', publicationRef: 'demo-book' }); + const snapshot = await provider.getManifest!(context(), 'demo-book'); + assert.equal(snapshot.items[0]?.id, 'chapter-one'); + const resource = await provider.readResource!(context(), { publicationRef: 'demo-book', ref: snapshot.items[0]!.ref }); + assert.match(resource.text!, /独立 Node 进程/); + await assert.rejects(provider.detail(context(), 'missing'), { code: 'RESOURCE_GONE' }); + assert.equal(plugin.status().state, 'running'); + assert.equal(plugin.status().pendingRequests, 0); + } finally { + await plugin.close(); + } +}); + +test('process timeout terminates pending work and requires a reload', async () => { + const source = await fixture('process.stdin.resume();'); + const plugin = await ProcessPlugin.load(source.directory, { timeoutMs: 100 }); + try { + await assert.rejects(plugin.providers()[0]!.detail(context(), 'book'), { code: 'PLUGIN_TIMEOUT' }); + assert.equal(plugin.status().state, 'failed'); + assert.equal(plugin.status().pendingRequests, 0); + await assert.rejects(plugin.providers()[0]!.detail(context(), 'book'), { code: 'PLUGIN_UNAVAILABLE' }); + } finally { + await plugin.close(); + await source.dispose(); + } +}); + +test('cancellation kills an uncooperative worker and rejects its concurrent calls', async () => { + const source = await fixture('process.stdin.resume();'); + const plugin = await ProcessPlugin.load(source.directory); + try { + const controller = new AbortController(); + const pending = plugin.providers()[0]!.detail(context(controller.signal), 'book'); + const concurrent = plugin.providers()[0]!.detail(context(), 'another-book'); + const outcomes = Promise.allSettled([pending, concurrent]); + controller.abort(); + for (const result of await outcomes) { + assert.equal(result.status, 'rejected'); + if (result.status === 'rejected') assert.equal(result.reason.code, 'PLUGIN_CANCELLED'); + } + assert.equal(plugin.status().pendingRequests, 0); + assert.equal(plugin.status().state, 'failed'); + } finally { + await plugin.close(); + await source.dispose(); + } +}); + +test('wire validation prevents plugins claiming host books or corrupting chapter identities', () => { + assert.throws(() => decodeChapterAcquisition({ kind: 'ready', publicationId: 'other-users-book' }), /must acquire chapter/); + assert.throws(() => decodeChapterAcquisition({ kind: 'action-required', action: { type: 'external', label: 'Open', url: 'javascript:alert(1)' } }), /HTTP or HTTPS/); + const item = { id: 'stable', seq: 0, title: 'Chapter', kind: 'chapter', mediaType: 'text/plain', ref: 'chapter' }; + assert.throws(() => decodeManifest({ publicationRef: 'wrong', items: [item] }, 'requested'), /does not match/); + assert.throws(() => decodeManifest({ publicationRef: 'book', items: [item, { ...item, seq: 1 }] }, 'book'), /duplicate chapter/); + assert.equal(decodeManifest({ publicationRef: 'book', items: [item] }, 'book').items[0]?.id, 'stable'); +}); + +test('oversized responses fail the plugin instead of buffering unbounded output', async () => { + const source = await fixture(`process.stdin.on('data', () => process.stdout.write('x'.repeat(4096)));`); + const plugin = await ProcessPlugin.load(source.directory, { maxMessageBytes: 1024 }); + try { + await assert.rejects(plugin.providers()[0]!.detail(context(), 'book'), { code: 'PLUGIN_MESSAGE_TOO_LARGE' }); + assert.equal(plugin.status().state, 'failed'); + } finally { + await plugin.close(); + await source.dispose(); + } +}); + +test('plugin entry cannot escape its package directory', async () => { + const source = await fixture(''); + try { + await mkdir(join(source.directory, 'package')); + await writeFile(join(source.directory, 'package', 'plugin.json'), JSON.stringify({ ...manifest, entry: '../main.mjs' })); + await assert.rejects(ProcessPlugin.load(join(source.directory, 'package')), { code: 'PLUGIN_INVALID' }); + } finally { + await source.dispose(); + } +}); diff --git a/server/test/sources-api.test.ts b/server/test/sources-api.test.ts new file mode 100644 index 0000000..e0a4c76 --- /dev/null +++ b/server/test/sources-api.test.ts @@ -0,0 +1,389 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import type { AppConfig } from '../src/config/index.ts'; +import { openDatabase } from '../src/db/index.ts'; +import { buildApp } from '../src/http/app.ts'; +import type { AppContext } from '../src/http/context.ts'; +import { Scanner } from '../src/indexer/scanner.ts'; +import { UserService } from '../src/services/users.ts'; +import { ShelfService } from '../src/services/shelf.ts'; +import { SyncService } from '../src/services/sync.ts'; +import { TtsService } from '../src/services/tts.ts'; +import { BrowseService } from '../src/services/browse.ts'; +import { UploadService } from '../src/services/uploads.ts'; +import type { CatalogPage, SourceProvider } from '../src/sources/types.ts'; + +interface Session { token: string; id: string } + +function auth(session: Session) { return { authorization: `Bearer ${session.token}` }; } + +async function harness() { + const root = await mkdtemp(join(tmpdir(), 'reader-sources-api-')); + const booksDir = join(root, 'books'); + const dataDir = join(root, 'data'); + await mkdir(booksDir); + await mkdir(dataDir); + const config: AppConfig = { + booksDir, dataDir, host: '127.0.0.1', port: 0, jwtSecret: 'sources-api-test-secret-stable-across-restarts', + accessTokenTtl: 86400, refreshTokenTtl: 86400, scanInterval: 0, watchInterval: 0, + logLevel: 'silent', publicUrl: '', corsOrigins: [], webDir: join(root, 'no-web'), + }; + let app: FastifyInstance; + let ctx: AppContext; + let active = false; + async function start() { + const db = openDatabase(config); + ctx = { config, db } as AppContext; + app = buildApp(ctx); + ctx.log = app.log; + ctx.scanner = new Scanner(db, config, { info() {}, warn() {} }); + ctx.users = new UserService(db, config); + ctx.shelf = new ShelfService(db); + ctx.sync = new SyncService(db, ctx.shelf); + ctx.tts = new TtsService(config); + ctx.browse = new BrowseService(db, config, ctx.shelf); + ctx.uploads = new UploadService(db, config, ctx.browse, ctx.scanner); + active = true; + await app.ready(); + } + async function stop() { + if (!active) return; + active = false; + try { await app.close(); } finally { ctx.db.close(); } + } + await start(); + const response = await app!.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { username: 'owner', password: 'password123' } }); + assert.equal(response.statusCode, 201, response.body); + const created = response.json(); + const admin: Session = { token: created.session.accessToken, id: created.user.id }; + return { + root, booksDir, dataDir, admin, + get app() { return app; }, get ctx() { return ctx; }, + async member(): Promise { + const create = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(admin), payload: { username: 'reader', password: 'password123' } }); + assert.equal(create.statusCode, 201, create.body); + const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: 'reader', password: 'password123' } }); + assert.equal(login.statusCode, 200, login.body); + return { token: login.json().accessToken, id: login.json().user.id }; + }, + async restart() { await stop(); await start(); }, + async close() { await stop(); await rm(root, { recursive: true, force: true }); }, + }; +} + +async function opdsFixture() { + const body = '第一章 远程下载\n\n这是经 OPDS 获取的本地可读正文。\n\n第二章 继续阅读\n\n下载后继续复用现有书架和阅读进度。'; + let downloads = 0; + const server = createServer((request, response) => { + if (request.url === '/catalog') { + response.setHeader('content-type', 'application/opds+json'); + response.end(JSON.stringify({ + metadata: { title: 'Test OPDS' }, + publications: [{ + metadata: { identifier: 'urn:reader:test:opds-txt', title: 'OPDS 集成测试书', author: [{ name: '测试作者' }] }, + links: [{ rel: 'http://opds-spec.org/acquisition/open-access', href: '/download/book.txt', type: 'text/plain' }], + }], + })); + } else if (request.url === '/download/book.txt') { + downloads += 1; + response.setHeader('content-type', 'text/plain; charset=utf-8'); + response.end(body); + } else { + response.statusCode = 404; response.end(); + } + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return { + url: `http://127.0.0.1:${address.port}/catalog`, body, + get downloads() { return downloads; }, + async close() { + server.closeAllConnections(); + await new Promise((done, reject) => server.close((error) => error ? reject(error) : done())); + }, + }; +} + +test('built-in source discovery and administrative boundaries preserve configuration and credentials', async (t) => { + const h = await harness(); t.after(() => h.close()); + const member = await h.member(); + const types = await h.app.inject({ method: 'GET', url: '/api/v1/sources/types', headers: auth(member) }); + assert.equal(types.statusCode, 200, types.body); + assert.deepEqual(types.json().types.map((type: { id: string }) => type.id).sort(), ['local', 'opds']); + assert.deepEqual(types.json().types.map((type: { id: string; pluginId: string; builtin: boolean }) => + ({ id: type.id, pluginId: type.pluginId, builtin: type.builtin })), [ + { id: 'local', pluginId: 'reader.local', builtin: true }, + { id: 'opds', pluginId: 'reader.opds', builtin: true }, + ]); + assert.ok(h.ctx.sources, 'buildApp initializes the source host'); + const payload = { id: 'secured-opds', pluginId: 'reader.opds', sourceType: 'opds', name: 'OPDS', config: { url: 'http://127.0.0.1:9999/catalog', username: 'private-config-user' } }; + const deniedCreate = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(member), payload }); + assert.equal(deniedCreate.statusCode, 403, deniedCreate.body); + const deniedInstall = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(member), payload: { folder: 'demo-chapters', trusted: true } }); + assert.equal(deniedInstall.statusCode, 403, deniedInstall.body); + const deniedList = await h.app.inject({ method: 'GET', url: '/api/v1/plugins', headers: auth(member) }); + assert.equal(deniedList.statusCode, 403, deniedList.body); + const noAuth = await h.app.inject({ method: 'GET', url: '/api/v1/sources' }); + assert.equal(noAuth.statusCode, 401, noAuth.body); + + const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload }); + assert.equal(created.statusCode, 201, created.body); + const password = 'source-password-not-in-a-dto'; + const credential = await h.app.inject({ method: 'PUT', url: '/api/v1/sources/secured-opds/credentials/password', headers: auth(member), payload: { value: password } }); + assert.equal(credential.statusCode, 200, credential.body); + const stored = h.ctx.db.get<{ encrypted_value: string }>('SELECT encrypted_value FROM source_credentials WHERE source_id = ? AND user_id = ?', 'secured-opds', member.id); + assert.ok(stored?.encrypted_value); + assert.ok(!stored.encrypted_value.includes(password)); + const publicSources = await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(member) }); + assert.equal(publicSources.statusCode, 200, publicSources.body); + assert.equal(publicSources.json().sources.find((source: { id: string }) => source.id === 'secured-opds').config, undefined); + assert.ok(!publicSources.body.includes('private-config-user')); + assert.ok(!publicSources.body.includes(password)); + const adminSources = await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(h.admin) }); + assert.equal(adminSources.statusCode, 200, adminSources.body); + assert.equal(adminSources.json().sources.find((source: { id: string }) => source.id === 'secured-opds').config.username, 'private-config-user'); + assert.ok(!adminSources.body.includes(password)); + const inlinePassword = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { ...payload, id: 'bad-password', config: { ...payload.config, password } } }); + assert.equal(inlinePassword.statusCode, 400, inlinePassword.body); +}); + +test('source query validation rejects repeated text parameters and invalid page limits', async (t) => { + const h = await harness(); t.after(() => h.close()); + for (const query of ['limit=0', 'limit=-1', 'limit=201', 'limit=1.5', 'limit=NaN', 'limit=10&limit=20']) { + const response = await h.app.inject({ method: 'GET', url: `/api/v1/sources/local/browse?${query}`, headers: auth(h.admin) }); + assert.equal(response.statusCode, 400, `${query}: ${response.body}`); + } + for (const query of ['q=first&q=second', 'q=', 'q=valid&cursor=one&cursor=two']) { + const response = await h.app.inject({ method: 'GET', url: `/api/v1/sources/local/search?${query}`, headers: auth(h.admin) }); + assert.equal(response.statusCode, 400, `${query}: ${response.body}`); + } +}); + +test('local source acquisition restores the existing publication to the requesting users shelf', async (t) => { + const h = await harness(); t.after(() => h.close()); + const member = await h.member(); + await writeFile(join(h.booksDir, '本地书.txt'), '第一章\n\n本地来源沿用既有内容和身份。'); + await h.ctx.scanner.scan(); + const browse = await h.app.inject({ method: 'GET', url: '/api/v1/sources/local/browse', headers: auth(member) }); + assert.equal(browse.statusCode, 200, browse.body); + const entry = browse.json().items.find((item: { title: string }) => item.title === '本地书'); + assert.ok(entry, browse.body); + h.ctx.db.run('UPDATE user_books SET hidden = 1 WHERE user_id = ? AND book_id = ?', member.id, entry.ref); + const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/local/acquire', headers: auth(member), payload: { entryRef: entry.ref, optionId: 'read' } }); + assert.equal(acquired.statusCode, 200, acquired.body); + assert.deepEqual(acquired.json(), { kind: 'ready', publicationId: entry.ref }); + const shelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) }); + assert.ok(shelf.json().items.some((book: { id: string }) => book.id === entry.ref), shelf.body); + const content = await h.app.inject({ method: 'GET', url: `/api/v1/books/${entry.ref}/content`, headers: auth(member) }); + assert.equal(content.statusCode, 200, content.body); + assert.match(content.body, /既有内容和身份/); +}); + +test('OPDS acquisition feeds existing reading, shelf and progress APIs and survives source disable and restart', async (t) => { + const h = await harness(); t.after(() => h.close()); + const catalog = await opdsFixture(); t.after(() => catalog.close()); + const member = await h.member(); + const source = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { + id: 'test-opds', pluginId: 'reader.opds', sourceType: 'opds', name: 'Test OPDS', config: { url: catalog.url }, + } }); + assert.equal(source.statusCode, 201, source.body); + const browse = await h.app.inject({ method: 'GET', url: '/api/v1/sources/test-opds/browse', headers: auth(member) }); + assert.equal(browse.statusCode, 200, browse.body); + const entry = browse.json().items[0]; + assert.equal(entry.title, 'OPDS 集成测试书'); + const request = { entryRef: entry.ref, optionId: entry.options[0].id }; + const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/test-opds/acquire', headers: auth(member), payload: request }); + assert.equal(acquired.statusCode, 200, acquired.body); + const { publicationId: bookId, kind } = acquired.json(); + assert.equal(kind, 'ready'); + assert.ok(bookId); + assert.equal(catalog.downloads, 1); + const originalFiles = (await readdir(join(h.dataDir, 'acquired'))).sort(); + assert.equal(originalFiles.length, 1); + const repeated = await h.app.inject({ method: 'POST', url: '/api/v1/sources/test-opds/acquire', headers: auth(member), payload: request }); + assert.equal(repeated.statusCode, 200, repeated.body); + assert.equal(repeated.json().publicationId, bookId); + assert.equal(catalog.downloads, 1, 'repeated acquisition reuses the imported file'); + assert.deepEqual((await readdir(join(h.dataDir, 'acquired'))).sort(), originalFiles, 'repeated acquisition creates no additional files'); + + const otherShelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(h.admin) }); + assert.ok(!otherShelf.json().items.some((book: { id: string }) => book.id === bookId), otherShelf.body); + const otherContent = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(h.admin) }); + assert.equal(otherContent.statusCode, 404, otherContent.body); + const unauthorizedAdd = await h.app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(h.admin), payload: { bookIds: [bookId], action: 'add' } }); + assert.equal(unauthorizedAdd.statusCode, 200, unauthorizedAdd.body); + assert.equal(unauthorizedAdd.json().applied, 0); + assert.deepEqual(unauthorizedAdd.json().failed, [{ path: bookId, reason: 'NO_LIVE_FILE' }]); + const stillForbidden = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(h.admin) }); + assert.equal(stillForbidden.statusCode, 404, stillForbidden.body); + + const removedFromShelf = await h.app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(member), payload: { bookIds: [bookId], action: 'remove' } }); + assert.equal(removedFromShelf.statusCode, 200, removedFromShelf.body); + assert.equal(removedFromShelf.json().applied, 1, removedFromShelf.body); + assert.deepEqual(removedFromShelf.json().failed, []); + const hiddenShelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) }); + assert.ok(!hiddenShelf.json().items.some((book: { id: string }) => book.id === bookId), hiddenShelf.body); + const restored = await h.app.inject({ method: 'POST', url: '/api/v1/sources/test-opds/acquire', headers: auth(member), payload: request }); + assert.equal(restored.statusCode, 200, restored.body); + assert.equal(restored.json().publicationId, bookId); + assert.equal(catalog.downloads, 1, 'restoring a book to the shelf reuses the existing download'); + assert.deepEqual((await readdir(join(h.dataDir, 'acquired'))).sort(), originalFiles); + + const manifest = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/manifest`, headers: auth(member) }); + assert.equal(manifest.statusCode, 200, manifest.body); + assert.equal(manifest.json().book.title, 'OPDS 集成测试书'); + const first = manifest.json().items[0]; assert.ok(first); + const asset = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/assets?ref=${encodeURIComponent(first.href)}`, headers: auth(member) }); + assert.equal(asset.statusCode, 200, asset.body); + assert.match(asset.body, /远程下载|OPDS/); + const content = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(member) }); + assert.equal(content.statusCode, 200, content.body); + assert.equal(content.body, catalog.body); + const shelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) }); + assert.ok(shelf.json().items.some((book: { id: string }) => book.id === bookId), shelf.body); + const progress = await h.app.inject({ method: 'PUT', url: `/api/v1/sync/progress/${bookId}`, headers: auth(member), payload: { + locator: first.href, percentage: 0.42, chapterTitle: first.title, device: 'test', updatedAt: Date.now(), + } }); + assert.equal(progress.statusCode, 200, progress.body); + assert.equal(progress.json().progress.percentage, 0.42); + + const disabled = await h.app.inject({ method: 'PATCH', url: '/api/v1/sources/test-opds', headers: auth(h.admin), payload: { enabled: false } }); + assert.equal(disabled.statusCode, 200, disabled.body); + const denied = await h.app.inject({ method: 'GET', url: '/api/v1/sources/test-opds/browse', headers: auth(member) }); + assert.equal(denied.json().error.code, 'SOURCE_DISABLED'); + await h.ctx.scanner.scan(); + await h.restart(); + const retained = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(member) }); + assert.equal(retained.statusCode, 200, retained.body); + assert.equal(retained.body, catalog.body); + const retainedProgress = await h.app.inject({ method: 'GET', url: `/api/v1/sync/progress/${bookId}`, headers: auth(member) }); + assert.equal(retainedProgress.json().progress.percentage, 0.42); + const retainedShelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) }); + assert.ok(retainedShelf.json().items.some((book: { id: string }) => book.id === bookId), retainedShelf.body); +}); + +test('source host enforces per-source and global concurrency limits and releases slots after completion', { timeout: 10_000 }, async (t) => { + const h = await harness(); t.after(() => h.close()); + const queued: Array<{ sourceId: string; release(): void }> = []; + const entered = new Map(); + const watchers: Array<{ sourceId: string; count: number; resolve(): void }> = []; + let hold = true; + const provider: SourceProvider = { + descriptor: { id: 'blocking', label: 'Concurrency test', version: '1', capabilities: ['browse', 'detail'] }, + async detail(_ctx, ref) { return { ref, title: ref }; }, + async acquire() { return { kind: 'action-required', action: { type: 'external', label: 'Test' } }; }, + async browse(ctx) { + const count = (entered.get(ctx.instance.id) ?? 0) + 1; + entered.set(ctx.instance.id, count); + for (const watcher of watchers) { + if (watcher.sourceId === ctx.instance.id && count >= watcher.count) watcher.resolve(); + } + if (!hold) return { items: [] }; + return new Promise((resolvePage) => { + queued.push({ sourceId: ctx.instance.id, release: () => resolvePage({ items: [] }) }); + }); + }, + }; + h.ctx.sources!.registry.register({ pluginId: 'reader.test', provider }); + for (const id of ['limit-one', 'limit-two', 'limit-three', 'limit-four', 'limit-five']) { + const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { + id, pluginId: 'reader.test', sourceType: 'blocking', name: id, config: {}, + } }); + assert.equal(created.statusCode, 201, created.body); + } + const waitUntilEntered = (sourceId: string, count: number): Promise => { + if ((entered.get(sourceId) ?? 0) >= count) return Promise.resolve(); + return new Promise((resolveEntered) => watchers.push({ sourceId, count, resolve: resolveEntered })); + }; + const browse = (sourceId: string) => h.app.inject({ + method: 'GET' as const, url: `/api/v1/sources/${sourceId}/browse`, headers: auth(h.admin), + }); + const pending: ReturnType[] = []; + try { + const first = browse('limit-one'); const second = browse('limit-one'); + pending.push(first, second); + await waitUntilEntered('limit-one', 2); + const blocked = await browse('limit-one'); + assert.equal(blocked.statusCode, 429, blocked.body); + assert.equal(blocked.json().error.code, 'RATE_LIMITED'); + assert.equal(entered.get('limit-one'), 2, 'rejected calls never invoke the provider'); + hold = false; + for (const request of queued.splice(0)) request.release(); + assert.equal((await first).statusCode, 200); + assert.equal((await second).statusCode, 200); + const recovered = await browse('limit-one'); + assert.equal(recovered.statusCode, 200, recovered.body); + + hold = true; + const sourceIds = ['limit-one', 'limit-two', 'limit-three', 'limit-four']; + const globalRequests: ReturnType[] = []; + for (const sourceId of sourceIds) { + const count = (entered.get(sourceId) ?? 0) + 2; + const pair = [browse(sourceId), browse(sourceId)]; + globalRequests.push(...pair); pending.push(...pair); + await waitUntilEntered(sourceId, count); + } + const globalBlocked = await browse('limit-five'); + assert.equal(globalBlocked.statusCode, 429, globalBlocked.body); + assert.equal(globalBlocked.json().error.code, 'RATE_LIMITED'); + assert.equal(entered.get('limit-five'), undefined); + hold = false; + queued.shift()!.release(); + assert.equal((await globalRequests[0]!).statusCode, 200); + const globalRecovered = await browse('limit-five'); + assert.equal(globalRecovered.statusCode, 200, globalRecovered.body); + } finally { + hold = false; + for (const request of queued.splice(0)) request.release(); + await Promise.allSettled(pending); + } +}); + +test('trusted example plugins can be installed, queried, disabled, reloaded after restart and uninstalled', async (t) => { + const h = await harness(); t.after(() => h.close()); + await mkdir(join(h.dataDir, 'plugins')); + await cp(resolve(import.meta.dirname, '../../examples/plugins/demo-chapters'), join(h.dataDir, 'plugins', 'demo-chapters'), { recursive: true }); + const untrusted = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'demo-chapters', trusted: false } }); + assert.ok(untrusted.statusCode >= 400 && untrusted.statusCode < 500, untrusted.body); + const installed = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'demo-chapters', trusted: true } }); + assert.equal(installed.statusCode, 201, installed.body); + const pluginId = 'reader.source.demo'; + const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { + id: 'demo-instance', pluginId, sourceType: 'demo-chapters', name: 'Demo', config: {}, + } }); + assert.equal(created.statusCode, 201, created.body); + const browse = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) }); + assert.equal(browse.statusCode, 200, browse.body); + assert.equal(browse.json().items[0].ref, 'demo-book'); + const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/demo-instance/acquire', headers: auth(h.admin), payload: { entryRef: 'demo-book' } }); + assert.equal(acquired.statusCode, 200, acquired.body); + assert.deepEqual(acquired.json(), { kind: 'chapters', publicationRef: 'demo-book' }); + const manifest = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/publications/demo-book/manifest', headers: auth(h.admin) }); + assert.equal(manifest.statusCode, 200, manifest.body); + const resource = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/publications/demo-book/resource?ref=chapter-one', headers: auth(h.admin) }); + assert.equal(resource.statusCode, 200, resource.body); + assert.match(resource.body, /独立 Node 进程/); + const disabled = await h.app.inject({ method: 'PATCH', url: `/api/v1/plugins/${pluginId}`, headers: auth(h.admin), payload: { enabled: false } }); + assert.equal(disabled.statusCode, 200, disabled.body); + const unavailable = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) }); + assert.equal(unavailable.json().error.code, 'PLUGIN_UNAVAILABLE'); + await h.restart(); + const stillUnavailable = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) }); + assert.equal(stillUnavailable.json().error.code, 'PLUGIN_UNAVAILABLE'); + const enabled = await h.app.inject({ method: 'PATCH', url: `/api/v1/plugins/${pluginId}`, headers: auth(h.admin), payload: { enabled: true } }); + assert.equal(enabled.statusCode, 200, enabled.body); + await h.restart(); + const reloaded = await h.app.inject({ method: 'GET', url: '/api/v1/sources/demo-instance/browse', headers: auth(h.admin) }); + assert.equal(reloaded.statusCode, 200, reloaded.body); + assert.equal(reloaded.json().items[0].ref, 'demo-book'); + const removed = await h.app.inject({ method: 'DELETE', url: `/api/v1/plugins/${pluginId}`, headers: auth(h.admin) }); + assert.equal(removed.statusCode, 200, removed.body); + const instances = await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(h.admin) }); + assert.ok(instances.json().sources.some((source: { id: string }) => source.id === 'demo-instance'), 'uninstall keeps instance configuration'); +}); diff --git a/server/test/tts.test.ts b/server/test/tts.test.ts index cef2213..7bb0854 100644 --- a/server/test/tts.test.ts +++ b/server/test/tts.test.ts @@ -124,6 +124,7 @@ before(async () => { after(async () => { await app?.close(); + ctx?.db.close(); await new Promise((resolve) => upstream?.close(() => resolve())); await rm(root, { recursive: true, force: true }); }); @@ -131,6 +132,7 @@ after(async () => { /** Rebuilds the instance so the environment (`TTS_URL`) is read fresh. */ beforeEach(async () => { await app?.close(); + ctx?.db.close(); await buildInstance(); if (!token) { const res = await app.inject({ @@ -146,6 +148,7 @@ beforeEach(async () => { test('an instance with no TTS_URL reports http:false rather than 404', async () => { delete process.env.TTS_URL; await app.close(); + ctx.db.close(); await buildInstance(); const res = await app.inject({ method: 'GET', url: '/api/v1/tts/voices', headers: auth() }); @@ -160,6 +163,7 @@ test('an instance with no TTS_URL reports http:false rather than 404', async () test('synthesis is refused with a clear error when TTS_URL is unset', async () => { delete process.env.TTS_URL; await app.close(); + ctx.db.close(); await buildInstance(); const res = await app.inject({ @@ -173,6 +177,7 @@ test('synthesis is refused with a clear error when TTS_URL is unset', async () = test('a sentence is proxied to the upstream service and streamed back as audio', async () => { process.env.TTS_URL = upstreamUrl; await app.close(); + ctx.db.close(); await buildInstance(); upstreamBehaviour = 'audio'; @@ -192,6 +197,7 @@ test('a sentence is proxied to the upstream service and streamed back as audio', test('an upstream that answers HTML is refused instead of served as silence', async () => { process.env.TTS_URL = upstreamUrl; await app.close(); + ctx.db.close(); await buildInstance(); upstreamBehaviour = 'html'; @@ -206,6 +212,7 @@ test('an upstream that answers HTML is refused instead of served as silence', as test('an upstream error is reported as such, not as a 200', async () => { process.env.TTS_URL = upstreamUrl; await app.close(); + ctx.db.close(); await buildInstance(); upstreamBehaviour = 'error'; @@ -220,6 +227,7 @@ test('an upstream error is reported as such, not as a 200', async () => { test('an over-long utterance is refused before it reaches the upstream', async () => { process.env.TTS_URL = upstreamUrl; await app.close(); + ctx.db.close(); await buildInstance(); upstreamBehaviour = 'audio'; lastUpstreamQuery = ''; @@ -236,6 +244,7 @@ test('an over-long utterance is refused before it reaches the upstream', async ( test('the voice list is passed through from the upstream service', async () => { process.env.TTS_URL = upstreamUrl; await app.close(); + ctx.db.close(); await buildInstance(); const res = await app.inject({ method: 'GET', url: '/api/v1/tts/voices', headers: auth() }); @@ -248,6 +257,7 @@ test('the voice list is passed through from the upstream service', async () => { test('the audio route requires a token, in the header or the query', async () => { process.env.TTS_URL = upstreamUrl; await app.close(); + ctx.db.close(); await buildInstance(); const anonymous = await app.inject({ method: 'GET', url: '/api/v1/tts?text=hello' }); diff --git a/server/test/uploads.test.ts b/server/test/uploads.test.ts index f08a364..98f745e 100644 --- a/server/test/uploads.test.ts +++ b/server/test/uploads.test.ts @@ -215,6 +215,7 @@ before(async () => { after(async () => { await app.close(); + ctx.db.close(); await rm(root, { recursive: true, force: true }); });