☰
Talebook 漫画阅读器接入契约:从页面 manifest 到安全图片分发的完整实现解析
2026/10/4 14:20:40 网站建设 项目流程
  • 后端
  • 前端
  • CMS

【免费下载链接】talebook

一个简单好用的个人书库

项目地址:https://gitcode.com/gh_mirrors/ta/talebook
点击查看免费下载

Talebook(个人书库)在保留传统 EPUB 阅读器的同时,引入独立的komga-reader作为漫画阅读前端,并由后端通过一套“契约式”HTTP API 驱动。本文以仓库文档 document/ComicReaderApi.zh_CN.md 为核心,结合后端实现 webserver/handlers/comic.py、资源受限的容器解析服务 webserver/services/comic_archive.py、前端 host 模板 webserver/resources/book/comic-reader.html 及测试 tests/test_comic_reader.py,完整讲解阅读器接入契约、权限模型、页面级访问凭证、进度同步与安全资源预算。读完本文,你将掌握如何在 Talebook 中接入独立漫画阅读器、如何正确调用四类 Comic API,以及这套契约在防路径泄露与资源滥用上的具体工程手段。

一、总体架构:后端渲染 host,Reader 只接触“公共视图”

Talebook 的漫画阅读方案遵循“薄 host + 独立 Reader”的分层设计。后端直接处理/read-comic/:bookId路由,渲染webserver/resources/book/comic-reader.html,并通过契约驱动独立的komga-reader。Reader 只接触页面 manifest、同源图片 URL 和阅读进度三个维度,不会读取Calibre 路径、Talebook 数据库、可下载归档或归档条目名。这意味着所有“私有信息”都只存在于后端,前端即使被完全审计,也拿不到本地文件系统结构。

1.1 前端分发:静态产物,不参与 npm/Docker 依赖

Reader 沿用 Candle Reader 的静态 JavaScript 分发模式,不作为 Talebook 的 npm 依赖参与 Nuxt/Docker 安装:

  • 上游komga-reader构建出自包含的 ESM、UMD 与 CSS;浏览器 bundle 内含隔离的 Vue runtime,不依赖全局Vue、裸模块解析或 CDN;
  • Talebook 将不可变上游 commit 的产物、许可证和版本记录固定在app/public/static/komga-reader/(实际包含komga-reader.es.js、komga-reader.umd.js、style.css、LICENSE、NOTICE、THIRD_PARTY_NOTICES);
  • 当前固定版本记录在仓库根目录 komga-reader-version.txt,值为d49a2e808601c7fc9b892a6c019a92eed017fd16。后端版本常量KOMGA_READER_VERSION与静态模块 URL 的?v=查询参数共用同一值,实现精确的缓存失效(见 webserver/handlers/comic.py)。

1.2 路由边界:/read-comic必须直达 Tornado

/read-comic/:bookId是 Tornado 路由(见 webserver/handlers/comic.py 中的routes()):

def routes(): return [ (r"/read-comic/([0-9]+)", ComicReaderHandler), (r"/api/book/([0-9]+)/comic/pages", ComicManifestHandler), (r"/api/book/([0-9]+)/comic/pages/([0-9]+)", ComicPageHandler), (r"/api/book/([0-9]+)/comic/progress", ComicProgressHandler), ]

后端先完成登录、权限、书籍媒体类型与容器选择校验,再渲染轻量 HTML host。Nginx 的 dev、SPA、SSR 配置及 Nuxt 本地开发代理都把这些路径直接转发给 Tornado。例如 conf/nginx/talebook.conf、conf/nginx/dev.conf、conf/nginx/server-side-render.conf 均使用:

