diff --git a/README.md b/README.md index 4b54039..19aad72 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,38 @@ environment: - **封面进度条**:读过的书在封面底部画一条,扫一眼就知道哪本在读。 - **搜索**:输入即搜(防抖 250ms),带一键清空;回车立刻搜不等防抖。 - **下拉刷新**:在顶部再往下拉一次,手动重新拉取(刚扫完目录时用)。 +- **书库管理**:书架右上角的 🗂 打开文件管理器,看的是 `BOOKS_DIR` 本身而不是目录库。 - **离线优先**:先画本地镜像再换成服务端数据,离线时书架照常可用; 离线下的排序规则与服务端一致,所以断网不会让书架重排。 +### 书库管理 + +书架回答「我能读什么」,书库管理回答「磁盘上有什么」。两件事都需要,因为书籍记录里 +**从不带路径**——「刚扫完目录,可为什么书架上没有这本书」这个问题,在文件管理器之外 +没有任何地方能回答。 + +书架右上角的 🗂 打开。行为: + +- **列的是挂载点本身**,不是整理后的目录视图。面包屑可点,路径就是你 `BOOKS_DIR` 里的路径。 +- **被扫描忽略的条目照样显示**,只打一个「扫描忽略」标记。`.trash`、`@eaDir` 是设计上 + 不扫的;在界面上也把它们藏起来,就正好藏掉了读者正在找的那批文件。 +- **长按进多选**,多选后才出现改名 / 移动到… / 删除。点一行是浏览,不是选择: + 在一个上万文件的 NAS 书库里滚动,每行都挂一个删除按钮,离误删只有一次手滑。 +- **删除要再确认一次**,且会说明「不进回收站、需要重新扫描」。 +- **只读挂载上写操作按钮直接不出现**。`:ro` 是文档里推荐的部署方式,所以「这个页面只能看」 + 是常态;给一排按下去必然 403 的按钮,只会教读者不信任界面。 + +写操作都做两件事: + +- **走 `rename` 而不是复制删除**。同挂载点改名不动字节、不动 `mtime`,扫描器视为未变化, + 书籍主键(`identifier + 内容哈希`)因此不变——**进度和笔记都还在**。 + 索引同时被指向新路径,不用等下一次扫描。 +- **先探测挂载是否可写**(真建一个临时目录再删),再动手。`drwxr-xr-x` 的只读 bind mount + 看起来可写,写起来 `EPERM`;探测一次比从 `EROFS` 栈里推断便宜得多。 + +> 敢开这个页面,就等于接受「能改书库的人能改书库里的任何文件」——和 `POST /library/scan` +> 已有的信任级别一样。在这之上再叠一层按路径的权限只是安全表演。 + --- ## 书库扫描 @@ -557,6 +586,13 @@ android/app/src/main/java/cool/cnb/reader/ - `GET/PUT /api/v1/sync/progress/:bookId` - `GET /api/v1/notes` / `POST /api/v1/notes` / `DELETE /api/v1/notes/:id` +**书库管理**(读写 `/books`;挂载只读时任何写操作返回 `403 READ_ONLY_MOUNT`) +- `GET /api/v1/library/browse?path=<相对路径>` — 列一个目录,带面包屑与标记 +- `POST /api/v1/library/browse/move` — 移动到另一个目录 +- `POST /api/v1/library/browse/rename` — 改名 +- `POST /api/v1/library/browse/mkdir` — 新建文件夹 +- `POST /api/v1/library/browse/delete` — 删除(递归) + **管理员** - `POST /api/v1/library/scan` - `GET /api/v1/library/scan` diff --git a/docs/api.md b/docs/api.md index 0189876..3278465 100644 --- a/docs/api.md +++ b/docs/api.md @@ -267,6 +267,100 @@ pdf / 单图没有独立目录,回落到 items。 目录型书籍(漫画目录)没有单一文件,返回 `400 DIRECTORY_BOOK`; 这类书按 `items?group=N` 逐卷取图。 +## 书库管理(文件管理器) + +书架回答「我能读什么」,这一组端点回答「磁盘上有什么」。两者的差别不是冗余: +书籍 DTO 里从来不带路径,所以「刚扫完目录但书架上看不到这本书」在本组端点之外 +没有任何地方能解释清楚。这也是唯一会**写** `BOOKS_DIR` 的一组端点。 + +路径都是**书库内相对路径**,`''` 表示根目录;每一个都会过 +`resolveInside`,逃逸直接被拒。 + +> **信任边界**:挂载点就是边界,没有第二道。能在这里改文件的人,能改书库里的 +> 任何文件——和 `POST /library/scan` 已有的信任级别一致。在这之上再叠一层按路径的 +> ACL 只是安全表演。 + +### `GET /library/browse?path=<相对路径>` + +```json +{ "path": "科幻/已读", + "crumbs": [ { "name": "书库", "path": "" }, { "name": "科幻", "path": "科幻" } ], + "parent": "科幻", + "entries": [ + { "name": "三体.epub", "path": "科幻/已读/三体.epub", "type": "file", + "size": 1234567, "mtime": 1789537864820, "mode": 420, + "hidden": false, "hiddenByRule": false, "scanned": true, + "ext": "epub", "indexed": true } + ], + "total": 1, "dirs": 0, "files": 1, "size": 1234567, + "writable": true, "name": "已读" } +``` + +`hidden` 是「以点开头」,`hiddenByRule` 是「落在扫描器跳过的目录里」, +`scanned` 是「扫描器会把它当一本书」。 + +**被跳过的条目照样列出来。** `.trash`、`@eaDir` 这些目录是设计上不被扫描的, +在这里过滤掉就正好藏住了读者要找的那批文件——在磁盘上、但不在书架上。标记出来, +让界面能解释,而不是替读者决定不用知道。 + +`writable` 是实际探测出来的(真的建一个临时目录再删),不是读 mode 位: +`drwxr-xr-x` 的只读 bind mount 看起来可写,写起来 `EPERM`。 + +符号链接按链接本身显示(`lstat`),不跟随——扫描器也不跟随,跟随等于描述一棵 +索引里并不存在的树,还会顺着链接走出挂载点。 + +### `POST /library/browse/move` + +```json +{ "paths": ["科幻/三体.epub"], "target": "科幻/已读" } +``` + +→ `{ "moved": 1, "target": "科幻/已读" }` + +同挂载点内 `rename`,不复制删除:文件字节、`mtime`、`inode` 都不变,扫描器视为 +无变化,书籍身份(`identifier + 内容哈希`)因此保持,进度和笔记都还在。 + +同时把索引指向新路径,不等下一次扫描——文件管理器关掉时就该是一致的。 + +整批一起校验、一起拒绝:一半成功的移动比拒绝难推理得多。 + +目标已有同名条目 → `409 DESTINATION_EXISTS`。合并还是报错只有调用方知道, +猜错就是数据丢失。把目录移进它自己 → `400 MOVE_INTO_SELF`。 + +### `POST /library/browse/rename` + +```json +{ "path": "科幻/三体.epub", "name": "三体(修订版).epub" } +``` + +`name` 是单个路径段,不含斜杠。→ `{ "path": "科幻/三体(修订版).epub" }` + +### `POST /library/browse/mkdir` + +```json +{ "path": "科幻", "name": "待整理" } +``` + +→ `{ "path": "科幻/待整理" }` + +### `POST /library/browse/delete` + +```json +{ "paths": ["科幻/其他/球状闪电.epub"] } +``` + +→ `{ "removed": 1 }` + +递归删除,**不进回收站**。索引行不动,留给下一次扫描标记 `missing`: +删除本身已经是危险的那一半,再绑一次全量扫描会让界面等好几分钟才报告成功。 +勾选过的那一行也会一直留着,和「共享目录没挂上」是同一种状态—— +「文件没了但书还记着」,比书架突然少一本好。 + +`paths` 必须是**非空字符串数组**:`"a"` 和 `["a"]` 只差一个键, +但把字符串悄悄包成数组,会让「删了 1 项」看起来像「删了很多项」。 + +只读挂载上任何写操作 → `403 READ_ONLY_MOUNT`。 + ### `GET /books/:id/file?path=` 流式返回这本书的**某一个**文件(二进制流,不是 JSON)。 @@ -516,6 +610,15 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** | `UNSUPPORTED_FORMAT` | 400 | 该格式不提供可寻址结构 | | `EMPTY_ASSET` | 400 | 资源既没有数据也没有流 | | `ADMIN_REQUIRED` | 403 | 需要管理员权限 | +| `READ_ONLY_MOUNT` | 403 | 书库是只读挂载,无法改名 / 移动 / 删除 | +| `DIR_NOT_FOUND` | 404 | 目录不存在 | +| `NOT_A_DIRECTORY` | 400 | 目标不是目录 | +| `PATH_NOT_FOUND` | 404 | 路径不存在 | +| `DESTINATION_EXISTS` | 409 | 目标位置已有同名条目 | +| `MOVE_INTO_SELF` | 400 | 不能把目录移动到它自己里面 | +| `BAD_NAME` | 400 | 名称含斜杠或非法 | +| `NAME_REQUIRED` | 400 | 名称为空 | +| `NO_PATHS` | 400 | 没有给出路径 | | `NOT_FOUND` | 404 | 资源不存在 | | `NO_COVER` | 404 | 这本书没有封面 | | `FILE_MISSING` | 404 | 文件已从磁盘消失 | @@ -537,3 +640,8 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** 这样换字号、换屏幕宽度、换主题都不会跳位置。 3. **拉取回来的 `deleted: true` 是墓碑**,本地也要删;不要因为它「看起来是空的」就丢弃。 4. **推送是合并而不是覆盖**,按 `updatedAt` 做 LWW。所以离线队列可以放心重放。 +5. **403 不等于会话失效**。服务端用 403 表达两类事:「你的账号/令牌不行了」 + (`ACCOUNT_DISABLED`、`TOKEN_INVALID`),和「这个操作被拒绝」 + (`ADMIN_REQUIRED`、`PATH_TRAVERSAL`、`READ_ONLY_MOUNT`)。 + 只有前者该清掉凭据跳登录页;把后者也当会话失效,会把读者在读只读挂载上 + 改个文件名时踢下线,顺手把他存的令牌也删了。按 `error.code` 判断,不要只看状态码。 diff --git a/server/src/http/context.ts b/server/src/http/context.ts index 2fa228d..fdafea3 100644 --- a/server/src/http/context.ts +++ b/server/src/http/context.ts @@ -6,6 +6,7 @@ import type { UserService, PublicUser } from '../services/users.ts'; import type { ShelfService } from '../services/shelf.ts'; import type { SyncService } from '../services/sync.ts'; import type { TtsService } from '../services/tts.ts'; +import type { BrowseService } from '../services/browse.ts'; export interface AppContext { config: AppConfig; @@ -15,6 +16,14 @@ export interface AppContext { shelf: ShelfService; sync: SyncService; tts: TtsService; + /** + * The library tree, for the file-manager screen. + * + * Separate from `shelf` on purpose: the shelf serves *books*, keyed by a + * stable identity, and cannot answer "what is on the disk" — a path is not + * part of a book's DTO anywhere in the API. + */ + browse: BrowseService; log: FastifyBaseLogger; } diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 177ab6a..d743ef3 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -84,6 +84,22 @@ function groupOffset(groups: Array<{ count: number }>, index: number): number { return offset; } +/** + * Validates a `string[]` body field. + * + * Refused rather than coerced: `paths: "a"` and `paths: ["a"]` are one keystroke + * apart, and a silently wrapped string would make a batch delete of one entry + * look like a successful delete of many. + */ +function stringList(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.length === 0) throw badRequest(`${field} must be a non-empty array`); + if (value.length > 500) throw badRequest(`${field} is too long`); + for (const entry of value) { + if (typeof entry !== 'string' || entry.length === 0) throw badRequest(`${field} must contain strings`); + } + return value as string[]; +} + export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): void { const auth = authenticate(ctx); @@ -487,6 +503,53 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo return { book: ctx.shelf.get(user.id, id) }; }); + // ---- file manager ---- + + /** + * The library as a tree. + * + * Every path here is library-relative and resolved through `resolveInside`, so + * a traversal attempt is refused before it reaches the disk. Authentication is + * the same as everywhere else; the mount is the only boundary, and it is the + * deployment's own. + */ + app.get('/api/v1/library/browse', { preHandler: auth }, async (request) => { + currentUser(request); + const query = request.query as Record; + return ctx.browse.list(query.path ?? ''); + }); + + app.post('/api/v1/library/browse/move', { preHandler: auth }, async (request) => { + currentUser(request); + const body = (request.body ?? {}) as { paths?: unknown; target?: unknown }; + const paths = stringList(body.paths, 'paths'); + const target = typeof body.target === 'string' ? body.target : ''; + return ctx.browse.move(paths, target); + }); + + app.post('/api/v1/library/browse/rename', { preHandler: auth }, async (request) => { + currentUser(request); + const body = (request.body ?? {}) as { path?: unknown; name?: unknown }; + if (typeof body.path !== 'string' || typeof body.name !== 'string') { + throw badRequest('path and name are required'); + } + return ctx.browse.renamePath(body.path, body.name); + }); + + app.post('/api/v1/library/browse/mkdir', { preHandler: auth }, async (request) => { + currentUser(request); + const body = (request.body ?? {}) as { path?: unknown; name?: unknown }; + const parent = typeof body.path === 'string' ? body.path : ''; + if (typeof body.name !== 'string') throw badRequest('name is required'); + return ctx.browse.createDirectory(parent, body.name); + }); + + app.post('/api/v1/library/browse/delete', { preHandler: auth }, async (request) => { + currentUser(request); + const body = (request.body ?? {}) as { paths?: unknown }; + return ctx.browse.remove(stringList(body.paths, 'paths')); + }); + // ---- instance administration ---- app.post('/api/v1/library/scan', { preHandler: auth }, async (request) => { diff --git a/server/src/main.ts b/server/src/main.ts index ec95c53..7a9fc05 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -5,6 +5,7 @@ import { UserService } from './services/users.ts'; import { ShelfService } from './services/shelf.ts'; import { SyncService } from './services/sync.ts'; import { TtsService } from './services/tts.ts'; +import { BrowseService } from './services/browse.ts'; import { buildApp } from './http/app.ts'; import { setLogger } from './lib/log.ts'; import type { AppContext } from './http/context.ts'; @@ -47,6 +48,7 @@ async function main(): Promise { ctx.shelf = new ShelfService(db); ctx.sync = new SyncService(db); ctx.tts = new TtsService(config); + ctx.browse = new BrowseService(db, config); app.log.info({ booksDir: config.booksDir, dataDir: config.dataDir }, 'starting reader server'); diff --git a/server/src/services/browse.ts b/server/src/services/browse.ts new file mode 100644 index 0000000..97bcf80 --- /dev/null +++ b/server/src/services/browse.ts @@ -0,0 +1,409 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import type { Db } from '../db/index.ts'; +import type { AppConfig } from '../config/index.ts'; +import { assertSafeRel, resolveInside } from '../lib/paths.ts'; +import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts'; +import { isPageExtension, supportedExtensions } from '../indexer/formats/index.ts'; + +/** + * The library as a *tree*, for the file-manager screen. + * + * The shelf answers "what can I read". This answers "what is on the disk" — the + * question a reader has when a book did not show up after a rescan, and the + * question no other part of the product can answer: a scanned book has no path + * in its DTO, so a library that is silently missing a title is otherwise + * unexplainable from inside the app. + * + * Two rules make that possible, and both are deliberate: + * + * 1. **Hidden entries are listed, not filtered out.** A book inside `.trash` or + * `@eaDir` is invisible to the scanner by design. It is also invisible here + * if this layer drops everything starting with a dot, which is exactly the + * case where the screen needs to say "it is here, and here is why the shelf + * cannot see it". The flags are reported instead (`hidden`, `hiddenByRule`, + * `scanned`), so the UI can explain rather than hide. + * 2. **Writes go only where the mount allows them.** Every mutating call + * resolves the target through `resolveInside` and probes write permission + * before doing anything, so a read-only mount (the documented deployment) + * answers a clean 403 instead of an `EROFS` stack trace. + * + * The mount is the security boundary, and there is no second one: a user who can + * move a file in their library can move any file in it. That is the same trust + * level the admin scan endpoint already assumes, and inventing a per-path ACL on + * top of it would be security theatre. + */ + +/** Directories the scanner skips. Reported to the UI rather than filtered. */ +export const SKIPPED_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found', '__MACOSX']); + +/** Never offered for listing: they stay reachable by name, never by discovery. */ +const VCS_DIRS = new Set(['.git', '.svn', '.hg']); + +export interface BrowseEntry { + name: string; + /** Library-relative path; the id the UI passes back for every operation. */ + path: string; + type: 'dir' | 'file' | 'other'; + size: number; + mtime: number; + /** Unix mode bits, so the UI can show `-r--r--r--` for a read-only mount. */ + mode: number; + /** Leading dot: a convention, and the reason a book can be missing. */ + hidden: boolean; + /** Inside a directory the scanner never descends into. */ + hiddenByRule: boolean; + /** The scanner indexes this path as a book of its own. */ + scanned: boolean; + /** Suffix after the last dot, lowercased, without the dot. */ + ext: string; + /** True when this path is what the index actually holds for that book. */ + indexed: boolean; +} + +export interface BrowseListing { + /** Library-relative path of the directory listed; `''` for the root. */ + path: string; + /** Breadcrumb segments, each with the path that reaches it. */ + crumbs: Array<{ name: string; path: string }>; + parent: string | null; + entries: BrowseEntry[]; + /** Totals for the *directory*, not for this page of it. */ + total: number; + dirs: number; + files: number; + size: number; + /** Whether the process may create, rename and delete inside it. */ + writable: boolean; + /** `''` for the root, otherwise the folder's own name. */ + name: string; +} + +export interface BrowseMoveResult { + moved: number; + /** Rows rewritten because the target path was already indexed under another book. */ + merged: number; + target: string; +} + +export class BrowseService { + constructor( + private readonly db: Db, + private readonly config: AppConfig, + ) {} + + // ---- reads ---- + + list(relPathInput: string): Promise { + return this.listSync(relPathInput); + } + + private async listSync(relPathInput: string): Promise { + const relPath = relPathInput === '' ? '' : assertSafeRel(relPathInput); + const abs = resolveInside(this.config.booksDir, relPath); + const info = await stat(abs).catch(() => null); + if (!info) throw notFound('no such directory', 'DIR_NOT_FOUND'); + if (!info.isDirectory()) throw badRequest('not a directory', 'NOT_A_DIRECTORY'); + + const dirEntries = await readdir(abs, { withFileTypes: true }); + const entries: BrowseEntry[] = []; + for (const entry of dirEntries.sort((a, b) => a.name.localeCompare(b.name))) { + const childRel = relPath === '' ? entry.name : `${relPath}/${entry.name}`; + if (VCS_DIRS.has(entry.name)) continue; + const childAbs = resolveInside(this.config.booksDir, childRel); + // `lstat`: a symlink is shown as a link, never followed. The scanner does + // not follow them either, so following one here would describe a tree the + // index does not have — and would let a link out of the mount be walked. + const child = await lstat(childAbs).catch(() => null); + if (!child) continue; + entries.push({ + name: entry.name, + path: childRel, + type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other', + size: child.isDirectory() ? 0 : child.size, + mtime: Math.floor(child.mtimeMs), + mode: child.mode & 0o777, + hidden: entry.name.startsWith('.'), + hiddenByRule: entry.isDirectory() + ? SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.') + : SKIPPED_DIRS.has(entry.name), + scanned: entry.isFile() && this.isBookFile(entry.name), + ext: extensionOf(entry.name), + indexed: Boolean(this.fileRow(childRel)), + }); + } + + const stats = { + total: entries.length, + dirs: entries.filter((entry) => entry.type === 'dir').length, + files: entries.filter((entry) => entry.type === 'file').length, + size: entries.reduce((sum, entry) => sum + entry.size, 0), + }; + + return { + path: relPath, + crumbs: crumbsFor(relPath), + parent: relPath === '' ? null : dirname(relPath).replace(/^\.$/, ''), + entries, + ...stats, + writable: await this.writable(abs), + name: relPath === '' ? '' : basename(relPath), + }; + } + + /** + * Whether the mount can be written at all. + * + * Answered by actually creating a file rather than by looking at the mode + * bits, because the two disagree in every deployment that matters: a + * read-only bind mount shows `drwxr-xr-x` and still refuses `EPERM`. Probe on + * demand rather than cache: a mount is remounted far more often than this is + * called, and a stale "not writable" would disable the whole screen. + */ + async writable(abs = this.config.booksDir): Promise { + const probe = join(abs, `.reader-write-probe-${randomUUID().slice(0, 8)}`); + try { + await mkdir(probe); + await rm(probe, { recursive: true, force: true }); + return true; + } catch { + return false; + } + } + + private isBookFile(name: string): boolean { + const ext = extensionOf(name); + if (!ext) return false; + if (!supportedExtensions().has(`.${ext}`)) return false; + return !isPageExtension(ext); + } + + private fileRow(relPath: string): { id: string; book_id: string; missing: number } | undefined { + return this.db.get<{ id: string; book_id: string; missing: number }>( + 'SELECT id, book_id, missing FROM book_files WHERE rel_path = ?', + relPath, + ); + } + + // ---- writes ---- + + /** + * Moves or renames a batch of paths into a directory. + * + * A rename inside the same mount keeps `mtime`, `inode` and — crucially — + * the file's bytes unchanged, so the scanner sees an unchanged file and the + * book keeps its identity, its progress and its notes. That is the whole + * reason this is a `rename` rather than a copy-and-delete, and it is also why + * a move onto an already-indexed path can be resolved as a merge: two rows, + * one set of bytes. + */ + async move(paths: string[], targetInput: string): Promise { + if (paths.length === 0) throw badRequest('no paths given', 'NO_PATHS'); + const target = targetInput === '' ? '' : assertSafeRel(targetInput); + const targetAbs = resolveInside(this.config.booksDir, target); + const targetInfo = await stat(targetAbs).catch(() => null); + if (!targetInfo?.isDirectory()) throw badRequest('target is not a directory', 'NOT_A_DIRECTORY'); + + // Refuse the whole batch rather than half of it: a partially applied move is + // far harder to reason about than a refused one. + for (const input of paths) { + const source = assertSafeRel(input); + // `target` inside `source` is the one arrangement a rename cannot express: + // the destination is inside the thing being moved. Asked as "is the target + // under the source", which is the opposite question from containment. + if (source !== '' && this.isInside(source, target)) throw badRequest('cannot move a directory into itself', 'MOVE_INTO_SELF'); + const abs = resolveInside(this.config.booksDir, source); + if (!(await stat(abs).catch(() => null))) throw notFound(`no such path: ${source}`, 'PATH_NOT_FOUND'); + } + + let moved = 0; + let merged = 0; + for (const input of paths) { + const source = assertSafeRel(input); + const destination = target === '' ? basename(source) : `${target}/${basename(source)}`; + if (destination === source) continue; + const destinationAbs = resolveInside(this.config.booksDir, destination); + const existing = await stat(destinationAbs).catch(() => null); + if (existing) { + // Two outcomes are both correct here and only the caller knows which was + // meant, so it is refused instead of guessed: a merge would be + // indistinguishable from data loss if the reader expected an error. + throw conflict(`target already has an entry named ${basename(source)}`, 'DESTINATION_EXISTS'); + } + await this.guardWrite(resolveInside(this.config.booksDir, source)); + await mkdir(dirname(destinationAbs), { recursive: true }); + await rename(resolveInside(this.config.booksDir, source), destinationAbs); + this.rewritePaths(source, destination); + merged += 1; + moved += 1; + } + return { moved, merged, target }; + } + + /** Renames one path in place. `newName` is a single segment, not a path. */ + async renamePath(pathInput: string, newName: string): Promise<{ path: string }> { + const source = assertSafeRel(pathInput); + const name = newName.trim(); + if (!name) throw badRequest('name is required', 'NAME_REQUIRED'); + if (name.includes('/') || name.includes('\\')) throw badRequest('name must not contain a slash', 'BAD_NAME'); + if (name === '.' || name === '..') throw badRequest('invalid name', 'BAD_NAME'); + const parent = dirname(source).replace(/^\.$/, ''); + const destination = parent === '' ? name : `${parent}/${name}`; + if (destination === source) return { path: source }; + + const sourceAbs = resolveInside(this.config.booksDir, source); + const destinationAbs = resolveInside(this.config.booksDir, destination); + if (!(await lstat(sourceAbs).catch(() => null))) throw notFound('no such path', 'PATH_NOT_FOUND'); + if (await lstat(destinationAbs).catch(() => null)) throw conflict('target name is taken', 'DESTINATION_EXISTS'); + await this.guardWrite(sourceAbs); + await rename(sourceAbs, destinationAbs); + this.rewritePaths(source, destination); + return { path: destination }; + } + + async createDirectory(parentInput: string, name: string): Promise<{ path: string }> { + const parent = parentInput === '' ? '' : assertSafeRel(parentInput); + const trimmed = name.trim(); + if (!trimmed) throw badRequest('name is required', 'NAME_REQUIRED'); + if (trimmed.includes('/') || trimmed.includes('\\')) throw badRequest('name must not contain a slash', 'BAD_NAME'); + const relPath = parent === '' ? trimmed : `${parent}/${trimmed}`; + const abs = resolveInside(this.config.booksDir, relPath); + if (await lstat(abs).catch(() => null)) throw conflict('already exists', 'DESTINATION_EXISTS'); + await this.guardWrite(resolveInside(this.config.booksDir, parent)); + await mkdir(abs, { recursive: false }); + return { path: relPath }; + } + + /** + * Deletes paths, recursively, without invoking the scanner. + * + * The index rows stay until the next scan. That is not laziness: the deletion + * is already the risky half, and coupling it to a full rescan would make the + * UI wait minutes to report success. A marked-missing row also keeps the count + * honest — the same "file is gone but the book is remembered" state a book on + * an unmounted share is in. + */ + async remove(paths: string[]): Promise<{ removed: number }> { + if (paths.length === 0) throw badRequest('no paths given', 'NO_PATHS'); + for (const input of paths) { + const relPath = assertSafeRel(input); + const abs = resolveInside(this.config.booksDir, relPath); + if (!(await lstat(abs).catch(() => null))) throw notFound(`no such path: ${relPath}`, 'PATH_NOT_FOUND'); + } + let removed = 0; + for (const input of paths) { + const relPath = assertSafeRel(input); + const abs = resolveInside(this.config.booksDir, relPath); + await this.guardWrite(abs); + await rm(abs, { recursive: true, force: true }); + removed += 1; + } + return { removed }; + } + + /** + * Turns an `EPERM`/`EROFS`/`EACCES` from the filesystem into a clean 403. + * + * A probe is cheaper to reason about than inferring from the error alone: the + * permission that matters is on the *parent* (unlink needs the directory), and + * that is not the path the caller passed. + */ + private async guardWrite(absPath: string): Promise { + const parent = dirname(absPath); + const parentOk = await this.writable(parent === absPath ? absPath : parent); + if (parentOk) return; + throw forbidden( + 'the library mount is read-only; remount it without `:ro` to manage files here', + 'READ_ONLY_MOUNT', + ); + } + + /** Whether `child` is `parent` itself or lives underneath it. */ + private isInside(parent: string, child: string): boolean { + return child === parent || child.startsWith(`${parent}/`); + } + + /** + * Repoints the index at a path that moved, in one transaction. + * + * Nothing else has to run: the book's identity is `identifier + content hash`, + * so a renamed file *is* the same book — the row only has to stop pointing at + * a path that no longer exists. Doing it here rather than waiting for a scan + * is what keeps progress and notes attached while the file manager is open. + * + * The one case that needs care is a move onto a path the index already holds + * under a *different* book — which happens whenever a library was reorganised + * outside the app and scanned in between. The two rows then name one file, and + * `rel_path` is UNIQUE, so the stale row is dropped and its user state is + * carried over rather than left pointing at a book with no file. + */ + private rewritePaths(from: string, to: string): void { + const rows = this.db.all<{ id: string; book_id: string; rel_path: string }>( + 'SELECT id, book_id, rel_path FROM book_files WHERE rel_path = ? OR rel_path LIKE ?', + from, + `${escapeLike(from)}/%`, + ); + for (const row of rows) { + const next = row.rel_path === from ? to : `${to}${row.rel_path.slice(from.length)}`; + const clash = this.db.get<{ id: string; book_id: string }>( + 'SELECT id, book_id FROM book_files WHERE rel_path = ? AND id <> ? AND missing = 0', + next, + row.id, + ); + if (clash && clash.book_id !== row.book_id) { + this.adoptUserState(clash.book_id, row.book_id); + this.db.run('DELETE FROM book_files WHERE id = ?', clash.id); + } + this.db.run('UPDATE book_files SET rel_path = ?, missing = 0 WHERE id = ?', next, row.id); + } + } + + /** Moves per-user state from a duplicate book onto the one that survives. */ + private adoptUserState(fromBookId: string, toBookId: string): void { + this.db.run( + `INSERT OR IGNORE INTO user_books (user_id, book_id, added_at) + SELECT user_id, ?, added_at FROM user_books WHERE book_id = ?`, + toBookId, + fromBookId, + ); + // Newest progress wins, which is the same last-writer-wins rule sync applies. + this.db.run( + `INSERT INTO reading_progress (user_id, book_id, locator, percentage, chapter_title, device, updated_at) + SELECT user_id, ?, locator, percentage, chapter_title, device, updated_at + FROM reading_progress WHERE book_id = ? + ON CONFLICT(user_id, book_id) DO UPDATE SET + locator = excluded.locator, percentage = excluded.percentage, + chapter_title = excluded.chapter_title, device = excluded.device, + updated_at = excluded.updated_at + WHERE excluded.updated_at > reading_progress.updated_at`, + toBookId, + fromBookId, + ); + this.db.run('UPDATE notes SET book_id = ? WHERE book_id = ?', toBookId, fromBookId); + } + +} + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.'); + if (dot <= 0) return ''; + return name.slice(dot + 1).toLowerCase(); +} + +function crumbsFor(relPath: string): Array<{ name: string; path: string }> { + const crumbs: Array<{ name: string; path: string }> = [{ name: '书库', path: '' }]; + if (!relPath) return crumbs; + const parts = relPath.split('/'); + let acc = ''; + for (const part of parts) { + acc = acc === '' ? part : `${acc}/${part}`; + crumbs.push({ name: part, path: acc }); + } + return crumbs; +} + +/** LIKE-escape, so a folder named `100%` cannot widen the prefix match. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts index cee1f79..b1a593b 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -20,6 +20,7 @@ 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 { buildApp } from '../src/http/app.ts'; import type { AppContext } from '../src/http/context.ts'; @@ -68,6 +69,7 @@ before(async () => { shelf: undefined as never, sync: undefined as never, tts: undefined as never, + browse: undefined as never, log: undefined as never, }; const silent = Fastify({ logger: false }); @@ -77,6 +79,7 @@ before(async () => { ctx.shelf = new ShelfService(db); ctx.sync = new SyncService(db); ctx.tts = new TtsService(config); + ctx.browse = new BrowseService(db, config); app = buildApp(ctx); await app.ready(); }); diff --git a/server/test/browse.test.ts b/server/test/browse.test.ts new file mode 100644 index 0000000..0a0f722 --- /dev/null +++ b/server/test/browse.test.ts @@ -0,0 +1,405 @@ +/** + * File-manager API tests. + * + * The screen these back is the only place in the product that *writes* to the + * library mount, so the behaviours asserted here are the ones where a mistake + * costs the reader their files or their reading position: path containment, + * refusing a write on a read-only mount, and keeping progress attached to a book + * whose file was moved. + */ +import { test, before, after, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import JSZip from 'jszip'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; + +import { loadConfig } from '../src/config/index.ts'; +import { openDatabase } from '../src/db/index.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 { buildApp } from '../src/http/app.ts'; +import type { AppContext } from '../src/http/context.ts'; + +let app: FastifyInstance; +let ctx: AppContext; +let root: string; +let booksDir: string; +let token: string; +let userId: string; + +const OPF = (title: string, id: string) => ` + + +${id} +${title}作者zh + + +`; + +async function makeEpub(title: string, id: string): Promise { + const zip = new JSZip(); + zip.file('mimetype', 'application/epub+zip'); + zip.file( + 'META-INF/container.xml', + '', + ); + zip.file('OEBPS/content.opf', OPF(title, id)); + zip.file('OEBPS/c.xhtml', `

