- 后端
- 前端
- CMS
【免费下载链接】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):
- 有在线阅读权限并已激活(
user.can_read()与user.is_active()); - 能查看目标书籍——私有书只允许所有者和管理员(
_can_user_view_book校验item.scope == "private"时必须是user.is_admin()或item.collector_id == user.id); - 目标书的
media_type为comic; - 至少含一个 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 | 书籍或页序不存在 |
| 409 | manifest 已更新(修订过期或图片尺寸变化) |
| 422 | 容器或页面无效 |
| 503 | 并发繁忙 |
响应正文只含可展示的简短说明,绝不含路径。
六、漫画进度同步
GET /api/book/:bookId/comic/progress POST /api/book/:bookId/comic/progress Content-Type: application/jsonPOST 请求体:
{ "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
一个简单好用的个人书库
相关推荐
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考