location ~ ^/(api|get|read|read-comic|opds|auth|books|media)/ { proxy_pass http://backend; }

关键约束:该路径不能通过前端$backend添加/api前缀,否则会破坏代理直通关系。后端模板从同源版本 URL 动态加载 ESM,并在刷新或离页时调用destroy();Nuxt 中不存在app/pages/read-comic页面,也不承担 Reader 生命周期或 API 适配。测试 tests/test_comic_reader.py 验证了 host 页面包含id="comic-reader-host"、/static/komga-reader/komga-reader.es.js与 API 路径,同时确保本地归档路径与page1.png等条目名不出现在响应体中。

二、权限与支持范围

manifest 与进度接口要求登录;页面图片通常也接受登录 Cookie/Basic Auth。对应账号必须同时满足四条前置条件(见 webserver/handlers/comic.py 的get_authorized_comic):

  1. 有在线阅读权限并已激活(user.can_read()与user.is_active());
  2. 能查看目标书籍——私有书只允许所有者和管理员(_can_user_view_book校验item.scope == "private"时必须是user.is_admin()或item.collector_id == user.id);
  3. 目标书的media_type为comic;
  4. 至少含一个 CBZ、图片 ZIP、CBR 或图片 RAR 容器。

容器选择遵循优先级CBZ > ZIP > CBR > RAR,由 webserver/services/comic_archive.py 的select_comic_container依据fmt_cbz、fmt_zip、fmt_cbr、fmt_rar字段顺序命中。漫画型 EPUB 不使用本契约,继续进入现有 EPUB 阅读器。

三、混合格式手动分类:POST /api/book/:bookId/media_type

当同一本书同时包含电子书格式(EPUB、MOBI、AZW、AZW3、PDF、TXT)和漫画容器(CBZ、ZIP、CBR、RAR)时,所有者或管理员可在详情页“文件处理”菜单中选择“设置为漫画”或“设置为电子书”:

POST /api/book/:bookId/media_type Content-Type: application/json {"media_type":"comic"}

实现位于 webserver/handlers/book.py 的BookSetMediaType:

  • media_type只接受comic或ebook,其他值返回params.media_type;
  • 目标书必须确实同时包含两类格式,否则返回media_type.not_mixed(“只有同时包含电子书和漫画格式的书籍才需要手动设置媒体类型”);
  • 成功后服务端设置media_type_locked=true,写入Item表;后续目录扫描或上传新格式仍会执行文件安全分析,但不会覆盖这个人工选择(见 webserver/handlers/book.py 的_save_media_type,仅当not item.media_type_locked时才自动合并);
  • 再次选择另一类型即可修改;单一类型书籍不显示该操作,直接调用也返回media_type.not_mixed。

测试 tests/test_comic_media_api.py 覆盖了“仅 EPUB 书籍设置 comic 返回media_type.not_mixed”“成功后media_type_locked=True且可再切换”等场景。

四、页面 manifest:GET /api/book/:bookId/comic/pages

GET /api/book/:bookId/comic/pages

成功响应(contract_version为 1):

{ "err": "ok", "contract_version": 1, "book_id": 42, "title": "示例漫画", "format": "CBZ", "revision": "9f6b69e617ec75d870c4", "pages_count": 2, "pages": [ { "id": "9f6b69e617ec75d870c4:0", "index": 0, "url": "/api/book/42/comic/pages/0?revision=9f6b69e617ec75d870c4&token=<signed-page-token>", "width": 1200, "height": 1800, "mime_type": "image/jpeg" } ] }

字段语义:

  • index是自然排序后的连续零基序号。排序键natural_page_sort_key做了 NFKC 归一化与大小写折叠,使第1页.png、第01页.png、第2页.png、第10页.png按直觉顺序排列(测试见 tests/test_comic_reader.py);
  • id为"{revision}:{index}"格式,在同一容器修订内稳定,适合保存进度;客户端仍应保存pageIndex作为修订变化后的回退;
  • revision是不包含路径信息的内容目录摘要:由_revision对格式名 + 每个条目的名称、字节数与校验和做 SHA-256 后取前 20 个十六进制字符(见 webserver/services/comic_archive.py)。文件元数据一旦变化,revision 即变化,旧图片请求返回 409。

逻辑错误沿用 Talebook JSON 信封({"err": ..., "msg": ...}),HTTP 状态为 200;客户端必须检查err。稳定错误码包括:

  • user.need_login;
  • comic.book_not_found;
  • comic.no_permission/comic.account_inactive;
  • comic.media_type/comic.container_missing;
  • comic.invalid_container/comic.empty;
  • comic.page_size/comic.page_type/comic.page_dimensions/comic.page_corrupt;
  • comic.busy。

错误消息不会包含本地文件路径或归档条目名——ComicArchiveError的构造即被设计为“稳定的、无路径的错误”(见 webserver/services/comic_archive.py),测试也断言错误消息不含目录名与条目名。

4.1 manifest 的构建与私有索引

manifest 由ComicArchiveService.get_manifest构建(webserver/services/comic_archive.py):

  • 先通过analyze_media_file做导入级安全分析(复用 webserver/services/media_analysis.py 的InvalidMediaError);
  • 再列条目、过滤忽略项(.DS_Store、Thumbs.db、ComicInfo.xml、__MACOSX、._*等)、拒绝重复路径、做自然排序;
  • 为每个条目读取头部(最多MAX_COMIC_PAGE_HEADER_BYTES = 2 MiB)并校验:字节数、魔数识别的 MIME 与扩展名匹配、Pillow 安全解码尺寸(单边不超过 32768 像素、总像素不超过 1 亿,超过即抛DecompressionBombWarning判定为comic.page_dimensions)。

manifest 使用最多 32 个文件修订的进程内 LRU 缓存(COMIC_MANIFEST_CACHE_SIZE = 32),缓存键基于realpath+ 格式 +st_dev/st_ino/st_size/st_mtime_ns,文件变化自动失效重建。

五、页面图片:GET /api/book/:bookId/comic/pages/:index

GET /api/book/:bookId/comic/pages/:index?revision=:revision&token=:signedPageToken

客户端只能提交:数字页序、manifest 返回的不透明修订、以及原样返回的token。服务端通过私有索引解析真实条目(即用page.entry_name定位),并在每次响应前复核 MIME、字节数和图片完整性。

5.1 页面级访问凭证(signed page token)

页面 token 由服务端cookie_secret签名(tornado.web.create_signed_value),有效期1 天,载荷绑定签发用户 ID、书籍 ID、页序与归档修订(见 webserver/handlers/comic.py):

payload = tornado.escape.json_encode({ "book_id": int(book_id), "page_index": int(page_index), "revision": revision, "user_id": principal.id, }) token = tornado.web.create_signed_value( str(self.settings["cookie_secret"]), COMIC_PAGE_TOKEN_NAME, # "comic-page-v1" payload, )

因此 token不能换页、换书或修改修订后复用;校验时逐项比对并重新按user_id加载用户(page_token_user)。请求若已携带有效登录态,可不依赖 token。token 过期、篡改、用户被删除,或用户后来失去阅读/私有书访问权限时,接口返回 401/403/404,不会继续输出图片。该机制用于 Rulia 等插件运行时:插件可以用用户配置中的账号密码获取 manifest,而图片加载器无需再次暴露账号密码。测试 tests/test_comic_reader.py 验证了“token 换页序后返回 401”“权限撤销后返回 403”。

5.2 响应头与协议错误状态

成功响应设置(见 webserver/handlers/comic.py):

Content-Type: image/* Content-Length: ... Cache-Control: private, max-age=3600, immutable Vary: Cookie X-Content-Type-Options: nosniff Content-Security-Policy: default-src 'none'; sandbox

其中Vary: Cookie防止带认证的私有图片被公共缓存复用。

协议错误使用稳定 HTTP 状态(write_protocol_error输出纯文本、no-store):

状态码含义
401未登录 / token 无效
403无阅读权限 / 账号未激活
404书籍或页序不存在
409manifest 已更新(修订过期或图片尺寸变化)
422容器或页面无效
503并发繁忙

响应正文只含可展示的简短说明,绝不含路径。

六、漫画进度同步

GET /api/book/:bookId/comic/progress POST /api/book/:bookId/comic/progress Content-Type: application/json

POST 请求体:

{ "progress": { "kind": "comic", "version": 1, "pageId": "9f6b69e617ec75d870c4:0", "pageIndex": 0, "percent": 50, "completed": false } }

服务端行为(见 webserver/handlers/comic.py):

  • normalized_progress要求kind == "comic"且version == COMIC_PROGRESS_VERSION (1),并把pageIndex钳制到[0, len(pages)-1],随后根据当前总页数重新计算percent = round((page_index + 1) * 100 / pages_count, 2)与completed(末页为 True);
  • 客户端提交的pageId必须与当前 manifest 在该pageIndex下的 ID 一致,否则返回comic.progress_stale(“漫画页面列表已更新,请刷新阅读器”);
  • 非法形状或超过2 KiB(MAX_COMIC_PROGRESS_BYTES = 2048)的负载返回comic.progress_invalid;
  • 数据复用 Talebook 的ReadingState.progress列,但契约与通用 EPUB/其他阅读器相互独立;保存时还会调用set_online_read(True)标记在线阅读。

GET 响应除progress外还包含update_time(state.progress_update_time的 ISO 格式)。前端 host 的进度策略值得参考(webserver/resources/book/comic-reader.html):翻页后 350ms 防抖合并保存(queueProgress),离页时用navigator.sendBeacon兜底,保存失败则弹出“阅读进度暂未保存,将在后续翻页时重试”的提示,且离开阅读器不被进度保存失败阻塞。

七、安全与资源预算

导入与读取都会校验容器。当前边界常量集中在 webserver/services/comic_archive.py:

维度上限
归档条目数最多 10,000 个
归档展开体积最多 512 MiB(导入检查);单条目导入检查最多 128 MiB;压缩比最多 200
在线阅读单页最多 32 MiB(MAX_COMIC_PAGE_BYTES),另设 2 MiB 头部读取上限
页面访问 token最长有效 1 天,按用户、书籍、页序和归档修订隔离,不包含账号密码
图片尺寸单边最多 32,768 像素,总像素最多 100,000,000
并发读取最多 4 个并发归档读取,单归档串行读取(64 槽哈希锁),等待 5 秒后返回 503comic.busy

同时拒绝:路径穿越(../outside.png)、重复路径、符号链接、加密归档、分卷、ZIP64、签名/扩展不匹配和损坏页面。RAR4/RAR5 由rarfile建立目录(测试样例见 tests/cases/comics 下的images-rar4.rar与encrypted.cbz),镜像内unar只负责解压选中的私有索引条目,不能绕过应用校验。

阅读时每次响应前的复核链(read_page,webserver/services/comic_archive.py)为:修订比对(不一致 409)→ 页序范围(越界 404)→ 解压并核对字节数与 32 MiB 上限(不符comic.page_corrupt)→ 头部魔数复核 MIME(不符comic.page_type)→ 尺寸复核(变化即 409)→ Pillowimage.verify()完整性验证。测试 tests/test_comic_reader.py 进一步确认:陈旧修订与越界页码永远不会选中条目,且所有错误消息都不含路径或条目名。

八、契约兼容与版本演进

本契约的所有细节——API、安全预算、进度格式——不随分发方式变化。当前阶段不发布 npm 或 release;首次上游 GitHub release 产生后,可再接入与 Candle Reader 相同的 release/dispatch 自动更新流程,届时只需更新komga-reader-version.txt与后端KOMGA_READER_VERSION常量即可完成整站缓存失效。对二次开发者而言,遵守“客户端只提交数字页序 + 不透明修订 + 原样 token”这一铁律,即可安全对接任何后续版本的阅读器。

参考文件索引

  • 契约文档:document/ComicReaderApi.zh_CN.md
  • 后端路由与授权:webserver/handlers/comic.py
  • 容器解析与资源预算:webserver/services/comic_archive.py
  • 媒体类型手动设置:webserver/handlers/book.py
  • 前端 host 模板:webserver/resources/book/comic-reader.html
  • 静态 Reader 产物:app/public/static/komga-reader
  • 版本记录:komga-reader-version.txt
  • Nginx 直通配置:conf/nginx/talebook.conf
  • 契约测试:tests/test_comic_reader.py、tests/test_comic_media_api.py
  • 后端
  • 前端
  • CMS

【免费下载链接】talebook

一个简单好用的个人书库

项目地址:https://gitcode.com/gh_mirrors/ta/talebook
点击查看免费下载
上一篇:NS-USBLoader完整指南:Switch游戏管理的终极解决方案
下一篇:Spinning Up 深度强化学习教程:Deep Deterministic Policy Gradient(DDPG)原理与实战解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询