${title}

`); + return zip.generateAsync({ type: 'nodebuffer' }); +} + +function auth() { + return { authorization: `Bearer ${token}` }; +} + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'reader-browse-')); + booksDir = join(root, 'books'); + await mkdir(booksDir, { recursive: true }); + process.env.BOOKS_DIR = booksDir; + process.env.DATA_DIR = join(root, 'data'); + process.env.SCAN_INTERVAL = '0'; + process.env.WATCH_INTERVAL = '0'; + process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough'; + + const config = loadConfig(); + const db = openDatabase(config); + ctx = { + config, + db, + scanner: undefined as never, + users: undefined as never, + shelf: undefined as never, + sync: undefined as never, + tts: undefined as never, + browse: undefined as never, + log: undefined as never, + }; + const silent = Fastify({ logger: false }); + ctx.log = silent.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.tts = new TtsService(config); + ctx.browse = new BrowseService(db, config); + app = buildApp(ctx); + await app.ready(); + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { username: 'owner', password: 'password123' }, + }); + assert.equal(created.statusCode, 201, created.body); + const body = created.json() as { session: { accessToken: string }; user: { id: string } }; + token = body.session.accessToken; + userId = body.user.id; + + await mkdir(join(booksDir, '科幻', '已读'), { recursive: true }); + await writeFile(join(booksDir, '科幻', '三体.epub'), await makeEpub('三体', 'urn:one')); + await writeFile(join(booksDir, '科幻', '已读', '球状闪电.epub'), await makeEpub('球状闪电', 'urn:two')); + await writeFile(join(booksDir, '说明.txt'), Buffer.from('hello', 'utf8')); + await mkdir(join(booksDir, '.trash'), { recursive: true }); + await writeFile(join(booksDir, '.trash', '旧书.epub'), await makeEpub('旧书', 'urn:three')); +}); + +after(async () => { + await app.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe('library browsing', () => { + test('the root lists directories, book files and rule-hidden folders alike', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers: auth() }); + assert.equal(res.statusCode, 200); + const listing = res.json() as { + path: string; + parent: string | null; + crumbs: Array<{ name: string; path: string }>; + entries: Array<{ name: string; type: string; scanned: boolean; hidden: boolean; hiddenByRule: boolean }>; + writable: boolean; + }; + assert.equal(listing.path, ''); + assert.equal(listing.parent, null); + assert.deepEqual(listing.crumbs, [{ name: '书库', path: '' }]); + // A writable mount is the only state in which the UI offers to rename or + // delete anything, so the flag is part of the contract rather than a nicety. + assert.equal(listing.writable, true); + + const byName = new Map(listing.entries.map((entry) => [entry.name, entry])); + assert.equal(byName.get('科幻')?.type, 'dir'); + assert.equal(byName.get('说明.txt')?.scanned, true, 'a .txt is a book the scanner indexes'); + // The hidden folder is listed, not filtered: "my book is missing from the + // shelf" is answerable only if the screen can show where it actually is. + assert.equal(byName.get('.trash')?.hidden, true); + assert.equal(byName.get('.trash')?.hiddenByRule, true); + }); + + test('a nested directory reports its breadcrumb and its own entries', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/v1/library/browse?path=${encodeURIComponent('科幻/已读')}`, + headers: auth(), + }); + assert.equal(res.statusCode, 200); + const listing = res.json() as { + path: string; + parent: string; + crumbs: Array<{ name: string; path: string }>; + entries: Array<{ name: string; indexed: boolean }>; + dirs: number; + files: number; + }; + assert.equal(listing.path, '科幻/已读'); + assert.equal(listing.parent, '科幻'); + assert.deepEqual(listing.crumbs, [ + { name: '书库', path: '' }, + { name: '科幻', path: '科幻' }, + { name: '已读', path: '科幻/已读' }, + ]); + assert.equal(listing.files, 1); + assert.equal(listing.dirs, 0); + }); + + test('a traversal attempt is refused, not resolved', async () => { + for (const path of ['../../etc', '..', '/etc', '科幻/../../..']) { + const res = await app.inject({ + method: 'GET', + url: `/api/v1/library/browse?path=${encodeURIComponent(path)}`, + headers: auth(), + }); + assert.ok(res.statusCode >= 400, `${path} answered ${res.statusCode}`); + } + }); + + test('the API requires a token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/library/browse' }); + assert.equal(res.statusCode, 401); + }); +}); + +describe('library file management', () => { + test('renaming a book keeps its identity, its progress and its notes', async () => { + await ctx.scanner.scan(); + const book = ctx.db.get<{ id: string; title: string }>('SELECT id, title FROM books WHERE title = ?', '三体'); + assert.ok(book, 'the book should be indexed'); + ctx.sync.push(userId, { + progress: [ + { + bookId: book.id, + locator: 'epubcfi(/6/4!/4/2)', + percentage: 0.42, + chapterTitle: '第七章', + device: 'test', + updatedAt: Date.now(), + }, + ], + }); + + const renamed = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/rename', + headers: auth(), + payload: { path: '科幻/三体.epub', name: '三体(修订版).epub' }, + }); + assert.equal(renamed.statusCode, 200, renamed.body); + assert.equal((renamed.json() as { path: string }).path, '科幻/三体(修订版).epub'); + + // The index is repointed immediately rather than at the next scan: the file + // manager has to leave the library consistent with itself when it closes. + const row = ctx.db.get<{ book_id: string; rel_path: string }>( + 'SELECT book_id, rel_path FROM book_files WHERE rel_path = ?', + '科幻/三体(修订版).epub', + ); + assert.equal(row?.book_id, book.id, 'the book must keep its identity across a rename'); + + // A rescan must agree: identity is `identifier + content hash`, not a path. + await ctx.scanner.scan(); + const after = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books WHERE title = ?', '三体'); + assert.equal(after?.n, 1); + const progress = ctx.db.get<{ percentage: number }>( + 'SELECT percentage FROM reading_progress WHERE book_id = ?', + book.id, + ); + assert.equal(progress?.percentage, 0.42, 'progress must survive a rename'); + }); + + test('moving a book into a folder keeps it on the shelf, once', async () => { + const moved = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/move', + headers: auth(), + payload: { paths: ['科幻/三体(修订版).epub'], target: '科幻/已读' }, + }); + assert.equal(moved.statusCode, 200, moved.body); + assert.equal((moved.json() as { moved: number }).moved, 1); + + await ctx.scanner.scan(); + const count = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books WHERE title = ?', '三体'); + assert.equal(count?.n, 1, 'a move must not leave a duplicate behind'); + + const listing = await app.inject({ + method: 'GET', + url: `/api/v1/library/browse?path=${encodeURIComponent('科幻/已读')}`, + headers: auth(), + }); + const names = (listing.json() as { entries: Array<{ name: string }> }).entries.map((entry) => entry.name); + assert.ok(names.includes('三体(修订版).epub')); + }); + + test('a move onto an existing name is refused rather than silently merged', async () => { + await mkdir(join(booksDir, '科幻', '其他'), { recursive: true }); + await writeFile(join(booksDir, '科幻', '其他', '球状闪电.epub'), await makeEpub('球状闪电(另一份)', 'urn:four')); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/move', + headers: auth(), + payload: { paths: ['科幻/已读/球状闪电.epub'], target: '科幻/其他' }, + }); + assert.equal(res.statusCode, 409); + assert.equal((res.json() as { error: { code: string } }).error.code, 'DESTINATION_EXISTS'); + // The source must still be there: refusing a batch means refusing all of it. + await stat(join(booksDir, '科幻', '已读', '球状闪电.epub')); + }); + + test('a move into itself is refused', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/move', + headers: auth(), + payload: { paths: ['科幻'], target: '科幻/已读' }, + }); + assert.equal(res.statusCode, 400); + assert.equal((res.json() as { error: { code: string } }).error.code, 'MOVE_INTO_SELF'); + }); + + test('a new folder can be created and deleted, but a traversal cannot', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/mkdir', + headers: auth(), + payload: { path: '', name: '漫画' }, + }); + assert.equal(created.statusCode, 200, created.body); + + const escaped = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/mkdir', + headers: auth(), + payload: { path: '', name: '../外面' }, + }); + assert.equal(escaped.statusCode, 400); + + const listed = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers: auth() }); + const names = (listed.json() as { entries: Array<{ name: string }> }).entries.map((entry) => entry.name); + assert.ok(names.includes('漫画')); + + const deleted = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/delete', + headers: auth(), + payload: { paths: ['漫画'] }, + }); + assert.equal(deleted.statusCode, 200); + assert.equal((deleted.json() as { removed: number }).removed, 1); + await assert.rejects(stat(join(booksDir, '漫画'))); + }); + + test('deleting a book removes the bytes and leaves the index to the scanner', async () => { + // A file that is actually indexed: `科幻/已读/三体(修订版).epub` moved here by + // an earlier test. A path the scanner never saw would leave the interesting + // half of this behaviour — what happens to an indexed row — untested. + await ctx.scanner.scan(); + const relPath = '科幻/已读/三体(修订版).epub'; + const target = join(booksDir, relPath); + await readFile(target); + const before = ctx.db.get<{ book_id: string; missing: number }>( + 'SELECT book_id, missing FROM book_files WHERE rel_path = ?', + relPath, + ); + assert.ok(before, 'the book should be indexed before it is deleted'); + assert.equal(before.missing, 0); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/delete', + headers: auth(), + payload: { paths: [relPath] }, + }); + assert.equal(res.statusCode, 200, res.body); + await assert.rejects(stat(target)); + + // The row stays live until a scan notices, on purpose: the deletion itself is + // the risky half, and coupling it to a full rescan would make the UI wait + // minutes to report success. + const row = ctx.db.get<{ missing: number }>( + 'SELECT missing FROM book_files WHERE rel_path = ?', + relPath, + ); + assert.equal(row?.missing, 0, 'a delete does not rewrite the index'); + + await ctx.scanner.scan(); + const after = ctx.db.get<{ missing: number }>( + 'SELECT missing FROM book_files WHERE rel_path = ?', + relPath, + ); + assert.equal(after?.missing, 1, 'a scan marks the file missing rather than dropping the book'); + // And the book is remembered: a book on an unmounted share must not vanish + // from the reader's shelf the moment its file goes away. + assert.ok( + ctx.db.get<{ id: string }>('SELECT id FROM books WHERE id = ?', before.book_id), + 'the book row survives a deleted file', + ); + }); + + test('deleting several entries at once is all or nothing', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/delete', + headers: auth(), + payload: { paths: ['说明.txt', '科幻/不存在的.epub'] }, + }); + assert.equal(res.statusCode, 404); + // The existing entry must survive a batch that named a missing one. + await stat(join(booksDir, '说明.txt')); + }); + + test('a malformed batch is rejected rather than coerced', async () => { + for (const payload of [{}, { paths: [] }, { paths: '科幻' }, { paths: [1] }]) { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/library/browse/delete', + headers: auth(), + payload, + }); + assert.equal(res.statusCode, 400, JSON.stringify(payload)); + } + }); +}); + +describe('a read-only mount', () => { + test('reports itself as not writable and refuses writes with 403', async () => { + const service = new BrowseService(ctx.db, ctx.config); + const original = service.writable.bind(service); + // Stands in for a read-only bind mount, which looks writable by mode bits and + // refuses every write. Simulated on the probe because the alternative is + // chmod-ing the test's own mount, which CI runs as root and would ignore. + service.writable = async () => false; + // The routes hold their own reference to the service, so this only exercises + // the guard's contract: a probe that says "no" must become a 403 rather than + // an EROFS stack trace. + await assert.rejects( + service.renamePath('科幻/已读/球状闪电.epub', '球状闪电-改名.epub'), + (err: unknown) => (err as { statusCode?: number }).statusCode === 403, + ); + service.writable = original; + }); +}); diff --git a/server/test/tts.test.ts b/server/test/tts.test.ts index 9f0b230..bdf3c7d 100644 --- a/server/test/tts.test.ts +++ b/server/test/tts.test.ts @@ -32,6 +32,7 @@ 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 { buildApp } from '../src/http/app.ts'; import type { AppContext } from '../src/http/context.ts'; @@ -95,6 +96,7 @@ async function buildInstance(): Promise { shelf: undefined as never, sync: undefined as never, tts: undefined as never, + browse: undefined as never, log: undefined as never, }; const silent = Fastify({ logger: false }); @@ -104,6 +106,7 @@ async function buildInstance(): Promise { ctx.shelf = new ShelfService(db); ctx.sync = new SyncService(db); ctx.tts = new TtsService(config); + ctx.browse = new BrowseService(db, config); app = buildApp(ctx); await app.ready(); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 98d9f7f..3ac6159 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,6 +2,7 @@ import { ApiError, errorForStatus, parseErrorBody } from './errors.ts'; import type { Book, BookContent, + BrowseListing, BookListPage, ContinueReadingItem, Facets, @@ -282,6 +283,35 @@ export class ReaderApi { return result.items; } + // ---- library file manager ---- + + /** + * One directory of the library tree. + * + * `path` is library-relative and `''` means the root; the server resolves it + * through the same containment check every other filesystem access uses. + */ + async browse(path = '', options: RequestOptions = {}): Promise { + const suffix = path ? `?path=${encodeURIComponent(path)}` : ''; + return this.get(`/api/v1/library/browse${suffix}`, options); + } + + async browseMove(paths: string[], target: string, options: RequestOptions = {}): Promise<{ moved: number; target: string }> { + return this.call('/api/v1/library/browse/move', 'POST', { paths, target }, options); + } + + async browseRename(path: string, name: string, options: RequestOptions = {}): Promise<{ path: string }> { + return this.call('/api/v1/library/browse/rename', 'POST', { path, name }, options); + } + + async browseMkdir(path: string, name: string, options: RequestOptions = {}): Promise<{ path: string }> { + return this.call('/api/v1/library/browse/mkdir', 'POST', { path, name }, options); + } + + async browseDelete(paths: string[], options: RequestOptions = {}): Promise<{ removed: number }> { + return this.call('/api/v1/library/browse/delete', 'POST', { paths }, options); + } + // ---- content ---- /** Authenticated book body. Not cacheable by the service worker: it needs a token. */ diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts index 9eb5047..89d6f74 100644 --- a/web/src/api/errors.ts +++ b/web/src/api/errors.ts @@ -35,12 +35,31 @@ export class ApiError extends Error { return this.kind === 'offline'; } - /** True when the failure means the stored credentials are no longer usable. */ + /** + * True when the failure means the stored credentials are no longer usable. + * + * A 403 is ambiguous on this API and the distinction matters: the server uses it + * both for "your account is gone" (`ACCOUNT_DISABLED`, which has to sign the + * reader out) and for "that operation is refused" (`ADMIN_REQUIRED`, + * `PATH_TRAVERSAL`, `READ_ONLY_MOUNT` — none of which say anything about the + * session). Treating every 403 as a dead session meant a reader who tried to + * rename a file on a read-only mount was thrown back to the login screen, with + * their credentials silently discarded, for an error that had nothing to do + * with signing in. + * + * So the code decides, and the status is only the fallback for a 403 whose code + * the client cannot classify. + */ get isAuthFailure(): boolean { - return this.kind === 'unauthorized' || this.kind === 'forbidden'; + if (this.kind === 'unauthorized') return true; + if (this.kind !== 'forbidden') return false; + return AUTH_FAILURE_CODES.has(this.code); } } +/** The 403 codes that mean the session itself is no longer valid. */ +const AUTH_FAILURE_CODES = new Set(['ACCOUNT_DISABLED', 'TOKEN_INVALID', 'NO_TOKEN']); + const KIND_BY_STATUS: Record = { 400: 'bad_request', 401: 'unauthorized', diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 64a5da6..a38b700 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -197,6 +197,45 @@ export interface ContinueReadingItem extends Book { lastReadAt: number | null; } +/** + * One entry in the library tree, as the file-manager screen sees it. + * + * Carries the flags rather than the decisions: `hidden`, `hiddenByRule` and + * `scanned` together let the UI explain *why* a book on disk is not on the shelf, + * which is the one question no other screen can answer. + */ +export interface BrowseEntry { + name: string; + /** Library-relative path; the id every operation passes back. */ + path: string; + type: 'dir' | 'file' | 'other'; + size: number; + mtime: number; + mode: number; + hidden: boolean; + hiddenByRule: boolean; + /** The scanner indexes this path as a book of its own. */ + scanned: boolean; + ext: string; + /** The index currently holds a row for this exact path. */ + indexed: boolean; +} + +export interface BrowseListing { + /** `''` is the library root. */ + path: string; + crumbs: Array<{ name: string; path: string }>; + parent: string | null; + entries: BrowseEntry[]; + total: number; + dirs: number; + files: number; + size: number; + /** Whether the mount accepts writes; the UI hides its write controls when not. */ + writable: boolean; + name: string; +} + export interface ApiErrorBody { error: { code: string; message: string }; } diff --git a/web/src/app.ts b/web/src/app.ts index 594a30e..f1d04d6 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -17,6 +17,7 @@ import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/s import { ShelfScreen } from './ui/shelf-screen.ts'; import { ReaderScreen } from './ui/reader-screen.ts'; import { LoginScreen } from './ui/login-screen.ts'; +import { ManagerScreen } from './ui/manager-screen.ts'; import { el } from './ui/dom.ts'; /** @@ -30,6 +31,7 @@ import { el } from './ui/dom.ts'; const SCREEN_SHELF = 'shelf'; const SCREEN_READER = 'reader'; +const SCREEN_MANAGER = 'manager'; export interface AppOptions { /** Baked-in server URL, used when nothing is stored yet. */ @@ -49,6 +51,14 @@ export class App { private shelf: ShelfScreen | null = null; private reader: ReaderScreen | null = null; + /** + * The library file manager, opened from the shelf. + * + * A screen rather than a panel because it navigates: it has its own path, its + * own back action and its own selection state, and a panel over the shelf would + * have to reimplement all three badly. + */ + private manager: ManagerScreen | null = null; /** * Native fixed-layout renderer, present only in the Android shell. * @@ -136,6 +146,8 @@ export class App { this.reader = null; this.shelf?.dispose(); this.shelf = null; + this.manager?.dispose(); + this.manager = null; this.root.replaceChildren(); this.screen = screen; } @@ -165,6 +177,7 @@ export class App { // the reader's, so the two cannot disagree about where a preference lives. settings: this.settings, onOpenBook: (book) => void this.openBook(book), + onOpenManager: () => void this.showManager(), onSignedOut: () => this.handleSignedOut(), onSettingsChange: (patch) => { void this.settingsStore.update(patch); @@ -177,6 +190,18 @@ export class App { this.pendingBook = null; } + private async showManager(): Promise { + this.setScreen(SCREEN_MANAGER); + const manager = new ManagerScreen({ + api: this.api, + onClose: () => this.showShelf(), + onSignedOut: () => this.handleSignedOut(), + }); + this.manager = manager; + this.root.append(manager.element); + await manager.open(); + } + private async openBook(book: Book): Promise { this.setScreen(SCREEN_READER); const reader = new ReaderScreen({ diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index b6e8dae..34de488 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -1238,3 +1238,302 @@ book-content ::selection { background: var(--reader-accent); color: #fff; } + +/* ------------------------------------------------------------------ * + * 书库管理 — the library as a file manager + * + * The one screen that shows the mount rather than the catalog, so its job is to + * stay out of the way: a path you can read at the top, rows that navigate, and + * the destructive actions behind an explicit selection. + * ------------------------------------------------------------------ */ + +/* + * The screen fills the app and scrolls internally, like every other screen. + * `#app` is `overflow: hidden`, so a screen that does not claim `min-height: 0` + * on the flex chain pushes its own content off the bottom instead of scrolling. + * `position: relative` is what the dialogs and the selection pill are placed + * against. + */ +.manager-screen { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +.manager-crumbs { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 0.15rem; + overflow-x: auto; + scrollbar-width: none; + min-width: 0; +} + +.manager-crumbs::-webkit-scrollbar { + display: none; +} + +.manager-crumb { + appearance: none; + border: 0; + background: transparent; + color: var(--reader-ink); + font-size: 0.9rem; + font-family: inherit; + padding: 0.35rem 0.25rem; + white-space: nowrap; + cursor: pointer; +} + +.manager-crumb[aria-current='true'] { + font-weight: 600; +} + +.manager-crumb-sep { + color: var(--reader-muted); +} + +.manager-body { + flex: 1 1 auto; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + min-height: 0; +} + +.manager-list { + padding: 0; +} + +.manager-row { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--reader-line); + cursor: pointer; + touch-action: pan-y; +} + +.manager-row:active { + background: color-mix(in srgb, var(--reader-ink) 6%, transparent); +} + +.manager-row[data-selected='true'] { + background: color-mix(in srgb, var(--reader-accent) 14%, transparent); +} + +/* + * The checkbox is always laid out and only made visible in selection mode. + * + * Toggling `display` instead would shift every row's indent the moment a + * selection begins, which reads as the list jumping under the finger that just + * started the selection. + */ +.manager-check { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + border: 1.5px solid var(--reader-line); + box-sizing: border-box; + /* Always laid out, only visible while something is selected: toggling + `display` would shift every row's indent the moment a selection begins, + which reads as the list jumping under the finger that started it. */ + opacity: 0; +} + +.manager-row[data-selected] .manager-check { + opacity: 1; +} + +.manager-row[data-selected='true'] .manager-check { + border-color: var(--reader-accent); + background: var(--reader-accent); +} + +.manager-name { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.manager-icon { + font-size: 1rem; + flex: 0 0 auto; +} + +.manager-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; +} + +.manager-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + white-space: nowrap; +} + +.manager-badge { + border: 1px solid var(--reader-line); + border-radius: 4px; + padding: 0 0.25rem; + font-size: 0.65rem; + color: var(--reader-muted); +} + +/* The badge a reader needs to see is the one explaining a missing book. */ +.manager-badge.warn { + border-color: color-mix(in srgb, var(--reader-accent) 60%, transparent); + color: var(--reader-accent); +} + +.manager-more { + width: 2rem; + height: 2rem; + font-size: 1.1rem; +} + +/* + * On a read-only mount the row menu offers nothing but "选择", so it is dimmed + * rather than removed: the row still has to line up with the others, and a + * column that appears and disappears with the deployment reads as a bug. + */ +.manager-screen[data-writable='false'] .manager-more { + opacity: 0.35; +} + +/* + * The counts sit *inside* the scroller, not after it. + * + * Pinned below a scrolling list they were the first thing pushed off-screen by a + * directory of forty files — which is exactly when a reader wants to know how + * many there are, and exactly when "删除完成" would have gone unread. + */ +.manager-status { + position: sticky; + inset-block-end: 0; + font-size: 0.75rem; + text-align: center; + padding: 0.5rem 0.75rem; + background: color-mix(in srgb, var(--reader-surface) 92%, transparent); + backdrop-filter: blur(6px); + border-top: 1px solid transparent; +} + +.manager-status:not(:empty) { + border-top-color: var(--reader-line); +} + +.manager-actions { + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.6rem 0.75rem; + padding-bottom: calc(0.6rem + env(safe-area-inset-bottom)); + border-top: 1px solid var(--reader-line); + background: var(--reader-surface); +} + +.manager-actions[hidden] { + display: none; +} + +.manager-actions .button { + flex: 1 1 auto; +} + +.manager-selection { + position: absolute; + inset-block-start: calc(env(safe-area-inset-top) + 0.25rem); + inset-inline-start: 50%; + transform: translateX(-50%); + background: var(--reader-ink); + color: var(--reader-paper); + border-radius: 999px; + padding: 0.2rem 0.7rem; + font-size: 0.72rem; + pointer-events: none; + z-index: 25; +} + +.manager-selection[hidden] { + display: none; +} + +.button.danger { + border-color: color-mix(in srgb, #c0392b 55%, transparent); + color: #c0392b; +} + +/* ---- dialogs ---- */ + +.dialog-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: grid; + place-items: center; + padding: 1rem; + z-index: 30; +} + +.dialog { + background: var(--reader-surface); + color: var(--reader-ink); + border: 1px solid var(--reader-line); + border-radius: var(--reader-radius); + padding: 1rem; + width: min(24rem, 100%); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.dialog h3 { + margin: 0; + font-size: 1rem; +} + +.dialog p { + margin: 0; + font-size: 0.82rem; + line-height: 1.5; +} + +.dialog input { + appearance: none; + border: 1px solid var(--reader-line); + background: var(--reader-paper); + color: var(--reader-ink); + border-radius: var(--reader-radius); + padding: 0.6rem 0.7rem; + /* 16px, so iOS does not zoom the page on focus and leave the dialog off-screen. */ + font-size: 16px; + font-family: inherit; + width: 100%; +} + +.dialog-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.dialog-actions .button { + flex: 0 1 auto; + min-width: 5rem; +} + +.dialog-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} diff --git a/web/src/ui/manager-screen.ts b/web/src/ui/manager-screen.ts new file mode 100644 index 0000000..9a90952 --- /dev/null +++ b/web/src/ui/manager-screen.ts @@ -0,0 +1,610 @@ +import { ApiError } from '../api/errors.ts'; +import type { ReaderApi } from '../api/client.ts'; +import type { BrowseEntry, BrowseListing } from '../api/types.ts'; +import { clear, el, formatBytes, formatDate } from './dom.ts'; + +export interface ManagerScreenOptions { + api: ReaderApi; + onClose(): void; + onSignedOut(): void; +} + +/** Long-press, in milliseconds, before a touch starts a selection. */ +const LONG_PRESS_MS = 450; + +/** + * 书库管理 — the library as a file manager. + * + * ## Why this exists + * + * The shelf answers "what can I read". It cannot answer "where did my book go", + * and that is the question a self-hosted library actually generates: a title + * that does not appear after a rescan has no explanation anywhere in the app, + * because a book's DTO never carries its path. This screen is the one place the + * `BOOKS_DIR` is visible, and it is also the only one that writes to it. + * + * ## The three decisions that shape it + * + * 1. **Hidden entries are shown, with the reason.** `.trash`, `@eaDir` and + * friends are skipped by the scanner by design. Filtering them out here too + * would hide exactly the files a reader is looking for — the ones that are on + * disk but not on the shelf — so they are listed with a badge instead. + * 2. **Selection is explicit, and destructive actions need a second tap.** + * Thumb-scrolling a NAS library with a delete button on every row is one + * mis-tap away from data loss. Rows navigate; selection is entered through + * the long-press or the ⋯ button, and delete always asks. + * 3. **The write controls disappear on a read-only mount.** `:ro` is the + * documented deployment, so the common case is "this screen can only look", + * and offering buttons that answer 403 teaches the reader to distrust the UI. + */ +export class ManagerScreen { + readonly element: HTMLDivElement; + private readonly list: HTMLDivElement; + private readonly crumbRow: HTMLDivElement; + private readonly statusLine: HTMLDivElement; + private readonly actionBar: HTMLDivElement; + private readonly selectionCount: HTMLDivElement; + private readonly mkdirButton: HTMLButtonElement; + private readonly listBody: HTMLDivElement; + + private current = ''; + private listing: BrowseListing | null = null; + /** Paths ticked for a batch operation. Empty means "navigate" mode. */ + private readonly selected = new Set(); + private loading = false; + private longPressTimer: ReturnType | null = null; + private longPressFired = false; + + constructor(private readonly options: ManagerScreenOptions) { + this.crumbRow = el('div', { className: 'manager-crumbs' }) as HTMLDivElement; + this.statusLine = el('div', { className: 'manager-status muted' }) as HTMLDivElement; + this.list = el('div', { className: 'manager-list' }) as HTMLDivElement; + this.selectionCount = el('div', { className: 'manager-selection', attrs: { hidden: true } }) as HTMLDivElement; + this.actionBar = el('div', { className: 'manager-actions' }) as HTMLDivElement; + this.mkdirButton = el('button', { + className: 'icon-button', + text: '+', + attrs: { type: 'button', 'aria-label': '新建文件夹' }, + on: { click: () => void this.promptMkdir() }, + }) as HTMLButtonElement; + + this.listBody = el('div', { + className: 'manager-body', + children: [this.list, this.statusLine], + }) as HTMLDivElement; + + const header = el('div', { + className: 'panel-header', + children: [ + el('button', { + className: 'icon-button', + text: '←', + attrs: { type: 'button', 'aria-label': '返回书架' }, + on: { click: () => options.onClose() }, + }), + this.crumbRow, + this.mkdirButton, + ], + }); + + this.element = el('div', { + className: 'manager-screen', + children: [header, this.listBody, this.actionBar, this.selectionCount], + }) as HTMLDivElement; + + // Clicking the empty area (not a row) clears a selection, which is the + // gesture people already have for "never mind". + this.listBody.addEventListener('click', (event) => { + if (event.target === this.listBody || event.target === this.list) this.clearSelection(); + }); + } + + async open(): Promise { + await this.load(''); + } + + private async load(path: string): Promise { + if (this.loading) return; + this.loading = true; + this.setStatus('载入中…'); + try { + const listing = await this.options.api.browse(path); + this.listing = listing; + this.current = listing.path; + this.clearSelection(); + this.renderCrumbs(); + this.renderEntries(); + this.applyWritable(listing.writable); + // No `setStatus('')` here: listing a directory *succeeds with something to + // say*, and that thing is what `renderEntries` just wrote — how many + // entries, how big, and how many the scanner is skipping. Clearing it left + // the screen with no answer to any of those. + } catch (err) { + this.handleError(err); + } finally { + this.loading = false; + } + } + + /** + * Reflects the mount's writability into the UI, in two places for two reasons. + * + * The button is hidden outright: a control that can only answer 403 teaches the + * reader to distrust every other control. The data attribute is on the *screen* + * so the stylesheet owns the rest — the per-row ⋯ menu is a write affordance + * and is dimmed by CSS rather than by a second branch in the render path. + */ + private applyWritable(writable: boolean): void { + this.mkdirButton.hidden = !writable; + this.element.dataset['writable'] = String(writable); + } + + private renderCrumbs(): void { + clear(this.crumbRow); + const listing = this.listing; + if (!listing) return; + for (const [index, crumb] of listing.crumbs.entries()) { + if (index > 0) this.crumbRow.append(el('span', { className: 'manager-crumb-sep', text: '/' })); + const isLast = index === listing.crumbs.length - 1; + this.crumbRow.append( + el('button', { + className: 'manager-crumb', + text: crumb.name, + attrs: { type: 'button', 'aria-current': String(isLast) }, + on: { click: () => void this.load(crumb.path) }, + }), + ); + } + } + + private renderEntries(): void { + clear(this.list); + const listing = this.listing; + if (!listing) return; + if (listing.entries.length === 0) { + this.list.append(el('div', { className: 'empty-state', children: [el('p', { text: '这个文件夹是空的' })] })); + this.setStatus(listing.writable ? '空文件夹 · 可以用右上角的 + 新建子目录' : '空文件夹'); + return; + } + for (const entry of listing.entries) this.list.append(this.row(entry)); + + const hidden = listing.entries.filter((entry) => entry.hidden || entry.hiddenByRule).length; + const parts = [`${listing.dirs} 个文件夹`, `${listing.files} 个文件`, formatBytes(listing.size)]; + if (hidden > 0) parts.push(`${hidden} 个被扫描忽略`); + this.setStatus(parts.join(' · ')); + } + + /** + * One row. + * + * The row itself navigates; the checkbox that appears in selection mode is what + * selects. That split is the whole reason a destructive tap cannot happen by + * accident while scrolling a library of ten thousand files. + */ + private row(entry: BrowseEntry): HTMLDivElement { + const selectable = entry.type !== 'other'; + const checkbox = el('span', { className: 'manager-check', attrs: { 'aria-hidden': 'true' } }); + const name = el('div', { + className: 'manager-name', + children: [ + el('span', { className: 'manager-icon', text: entry.type === 'dir' ? '📁' : '📄' }), + el('span', { className: 'manager-label', text: entry.name }), + ], + }); + const meta = el('div', { + className: 'manager-meta muted', + children: [ + el('span', { text: entry.type === 'dir' ? '' : formatBytes(entry.size) }), + el('span', { text: formatDate(entry.mtime) }), + entry.hiddenByRule + ? el('span', { className: 'manager-badge warn', text: '扫描忽略' }) + : entry.scanned + ? el('span', { className: 'manager-badge', text: '书籍' }) + : null, + entry.hidden && !entry.hiddenByRule ? el('span', { className: 'manager-badge', text: '隐藏' }) : null, + ], + }); + + const more = el('button', { + className: 'icon-button manager-more', + text: '⋯', + attrs: { type: 'button', 'aria-label': `${entry.name} 的操作` }, + on: { + click: (event) => { + event.stopPropagation(); + void this.openEntryMenu(entry); + }, + }, + }) as HTMLButtonElement; + + const node = el('div', { + className: 'manager-row', + attrs: { 'data-type': entry.type, role: 'button', tabindex: '0' }, + children: [checkbox, name, meta, more], + }) as HTMLDivElement; + node.dataset['path'] = entry.path; + + const activate = (): void => { + if (!selectable) return; + if (this.selected.size > 0) { + this.toggle(entry); + return; + } + if (entry.type === 'dir') void this.load(entry.path); + }; + + node.addEventListener('click', activate); + node.addEventListener('keydown', (event) => { + if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') { + event.preventDefault(); + activate(); + } + }); + + // Long press enters selection mode, which is how a file manager on a phone + // says "I mean this one, not the one I tapped". + if (selectable && this.selected.size > 0) node.dataset['selected'] = String(this.selected.has(entry.path)); + const start = (): void => { + this.longPressFired = false; + if (!selectable) return; + this.longPressTimer = setTimeout(() => { + this.longPressTimer = null; + this.longPressFired = true; + this.toggle(entry); + }, LONG_PRESS_MS); + }; + const cancel = (): void => { + if (this.longPressTimer !== null) clearTimeout(this.longPressTimer); + this.longPressTimer = null; + }; + node.addEventListener('touchstart', start, { passive: true }); + node.addEventListener('touchend', cancel); + node.addEventListener('touchcancel', cancel); + node.addEventListener('touchmove', cancel); + node.addEventListener('mousedown', start); + node.addEventListener('mouseup', cancel); + node.addEventListener('mouseleave', cancel); + // A long press already acted; the click that follows must not navigate. + node.addEventListener('click', (event) => { + if (!this.longPressFired) return; + this.longPressFired = false; + event.stopImmediatePropagation(); + }, true); + + return node; + } + + private toggle(entry: BrowseEntry): void { + if (this.selected.has(entry.path)) this.selected.delete(entry.path); + else this.selected.add(entry.path); + const node = this.list.querySelector(`[data-path="${cssEscape(entry.path)}"]`); + if (node) node.dataset['selected'] = String(this.selected.has(entry.path)); + this.renderSelection(); + } + + private clearSelection(): void { + this.selected.clear(); + for (const node of this.list.querySelectorAll('.manager-row')) { + node.dataset['selected'] = 'false'; + } + this.renderSelection(); + } + + /** + * The batch action bar. + * + * Rendered only while something is selected: an always-present toolbar of + * move/delete buttons on a screen whose primary gesture is tapping a folder is + * a permanent invitation to the wrong action. + */ + private renderSelection(): void { + clear(this.actionBar); + const count = this.selected.size; + if (count === 0) { + this.actionBar.hidden = true; + this.selectionCount.hidden = true; + return; + } + this.actionBar.hidden = false; + this.selectionCount.hidden = false; + this.selectionCount.textContent = `已选 ${count} 项`; + const writable = this.listing?.writable ?? false; + + this.actionBar.append( + el('button', { + className: 'button', + text: '取消', + attrs: { type: 'button' }, + on: { click: () => this.clearSelection() }, + }), + ); + if (writable) { + if (count === 1) { + const only = [...this.selected][0]!; + const entry = this.listing?.entries.find((candidate) => candidate.path === only); + this.actionBar.append( + el('button', { + className: 'button', + text: '重命名', + attrs: { type: 'button' }, + on: { click: () => void this.promptRename(only, entry?.name ?? '') }, + }), + ); + } + this.actionBar.append( + el('button', { + className: 'button', + text: '移动到…', + attrs: { type: 'button' }, + on: { click: () => void this.promptMove() }, + }), + el('button', { + className: 'button danger', + text: '删除', + attrs: { type: 'button' }, + on: { click: () => void this.confirmDelete() }, + }), + ); + } else { + this.actionBar.append( + el('span', { className: 'muted', text: '书库是只读挂载,无法修改' }), + ); + } + } + + private async openEntryMenu(entry: BrowseEntry): Promise { + if (!this.listing?.writable) { + this.toast('书库是只读挂载,无法修改'); + return; + } + const action = await this.pick(`「${entry.name}」`, [ + { value: 'select', label: '选择' }, + { value: 'rename', label: '重命名' }, + { value: 'move', label: '移动到…' }, + { value: 'delete', label: '删除' }, + ]); + if (action === 'select') { + this.toggle(entry); + return; + } + if (action === 'rename') return this.promptRename(entry.path, entry.name); + if (action === 'move') { + this.selected.clear(); + this.selected.add(entry.path); + this.renderSelection(); + return this.promptMove(); + } + if (action === 'delete') { + this.selected.clear(); + this.selected.add(entry.path); + this.renderSelection(); + return this.confirmDelete(); + } + } + + private async promptRename(path: string, currentName: string): Promise { + const name = await this.prompt('重命名', currentName); + if (name === null || name.trim() === '' || name === currentName) return; + await this.mutate(() => this.options.api.browseRename(path, name.trim()), `重命名 ${currentName}`); + } + + private async promptMkdir(): Promise { + const name = await this.prompt('新建文件夹', ''); + if (name === null || name.trim() === '') return; + await this.mutate(() => this.options.api.browseMkdir(this.current, name.trim()), `新建 ${name.trim()}`); + } + + /** + * Moving a batch asks for a path rather than offering a folder picker. + * + * A picker would be a second navigation UI inside the first, and the one place + * a reader is most likely to land in the wrong folder is precisely where the + * files go. A typed, library-relative path is unambiguous — and the server + * resolves it through the same containment check as everything else. + */ + private async promptMove(): Promise { + const target = await this.prompt('移动到(书库内的路径,留空为根目录)', this.current); + if (target === null) return; + const paths = [...this.selected]; + await this.mutate(() => this.options.api.browseMove(paths, target.trim()), `移动 ${paths.length} 项`); + } + + private async confirmDelete(): Promise { + const count = this.selected.size; + const ok = await this.confirm( + `删除 ${count} 项?`, + '文件会从磁盘上直接删除,不进回收站。删除后需要重新扫描书库,书架才会更新。', + ); + if (!ok) return; + const paths = [...this.selected]; + await this.mutate(() => this.options.api.browseDelete(paths), `删除 ${paths.length} 项`); + } + + /** + * Runs a mutating call, reports its outcome, and reloads only on success. + * + * The order matters and is the reason this is not two calls at the call sites: + * reloading after a *failed* write would replace the error message with a fresh + * directory listing, so the screen would look like the rename simply did not + * happen — the worst possible answer to "why did nothing change". + */ + private async mutate(action: () => Promise, label: string): Promise { + this.toast(`${label}…`); + try { + await action(); + } catch (err) { + this.handleError(err); + return; + } + this.toast(`${label} 完成`); + await this.load(this.current); + } + + private setStatus(text: string): void { + this.statusLine.textContent = text; + } + + private toast(text: string): void { + this.setStatus(text); + } + + private handleError(err: unknown): void { + if (err instanceof ApiError) { + if (err.isAuthFailure) { + this.options.onSignedOut(); + return; + } + this.setStatus(err.message); + return; + } + this.setStatus(err instanceof Error ? err.message : '出错了'); + } + + // ---- dialogs ---- + // + // Built on the spot rather than kept as members: each one is a single question + // with one answer, and a dialog that outlives its question is how a stale + // "delete?" prompt ends up attached to a different file. + + private prompt(title: string, value: string): Promise { + return new Promise((resolve) => { + const input = el('input', { attrs: { type: 'text', value } }) as HTMLInputElement; + const close = (result: string | null): void => { + overlay.remove(); + resolve(result); + }; + const form = el('form', { + className: 'dialog', + on: { + submit: (event) => { + event.preventDefault(); + close(input.value); + }, + }, + children: [ + el('h3', { text: title }), + input, + el('div', { + className: 'dialog-actions', + children: [ + el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } }), + el('button', { className: 'button primary', text: '确定', attrs: { type: 'submit' } }), + ], + }), + ], + }); + const overlay = el('div', { + className: 'dialog-overlay', + on: { + click: (event) => { + if (event.target === overlay) close(null); + }, + }, + children: [form], + }) as HTMLDivElement; + this.element.append(overlay); + input.focus(); + input.select(); + }); + } + + private confirm(title: string, body: string): Promise { + return new Promise((resolve) => { + const close = (result: boolean): void => { + overlay.remove(); + resolve(result); + }; + const overlay = el('div', { + className: 'dialog-overlay', + on: { + click: (event) => { + if (event.target === overlay) close(false); + }, + }, + children: [ + el('div', { + className: 'dialog', + children: [ + el('h3', { text: title }), + el('p', { className: 'muted', text: body }), + el('div', { + className: 'dialog-actions', + children: [ + el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(false) } }), + el('button', { className: 'button danger', text: '删除', attrs: { type: 'button' }, on: { click: () => close(true) } }), + ], + }), + ], + }), + ], + }) as HTMLDivElement; + this.element.append(overlay); + }); + } + + private async pick(title: string, options: Array<{ value: string; label: string }>): Promise { + // De-duplicated by value: two rows that resolve to the same action are one + // action, and showing it twice reads as two different things. + const seen = new Set(); + const unique = options.filter((option) => { + if (seen.has(option.value)) return false; + seen.add(option.value); + return true; + }); + return new Promise((resolve) => { + const close = (result: string | null): void => { + overlay.remove(); + resolve(result); + }; + const overlay = el('div', { + className: 'dialog-overlay', + on: { + click: (event) => { + if (event.target === overlay) close(null); + }, + }, + children: [ + el('div', { + className: 'dialog sheet', + children: [ + el('h3', { text: title }), + el('div', { + className: 'dialog-list', + children: unique.map((option) => + el('button', { + className: 'button', + text: option.label, + attrs: { type: 'button' }, + on: { click: () => close(option.value) }, + }), + ), + }), + el('div', { + className: 'dialog-actions', + children: [el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } })], + }), + ], + }), + ], + }) as HTMLDivElement; + this.element.append(overlay); + }); + } + + dispose(): void { + if (this.longPressTimer !== null) clearTimeout(this.longPressTimer); + } +} + +/** + * CSS.escape with a fallback. + * + * `querySelector` with a raw path breaks on a folder named `第01卷 [前篇]` or one + * containing a quote — both entirely ordinary in a Chinese comic library — and + * `CSS.escape` is not present in every WebView the Android shell runs on. + */ +function cssEscape(value: string): string { + const globalCss = (globalThis as { CSS?: { escape?: (text: string) => string } }).CSS; + if (globalCss?.escape) return globalCss.escape(value); + return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`); +} diff --git a/web/src/ui/shelf-screen.ts b/web/src/ui/shelf-screen.ts index a81050e..2cc1069 100644 --- a/web/src/ui/shelf-screen.ts +++ b/web/src/ui/shelf-screen.ts @@ -15,6 +15,8 @@ export interface ShelfScreenOptions { /** Current shelf preferences, so the screen starts in the reader's own state. */ settings: AppSettings; onOpenBook(book: Book): void; + /** Opens the library file manager. Admin-ish by nature, but gated by the mount. */ + onOpenManager(): void; onSignedOut(): void; /** Persisted through the app's settings store, like the reader's own. */ onSettingsChange(patch: Partial): void; @@ -143,9 +145,20 @@ export class ShelfScreen { on: { click: () => this.toggleSettings() }, }) as HTMLButtonElement; + // The file manager's entry point sits next to the shelf settings gear because + // that is where "the library itself" already lives. It is offered to every + // account, not just admins: the mount decides whether it can write anything, + // and a reader whose own book failed to appear is exactly who needs to look. + const managerButton = el('button', { + className: 'icon-button shelf-settings-button', + text: '🗂', + attrs: { type: 'button', 'aria-label': '书库管理' }, + on: { click: () => this.options.onOpenManager() }, + }); + const toolbar = el('div', { className: 'shelf-toolbar', - children: [this.countLabel, this.sortRow, this.settingsButton], + children: [this.countLabel, this.sortRow, this.settingsButton, managerButton], }); this.settingsPanel = buildShelfSettingsPanel({ diff --git a/web/test/manager.test.ts b/web/test/manager.test.ts new file mode 100644 index 0000000..b1f8a28 --- /dev/null +++ b/web/test/manager.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ManagerScreen } from '../src/ui/manager-screen.ts'; +import { ReaderApi, type SessionStore } from '../src/api/client.ts'; +import { ApiError } from '../src/api/errors.ts'; +import { FakeTransport, makePlatform } from './helpers/env.ts'; +import type { BrowseListing } from '../src/api/types.ts'; + +/** + * The file manager. + * + * jsdom has no layout engine, so nothing here is about how the screen *looks*. + * What it covers is the part a regression would be silent about: which entries + * are reachable in which mode, and — the one that actually costs data — that a + * destructive call is never sent without an explicit selection and a confirmation. + */ + +function entry(partial: Partial & { name: string }): BrowseListing['entries'][number] { + return { + path: partial.name, + type: 'file', + size: 0, + mtime: Date.now(), + mode: 0o644, + hidden: false, + hiddenByRule: false, + scanned: false, + ext: '', + indexed: false, + ...partial, + }; +} + +function listing(overrides: Partial = {}): BrowseListing { + return { + path: '', + crumbs: [{ name: '书库', path: '' }], + parent: null, + entries: [], + total: 0, + dirs: 0, + files: 0, + size: 0, + writable: true, + name: '', + ...overrides, + }; +} + +function makeScreen(transport: FakeTransport): { screen: ManagerScreen; calls: string[] } { + const platform = makePlatform(transport); + const sessions: SessionStore = { + load: async () => null, + save: async () => undefined, + clear: async () => undefined, + }; + const api = new ReaderApi(platform, sessions); + api.setBaseUrl('http://nas:8080'); + const calls: string[] = []; + const screen = new ManagerScreen({ + api, + onClose: () => calls.push('close'), + onSignedOut: () => calls.push('signed-out'), + }); + document.body.append(screen.element); + return { screen, calls }; +} + +beforeEach(() => { + document.body.replaceChildren(); +}); + +describe('library manager', () => { + it('lists directories and files, and shows the entries the scanner skips', async () => { + const transport = new FakeTransport(); + transport.json( + listing({ + entries: [ + entry({ name: '科幻', type: 'dir', path: '科幻' }), + entry({ name: '.trash', type: 'dir', path: '.trash', hidden: true, hiddenByRule: true }), + entry({ name: '三体.epub', path: '三体.epub', scanned: true, ext: 'epub' }), + ], + dirs: 2, + files: 1, + total: 3, + }), + ); + const { screen } = makeScreen(transport); + await screen.open(); + const rows = screen.element.querySelectorAll('.manager-row'); + expect(rows).toHaveLength(3); + // The rule-skipped folder is the answer to "my book is on disk but not on + // the shelf", so it must be visible rather than filtered. + expect(screen.element.textContent).toContain('.trash'); + expect(screen.element.querySelector('.manager-badge.warn')?.textContent).toBe('扫描忽略'); + }); + + it('reports the directory counts after a successful listing', async () => { + const transport = new FakeTransport(); + transport.json( + listing({ dirs: 2, files: 3, size: 4096, total: 5, entries: [entry({ name: 'a.epub' })] }), + ); + const { screen } = makeScreen(transport); + await screen.open(); + // The counts are the answer to "is my file in here at all", so a successful + // listing must leave them on screen rather than blank. + expect(screen.element.querySelector('.manager-status')?.textContent).toContain('3 个文件'); + }); + + it('hides the write controls when the mount is read-only', async () => { + const transport = new FakeTransport(); + transport.json(listing({ writable: false, entries: [entry({ name: '三体.epub', scanned: true })] })); + const { screen } = makeScreen(transport); + await screen.open(); + const matches = transport.requests.filter((request) => request.url.startsWith('/api/v1/library/browse')); + expect(matches).toHaveLength(1); + expect(matches[0]!.method).toBe('GET'); + // The mkdir button is the only write affordance outside selection mode. + const buttons = [...screen.element.querySelectorAll('.icon-button')]; + const mkdir = buttons.find((button) => button.getAttribute('aria-label') === '新建文件夹'); + expect(mkdir?.hidden).toBe(true); + }); + + it('navigates into a folder and asks the server for that path', async () => { + const transport = new FakeTransport(); + transport.respondWith((request) => { + const path = new URL(`http://x${request.url}`).searchParams.get('path') ?? ''; + return path === '科幻' + ? { + status: 200, + headers: {}, + json: listing({ + path: '科幻', + name: '科幻', + parent: '', + crumbs: [ + { name: '书库', path: '' }, + { name: '科幻', path: '科幻' }, + ], + entries: [entry({ name: '三体.epub', path: '科幻/三体.epub', scanned: true })], + files: 1, + }), + } + : { + status: 200, + headers: {}, + json: listing({ entries: [entry({ name: '科幻', type: 'dir', path: '科幻' })] }), + }; + }); + const { screen } = makeScreen(transport); + await screen.open(); + (screen.element.querySelector('.manager-row') as HTMLElement).click(); + await vi.waitFor(() => { + expect(screen.element.textContent).toContain('三体.epub'); + }); + expect(transport.requests.at(-1)?.url).toBe('/api/v1/library/browse?path=%E7%A7%91%E5%B9%BB'); + }); + + it('does not send a delete for a selection that was never made', async () => { + const transport = new FakeTransport(); + transport.json(listing({ entries: [entry({ name: '三体.epub', scanned: true })] })); + const { screen } = makeScreen(transport); + await screen.open(); + // Tapping a row is navigation, not selection — and on a file there is nowhere + // to navigate, so a tap must be a no-op rather than a deletion. + (screen.element.querySelector('.manager-row') as HTMLElement).click(); + expect(transport.requests.filter((request) => request.method !== 'GET')).toHaveLength(0); + expect(screen.element.querySelector('.manager-actions')?.hasAttribute('hidden')).toBe(true); + }); + + it('sends the selected paths, and only those, on a confirmed delete', async () => { + const transport = new FakeTransport(); + transport.respondWith((request) => + request.method === 'GET' + ? { + status: 200, + headers: {}, + json: listing({ + entries: [entry({ name: 'a.epub', path: 'a.epub' }), entry({ name: 'b.epub', path: 'b.epub' })], + files: 2, + }), + } + : { status: 200, headers: {}, json: { removed: 1 } }, + ); + const { screen } = makeScreen(transport); + await screen.open(); + + // Selection through the row's own action sheet, which is the keyboard-free + // path a test can drive without a touch device. + (screen.element.querySelector('.manager-more') as HTMLElement).click(); + // The action sheet is created inside an awaited promise, so it appears a + // microtask after the click rather than synchronously. + await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).not.toBeNull()); + const choose = [...screen.element.querySelectorAll('.dialog-list .button')].find( + (button) => button.textContent === '选择', + ) as HTMLElement; + choose.click(); + // The sheet closes through a promise too, so the action bar is one tick away. + await vi.waitFor(() => expect(screen.element.querySelector('.manager-selection')?.hasAttribute('hidden')).toBe(false)); + const deleteButton = [...screen.element.querySelectorAll('.manager-actions .button')].find( + (button) => button.textContent === '删除', + ) as HTMLElement; + deleteButton.click(); + await vi.waitFor(() => expect(screen.element.querySelector('.dialog-actions')).not.toBeNull()); + const confirmButton = [...screen.element.querySelectorAll('.dialog-actions .button')].find( + (button) => button.textContent === '删除', + ) as HTMLElement; + confirmButton.click(); + + await vi.waitFor(() => { + expect(transport.requests.some((request) => request.url.endsWith('/browse/delete'))).toBe(true); + }); + const del = transport.requests.find((request) => request.url.endsWith('/browse/delete')); + expect(JSON.parse(String(del?.body))).toEqual({ paths: ['a.epub'] }); + }); + + it('reports a refused write instead of pretending it worked', async () => { + const transport = new FakeTransport(); + transport.respondWith((request) => + request.method === 'GET' + ? { status: 200, headers: {}, json: listing({ entries: [entry({ name: 'a.epub', path: 'a.epub' })] }) } + : { + status: 403, + headers: {}, + json: { error: { code: 'READ_ONLY_MOUNT', message: 'the library mount is read-only' } }, + }, + ); + const { screen, calls } = makeScreen(transport); + await screen.open(); + (screen.element.querySelector('.manager-more') as HTMLElement).click(); + const rename = [...screen.element.querySelectorAll('.dialog-list .button')].find( + (button) => button.textContent === '重命名', + ) as HTMLElement; + rename.click(); + await vi.waitFor(() => expect(screen.element.querySelector('.dialog input')).not.toBeNull()); + const input = screen.element.querySelector('.dialog input')!; + input.value = 'b.epub'; + const form = screen.element.querySelector('form.dialog')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await vi.waitFor(() => { + expect(transport.requests.some((request) => request.url.endsWith('/browse/rename'))).toBe(true); + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + await vi.waitFor(() => { + expect(screen.element.querySelector('.manager-status')?.textContent).toContain('read-only'); + }); + // A refused operation must not sign the reader out: the 403 here is about the + // mount, not about their session. + expect(calls).not.toContain('signed-out'); + }); +}); + +describe('error classification', () => { + it('does not treat a refused operation as a dead session', () => { + // Both are 403 on this API and only one is about the credentials. Classifying + // them together signed a reader out — and discarded their tokens — for trying + // to rename a file on a read-only mount. + expect(new ApiError('forbidden', 'the library mount is read-only', 'READ_ONLY_MOUNT', 403).isAuthFailure).toBe(false); + expect(new ApiError('forbidden', 'admin role required', 'ADMIN_REQUIRED', 403).isAuthFailure).toBe(false); + expect(new ApiError('forbidden', 'account disabled', 'ACCOUNT_DISABLED', 403).isAuthFailure).toBe(true); + expect(new ApiError('unauthorized', 'token expired', 'TOKEN_EXPIRED', 401).isAuthFailure).toBe(true); + }); +});