diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f24db..1023b56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: - uses: android-actions/setup-android@v3 with: packages: platform-tools platforms;android-35 build-tools;35.0.1 - - run: ./gradlew --no-daemon :app:assembleDebug + - run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug working-directory: android - uses: actions/upload-artifact@v4 with: diff --git a/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt b/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt index 0bf6eec..9e2ad4b 100644 --- a/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt +++ b/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt @@ -1,5 +1,8 @@ package com.htmake.readerpro.web +import java.net.URI +import java.util.Locale + import android.annotation.SuppressLint import android.content.Context import android.content.Intent @@ -163,7 +166,7 @@ class WebHost( override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val url = request.url.toString() - if (isInternalUrl(url)) return false + if (isInternalUrl(url, request.isForMainFrame)) return false openExternally(url) return true } @@ -217,13 +220,13 @@ class WebHost( * images and stylesheets out of the in-memory resource map; letting it be treated * as external would break every illustrated book. */ -internal fun isInternalUrl(url: String): Boolean { - val internalPrefixes = listOf( - WebHost.ASSET_ORIGIN, - "reader-res:", - "blob:", - "data:", - "about:", - ) - return internalPrefixes.any { url.startsWith(it) } +internal fun isInternalUrl(url: String, isMainFrame: Boolean = true): Boolean { + val uri = runCatching { URI(url) }.getOrNull() ?: return false + return when (uri.scheme?.lowercase(Locale.ROOT)) { + "https" -> uri.host.equals(WebHost.ASSET_HOST, ignoreCase = true) && + uri.rawUserInfo == null && (uri.port == -1 || uri.port == 443) && + uri.rawPath.orEmpty().startsWith("/assets/") + "reader-res", "blob", "data", "about" -> !isMainFrame + else -> false + } } diff --git a/android/app/src/test/java/com/htmake/readerpro/web/WebHostUrlTest.kt b/android/app/src/test/java/com/htmake/readerpro/web/WebHostUrlTest.kt new file mode 100644 index 0000000..94f2682 --- /dev/null +++ b/android/app/src/test/java/com/htmake/readerpro/web/WebHostUrlTest.kt @@ -0,0 +1,33 @@ +package com.htmake.readerpro.web + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebHostUrlTest { + @Test fun onlyExactAssetOriginsStayInsideTheMainFrame() { + for (url in listOf( + "https://appassets.androidplatform.net/assets/index.html", + "https://APPASSETS.ANDROIDPLATFORM.NET:443/assets/index.html#/home", + )) assertTrue(url, isInternalUrl(url)) + + for (url in listOf( + "https://appassets.androidplatform.net.evil.test/assets/index.html", + "https://appassets.androidplatform.net@evil.test/assets/index.html", + "https://user@appassets.androidplatform.net/assets/index.html", + "https://appassets.androidplatform.net:444/assets/index.html", + "https://appassets.androidplatform.net/other/index.html", + "http://appassets.androidplatform.net/assets/index.html", + "https://appassets.androidplatform.net\\@evil.test/assets/index.html", + "javascript:alert(1)", + "not a valid URI", + )) assertFalse(url, isInternalUrl(url)) + } + + @Test fun documentSchemesAreRestrictedToSubframes() { + for (url in listOf("reader-res:cover.png", "blob:https://example.com/id", "data:text/html,hello", "about:blank")) { + assertFalse(url, isInternalUrl(url)) + assertTrue(url, isInternalUrl(url, isMainFrame = false)) + } + } +} diff --git a/docs/api.md b/docs/api.md index 09978a6..a3fbd1c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,6 +69,8 @@ ## 认证 +认证请求超出频率或密码派生并发上限时返回 `429 AUTH_RATE_LIMITED` / `429 AUTH_BUSY`,并通过 `Retry-After` 指示最少等待秒数。客户端不应立即重试。 + ### `POST /auth/register` — 公开 ```json @@ -87,6 +89,7 @@ { "user": { "id": "...", "username": "owner", "displayName": "图书馆长", "role": "admin", "createdAt": 1789537801289 }, "session": { "user": {...}, "accessToken": "...", "accessTokenExpiresAt": 1789624201289, + "resourceToken": "...", "resourceTokenExpiresAt": 1789538701289, "refreshToken": "...", "refreshTokenExpiresAt": 1821073801289 } } ``` @@ -103,6 +106,8 @@ **刷新令牌会轮转**:传入的那个立即作废。客户端必须持久化新的那个。 +登录、注册和刷新同时签发 `resourceToken`,最长有效期为 15 分钟,不超过访问令牌的有效期。它只允许通过 `resource_token` 查询参数访问本账号可读的书籍资源和 HTTP 朗读,不能用作普通 API 的 Bearer 凭据。客户端应依据 `resourceTokenExpiresAt` 提前刷新;旧服务端缺少这些字段时可回退到旧访问令牌。密码变更、账号停用和认证版本失效同样会撤销资源票据。 + ### `POST /auth/logout` `{ "refreshToken": "..." }` → `{ "ok": true }`。幂等,重复调用不报错。 @@ -307,16 +312,16 @@ pdf / 单图没有独立目录,回落到 items。来源章节出版物(`chap 所以服务端会说 `Accept-Ranges: none` 并回 200,让客户端能做计划,而不是付出一次它没 预期到的下载。 -**子资源可以带 `?access_token=`。** 章节里的 ``、`` 是浏览器 +**子资源应携带 `?resource_token=`。** 为兼容旧客户端仍接受 `?access_token=`。章节里的 ``、`` 是浏览器 自己发起的,没法带 `Authorization` 头。这类请求允许把令牌放进 query, -但**仅限** `GET` + 下列端点: +但**仅限** `GET` / `HEAD` + 下列端点: - `/api/v1/books/:id/assets` - `/api/v1/books/:id/cover` - `/api/v1/books/:id/content` +- `/api/v1/tts`(HTTP 朗读) -其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌 —— URL 会进日志、 -进历史、进 `Referer`,能改状态的令牌不该走那条路。 +其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌。短期资源票据不能访问一般 API,也不能修改状态。服务端请求日志不记录查询参数,并设置 `Referrer-Policy: no-referrer`;反向代理日志仍应另外配置凭据脱敏。 **epub 章节返回的是重写过的 HTML。** 章节内的相对资源引用(`images/pic.png`)已经被 服务端改写成指向本端点的绝对地址。客户端直接把 HTML 交给 WebView 即可,不需要自己 @@ -711,7 +716,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** 一个配错的 HTTP 朗读合成接口(指到一个网页)会以 200 返回 HTML,塞进 `` 里 既没有声音也没有事件——这是最难排查的一种「成功」。 - 这是唯一接受**查询串令牌**的接口之一(另一个是 `/books/:id/assets`、`/cover`、`/content`): - `` 无法携带 `Authorization` 头。因此它被精确地限制为 `GET /tts`, + `` 无法携带 `Authorization` 头。优先使用短期只读 `resource_token`,兼容旧客户端的 `access_token`;查询串鉴权精确限制为 `GET/HEAD /tts`, 能力查询 `/tts/voices` 不支持查询串令牌。 - 响应带 `cache-control: private, no-store`,避免服务配置更新后继续使用旧音频;服务端按上游配置缓存合成结果, 并用 `x-tts-cache: hit | miss` 标明本次是否命中缓存。 @@ -791,12 +796,12 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** ## 同步 -### `GET /sync?since=&bookId=<可选>` +### `GET /sync?since=&bookId=<可选>` -`since` 是上次响应里的 `serverTime`(首次传 0)。 +`since` 是上次响应里的 `serverTime`(首次传 0)。`serverTime` 是服务端分配的单调递增同步游标,不应使用客户端时间或记录的 `updatedAt` 替代;密集写入时它可能领先于实际时钟。 ```json -{ "serverTime": 1789537864820, +{ "serverTime": 1789537864820, "hasMore": false, "progress": [ { "bookId": "...", "locator": "epubcfi(/6/4!/4/2)", "percentage": 0.42, "chapterTitle": "第七章", "device": "android-a", "updatedAt": 1000 } ], "notes": [ { "id": "...", "bookId": "...", "type": "highlight", "locator": "...", @@ -804,7 +809,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** "updatedAt": 1789537864623, "deleted": false } ] } ``` -**客户端必须保存返回的 `serverTime`**,下次带回来才能只拿增量。 +每页进度和笔记合计最多 5000 条。**客户端必须在应用本页后保存返回的 `serverTime`**;如果 `hasMore: true`,立即使用该游标继续拉取,直到 `hasMore: false`。旧客户端不识别 `hasMore` 时,也可以通过后续轮询取完剩余分页。记录的 `updatedAt` 仅用于冲突比较,离线写入仍会取得新的服务端游标。 ### `POST /sync` @@ -818,12 +823,12 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** 两个数组至少给一个(都缺、或不是数组返回 `400 BAD_PAYLOAD`),总数上限 5000(超了返回 `400 BATCH_TOO_LARGE`)。 -`updatedAt` 是**客户端时钟**的毫秒时间戳,决定 LWW 谁赢。 +`updatedAt` 是**客户端时钟**的毫秒时间戳,决定 LWW 谁赢。即使上传的旧记录未赢得合并,服务端也会为本次写入发布新的同步游标,让增量响应包含合并后的权威记录,供客户端纠正本地状态。 -响应把合并后的当前状态(相当于 `since=0` 的全量拉取)一并返回,客户端不用再多发一次拉取: +请求可以额外携带 `since`,使用上次响应中的 `serverTime`,让响应只返回该游标后的变更。省略时按 `since=0` 返回合并后的状态。响应同样分页,`hasMore: true` 时继续调用 `GET /sync`,不应重复推送整批数据: ```json -{ "accepted": 1, "rejected": 0, "serverTime": 1789537864820, +{ "accepted": 1, "rejected": 0, "serverTime": 1789537864820, "hasMore": false, "progress": [...], "notes": [...] } ``` diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 23b22dc..e8a4410 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -31,6 +31,7 @@ | `DATA_DIR` | 当前工作目录下的 `data` | 服务端状态和缓存目录 | | `HOST` | `0.0.0.0` | 监听地址 | | `PORT` | `5888` | 监听端口 | +| `TRUST_PROXY` | `false` | 可信反向代理的 IP/CIDR,多个以逗号分隔;同机代理可用 `loopback`,不接受无条件信任的 `true` | | `READER_TOKEN_SECRET` | 自动生成 | 至少 16 字符的签名密钥;默认保存在 `DATA_DIR/token.secret` | | `LOG_LEVEL` | `info` | 日志级别 | | `MEDIA_FFPROBE_PATH` | `ffprobe` | 影音技术信息读取程序;缺失时仍可扫描入库,时长、标签及章节信息可能不完整 | @@ -39,6 +40,10 @@ 修改 Compose 环境变量后执行 `docker compose up -d`。签名密钥需要稳定保存,否则已有会话会失效。数据目录中的账号、进度和笔记不能靠重新扫描书籍恢复。 +默认不信任客户端传来的 `X-Forwarded-*`。使用反向代理时,必须在 `TRUST_PROXY` 中明确列出代理地址,并让代理覆盖转发头,否则限流会按代理自身的 IP 聚合,HTTPS 同源识别也无法使用转发后的协议和主机。不要把所有客户端可直接访问的网段列为可信代理。 + +认证接口内置限流:登录每 IP 每分钟 60 次,同一 IP 和用户名每分钟最多 10 次连续失败(成功后清零);注册每 IP 每 10 分钟 5 次;刷新每 IP 每分钟 240 次;修改和重置密码每 IP 每 10 分钟 20 次。密码派生运算同时最多执行 4 个,超限返回 `429` 和 `Retry-After`。限流状态为单进程内存状态,重启会清空,多副本部署需在网关增加共享限流。 + ## HTTPS 与反向代理 局域网可直接访问 `http://<服务器IP>:5888`。对公网提供服务时使用 HTTPS,保护登录凭据和访问令牌。 diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 01b3c67..4d73123 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -15,6 +15,7 @@ export interface AppConfig { /** Writable directory owned by this server. Never inside booksDir. */ dataDir: string; host: string; + trustProxy?: false | string[]; port: number; /** Persistent token signing secret, auto generated on first boot if absent. */ jwtSecret: string; @@ -112,10 +113,14 @@ export function loadConfig(): AppConfig { } mkdirSync(dataDir, { recursive: true }); + const proxy = process.env.TRUST_PROXY?.trim(); + if (proxy === 'true') throw new Error('TRUST_PROXY must list trusted proxy IPs or CIDRs, not true'); + return { booksDir, dataDir, host: process.env.HOST ?? '0.0.0.0', + trustProxy: proxy && proxy !== 'false' ? proxy.split(',').map(value => value.trim()).filter(Boolean) : false, port: envInt('PORT', 5888), jwtSecret: loadOrCreateSecret(dataDir), accessTokenTtl: envInt('ACCESS_TOKEN_TTL', 60 * 60 * 24 * 7), diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 3bdc3ec..00a4ade 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -1,7 +1,7 @@ import { DatabaseSync } from 'node:sqlite'; import { join } from 'node:path'; import type { AppConfig } from '../config/index.ts'; -import { MIGRATIONS_SQL, SCHEMA_SQL } from './schema.ts'; +import { MIGRATIONS_SQL, SCHEMA_SQL, SYNC_TRACKING_SQL } from './schema.ts'; /** * Thin wrapper over the built-in node:sqlite driver. @@ -22,6 +22,7 @@ export class Db { this.raw.exec('PRAGMA foreign_keys = ON;'); this.raw.exec(SCHEMA_SQL); this.applyMigrations(); + this.transaction(() => this.raw.exec(SYNC_TRACKING_SQL)); } /** diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8a47c9d..26e3e03 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -23,6 +23,34 @@ ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0; ALTER TABLE books ADD COLUMN content_hash_kind TEXT NOT NULL DEFAULT 'file'; ALTER TABLE chapter_resources ADD COLUMN media_type TEXT NOT NULL DEFAULT 'text/plain'; ALTER TABLE user_books ADD COLUMN reading_status TEXT; +ALTER TABLE reading_progress ADD COLUMN sync_revision INTEGER NOT NULL DEFAULT 0; +ALTER TABLE notes ADD COLUMN sync_revision INTEGER NOT NULL DEFAULT 0; +`; + +export const SYNC_TRACKING_SQL = ` +CREATE TABLE IF NOT EXISTS sync_clock ( + id INTEGER PRIMARY KEY CHECK(id = 1), + revision INTEGER NOT NULL +); +INSERT OR IGNORE INTO sync_clock(id, revision) VALUES(1, 0); +CREATE INDEX IF NOT EXISTS idx_progress_sync ON reading_progress(user_id, sync_revision); +CREATE INDEX IF NOT EXISTS idx_notes_sync ON notes(user_id, sync_revision); +${[ + { table: 'reading_progress', key: 'user_id = NEW.user_id AND book_id = NEW.book_id', fields: ['locator', 'percentage', 'chapter_title', 'device', 'updated_at'] }, + { table: 'notes', key: 'id = NEW.id', fields: ['type', 'locator', 'text', 'comment', 'color', 'updated_at', 'deleted'] }, +].map(({ table, key, fields }) => ` +CREATE TRIGGER IF NOT EXISTS ${table}_sync_insert AFTER INSERT ON ${table} +BEGIN + UPDATE sync_clock SET revision = MAX(revision + 1, CAST(unixepoch('subsec') * 1000 AS INTEGER)) WHERE id = 1; + UPDATE ${table} SET sync_revision = (SELECT revision FROM sync_clock WHERE id = 1) WHERE ${key}; +END; +CREATE TRIGGER IF NOT EXISTS ${table}_sync_update AFTER UPDATE OF ${fields.join(', ')} ON ${table} +BEGIN + UPDATE sync_clock SET revision = MAX(revision + 1, CAST(unixepoch('subsec') * 1000 AS INTEGER)) WHERE id = 1; + UPDATE ${table} SET sync_revision = (SELECT revision FROM sync_clock WHERE id = 1) WHERE ${key}; +END; +UPDATE ${table} SET updated_at = updated_at WHERE sync_revision = 0; +`).join('\n')} `; export const SCHEMA_SQL = ` diff --git a/server/src/http/app.ts b/server/src/http/app.ts index 1541ae6..5101b76 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -20,12 +20,14 @@ import {BusinessSettings} from '../services/business-settings.ts'; import {TtsService} from '../services/tts.ts'; import {registerBusinessSettingsRoutes} from './routes/business-settings.ts'; import {registerAiRoutes} from './routes/ai.ts'; +import { serializeRequest } from './request-log.ts'; +import { registerAuthProtection } from './auth-protection.ts'; export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyInstance { ctx.settings??=new BusinessSettings(ctx.db,ctx.config); ctx.tts=new TtsService(ctx.config,ctx.settings.read('tts')); const app = Fastify({ - logger: { level: ctx.config.logLevel }, + logger: { level: ctx.config.logLevel, serializers: { req: serializeRequest } }, // Book bodies are streamed straight from disk, so a JSON limit this small is // fine and protects the server from oversized sync payloads. Uploads are the // exception and are not bounded here: a multipart body is streamed by the @@ -33,7 +35,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns // limit is what bounds a request. Raising this number would instead let one // JSON request (a sync batch, a batch metadata edit) hold 400MB of heap. bodyLimit: 8 * 1024 * 1024, - trustProxy: true, + trustProxy: ctx.config.trustProxy ?? false, // Source publications use opaque plugin-owned references, often longer than // the router's default 100-character parameter limit. routerOptions: { maxParamLength: 16_384 }, @@ -56,6 +58,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns }); app.addHook('onRequest', async (request, reply) => { + reply.header('referrer-policy', 'no-referrer'); if (!isOriginAllowed(ctx.config, request)) { reply.status(403).send({ error: { code: 'FORBIDDEN', message: 'origin not allowed' } }); return; @@ -63,7 +66,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns reply.header('access-control-allow-origin', resolveCorsOrigin(ctx.config, request)); reply.header('access-control-allow-headers', 'authorization, content-type, range'); reply.header('access-control-allow-methods', 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'); - reply.header('access-control-expose-headers', 'accept-ranges, content-length, content-range'); + reply.header('access-control-expose-headers', 'accept-ranges, content-length, content-range, retry-after'); // `vary: origin` is required whenever the allowed origin is reflected rather // than fixed, otherwise a shared cache will serve one origin's response to // another. @@ -73,6 +76,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns } }); + registerAuthProtection(app); app.options('/*', async (_request, reply) => reply.status(204).send()); registerAuthRoutes(app, ctx); diff --git a/server/src/http/auth-protection.ts b/server/src/http/auth-protection.ts new file mode 100644 index 0000000..bf499d1 --- /dev/null +++ b/server/src/http/auth-protection.ts @@ -0,0 +1,31 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { AuthRateLimiter } from '../services/auth-limits.ts'; + +function accountKey(request: FastifyRequest): string | null { + if (request.routeOptions.url !== '/api/v1/auth/login') return null; + const username = (request.body as { username?: unknown } | null)?.username; + return typeof username === 'string' && username.length <= 100 + ? JSON.stringify(['login', request.ip, username.trim().toLowerCase()]) : null; +} + +export function registerAuthProtection(app: FastifyInstance): void { + const limiter = new AuthRateLimiter(); + app.addHook('onRequest', async request => { + if (request.method !== 'POST') return; + const path = request.routeOptions.url; + let policy: { limit: number; windowMs: number } | undefined; + if (path === '/api/v1/auth/login') policy = { limit: 60, windowMs: 60_000 }; + else if (path === '/api/v1/auth/register') policy = { limit: 5, windowMs: 600_000 }; + else if (path === '/api/v1/auth/refresh') policy = { limit: 240, windowMs: 60_000 }; + else if (path === '/api/v1/auth/password' || path === '/api/v1/admin/users/:id/password') policy = { limit: 20, windowMs: 600_000 }; + if (policy) limiter.consume(JSON.stringify([path, request.ip]), policy.limit, policy.windowMs); + }); + app.addHook('preHandler', async request => { + const key = accountKey(request); + if (key) limiter.consume(key, 10, 60_000); + }); + app.addHook('onResponse', async (request, reply) => { + const key = accountKey(request); + if (key && reply.statusCode < 400) limiter.reset(key); + }); +} diff --git a/server/src/http/auth.ts b/server/src/http/auth.ts index 249529b..6a65152 100644 --- a/server/src/http/auth.ts +++ b/server/src/http/auth.ts @@ -1,6 +1,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import type { AppContext } from './context.ts'; -import { verifyAccessToken } from '../services/tokens.ts'; +import { verifyAccessToken, verifyResourceToken } from '../services/tokens.ts'; import { toPublicUser } from '../services/users.ts'; import { forbidden, unauthorized } from '../lib/errors.ts'; @@ -13,9 +13,9 @@ import { forbidden, unauthorized } from '../lib/errors.ts'; */ export function authenticate(ctx: Pick & {users:Pick}) { return async function preHandler(request: FastifyRequest, _reply: FastifyReply): Promise { - const token = bearerToken(request); - if (!token) throw unauthorized('missing bearer token', 'NO_TOKEN'); - const payload = verifyAccessToken(ctx.config, token); + const credential = bearerToken(request); + if (!credential) throw unauthorized('missing bearer token', 'NO_TOKEN'); + const payload = credential.resource ? verifyResourceToken(ctx.config, credential.token) : verifyAccessToken(ctx.config, credential.token); const row = ctx.users.byId(payload.sub); if (!row) throw unauthorized('account no longer exists', 'TOKEN_INVALID'); if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED'); @@ -39,19 +39,20 @@ export function authenticate(ctx: Pick & {users:Pick; + if (typeof query.resource_token === 'string' && query.resource_token) return { token: query.resource_token, resource: true }; const value = query.access_token; - return typeof value === 'string' && value.length > 0 ? value : null; + return typeof value === 'string' && value.length > 0 ? { token: value, resource: false } : null; } /** Whether this request may authenticate from the query string. */ function queryTokenAllowed(request: FastifyRequest): boolean { - if (request.method !== 'GET') return false; + if (request.method !== 'GET' && request.method !== 'HEAD') return false; const path = (request.url.split('?')[0] ?? '').replace(/\/$/, ''); // Speech, for the same reason: it is a resource an `` element fetches @@ -60,8 +61,7 @@ function queryTokenAllowed(request: FastifyRequest): boolean { // here is a read of audio derived from a sentence the client already holds. if (path === '/api/v1/tts') return true; - if (!path.startsWith('/api/v1/books/')) return false; - return path.endsWith('/assets') || path.endsWith('/cover') || path.endsWith('/content'); + return /^\/api\/v1\/books\/[^/]+\/(?:assets|cover|content)$/.test(path); } export function requireAdmin(request: FastifyRequest): void { diff --git a/server/src/http/cors.ts b/server/src/http/cors.ts index 47a977d..e68bfe0 100644 --- a/server/src/http/cors.ts +++ b/server/src/http/cors.ts @@ -27,8 +27,8 @@ export function resolveCorsOrigin(config: AppConfig, request: FastifyRequest): s if (config.corsOrigins.includes(origin)) return origin; - const proto = (String(request.headers['x-forwarded-proto'] ?? request.protocol ?? 'http').split(',')[0] ?? '').trim(); - const host = (String(request.headers['x-forwarded-host'] ?? request.headers.host ?? '').split(',')[0] ?? '').trim(); + const proto = request.protocol ?? 'http'; + const host = request.host ?? request.headers.host; if (host && origin === `${proto}://${host}`) return origin; // `file://` requests arrive with the literal origin "null". The Android shell @@ -51,8 +51,8 @@ export function isOriginAllowed(config: AppConfig, request: FastifyRequest): boo // sharing its persisted settings with another hostname. Treat that request // as same-origin even when the stored allowlist belongs to the other host. // Reverse proxies provide the public scheme/host through these headers. - const proto = (String(request.headers['x-forwarded-proto'] ?? request.protocol ?? 'http').split(',')[0] ?? '').trim(); - const host = (String(request.headers['x-forwarded-host'] ?? request.headers.host ?? '').split(',')[0] ?? '').trim(); + const proto = request.protocol ?? 'http'; + const host = request.host ?? request.headers.host; if (host && origin === `${proto}://${host}`) return true; return config.corsOrigins.includes(origin) || (origin === 'null' && config.corsOrigins.includes('null')); } diff --git a/server/src/http/errors.ts b/server/src/http/errors.ts index ece208f..32e3ec7 100644 --- a/server/src/http/errors.ts +++ b/server/src/http/errors.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { AppError } from '../lib/errors.ts'; +import { AppError, RateLimitError } from '../lib/errors.ts'; /** * Single error serialiser. Responses intentionally keep the same shape across @@ -8,6 +8,7 @@ import { AppError } from '../lib/errors.ts'; export function registerErrorHandler(app: FastifyInstance): void { app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { + if (error instanceof RateLimitError) reply.header('retry-after', error.retryAfter); reply.status(error.statusCode).send({ error: { code: error.code, message: error.message } }); return; } diff --git a/server/src/http/request-log.ts b/server/src/http/request-log.ts new file mode 100644 index 0000000..c228851 --- /dev/null +++ b/server/src/http/request-log.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify'; + +export function requestLogPath(url: string): string { + return url.split(/[?#]/, 1)[0] ?? ''; +} + +export function serializeRequest(request: FastifyRequest) { + return { + method: request.method, + url: requestLogPath(request.url), + hostname: request.hostname, + remoteAddress: request.ip, + }; +} diff --git a/server/src/http/routes/auth.ts b/server/src/http/routes/auth.ts index 41ed9e1..477bec4 100644 --- a/server/src/http/routes/auth.ts +++ b/server/src/http/routes/auth.ts @@ -54,20 +54,21 @@ export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void app.post('/api/v1/auth/login', async (request) => { const body = (request.body ?? {}) as Credentials; - if (!body.username || !body.password) throw badRequest('username and password are required'); + if (typeof body.username !== 'string' || !body.username || body.username.length > 100 || typeof body.password !== 'string' || !body.password || body.password.length > 1024) throw badRequest('valid username and password are required'); const session = await ctx.users.login(body.username, body.password, request.headers['user-agent'] ?? ''); return session; }); app.post('/api/v1/auth/refresh', async (request) => { const body = (request.body ?? {}) as { refreshToken?: string }; - if (!body.refreshToken) throw badRequest('refreshToken is required'); + if (typeof body.refreshToken !== 'string' || !body.refreshToken || body.refreshToken.length > 4096) throw badRequest('valid refreshToken is required'); return ctx.users.refresh(body.refreshToken); }); app.post('/api/v1/auth/logout', async (request) => { const body = (request.body ?? {}) as { refreshToken?: string }; // Logout is idempotent: a client that is already signed out must not error. + if (body.refreshToken !== undefined && (typeof body.refreshToken !== 'string' || body.refreshToken.length > 4096)) throw badRequest('valid refreshToken is required'); if (body.refreshToken) ctx.users.revokeByToken(body.refreshToken); return { ok: true }; }); @@ -82,7 +83,7 @@ export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void app.post('/api/v1/auth/password', { preHandler: authenticateFn }, async (request) => { const user = currentUser(request); const body = (request.body ?? {}) as { currentPassword?: string; newPassword?: string }; - if (!body.currentPassword || !body.newPassword) throw badRequest('currentPassword and newPassword are required'); + if (typeof body.currentPassword !== 'string' || !body.currentPassword || body.currentPassword.length > 1024 || typeof body.newPassword !== 'string' || !body.newPassword || body.newPassword.length > 1024) throw badRequest('valid currentPassword and newPassword are required'); await ctx.users.changePassword(user.id, body.currentPassword, body.newPassword); return { ok: true }; }); diff --git a/server/src/http/routes/media-proxy.ts b/server/src/http/routes/media-proxy.ts index a89071d..81d709e 100644 --- a/server/src/http/routes/media-proxy.ts +++ b/server/src/http/routes/media-proxy.ts @@ -2,6 +2,7 @@ import {Readable} from 'node:stream'; import type {FastifyInstance} from 'fastify'; import type {MediaRuntime} from '../../media/runtime.ts'; import {AppError} from '../../lib/errors.ts'; +import {requestLogPath} from '../request-log.ts'; /** Streaming bridge. No media database connection or synchronous work in the reading host. */ export function registerMediaProxy(app:FastifyInstance,runtime:Pick):void { @@ -39,7 +40,7 @@ export function registerMediaProxy(app:FastifyInstance,runtime:Pick { const user = currentUser(request); const q = request.query as { since?: string; bookId?: string }; - const since = q.since ? Number.parseInt(q.since, 10) : 0; - if (!Number.isFinite(since) || since < 0) throw badRequest('since must be a non-negative integer'); + const since = q.since ? Number(q.since) : 0; + if (!Number.isSafeInteger(since) || since < 0) throw badRequest('since must be a non-negative integer'); return ctx.sync.pull(user.id, since, q.bookId); }); @@ -26,7 +26,9 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void */ app.post('/api/v1/sync', { preHandler: auth }, async (request) => { const user = currentUser(request); - const body = (request.body ?? {}) as { progress?: unknown; notes?: unknown }; + const body = (request.body ?? {}) as { progress?: unknown; notes?: unknown; since?: unknown }; + const since = body.since ?? 0; + if (typeof since !== 'number' || !Number.isSafeInteger(since) || since < 0) throw badRequest('since must be a non-negative integer'); // Validate the shape explicitly. Iterating a non-array would otherwise walk // a string character by character and report nonsense rejections. if (body.progress !== undefined && !Array.isArray(body.progress)) { @@ -46,7 +48,7 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void const result = ctx.sync.push(user.id, { progress, notes }); // Returning the merged state lets the client reconcile in the same round // trip instead of issuing a follow-up pull. - return { ...result, ...ctx.sync.pull(user.id, 0, undefined), accepted: result.accepted, rejected: result.rejected }; + return { ...result, ...ctx.sync.pull(user.id, since, undefined) }; }); app.get('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => { @@ -66,6 +68,7 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void const user = currentUser(request); const { bookId } = request.params as { bookId: string }; const body = (request.body ?? {}) as Partial; + validateSyncRecord(body); ctx.sync.push(user.id, { progress: [{ bookId, @@ -82,14 +85,16 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void app.get('/api/v1/notes', { preHandler: auth }, async (request) => { const user = currentUser(request); const q = request.query as { bookId?: string; since?: string }; - const since = q.since ? Number.parseInt(q.since, 10) : 0; - const pulled = ctx.sync.pull(user.id, since, q.bookId); - return { notes: pulled.notes.filter((n) => !n.deleted), serverTime: pulled.serverTime }; + const since = q.since ? Number(q.since) : 0; + if (!Number.isSafeInteger(since) || since < 0) throw badRequest('since must be a non-negative integer'); + const pulled = ctx.sync.pull(user.id, since, q.bookId, false); + return { notes: pulled.notes.filter((n) => !n.deleted), serverTime: pulled.serverTime, hasMore: pulled.hasMore }; }); app.post('/api/v1/notes', { preHandler: auth }, async (request, reply) => { const user = currentUser(request); const body = (request.body ?? {}) as Partial; + validateSyncRecord(body); if (!body.bookId) throw badRequest('bookId is required'); const note: NoteRecord = { id: body.id ?? crypto.randomUUID(), diff --git a/server/src/lib/errors.ts b/server/src/lib/errors.ts index b52e66e..318e0f9 100644 --- a/server/src/lib/errors.ts +++ b/server/src/lib/errors.ts @@ -11,6 +11,12 @@ export class AppError extends Error { } export const badRequest = (message: string, code = 'BAD_REQUEST') => new AppError(400, code, message); + +export class RateLimitError extends AppError { + constructor(readonly retryAfter: number, code = 'AUTH_RATE_LIMITED') { + super(429, code, '请求过于频繁,请稍后重试'); + } +} export const unauthorized = (message = 'unauthorized', code = 'UNAUTHORIZED') => new AppError(401, code, message); export const forbidden = (message = 'forbidden', code = 'FORBIDDEN') => new AppError(403, code, message); export const notFound = (message = 'not found', code = 'NOT_FOUND') => new AppError(404, code, message); diff --git a/server/src/services/auth-limits.ts b/server/src/services/auth-limits.ts new file mode 100644 index 0000000..17b9174 --- /dev/null +++ b/server/src/services/auth-limits.ts @@ -0,0 +1,41 @@ +import { RateLimitError } from '../lib/errors.ts'; + +export class AuthRateLimiter { + private readonly buckets = new Map(); + private nextSweep = 0; + + constructor(private readonly now = Date.now, private readonly capacity = 10_000) {} + + consume(key: string, limit: number, windowMs: number): void { + const now = this.now(); + if (now >= this.nextSweep) { + for (const [entry, bucket] of this.buckets) if (bucket.expiresAt <= now) this.buckets.delete(entry); + this.nextSweep = now + 10_000; + } + let bucket = this.buckets.get(key); + if (!bucket || bucket.expiresAt <= now) { + if (!bucket && this.buckets.size >= this.capacity) throw new RateLimitError(10); + bucket = { count: 0, expiresAt: now + windowMs }; + this.buckets.set(key, bucket); + } + if (bucket.count >= limit) throw new RateLimitError(Math.max(1, Math.ceil((bucket.expiresAt - now) / 1000))); + bucket.count += 1; + } + + reset(key: string): void { + this.buckets.delete(key); + } +} + +export class AuthWorkLimiter { + private active = 0; + + constructor(private readonly capacity = 4) {} + + async run(work: () => Promise): Promise { + if (this.active >= this.capacity) throw new RateLimitError(1, 'AUTH_BUSY'); + this.active += 1; + try { return await work(); } + finally { this.active -= 1; } + } +} diff --git a/server/src/services/sync.ts b/server/src/services/sync.ts index b79e8ad..0ad9076 100644 --- a/server/src/services/sync.ts +++ b/server/src/services/sync.ts @@ -15,6 +15,11 @@ import type { BookDto, ShelfService } from './shelf.ts'; const HISTORY_KEEP = 50; const HISTORY_SESSION_MS = 30 * 60 * 1000; const HISTORY_JUMP = 0.05; +const SYNC_PAGE_SIZE = 5000; +const SYNC_STRING_LIMITS: Record = { + id: 16_384, bookId: 16_384, locator: 65_536, chapterTitle: 4096, + device: 1024, type: 32, text: 1_048_576, comment: 1_048_576, color: 100, +}; export interface ProgressRecord { bookId: string; @@ -45,6 +50,25 @@ export interface SyncPushRequest { notes?: NoteRecord[]; } +export interface SyncPullResult { + serverTime: number; + hasMore: boolean; + progress: ProgressRecord[]; + notes: NoteRecord[]; +} + +export function validateSyncRecord(value: unknown): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw badRequest('sync records must be objects', 'BAD_PAYLOAD'); + const record = value as Record; + for (const [field, limit] of Object.entries(SYNC_STRING_LIMITS)) { + const fieldValue = record[field]; + if (fieldValue !== undefined && (typeof fieldValue !== 'string' || fieldValue.length > limit)) throw badRequest(`invalid ${field}`, 'BAD_PAYLOAD'); + } + if (record.updatedAt !== undefined && (typeof record.updatedAt !== 'number' || !Number.isSafeInteger(record.updatedAt) || record.updatedAt < 0)) throw badRequest('invalid updatedAt', 'BAD_PAYLOAD'); + if (record.percentage !== undefined && (typeof record.percentage !== 'number' || !Number.isFinite(record.percentage))) throw badRequest('invalid percentage', 'BAD_PAYLOAD'); + if (record.deleted !== undefined && typeof record.deleted !== 'boolean') throw badRequest('invalid deleted', 'BAD_PAYLOAD'); +} + /** * One row of the "继续阅读" strip: a whole book plus where the reader left off. * @@ -89,10 +113,11 @@ export class SyncService { push(userId: string, payload: SyncPushRequest): { accepted: number; rejected: number } { let accepted = 0; let rejected = 0; + for (const item of [...(payload.progress ?? []), ...(payload.notes ?? [])]) validateSyncRecord(item); this.db.transaction(() => { for (const item of payload.progress ?? []) { - if (!item.bookId || typeof item.updatedAt !== 'number') { + if (!item || typeof item.bookId !== 'string' || !item.bookId || !Number.isSafeInteger(item.updatedAt) || item.updatedAt < 0) { rejected += 1; continue; } @@ -100,7 +125,7 @@ export class SyncService { accepted += 1; } for (const item of payload.notes ?? []) { - if (!item.bookId || !item.id) { + if (!item || typeof item.bookId !== 'string' || !item.bookId || typeof item.id !== 'string' || !item.id) { rejected += 1; continue; } @@ -208,34 +233,40 @@ export class SyncService { } /** Everything changed strictly after `since`, for the client's catch-up. */ - pull(userId: string, since: number, bookId?: string): { - serverTime: number; - progress: ProgressRecord[]; - notes: NoteRecord[]; - } { - const progressRows = this.db.all<{ + pull(userId: string, since: number, bookId?: string, includeProgress = true): SyncPullResult { + return this.db.transaction(() => this.pullPage(userId, since, bookId, includeProgress)); + } + + private pullPage(userId: string, since: number, bookId: string | undefined, includeProgress: boolean): SyncPullResult { + this.db.run('UPDATE sync_clock SET revision = MAX(revision, ?) WHERE id = 1', Date.now()); + const watermark = this.db.get<{ revision: number }>('SELECT revision FROM sync_clock WHERE id = 1')!.revision; + const progressRows = includeProgress ? this.db.all<{ book_id: string; locator: string; percentage: number; chapter_title: string; - device: string; updated_at: number; + device: string; updated_at: number; sync_revision: number; }>( - `SELECT book_id, locator, percentage, chapter_title, device, updated_at - FROM reading_progress WHERE user_id = ? AND updated_at > ? ${bookId ? 'AND book_id = ?' : ''} - ORDER BY updated_at ASC LIMIT 5000`, + `SELECT book_id, locator, percentage, chapter_title, device, updated_at, sync_revision + FROM reading_progress WHERE user_id = ? AND sync_revision > ? ${bookId ? 'AND book_id = ?' : ''} + ORDER BY sync_revision ASC LIMIT ${SYNC_PAGE_SIZE + 1}`, ...(bookId ? [userId, since, bookId] : [userId, since]), - ); + ) : []; const noteRows = this.db.all<{ id: string; book_id: string; type: string; locator: string; text: string; - comment: string; color: string; updated_at: number; deleted: number; + comment: string; color: string; updated_at: number; deleted: number; sync_revision: number; }>( - `SELECT id, book_id, type, locator, text, comment, color, updated_at, deleted - FROM notes WHERE user_id = ? AND updated_at > ? ${bookId ? 'AND book_id = ?' : ''} - ORDER BY updated_at ASC LIMIT 5000`, + `SELECT id, book_id, type, locator, text, comment, color, updated_at, deleted, sync_revision + FROM notes WHERE user_id = ? AND sync_revision > ? ${bookId ? 'AND book_id = ?' : ''} + ORDER BY sync_revision ASC LIMIT ${SYNC_PAGE_SIZE + 1}`, ...(bookId ? [userId, since, bookId] : [userId, since]), ); + const revisions = [...progressRows, ...noteRows].map(row => row.sync_revision).sort((left, right) => left - right); + const hasMore = revisions.length > SYNC_PAGE_SIZE; + const serverTime = hasMore ? revisions[SYNC_PAGE_SIZE - 1]! : Math.max(since, watermark); return { - serverTime: Date.now(), - progress: progressRows.map((row) => ({ + serverTime, + hasMore, + progress: progressRows.filter(row => row.sync_revision <= serverTime).map((row) => ({ bookId: row.book_id, locator: row.locator, percentage: row.percentage, @@ -243,7 +274,7 @@ export class SyncService { device: row.device, updatedAt: row.updated_at, })), - notes: noteRows.map((row) => ({ + notes: noteRows.filter(row => row.sync_revision <= serverTime).map((row) => ({ id: row.id, bookId: row.book_id, type: row.type as NoteRecord['type'], diff --git a/server/src/services/tokens.ts b/server/src/services/tokens.ts index 31043ca..c082746 100644 --- a/server/src/services/tokens.ts +++ b/server/src/services/tokens.ts @@ -5,7 +5,7 @@ import { unauthorized } from '../lib/errors.ts'; export interface TokenPayload { sub: string; role: 'admin' | 'member'; - type: 'access'; + type: 'access' | 'resource'; iat: number; exp: number; ver?: number; @@ -20,10 +20,26 @@ export interface TokenPayload { export function signAccessToken( config: AppConfig, user: { id: string; role: 'admin' | 'member'; authVersion?: number }, +): { token: string; expiresAt: number } { + return signToken(config, user, 'access', config.accessTokenTtl); +} + +export function signResourceToken( + config: AppConfig, + user: { id: string; role: 'admin' | 'member'; authVersion?: number }, +): { token: string; expiresAt: number } { + return signToken(config, user, 'resource', Math.min(config.accessTokenTtl, 15 * 60)); +} + +function signToken( + config: AppConfig, + user: { id: string; role: 'admin' | 'member'; authVersion?: number }, + type: TokenPayload['type'], + ttl: number, ): { token: string; expiresAt: number } { const issuedAt = Math.floor(Date.now() / 1000); - const expiresAt = issuedAt + config.accessTokenTtl; - const payload: TokenPayload = { sub: user.id, role: user.role, type: 'access', iat: issuedAt, exp: expiresAt, ver: user.authVersion ?? 0 }; + const expiresAt = issuedAt + ttl; + const payload: TokenPayload = { sub: user.id, role: user.role, type, iat: issuedAt, exp: expiresAt, ver: user.authVersion ?? 0 }; const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const body = base64url(JSON.stringify(payload)); const signature = sign(config.jwtSecret, `${header}.${body}`); @@ -31,6 +47,14 @@ export function signAccessToken( } export function verifyAccessToken(config: AppConfig, token: string): TokenPayload { + return verifyToken(config, token, 'access'); +} + +export function verifyResourceToken(config: AppConfig, token: string): TokenPayload { + return verifyToken(config, token, 'resource'); +} + +function verifyToken(config: AppConfig, token: string, type: TokenPayload['type']): TokenPayload { const parts = token.split('.'); if (parts.length !== 3) throw unauthorized('malformed token', 'TOKEN_INVALID'); const [header, body, signature] = parts as [string, string, string]; @@ -46,7 +70,7 @@ export function verifyAccessToken(config: AppConfig, token: string): TokenPayloa } catch { throw unauthorized('bad token payload', 'TOKEN_INVALID'); } - if (payload.type !== 'access') throw unauthorized('wrong token type', 'TOKEN_INVALID'); + if (!payload || typeof payload.sub !== 'string' || payload.type !== type || !Number.isSafeInteger(payload.exp)) throw unauthorized('wrong token type or payload', 'TOKEN_INVALID'); if (payload.exp * 1000 <= Date.now()) throw unauthorized('token expired', 'TOKEN_EXPIRED'); return payload; } diff --git a/server/src/services/users.ts b/server/src/services/users.ts index 38382b7..5b77b69 100644 --- a/server/src/services/users.ts +++ b/server/src/services/users.ts @@ -2,10 +2,11 @@ import { randomUUID } from 'node:crypto'; import type { Db } from '../db/index.ts'; import type { AppConfig } from '../config/index.ts'; import { hashPassword, verifyPassword } from './passwords.ts'; -import { hashToken, newRefreshToken, signAccessToken } from './tokens.ts'; +import { hashToken, newRefreshToken, signAccessToken, signResourceToken } from './tokens.ts'; import { conflict, forbidden, notFound, unauthorized } from '../lib/errors.ts'; import { RegistrationService } from './registration.ts'; import { BusinessSettingsReader } from './business-settings.ts'; +import { AuthWorkLimiter } from './auth-limits.ts'; export type Role = 'admin' | 'member'; @@ -43,6 +44,7 @@ export function toPublicUser(row: UserRow): PublicUser { } export class UserService { + private readonly passwordWork = new AuthWorkLimiter(); constructor( private readonly db: Db, private readonly config: AppConfig, @@ -86,7 +88,7 @@ export class UserService { const existing = this.db.get<{ id: string }>('SELECT id FROM users WHERE username = ? COLLATE NOCASE', username); if (existing) throw conflict('username already taken', 'USERNAME_TAKEN'); - const passwordHash = await hashPassword(input.password); + const passwordHash = await this.passwordWork.run(() => hashPassword(input.password)); return this.db.transaction(() => { admission?.(); if (this.db.get('SELECT id FROM users WHERE username = ? COLLATE NOCASE', username)) throw conflict('username already taken', 'USERNAME_TAKEN'); @@ -141,19 +143,20 @@ export class UserService { user: PublicUser; accessToken: string; accessTokenExpiresAt: number; + resourceToken: string; + resourceTokenExpiresAt: number; refreshToken: string; refreshTokenExpiresAt: number; }> { const row = this.db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', username.trim()); // Always run a hash comparison so a missing user and a wrong password take // comparable time and cannot be distinguished by timing. - const ok = row - ? await verifyPassword(password, row.password_hash) - : await verifyPassword(password, 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA'); + const ok = await this.passwordWork.run(() => verifyPassword(password, row?.password_hash ?? 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA')); if (!row || !ok) throw unauthorized('invalid username or password', 'BAD_CREDENTIALS'); if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED'); const access = signAccessToken(this.config, { id: row.id, role: row.role, authVersion: row.auth_version ?? 0 }); + const resource = signResourceToken(this.config, { id: row.id, role: row.role, authVersion: row.auth_version ?? 0 }); const refresh = newRefreshToken(); const refreshExpiresAt = Date.now() + this.config.refreshTokenTtl * 1000; this.db.run( @@ -165,6 +168,8 @@ export class UserService { user: toPublicUser(row), accessToken: access.token, accessTokenExpiresAt: access.expiresAt, + resourceToken: resource.token, + resourceTokenExpiresAt: resource.expiresAt, refreshToken: refresh.token, refreshTokenExpiresAt: refreshExpiresAt, }; @@ -175,6 +180,8 @@ export class UserService { user: PublicUser; accessToken: string; accessTokenExpiresAt: number; + resourceToken: string; + resourceTokenExpiresAt: number; refreshToken: string; refreshTokenExpiresAt: number; }> { @@ -192,6 +199,7 @@ export class UserService { this.db.run('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?', Date.now(), row.id); const access = signAccessToken(this.config, { id: user.id, role: user.role, authVersion: user.auth_version ?? 0 }); + const resource = signResourceToken(this.config, { id: user.id, role: user.role, authVersion: user.auth_version ?? 0 }); const next = newRefreshToken(); const expiresAt = Date.now() + this.config.refreshTokenTtl * 1000; this.db.run( @@ -203,6 +211,8 @@ export class UserService { user: toPublicUser(user), accessToken: access.token, accessTokenExpiresAt: access.expiresAt, + resourceToken: resource.token, + resourceTokenExpiresAt: resource.expiresAt, refreshToken: next.token, refreshTokenExpiresAt: expiresAt, }; @@ -223,13 +233,13 @@ export class UserService { async changePassword(userId: string, currentPassword: string, nextPassword: string): Promise { const row = this.byId(userId); if (!row) throw notFound('user not found'); - if (!(await verifyPassword(currentPassword, row.password_hash))) { + if (!(await this.passwordWork.run(() => verifyPassword(currentPassword, row.password_hash)))) { throw unauthorized('current password is incorrect', 'BAD_CREDENTIALS'); } if (nextPassword.length < 8) throw conflict('password must be at least 8 characters', 'PASSWORD_TOO_SHORT'); this.db.run( 'UPDATE users SET password_hash = ?, auth_version = auth_version + 1, updated_at = ? WHERE id = ?', - await hashPassword(nextPassword), Date.now(), userId, + await this.passwordWork.run(() => hashPassword(nextPassword)), Date.now(), userId, ); // A password change must invalidate every other device's session. this.revokeAll(userId); @@ -251,7 +261,7 @@ export class UserService { async resetPassword(userId: string, password: string): Promise { if (!this.byId(userId)) throw notFound('用户不存在'); if (password.length < 8 || password.length > 1024) throw conflict('密码须为 8–1024 位','PASSWORD_TOO_SHORT'); - const hash = await hashPassword(password); + const hash = await this.passwordWork.run(() => hashPassword(password)); this.db.transaction(()=>{ this.db.run('UPDATE users SET password_hash=?,auth_version=auth_version+1,updated_at=? WHERE id=?',hash,Date.now(),userId); this.revokeAll(userId); diff --git a/server/test/android-cors.test.ts b/server/test/android-cors.test.ts index 46a1cf4..28d4b67 100644 --- a/server/test/android-cors.test.ts +++ b/server/test/android-cors.test.ts @@ -1,9 +1,23 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import Fastify from 'fastify'; import type { FastifyRequest } from 'fastify'; import type { AppConfig } from '../src/config/index.ts'; import { isOriginAllowed, resolveCorsOrigin } from '../src/http/cors.ts'; +test('untrusted forwarded protocol and host do not expand the same-origin allowlist', async () => { + const config = { corsOrigins: ['https://allowed.test'] } as AppConfig; + for (const trusted of [false, true]) { + const app = Fastify({ trustProxy: trusted ? ['loopback'] : false }); + app.get('/', request => ({ allowed: isOriginAllowed(config, request), origin: resolveCorsOrigin(config, request) })); + try { + const response = await app.inject({ url: '/', headers: { host: 'internal.test', origin: 'https://public.test', 'x-forwarded-proto': 'https', 'x-forwarded-host': 'public.test' } }); + assert.equal(response.json().allowed, trusted); + assert.equal(response.json().origin, trusted ? 'https://public.test' : 'https://allowed.test'); + } finally { await app.close(); } + } +}); + test('Android origin is allowed and echoed with a restricted H5 allowlist', () => { const config = { corsOrigins: ['https://reader.example.com'] } as AppConfig; const origin = 'https://appassets.androidplatform.net'; @@ -18,7 +32,7 @@ test('Android origin is allowed and echoed with a restricted H5 allowlist', () = test('the public host is allowed when persisted CORS settings belong to another deployment', () => { const config = { corsOrigins: ['https://reader.example.com'] } as AppConfig; const request = { - protocol: 'http', + protocol: 'https', headers: { origin: 'https://reader-uat.example.com', host: 'reader-uat.example.com', diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 5602f7f..b661c94 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -373,6 +373,52 @@ test('malformed and oversized sync payloads are rejected cleanly', async () => { assert.equal((res.json() as { error: { code: string } }).error.code, 'BATCH_TOO_LARGE'); }); +test('malformed authentication and sync fields return client errors', async () => { + const owner = await login('owner'); + const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!; + for (const payload of [ + { username: {}, password: 'password123' }, + { username: 'owner', password: [] }, + { username: 'owner', password: 'x'.repeat(1025) }, + ]) { + const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload }); + assert.equal(response.statusCode, 400); + } + for (const action of ['refresh', 'logout']) { + const response = await app.inject({ method: 'POST', url: `/api/v1/auth/${action}`, payload: { refreshToken: {} } }); + assert.equal(response.statusCode, 400); + } + for (const payload of [ + { progress: [null] }, + { progress: [{ bookId: book.id, updatedAt: 1, locator: {} }] }, + { notes: [{ bookId: book.id, id: 'invalid-note', deleted: 'false' }] }, + { since: -1, progress: [] }, + ]) { + const response = await app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload }); + assert.equal(response.statusCode, 400); + } + for (const since of ['123oops', '-1', '1.5', '9007199254740992']) { + const response = await app.inject({ method: 'GET', url: `/api/v1/sync?since=${since}`, headers: auth(owner.token) }); + assert.equal(response.statusCode, 400); + } +}); + +test('sync pushes return only changes after the supplied server cursor', async () => { + const owner = await login('owner'); + const before = await app.inject({ method: 'GET', url: '/api/v1/sync?since=0', headers: auth(owner.token) }); + const since = (before.json() as { serverTime: number }).serverTime; + const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!; + const response = await app.inject({ + method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), + payload: { since, notes: [{ id: 'delta-note', bookId: book.id, text: 'delta', updatedAt: 1 }] }, + }); + assert.equal(response.statusCode, 200); + const result = response.json() as { notes: Array<{ id: string }>; progress: unknown[]; hasMore: boolean }; + assert.deepEqual(result.notes.map(note => note.id), ['delta-note']); + assert.equal(result.progress.length, 0); + assert.equal(result.hasMore, false); +}); + test('a tampered token is refused', async () => { // Use the member token: forging admin onto an already-admin token would // re-serialise to the identical string and prove nothing. @@ -1017,6 +1063,23 @@ test('a volume archive beside volume folders stays on the shelf and serves its p } }); +test('rate-limited authentication retains CORS and retry metadata', async () => { + for (let index = 0; index <= 60; index++) { + const response = await app.inject({ + method: 'POST', url: '/api/v1/auth/login', remoteAddress: '192.0.2.99', + headers: { origin: 'https://reader-client.test' }, + payload: { username: `missing-rate-user-${index}`, password: 'wrong-password' }, + }); + assert.equal(response.statusCode, index < 60 ? 401 : 429); + if (index === 60) { + assert.equal(response.headers['access-control-allow-origin'], 'https://reader-client.test'); + assert.match(String(response.headers['access-control-expose-headers']), /retry-after/); + assert.equal(response.headers['referrer-policy'], 'no-referrer'); + assert.ok(Number(response.headers['retry-after']) > 0); + } + } +}); + test('the shelf can be filtered to one folder', async () => { /* * The library screen's browsing half, at the layer that has to make it possible. diff --git a/server/test/auth-protection.test.ts b/server/test/auth-protection.test.ts new file mode 100644 index 0000000..cf33903 --- /dev/null +++ b/server/test/auth-protection.test.ts @@ -0,0 +1,107 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { AuthRateLimiter, AuthWorkLimiter } from '../src/services/auth-limits.ts'; +import { registerAuthProtection } from '../src/http/auth-protection.ts'; +import { registerErrorHandler } from '../src/http/errors.ts'; +import { unauthorized } from '../src/lib/errors.ts'; + +test('rate windows expire and distinct keys do not share an allowance', () => { + let now = 100; + const limiter = new AuthRateLimiter(() => now); + limiter.consume('first', 1, 60_000); + assert.throws(() => limiter.consume('first', 1, 60_000), { statusCode: 429, retryAfter: 60 }); + limiter.consume('second', 1, 60_000); + now += 60_000; + limiter.consume('first', 1, 60_000); + limiter.reset('first'); + limiter.consume('first', 1, 60_000); +}); + +test('rate limiter bounds memory without evicting active limits', () => { + let now = 0; + const limiter = new AuthRateLimiter(() => now, 2); + limiter.consume('first', 1, 60_000); + limiter.consume('second', 1, 60_000); + assert.throws(() => limiter.consume('third', 1, 60_000), { statusCode: 429 }); + assert.throws(() => limiter.consume('first', 1, 60_000), { statusCode: 429 }); + now += 60_000; + limiter.consume('third', 1, 60_000); +}); + +test('password work is bounded and slots are released after failures', async () => { + const limiter = new AuthWorkLimiter(1); + let release!: () => void; + const pending = limiter.run(() => new Promise(resolve => { release = resolve; })); + await assert.rejects(limiter.run(async () => 'blocked'), { statusCode: 429, code: 'AUTH_BUSY' }); + release(); + await pending; + await assert.rejects(limiter.run(async () => { throw new Error('failure'); }), /failure/); + assert.equal(await limiter.run(async () => 'available'), 'available'); +}); + +function build(trustProxy: false | string[] = false) { + const app = Fastify({ trustProxy, logger: false }); + registerErrorHandler(app); + registerAuthProtection(app); + app.post('/api/v1/auth/login', async request => { + if ((request.body as { password: string }).password !== 'correct') throw unauthorized('bad credentials'); + return { ok: true, ip: request.ip }; + }); + return app; +} + +test('failed account attempts return 429 and Retry-After without a proxy-header bypass', async () => { + const app = build(); + try { + for (let index = 0; index < 11; index++) { + const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', headers: { 'x-forwarded-for': `10.0.0.${index}` }, payload: { username: ' OWNER ', password: 'wrong' } }); + assert.equal(response.statusCode, index < 10 ? 401 : 429); + if (index === 10) assert.ok(Number(response.headers['retry-after']) > 0); + } + const other = await app.inject({ method: 'POST', url: '/api/v1/auth/login', remoteAddress: '192.0.2.2', payload: { username: 'owner', password: 'wrong' } }); + assert.equal(other.statusCode, 401); + } finally { await app.close(); } +}); + +test('successful authentication clears account failures, not the per-IP budget', async () => { + const app = build(); + try { + for (let round = 0; round < 2; round++) { + for (let index = 0; index < 9; index++) { + const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: 'owner', password: 'wrong' } }); + assert.equal(response.statusCode, 401); + } + const success = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: 'OWNER', password: 'correct' } }); + assert.equal(success.statusCode, 200); + } + for (let index = 20; index <= 60; index++) { + const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: `user-${index}`, password: 'correct' } }); + assert.equal(response.statusCode, index < 60 ? 200 : 429); + } + } finally { await app.close(); } +}); + +test('forwarded client addresses are honored only for explicitly trusted proxies', async () => { + for (const trustProxy of [false, ['loopback']] as const) { + const app = build(trustProxy === false ? false : [...trustProxy]); + try { + const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', headers: { 'x-forwarded-for': '192.0.2.5' }, payload: { username: 'owner', password: 'correct' } }); + assert.equal(response.json().ip, trustProxy === false ? '127.0.0.1' : '192.0.2.5'); + } finally { await app.close(); } + } +}); + +test('registration attempts are bounded independently for each client address', async () => { + const app = build(); + app.post('/api/v1/auth/register', async () => ({ ok: true })); + try { + for (let index = 0; index < 6; index++) { + const response = await app.inject({ method: 'POST', url: '/api/v1/auth/register' }); + assert.equal(response.statusCode, index < 5 ? 200 : 429); + if (index === 5) assert.ok(Number(response.headers['retry-after']) > 0); + } + const other = await app.inject({ method: 'POST', url: '/api/v1/auth/register', remoteAddress: '192.0.2.2' }); + assert.equal(other.statusCode, 200); + } finally { await app.close(); } +}); diff --git a/server/test/config.test.ts b/server/test/config.test.ts index 9c1d3f7..e2ccaab 100644 --- a/server/test/config.test.ts +++ b/server/test/config.test.ts @@ -17,6 +17,15 @@ import { loadConfig } from '../src/config/index.ts'; const SECRET_ENV = ['READER_TOKEN_SECRET', 'JWT_SECRET'] as const; +test('proxy trust defaults to off and accepts only an explicit allowlist', async t => { + const root = await mkdtemp(join(tmpdir(), 'reader-proxy-config-')); + t.after(() => rm(root, { recursive: true, force: true })); + const vars = { DATA_DIR: join(root, 'data'), BOOKS_DIR: join(root, 'books') }; + await withEnv(vars, () => assert.equal(loadConfig().trustProxy, false)); + await withEnv({ ...vars, TRUST_PROXY: 'loopback, 192.0.2.10' }, () => assert.deepEqual(loadConfig().trustProxy, ['loopback', '192.0.2.10'])); + await withEnv({ ...vars, TRUST_PROXY: 'true' }, () => assert.throws(() => loadConfig(), /TRUST_PROXY/)); +}); + test('refuses to start from a backup or incomplete restore directory', async t => { const root = await mkdtemp(join(tmpdir(), 'reader-config-backup-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -34,7 +43,7 @@ async function withEnv( fn: () => Promise | void, ): Promise { const saved = new Map(); - for (const key of [...SECRET_ENV, 'DATA_DIR', 'BOOKS_DIR']) { + for (const key of [...SECRET_ENV, 'DATA_DIR', 'BOOKS_DIR', 'TRUST_PROXY']) { saved.set(key, process.env[key]); delete process.env[key]; } diff --git a/server/test/library-scale.test.ts b/server/test/library-scale.test.ts index 899b665..62738c0 100644 --- a/server/test/library-scale.test.ts +++ b/server/test/library-scale.test.ts @@ -21,7 +21,8 @@ test('10,000 indexed books keep shelf queries paginated and incremental sync bou assert.equal(shelf.list('u',{search:'b09999'}).items.length,1); const queryMs=performance.now()-query,sync=new SyncService(db,shelf),syncStart=performance.now(); sync.push('u',{progress:[{bookId:'b09999',locator:'r1:0:s',percentage:0,chapterTitle:'s',device:'test',updatedAt:10}]}); - assert.equal(sync.pull('u',9).progress.length,1);assert.equal(sync.pull('u',10).progress.length,0); + const pulled=sync.pull('u',0); + assert.equal(pulled.progress.length,1);assert.equal(sync.pull('u',pulled.serverTime).progress.length,0); t.diagnostic(JSON.stringify({rows:10000,seedMs:Math.round(seedMs),threeQueriesMs:Math.round(queryMs),syncMs:Math.round(performance.now()-syncStart)})); }finally{db.close();} }); diff --git a/server/test/request-log.test.ts b/server/test/request-log.test.ts new file mode 100644 index 0000000..d775038 --- /dev/null +++ b/server/test/request-log.test.ts @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Writable } from 'node:stream'; +import Fastify from 'fastify'; +import { requestLogPath, serializeRequest } from '../src/http/request-log.ts'; +import { registerMediaProxy } from '../src/http/routes/media-proxy.ts'; + +test('request logs omit query credentials without changing the request', async () => { + const lines: string[] = []; + const stream = new Writable({ write(chunk, _encoding, callback) { lines.push(String(chunk)); callback(); } }); + const app = Fastify({ logger: { stream, serializers: { req: serializeRequest } } }); + app.get('/resource', request => ({ query: request.query })); + try { + const response = await app.inject('/resource?access_token=private-access&ticket=private-ticket'); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().query, { access_token: 'private-access', ticket: 'private-ticket' }); + const output = lines.join(''); + assert.ok(output.includes('incoming request')); + assert.ok(output.includes('/resource')); + assert.ok(!output.includes('private-access')); + assert.ok(!output.includes('private-ticket')); + assert.equal(requestLogPath('/resource?access_token=secret#fragment'), '/resource'); + } finally { await app.close(); } +}); + +test('media proxy errors do not log upstream credential-bearing messages', async () => { + const lines: string[] = []; + const stream = new Writable({ write(chunk, _encoding, callback) { lines.push(String(chunk)); callback(); } }); + const app = Fastify({ logger: { stream, serializers: { req: serializeRequest } } }); + registerMediaProxy(app, { + async request() { throw new Error('failed URL with private-ticket'); }, + async close() {}, + }); + try { + const response = await app.inject('/api/v1/media/play?ticket=private-ticket'); + assert.equal(response.statusCode, 503); + assert.ok(!lines.join('').includes('private-ticket')); + } finally { await app.close(); } +}); diff --git a/server/test/resource-tokens.test.ts b/server/test/resource-tokens.test.ts new file mode 100644 index 0000000..fce8d8b --- /dev/null +++ b/server/test/resource-tokens.test.ts @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import type { AppConfig } from '../src/config/index.ts'; +import { Db } from '../src/db/index.ts'; +import { authenticate, currentUser } from '../src/http/auth.ts'; +import { registerErrorHandler } from '../src/http/errors.ts'; +import { signAccessToken, signResourceToken, verifyAccessToken, verifyResourceToken } from '../src/services/tokens.ts'; +import { UserService, type UserRow } from '../src/services/users.ts'; + +const config = { jwtSecret: 'resource-token-test-secret', accessTokenTtl: 604_800, refreshTokenTtl: 31_536_000 } as AppConfig; +const identity = { id: 'user', role: 'member' as const, authVersion: 0 }; + +test('resource credentials are short lived and cannot be used as access tokens', () => { + const resource = signResourceToken(config, identity); + assert.ok(resource.expiresAt <= Date.now() + 15 * 60_000); + assert.equal(verifyResourceToken(config, resource.token).type, 'resource'); + assert.throws(() => verifyAccessToken(config, resource.token), { code: 'TOKEN_INVALID' }); + assert.throws(() => verifyResourceToken(config, signAccessToken(config, identity).token), { code: 'TOKEN_INVALID' }); + assert.throws(() => verifyResourceToken(config, resource.token + 'tampered'), { code: 'TOKEN_INVALID' }); + const expired = signResourceToken({ ...config, accessTokenTtl: -1 }, identity); + assert.throws(() => verifyResourceToken(config, expired.token), { code: 'TOKEN_EXPIRED' }); +}); + +test('resource tickets authenticate only allowlisted reads and still enforce account revocation', async () => { + const row: UserRow = { id: 'user', username: 'reader', display_name: '', password_hash: '', role: 'member', disabled: 0, created_at: 0, updated_at: 0, auth_version: 0 }; + const app = Fastify({ logger: false }); + registerErrorHandler(app); + const auth = authenticate({ config, users: { byId: id => id === row.id ? row : undefined } }); + for (const path of ['/api/v1/books/book/assets', '/api/v1/books/book/cover', '/api/v1/books/book/content', '/api/v1/books/book/manifest', '/api/v1/tts', '/api/v1/auth/me']) { + app.get(path, { preHandler: auth }, request => ({ user: currentUser(request).id })); + } + app.post('/api/v1/books/book/assets', { preHandler: auth }, () => ({ ok: true })); + const token = signResourceToken(config, identity).token; + try { + for (const path of ['/api/v1/books/book/assets', '/api/v1/books/book/cover', '/api/v1/books/book/content', '/api/v1/tts']) { + for (const method of ['GET', 'HEAD'] as const) assert.equal((await app.inject({ method, url: `${path}?resource_token=${token}` })).statusCode, 200); + } + for (const path of ['/api/v1/auth/me', '/api/v1/books/book/manifest']) assert.equal((await app.inject(`${path}?resource_token=${token}`)).statusCode, 401); + assert.equal((await app.inject({ method: 'POST', url: `/api/v1/books/book/assets?resource_token=${token}` })).statusCode, 401); + assert.equal((await app.inject({ url: '/api/v1/auth/me', headers: { authorization: `Bearer ${token}` } })).statusCode, 401); + assert.equal((await app.inject(`/api/v1/books/book/assets?access_token=${token}`)).statusCode, 401); + const access = signAccessToken(config, identity).token; + assert.equal((await app.inject(`/api/v1/books/book/assets?resource_token=${access}`)).statusCode, 401); + assert.equal((await app.inject(`/api/v1/books/book/assets?resource_token=invalid&access_token=${access}`)).statusCode, 401); + row.disabled = 1; + assert.equal((await app.inject(`/api/v1/books/book/assets?resource_token=${token}`)).statusCode, 403); + row.disabled = 0; + row.auth_version = 1; + assert.equal((await app.inject(`/api/v1/books/book/assets?resource_token=${token}`)).statusCode, 401); + } finally { await app.close(); } +}); + +test('login and refresh issue resource credentials tied to the current account version', async () => { + const db = new Db(':memory:'); + try { + const users = new UserService(db, config); + const user = await users.create({ username: 'resource-reader', password: 'password123' }); + const session = await users.login('resource-reader', 'password123'); + assert.equal(verifyResourceToken(config, session.resourceToken).sub, user.id); + assert.ok(session.resourceTokenExpiresAt < session.accessTokenExpiresAt); + const refreshed = await users.refresh(session.refreshToken); + assert.equal(verifyResourceToken(config, refreshed.resourceToken).ver, 0); + await users.resetPassword(user.id, 'another-password'); + const replacement = await users.login('resource-reader', 'another-password'); + assert.equal(verifyResourceToken(config, replacement.resourceToken).ver, 1); + } finally { db.close(); } +}); diff --git a/server/test/sync-cursor.test.ts b/server/test/sync-cursor.test.ts new file mode 100644 index 0000000..6ab0171 --- /dev/null +++ b/server/test/sync-cursor.test.ts @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { Db } from '../src/db/index.ts'; +import { SCHEMA_SQL } from '../src/db/schema.ts'; +import { ShelfService } from '../src/services/shelf.ts'; +import { SyncService } from '../src/services/sync.ts'; + +function seed(db: Pick): void { + db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('user','reader','unused','member',1,1),('other','other','unused','member',1,1)"); + db.run("INSERT INTO books(id,content_hash,format,created_at,updated_at) VALUES('book','hash','txt',1,1),('second','second','txt',1,1)"); +} + +function progress(bookId = 'book', updatedAt = 1) { + return { bookId, updatedAt, locator: 'chapter1', percentage: 0.5, chapterTitle: 'one', device: 'phone' }; +} + +test('offline writes and tombstones are visible after an already-issued server cursor', () => { + const db = new Db(':memory:'); + try { + seed(db); + const sync = new SyncService(db, new ShelfService(db)); + const before = sync.pull('user', 0).serverTime; + sync.push('user', { progress: [progress()], notes: [{ id: 'note', bookId: 'book', type: 'note', locator: '', text: 'offline', comment: '', color: '', updatedAt: 1, deleted: false }] }); + const changed = sync.pull('user', before); + assert.equal(changed.progress.length, 1); + assert.equal(changed.notes.length, 1); + assert.equal(changed.progress[0]!.updatedAt, 1); + assert.ok(changed.serverTime > before); + db.run("UPDATE notes SET deleted = 1 WHERE id = 'note'"); + assert.equal(sync.pull('user', changed.serverTime).notes[0]!.deleted, true); + assert.deepEqual(sync.pull('other', before).notes, []); + assert.deepEqual(sync.pull('user', before, 'second').progress, []); + } finally { db.close(); } +}); + +test('pagination shares one boundary across progress and notes with identical client timestamps', () => { + const db = new Db(':memory:'); + try { + seed(db); + const sync = new SyncService(db, new ShelfService(db)); + db.transaction(() => { + const insert = db.prepare("INSERT INTO notes(id,user_id,book_id,type,updated_at) VALUES(?,'user','book','note',1)"); + for (let index = 0; index < 4999; index++) insert.run(`note-${index}`); + db.run("INSERT INTO reading_progress(user_id,book_id,updated_at) VALUES('user','book',1)"); + insert.run('last-note'); + }); + const first = sync.pull('user', 0); + assert.equal(first.progress.length + first.notes.length, 5000); + assert.equal(first.hasMore, true); + assert.equal(first.progress.length, 1); + const second = sync.pull('user', first.serverTime); + assert.equal(second.notes.length, 1); + assert.equal(second.notes[0]!.id, 'last-note'); + assert.equal(second.hasMore, false); + assert.equal(sync.pull('user', second.serverTime).notes.length, 0); + const notesOnly = sync.pull('user', 0, undefined, false); + assert.equal(notesOnly.progress.length, 0); + assert.equal(notesOnly.notes.length, 5000); + assert.equal(notesOnly.hasMore, false); + } finally { db.close(); } +}); + +test('cursor advances for tied timestamps and does not regress with the wall clock', () => { + const db = new Db(':memory:'); + try { + seed(db); + const sync = new SyncService(db, new ShelfService(db)); + db.run('UPDATE sync_clock SET revision = ? WHERE id = 1', Date.now() + 60_000); + const before = sync.pull('user', 0).serverTime; + sync.push('user', { progress: [progress()] }); + const first = sync.pull('user', before); + assert.ok(first.serverTime > before); + sync.push('user', { progress: [{ ...progress(), locator: 'changed' }] }); + const second = sync.pull('user', first.serverTime); + assert.ok(second.serverTime > first.serverTime); + assert.equal(second.progress[0]!.locator, 'changed'); + sync.push('user', { progress: [{ ...progress('book', 0), locator: 'stale' }] }); + const reconciled = sync.pull('user', second.serverTime); + assert.equal(reconciled.progress[0]!.locator, 'changed'); + assert.equal(reconciled.progress[0]!.updatedAt, 1); + } finally { db.close(); } +}); + +test('existing databases are backfilled once and retain their cursor after reopening', () => { + const directory = mkdtempSync(join(tmpdir(), 'reader-sync-migration-')); + const path = join(directory, 'reader.db'); + try { + const legacy = new DatabaseSync(path); + legacy.exec(SCHEMA_SQL); + seed({ run(sql: string) { legacy.exec(sql); } }); + legacy.exec("INSERT INTO reading_progress(user_id,book_id,updated_at) VALUES('user','book',1)"); + legacy.exec("INSERT INTO notes(id,user_id,book_id,type,updated_at) VALUES('old-note','user','book','note',1)"); + legacy.close(); + const upgraded = new Db(path); + const first = new SyncService(upgraded, new ShelfService(upgraded)).pull('user', 0); + assert.equal(first.progress.length, 1); + assert.equal(first.notes.length, 1); + upgraded.close(); + const reopened = new Db(path); + try { + const next = new SyncService(reopened, new ShelfService(reopened)).pull('user', first.serverTime); + assert.equal(next.progress.length + next.notes.length, 0); + } finally { reopened.close(); } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +test('invalid records fail validation before any batch records are written', () => { + const db = new Db(':memory:'); + try { + seed(db); + const sync = new SyncService(db, new ShelfService(db)); + for (const invalid of [null, [], { ...progress(), locator: {} }, { ...progress(), updatedAt: Infinity }, { ...progress(), percentage: 'wrong' }]) { + assert.throws(() => sync.push('user', { progress: [progress(), invalid as never] }), { code: 'BAD_PAYLOAD' }); + assert.equal(sync.progressFor('user', 'book'), null); + } + } finally { db.close(); } +}); diff --git a/server/test/user-management.test.ts b/server/test/user-management.test.ts index 5e02e82..90cb475 100644 --- a/server/test/user-management.test.ts +++ b/server/test/user-management.test.ts @@ -16,7 +16,8 @@ test('admin users, persisted registration policy, bounded invites and immediate const db=new Db(join(root,'data','reader.db')),users=new UserService(db,config); const app=buildApp({db,config,users} as AppContext); t.after(async()=>{await app.close();db.close();await rm(root,{recursive:true,force:true});}); - const register=(username:string,inviteCode?:string)=>app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username,password:'password123',inviteCode}}); + let registrationClient=0; + const register=(username:string,inviteCode?:string)=>app.inject({method:'POST',url:'/api/v1/auth/register',remoteAddress:`192.0.2.${++registrationClient}`,payload:{username,password:'password123',inviteCode}}); const first=await register('owner');assert.equal(first.statusCode,201); const owner=first.json().session,headers={authorization:'Bearer '+owner.accessToken}; const setting=(mode:string)=>app.inject({method:'PATCH',url:'/api/v1/admin/registration',headers,payload:{mode}}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0f07a5c..243f37c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -179,7 +179,7 @@ export class ReaderApi { /** Renew before expiry; transient failures leave the persisted login intact. */ async renewSessionIfNeeded(): Promise { - if (!this.session || this.session.accessTokenExpiresAt > Date.now() + 5 * 60_000) return; + if (!this.session || Math.min(this.session.accessTokenExpiresAt, this.session.resourceTokenExpiresAt ?? Infinity) > Date.now() + 5 * 60_000) return; await this.refreshSession(this.sessionGeneration); } @@ -710,7 +710,7 @@ export class ReaderApi { } async push( - payload: { progress?: ProgressInput[]; notes?: NoteInput[] }, + payload: { progress?: ProgressInput[]; notes?: NoteInput[]; since?: number }, options: RequestOptions = {}, ): Promise { return this.call('/api/v1/sync', 'POST', payload, options); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index ebf5ac2..485dde7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -11,6 +11,8 @@ export interface Session { user: User; accessToken: string; accessTokenExpiresAt: number; + resourceToken?: string; + resourceTokenExpiresAt?: number; refreshToken: string; refreshTokenExpiresAt: number; } @@ -209,6 +211,7 @@ export interface Note { export interface SyncPull { serverTime: number; + hasMore?: boolean; progress: Progress[]; notes: Note[]; } diff --git a/web/src/app/router/lazy-pages.tsx b/web/src/app/router/lazy-pages.tsx index 22d8107..95d482e 100644 --- a/web/src/app/router/lazy-pages.tsx +++ b/web/src/app/router/lazy-pages.tsx @@ -25,6 +25,13 @@ const sourceCredentials = () => import('../../features/sources/pages/SourceCrede const stats = () => import('../../features/stats/pages/StatsPage.tsx'); const sourceExtension = () => import('../../features/sources/pages/SourceExtensionPage.tsx'); const admin = () => import('../../features/account/pages/AdminPages.tsx'); +const mediaDetail = () => import('../../features/media/pages/MediaDetailPage.tsx'); +const mediaPlayer = () => import('../../features/media/pages/MediaPlayerPage.tsx'); +const mediaFolders = () => import('../../features/media/pages/MediaFoldersPage.tsx'); +const mediaNarrators = () => import('../../features/media/pages/MediaNarratorsPage.tsx'); +const library = () => import('../../features/library/pages/LibraryPage.tsx'); +const sources = () => import('../../features/sources/pages/SourcesPage.tsx'); +const sourceCatalog = () => import('../../features/sources/pages/SourceCatalogPage.tsx'); const Reader = deferred(reader, 'ReaderPage'); const Libraries = deferred(libraries, 'MediaLibrariesPage'); @@ -44,6 +51,15 @@ const Stats = deferred(stats, 'StatsPage'); const AdminUsers = deferred(admin, 'AdminUsersPage'); const AdminRegistration = deferred(admin, 'AdminRegistrationPage'); const AdminServices = deferred(admin, 'AdminServicesPage'); +const MediaDetail = deferred(mediaDetail, 'MediaDetailPage'); +const MediaPlayer = deferred(mediaPlayer, 'MediaPlayerPage'); +const MediaFolders = deferred(mediaFolders, 'MediaFoldersPage'); +const MediaNarrators = deferred(mediaNarrators, 'MediaNarratorsPage'); +const Library = deferred(library, 'LibraryPage'); +const LibraryBrowse = deferred(library, 'LibraryBrowsePage'); +const LibraryFiles = deferred(library, 'LibraryFilesPage'); +const Sources = deferred(sources, 'SourcesPage'); +const SourceCatalog = deferred(sourceCatalog, 'SourceCatalogPage'); export function ReaderPage() { return ; } export function MediaLibrariesPage() { return ; } @@ -63,3 +79,12 @@ export function SourceExtensionPage() { return ; } export function AdminRegistrationPage() { return ; } export function AdminServicesPage() { return ; } +export function MediaDetailPage() { return ; } +export function MediaPlayerPage() { return ; } +export function MediaFoldersPage() { return ; } +export function MediaNarratorsPage() { return ; } +export function LibraryPage() { return ; } +export function LibraryBrowsePage() { return ; } +export function LibraryFilesPage() { return ; } +export function SourcesPage() { return ; } +export function SourceCatalogPage() { return ; } diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx index 82eed49..aec8a9f 100644 --- a/web/src/app/router/routes.tsx +++ b/web/src/app/router/routes.tsx @@ -4,28 +4,23 @@ import { MediaCatalogPage } from '../../features/media/pages/MediaCatalogPage.ts import { MediaSearchPage } from '../../features/media/pages/MediaSearchPage.tsx'; import { MediaFavoritesPage } from '../../features/media/pages/MediaFavoritesPage.tsx'; import { MediaHistoryPage } from '../../features/media/pages/MediaHistoryPage.tsx'; -import { MediaDetailPage } from '../../features/media/pages/MediaDetailPage.tsx'; import { MediaQueuePage } from '../../features/media/pages/MediaQueuePage.tsx'; -import { MediaPlayerPage } from '../../features/media/pages/MediaPlayerPage.tsx'; import { RouteErrorBoundary } from './error-boundary.tsx'; import { ShelfPage } from '../../features/shelf/pages/ShelfPage.tsx'; -import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features/library/pages/LibraryPage.tsx'; -import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx'; import { MinePage } from '../../features/account/pages/MinePage.tsx'; import { LegacySettingsRedirect, SettingsPage } from '../../features/account/pages/SettingsPage.tsx'; import { AccountPage, OpdsPage } from '../../features/account/pages/AccountPages.tsx'; import { HomePage } from '../../features/home/pages/HomePage.tsx'; import { SearchPage } from '../../features/search/pages/SearchPage.tsx'; -import { MediaFoldersPage } from '../../features/media/pages/MediaFoldersPage.tsx'; -import { MediaNarratorsPage } from '../../features/media/pages/MediaNarratorsPage.tsx'; import { ReaderPage, MediaLibrariesPage, MediaLibraryCreatePage, MediaLibraryEditPage, MediaLibraryPermissionsPage, MediaTasksPage, MediaMetadataEditPage, MediaMatchPage, MediaChaptersPage, MediaEditionPage, MediaStructurePage, SourceEditorPage, SourceCredentialsPage, SourceExtensionPage, StatsPage, AdminUsersPage, AdminRegistrationPage, AdminServicesPage, + MediaDetailPage, MediaPlayerPage, MediaFoldersPage, MediaNarratorsPage, + LibraryPage, LibraryBrowsePage, LibraryFilesPage, SourcesPage, SourceCatalogPage, } from './lazy-pages.tsx'; -import { SourceCatalogPage } from '../../features/sources/pages/SourceCatalogPage.tsx'; export const routes = [{ path: '/', element: , errorElement: , children: [ @@ -107,4 +102,3 @@ export const routes = [{ ], }] satisfies RouteObject[]; - diff --git a/web/src/core/sync.ts b/web/src/core/sync.ts index 9f28291..a58b89a 100644 --- a/web/src/core/sync.ts +++ b/web/src/core/sync.ts @@ -279,10 +279,16 @@ export class SyncEngine { // pull is the real work — that is the poll's whole purpose. const pushed = await this.pushOutbox(run); this.assertCurrent(run); - const pull = pushed ?? (await this.api.pull(this.offline.current.serverTime, undefined, { signal: run.signal })); - this.assertCurrent(run); - await this.offline.applyPulled(pull); - this.assertCurrent(run); + let pull = pushed ?? (await this.api.pull(this.offline.current.serverTime, undefined, { signal: run.signal })); + for (;;) { + this.assertCurrent(run); + await this.offline.applyPulled(pull); + this.assertCurrent(run); + if (!pull.hasMore) break; + const cursor = pull.serverTime; + pull = await this.api.pull(cursor, undefined, { signal: run.signal }); + if (pull.serverTime <= cursor && pull.hasMore) throw new ApiError('server', '同步分页游标没有前进', 'INVALID_SYNC_CURSOR'); + } this.rememberNoteBooks(); this.backoff = RETRY_BASE_MS; @@ -359,6 +365,7 @@ export class SyncEngine { const progressChunk = batch.progress.slice(offset, offset + CHUNK); const noteChunk = notes.slice(offset, offset + CHUNK); const result = await this.api.push({ + since: this.offline.current.serverTime, ...(progressChunk.length > 0 ? { progress: progressChunk } : {}), ...(noteChunk.length > 0 ? { notes: noteChunk } : {}), }, { signal: run.signal }); diff --git a/web/src/features/account/pages/AdminPages.tsx b/web/src/features/account/pages/AdminPages.tsx index 792b292..c3b2945 100644 --- a/web/src/features/account/pages/AdminPages.tsx +++ b/web/src/features/account/pages/AdminPages.tsx @@ -33,5 +33,5 @@ export function AdminServicesPage() { const media=useMediaEnabled(); const state=useAuthStore(store=>(store.verifiedUser??store.session?.user)?.mediaState); const labels={disabled:'已关闭',stopping:'正在停止',starting:'正在启动',ready:'已就绪',failed:'启动失败,请检查服务日志',closed:'已停止'}; - return 影音服务:{state?labels[state]:'状态待确认'}void runtime.refreshCapabilities().catch(()=>undefined)} />; + return 影音服务:{state?labels[state]:'状态待确认'}} onSaved={()=>void runtime.refreshCapabilities().catch(()=>undefined)} />; } diff --git a/web/src/net/api.ts b/web/src/net/api.ts index 356fa59..3e67657 100644 --- a/web/src/net/api.ts +++ b/web/src/net/api.ts @@ -120,6 +120,8 @@ export interface ProgressDto { export interface Session { accessToken: string; + resourceToken?: string; + resourceTokenExpiresAt?: number; refreshToken: string; accessTokenExpiresAt: number; refreshTokenExpiresAt: number; @@ -280,9 +282,12 @@ export class ApiClient { * exists to avoid. */ coverUrl(bookId: string): string { - const token = this.session?.accessToken; + const resource = this.session?.resourceToken && (this.session.resourceTokenExpiresAt ?? Infinity) > Date.now() + ? this.session.resourceToken : null; + const token = resource || this.session?.accessToken; + const parameter = resource ? 'resource_token' : 'access_token'; const path = `/api/v1/books/${encodeURIComponent(bookId)}/cover`; - return this.url(token ? `${path}?access_token=${encodeURIComponent(token)}` : path); + return this.url(token ? `${path}?${parameter}=${encodeURIComponent(token)}` : path); } async shelf(params: { search?: string; page?: number; pageSize?: number } = {}): Promise<{ @@ -343,6 +348,9 @@ export class ApiClient { ): Promise { await this.acquire(); try { + if (!options.anonymous && this.session?.resourceToken && (this.session.resourceTokenExpiresAt ?? Infinity) <= Date.now() + 5 * 60_000) { + await this.refresh(); + } const response = await this.send(method, path, options); if (response.status !== 401 || options.anonymous) { if (!response.ok) throw await toApiError(response); diff --git a/web/src/net/asset-url.ts b/web/src/net/asset-url.ts index 84e1a4b..f938551 100644 --- a/web/src/net/asset-url.ts +++ b/web/src/net/asset-url.ts @@ -22,13 +22,6 @@ * the other half of the arrangement — it is an allowlist, not a blanket rule, so * a leaked URL cannot trigger a scan or write metadata. * - * Tokens in URLs do leak: into browser history, into a proxy's access log, into a - * `Referer` header. Three things keep that acceptable here. Asset URLs are only - * ever built for a document that is being displayed in the reading frame, never - * for an outbound link. They are scoped to a single book and expire with the - * access token. And the endpoint they reach serves immutable content addressed by - * content hash — the worst a leaked URL gives away is a page of a book whose - * reader already had it. */ import type { ApiClient } from './api.ts'; @@ -47,8 +40,8 @@ import type { ApiClient } from './api.ts'; export interface AssetSigning { /** The API's base, so a root-relative URL can be told from a foreign one. */ baseUrl: string; - /** The access token, read at the moment the signer is built. */ - accessToken: string; + accessToken: string | (() => string | null); + resourceToken?: string | (() => string | null); } /** @@ -86,16 +79,21 @@ export interface AssetSigning { * about which URLs are real. */ export function makeAssetSigner(api: AssetSigning): ((url: string) => string | null) | null { - const token = api.accessToken; - if (!token) return null; + const readToken = (value: AssetSigning['accessToken'] | undefined) => typeof value === 'function' ? value() : value; + if (!readToken(api.resourceToken) && !readToken(api.accessToken)) return null; return (url) => { if (!isApiAsset(url, api.baseUrl) || hasToken(url)) return null; - return appendToken(url, token); + const resource = readToken(api.resourceToken); + const token = resource || readToken(api.accessToken); + return token ? appendToken(url, token, resource ? 'resource_token' : 'access_token') : null; }; } export function withAssetToken(html: string, api: ApiClient): string { - const token = api.currentSession?.accessToken; + const session = api.currentSession; + const resource = session?.resourceToken && (session.resourceTokenExpiresAt ?? Infinity) > Date.now() ? session.resourceToken : null; + const token = resource || session?.accessToken; + const parameter = resource ? 'resource_token' : 'access_token'; if (!token) return html; const parser = new DOMParser(); @@ -113,7 +111,7 @@ export function withAssetToken(html: string, api: ApiClient): string { const value = element.getAttribute(attribute); if (!value || !isApiAsset(value, api.baseUrl)) continue; if (hasToken(value)) continue; - element.setAttribute(attribute, appendToken(value, token)); + element.setAttribute(attribute, appendToken(value, token, parameter)); touched = true; } } @@ -122,7 +120,7 @@ export function withAssetToken(html: string, api: ApiClient): string { for (const element of Array.from(doc.querySelectorAll('[style]'))) { const value = element.getAttribute('style'); if (!value) continue; - const rewritten = rewriteCssUrls(value, api.baseUrl, token); + const rewritten = rewriteCssUrls(value, api.baseUrl, token, parameter); if (rewritten !== value) { element.setAttribute('style', rewritten); touched = true; @@ -131,7 +129,7 @@ export function withAssetToken(html: string, api: ApiClient): string { for (const style of Array.from(doc.querySelectorAll('style'))) { const value = style.textContent; if (!value) continue; - const rewritten = rewriteCssUrls(value, api.baseUrl, token); + const rewritten = rewriteCssUrls(value, api.baseUrl, token, parameter); if (rewritten !== value) { style.textContent = rewritten; touched = true; @@ -156,20 +154,20 @@ function isApiAsset(value: string, base: string): boolean { } function hasToken(value: string): boolean { - return value.includes('access_token='); + return value.includes('access_token=') || value.includes('resource_token='); } -function appendToken(value: string, token: string): string { +function appendToken(value: string, token: string, parameter = 'access_token'): string { const [path, fragment] = splitFragment(value); const separator = path.includes('?') ? '&' : '?'; - return `${path}${separator}access_token=${encodeURIComponent(token)}${fragment}`; + return `${path}${separator}${parameter}=${encodeURIComponent(token)}${fragment}`; } /** Rewrite `url(...)` inside a CSS string, leaving anything else untouched. */ -function rewriteCssUrls(css: string, base: string, token: string): string { +function rewriteCssUrls(css: string, base: string, token: string, parameter = 'access_token'): string { return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, quote: string, value: string) => { if (!isApiAsset(value, base) || hasToken(value)) return full; - return `url(${quote}${appendToken(value, token)}${quote})`; + return `url(${quote}${appendToken(value, token, parameter)}${quote})`; }); } diff --git a/web/src/render/speech.ts b/web/src/render/speech.ts index 434e17e..f0c327b 100644 --- a/web/src/render/speech.ts +++ b/web/src/render/speech.ts @@ -119,6 +119,8 @@ export interface SpeechFactoryOptions { kind: Exclude; baseUrl: string; accessToken: () => string | null; + resourceToken?: () => string | null; + prepareRequest?: () => Promise | void; nativeBridge: NativeSpeechBridge | null; /** System-engine injection points, forwarded for tests. */ synthesis?: SpeechSynthesis | null; @@ -158,6 +160,8 @@ export function createSpeechEngine(options: SpeechFactoryOptions): SpeechEngine ...(options.httpTimeoutMs?{requestTimeoutMs:options.httpTimeoutMs}:{}), baseUrl: options.baseUrl, accessToken: options.accessToken, + ...(options.resourceToken ? { resourceToken: options.resourceToken } : {}), + ...(options.prepareRequest ? { prepareRequest: options.prepareRequest } : {}), ...(options.onError ? { onError: options.onError } : {}), }); return new HttpTtsAdapter(engine); diff --git a/web/src/render/tts-http.ts b/web/src/render/tts-http.ts index 1739d14..5d050a1 100644 --- a/web/src/render/tts-http.ts +++ b/web/src/render/tts-http.ts @@ -57,6 +57,8 @@ export interface HttpTtsOptions { baseUrl: string; /** Bearer token; sent as a query parameter, see the note above. */ accessToken: () => string | null; + resourceToken?: () => string | null; + prepareRequest?: () => Promise | void; /** Injectable for tests: the element factory, so no audio device is needed. */ createAudio?: () => HTMLAudioElement; /** Injectable for tests. */ @@ -285,6 +287,19 @@ export class HttpTtsEngine { */ private speakNext(): void { if (this.state !== 'playing') return; + const generation = ++this.generation; + const preparation = this.options.prepareRequest?.(); + if (preparation) { + void preparation.then(() => this.playNext(generation), () => { + if (generation === this.generation && this.state === 'playing') this.fail('朗读凭据续期失败,请重试'); + }); + return; + } + this.playNext(generation); + } + + private playNext(generation: number): void { + if (generation !== this.generation || this.state !== 'playing') return; const nextIndex = this.cursor + 1; const text = this.queue[nextIndex]; if (text === undefined) { @@ -294,7 +309,6 @@ export class HttpTtsEngine { this.cursor = nextIndex; this.emit(); - const generation = this.generation; const url = this.buildUrl('/api/v1/tts', { text, ...(this.voiceId ? { voice: this.voiceId } : {}), @@ -440,8 +454,9 @@ export class HttpTtsEngine { const base = this.options.baseUrl.replace(/\/+$/, ''); if (!base) return null; const query = new URLSearchParams(params); - const token = this.options.accessToken(); - if (token) query.set('access_token', token); + const resource = this.options.resourceToken?.(); + const token = resource || this.options.accessToken(); + if (token) query.set(resource ? 'resource_token' : 'access_token', token); const suffix = query.size > 0 ? `?${query.toString()}` : ''; return `${base}${path}${suffix}`; } diff --git a/web/src/ui/business-settings.tsx b/web/src/ui/business-settings.tsx index f94cd20..51fdabf 100644 --- a/web/src/ui/business-settings.tsx +++ b/web/src/ui/business-settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import {FloatingNotice} from './floating-notice.tsx'; import { ConfirmSheet } from './sheet.tsx'; import '../styles/business-settings.css'; -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, ReactNode } from 'react'; export interface SettingsTransport { businessSettingsRequest(path?:string,method?:string,body?:unknown,signal?:AbortSignal):Promise; @@ -13,7 +13,7 @@ interface SettingsView { group:string;label:string;revision:number;values:Record;secrets:Record; fields:Array<{key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>; } -export function BusinessSettings({api,group,onSaved,selectControl,showMedia=true}:{api:SettingsTransport;group?:string;onSaved?:()=>void;selectControl?:any;showMedia?:boolean}){ +export function BusinessSettings({api,group,onSaved,selectControl,showMedia=true,modulesStatus}:{api:SettingsTransport;group?:string;onSaved?:()=>void;selectControl?:any;showMedia?:boolean;modulesStatus?:ReactNode}){ const Select=selectControl??'select'; const [groups,setGroups]=useState([]),[selected,setSelected]=useState(group||'tts'); const [values,setValues]=useState>({}),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''),[retry,setRetry]=useState(0); @@ -43,6 +43,7 @@ export function BusinessSettings({api,group,onSaved,selectControl,showMedia=true {!group&&配置分类)=>setSelected(event.currentTarget.value)}>{visibleGroups.map(item=>{item.label})}} {!current&&!busy&&setRetry(value=>value+1)}>重新读取配置} {current&&<> + {selected==='modules'&&modulesStatus} {selected==='modules'?'保存后立即生效,无需重启。关闭影音将停止播放及后台任务,保留全部影音数据。':'保存后生效,无需重启。进行中的任务保留原配置,新任务使用更新后的配置。'} {event.preventDefault();if(selected==='modules'&¤t.values.mediaEnabled&&!values.mediaEnabled)setConfirmMedia(true);else save();}}> {current.fields.filter(field=>showMedia||!(selected==='scanning'&&(/^(media|libraries$|files$)/.test(field.key)))&&!(selected==='ai'&&['scanEnabled','scanPrompt','batchSize'].includes(field.key))).map(field=>{field.label} diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx index 1fa4f40..7803f34 100644 --- a/web/src/ui/reader-screen.tsx +++ b/web/src/ui/reader-screen.tsx @@ -761,6 +761,8 @@ export class ReaderScreen { let failure = ''; const engine = createSpeechEngine({ kind, baseUrl: this.options.api.baseUrl, accessToken: () => this.options.api.currentSession()?.accessToken ?? null, + resourceToken: () => this.options.api.currentSession()?.resourceToken ?? null, + prepareRequest: () => this.prepareSpeechRequest(), nativeBridge: this.options.speechBridge ?? null, onError: message => { failure = message; this.flashStatus(message, 6000); }, }); @@ -1068,6 +1070,13 @@ export class ReaderScreen { void this.renderToc(); } + private prepareSpeechRequest(): Promise | void { + const session = this.options.api.currentSession(); + if (session && Math.min(session.accessTokenExpiresAt, session.resourceTokenExpiresAt ?? Infinity) <= Date.now() + 5 * 60_000) { + return this.options.api.renewSessionIfNeeded(); + } + } + /** * Builds the view, the gestures and the key bindings against the stage. * @@ -1083,14 +1092,10 @@ export class ReaderScreen { this.view?.dispose(); if (!this.doc) return; const doc = this.doc; - // The token the browser cannot attach for itself. Built once per view rather - // than per chapter: the reader's session does not change while a book is open, - // and a signer that read the token lazily would have to be rebuilt whenever one - // arrived — which is a second lifecycle to keep in step with the first. - const token = this.options.api.currentSession()?.accessToken; const signAssetUrl = makeAssetSigner({ baseUrl: this.options.api.baseUrl, - ...(token ? { accessToken: token } : { accessToken: '' }), + accessToken: () => this.options.api.currentSession()?.accessToken ?? null, + resourceToken: () => this.options.api.currentSession()?.resourceToken ?? null, }); this.view = new ReaderView({ onSelection: (anchor, rect) => { @@ -1938,6 +1943,8 @@ export class ReaderScreen { kind, baseUrl: this.options.api.baseUrl, accessToken: () => this.options.api.currentSession()?.accessToken ?? null, + resourceToken: () => this.options.api.currentSession()?.resourceToken ?? null, + prepareRequest: () => this.prepareSpeechRequest(), nativeBridge: this.options.speechBridge ?? null, onError: (message) => this.flashStatus(message, 3200), }); diff --git a/web/test/admin-services-page.test.tsx b/web/test/admin-services-page.test.tsx new file mode 100644 index 0000000..9d57fcc --- /dev/null +++ b/web/test/admin-services-page.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { render } from '../src/shared/ui/render-root.ts'; +import { AdminServicesPage } from '../src/features/account/pages/AdminPages.tsx'; +import { RuntimeContext } from '../src/app/providers/runtime-context.tsx'; +import { useAuthStore } from '../src/shared/stores/auth.store.ts'; +import type { User } from '../src/api/types.ts'; +import { dismissAllNotices } from '../src/ui/notifications.ts'; + +const root = document.createElement('div'); +const groups = [ + { group: 'tts', label: '朗读', revision: 1, values: {}, secrets: {}, fields: [] }, + { group: 'modules', label: '功能模块', revision: 1, values: {}, secrets: {}, fields: [] }, + { group: 'tmdb', label: 'TMDB', revision: 1, values: {}, secrets: {}, fields: [] }, +]; +const runtime = { api: { businessSettingsRequest: vi.fn(async () => ({ groups })) } } as any; +const user: User = { id: 'admin', username: 'admin', displayName: '管理员', role: 'admin', createdAt: 0 }; +const paint = () => act(async () => render( + + + , root, +)); + +afterEach(() => { + act(() => render(null, root)); + useAuthStore.setState({ session: null, verifiedUser: null }); + dismissAllNotices(); +}); + +const selectGroup = (group: string) => act(() => { + const select = root.querySelector('[aria-label="配置分类"]')!; + select.value = group; + select.dispatchEvent(new Event('change', { bubbles: true })); +}); + +it('shows the ready status only within the modules settings', async () => { + useAuthStore.setState({ verifiedUser: { ...user, mediaState: 'ready' } }); + await paint(); + expect(root.querySelector('[role="status"]')).toBeNull(); + selectGroup('modules'); + expect(root.querySelector('[aria-label="功能模块"] [role="status"]')?.textContent).toBe('影音服务:已就绪'); + selectGroup('tmdb'); + expect(root.querySelector('[role="status"]')).toBeNull(); +}); + +it.each([ + ['disabled', '已关闭'], + ['stopping', '正在停止'], + ['starting', '正在启动'], + ['failed', '启动失败,请检查服务日志'], + ['closed', '已停止'], + [undefined, '状态待确认'], +] as const)('shows %s only in modules and updates it when capabilities change', async (mediaState, label) => { + useAuthStore.setState({ verifiedUser: mediaState ? { ...user, mediaState } : user }); + await paint(); + expect(root.querySelector('[role="status"]')).toBeNull(); + selectGroup('modules'); + expect(root.querySelector('[aria-label="功能模块"] [role="status"]')?.textContent).toBe(`影音服务:${label}`); + act(() => useAuthStore.getState().verify({ ...user, mediaState: 'ready' })); + expect(root.querySelector('[role="status"]')?.textContent).toBe('影音服务:已就绪'); + selectGroup('tts'); + expect(root.querySelector('[role="status"]')).toBeNull(); +}); diff --git a/web/test/api.test.ts b/web/test/api.test.ts index 6c737b2..144ec79 100644 --- a/web/test/api.test.ts +++ b/web/test/api.test.ts @@ -50,6 +50,32 @@ function client(): ApiClient { } describe('api client', () => { + test('cover URLs prefer resource credentials and fall back after resource expiry', async () => { + const api = client(); + responder = () => json({ accessToken: 'access', resourceToken: 'resource', resourceTokenExpiresAt: Date.now() + 60_000 }); + await api.login('reader', 'password'); + assert.equal(new URL(api.coverUrl('book')).searchParams.get('resource_token'), 'resource'); + responder = () => json({ accessToken: 'access', resourceToken: 'expired', resourceTokenExpiresAt: Date.now() - 1 }); + await api.login('reader', 'password'); + const url = new URL(api.coverUrl('book')); + assert.equal(url.searchParams.get('access_token'), 'access'); + assert.equal(url.searchParams.has('resource_token'), false); + }); + + test('refreshes resource credentials proactively while the access token is valid', async () => { + const api = client(); + responder = url => { + if (url.includes('/auth/login')) return json({ accessToken: 'access', resourceToken: 'old', resourceTokenExpiresAt: 0, refreshToken: 'refresh' }); + if (url.includes('/auth/refresh')) return json({ accessToken: 'renewed', resourceToken: 'new', resourceTokenExpiresAt: Date.now() + 900_000 }); + return json({ items: [] }); + }; + await api.login('reader', 'password'); + await api.shelf(); + assert.equal(api.currentSession?.resourceToken, 'new'); + assert.equal(calls.filter(call => call.url.includes('/auth/refresh')).length, 1); + assert.equal((calls.at(-1)!.init.headers as Record).authorization, 'Bearer renewed'); + }); + test('a 401 is retried once after a refresh, and the new token is used', async () => { const api = client(); responder = (url) => { diff --git a/web/test/asset-url.test.ts b/web/test/asset-url.test.ts index 1e7c153..ef1d71b 100644 --- a/web/test/asset-url.test.ts +++ b/web/test/asset-url.test.ts @@ -20,7 +20,26 @@ before(() => { g.document = dom.window.document; }); -const { withAssetToken } = await import('../src/net/asset-url.ts'); +const { withAssetToken, makeAssetSigner } = await import('../src/net/asset-url.ts'); + +test('asset signing uses resource tickets and reads rotations lazily', () => { + let resource = 'resource-first'; + const signer = makeAssetSigner({ baseUrl: '', accessToken: 'private-access', resourceToken: () => resource })!; + assert.equal(signer('/api/v1/books/book/assets?ref=image#cover'), '/api/v1/books/book/assets?ref=image&resource_token=resource-first#cover'); + resource = 'resource-next'; + assert.match(signer('/api/v1/books/book/assets?ref=image')!, /resource_token=resource-next/); + assert.equal(signer('https://foreign.test/api/v1/books/book/assets?ref=image'), null); + assert.equal(signer('/api/v1/books/book/assets?resource_token=existing'), null); +}); + +test('chapter markup prefers resource tickets for attributes and CSS', () => { + const api = { baseUrl: '', currentSession: { accessToken: 'private-access', resourceToken: 'readonly-ticket' } } as unknown as ApiClient; + const html = ''; + const signed = withAssetToken(html, api); + assert.equal((signed.match(/resource_token=readonly-ticket/g) ?? []).length, 2); + assert.ok(!signed.includes('private-access')); + assert.equal(withAssetToken(signed, api), signed); +}); function client(token: string | null, baseUrl = ''): ApiClient { return { diff --git a/web/test/resource-token-session.test.ts b/web/test/resource-token-session.test.ts new file mode 100644 index 0000000..ebdcb97 --- /dev/null +++ b/web/test/resource-token-session.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from 'vitest'; +import { ReaderApi } from '../src/api/client.ts'; +import { FakeTransport, makePlatform } from './helpers/env.ts'; + +function setup() { + const transport = new FakeTransport(); + const api = new ReaderApi(makePlatform(transport), { load: async () => null, save: async () => {}, clear: async () => {} }); + api.setBaseUrl('https://reader.test'); + const session = { + user: { id: 'reader', username: 'reader', displayName: '', role: 'member' as const, createdAt: 0 }, + accessToken: 'private-access', accessTokenExpiresAt: Date.now() + 3_600_000, + resourceToken: 'old-resource', resourceTokenExpiresAt: Date.now() + 1000, + refreshToken: 'refresh', refreshTokenExpiresAt: Date.now() + 86_400_000, + }; + return { transport, api, session }; +} + +it('renews expiring resource tickets even when the access token is still valid', async () => { + const { transport, api, session } = setup(); + transport.json(session); + await api.login('reader', 'password'); + transport.json({ ...session, resourceToken: 'new-resource', resourceTokenExpiresAt: Date.now() + 900_000 }); + await api.renewSessionIfNeeded(); + expect(transport.requests.at(-1)!.url).toContain('/auth/refresh'); + expect(api.currentSession()?.resourceToken).toBe('new-resource'); +}); + +it('keeps legacy sessions without resource ticket fields compatible', async () => { + const { transport, api, session } = setup(); + const { resourceToken: _token, resourceTokenExpiresAt: _expiresAt, ...legacy } = session; + transport.json(legacy); + await api.login('reader', 'password'); + await api.renewSessionIfNeeded(); + expect(transport.requests).toHaveLength(1); +}); + +it('keeps an unexpired access session usable if proactive resource renewal loses connectivity', async () => { + const { transport, api, session } = setup(); + transport.json(session); + await api.login('reader', 'password'); + transport.respondWith(request => { + if (request.url.endsWith('/auth/refresh')) throw new Error('network unavailable'); + return { status: 200, headers: {}, json: { items: [], total: 0, page: 1, pageSize: 50 } }; + }); + const result = await api.listBooks(); + expect(result.items).toEqual([]); + expect(api.currentSession()?.accessToken).toBe('private-access'); + expect(transport.requests.at(-1)!.headers.authorization).toBe('Bearer private-access'); +}); diff --git a/web/test/sync.test.ts b/web/test/sync.test.ts index 0155923..55146fa 100644 --- a/web/test/sync.test.ts +++ b/web/test/sync.test.ts @@ -631,6 +631,70 @@ describe('SyncEngine', () => { expect(pulls[1]!.url).toContain('since=777'); }); + it('drains paginated pulls before declaring sync complete', async () => { + const { api, transport, offline, engine } = build(); + transport.json(SESSION); + await api.login('me', 'password12'); + await offline.load(); + transport.respondWith(request => ({ status: 200, headers: {}, json: request.url.includes('since=0') + ? { serverTime: 10, hasMore: true, progress: [], notes: [{ id: 'first', bookId: 'b', type: 'note', locator: '', text: 'one', comment: '', color: '', updatedAt: 1 }] } + : { serverTime: 20, hasMore: false, progress: [], notes: [{ id: 'second', bookId: 'b', type: 'note', locator: '', text: 'two', comment: '', color: '', updatedAt: 1 }] }, + })); + await engine.syncNow(); + expect(offline.current.serverTime).toBe(20); + expect(Object.keys(offline.current.notes).sort()).toEqual(['first', 'second']); + const pulls = transport.requests.filter(request => request.url.includes('/sync?')); + expect(pulls.map(request => request.url.split('?')[1])).toEqual(['since=0', 'since=10']); + expect(engine.status().state).toBe('idle'); + }); + + it('uses a push cursor and drains the rest of its merged response', async () => { + const { api, transport, offline, engine, platform } = build(); + transport.json(SESSION); + await api.login('me', 'password12'); + await offline.load(); + await offline.applyPulled({ serverTime: 5, progress: [], notes: [] }); + await offline.setProgress({ bookId: 'b', locator: 'new', percentage: 0.5, chapterTitle: '', device: platform.deviceLabel, updatedAt: 1 }); + transport.respondWith(request => ({ status: 200, headers: {}, json: request.method === 'POST' + ? { accepted: 1, rejected: 0, serverTime: 10, hasMore: true, progress: [], notes: [] } + : { serverTime: 20, hasMore: false, progress: [], notes: [{ id: 'last', bookId: 'b', type: 'note', locator: '', text: 'last', comment: '', color: '', updatedAt: 1 }] }, + })); + await engine.syncNow(); + const push = transport.requests.find(request => request.url.endsWith('/sync') && request.method === 'POST')!; + expect(JSON.parse(bodyText(push)).since).toBe(5); + expect(offline.current.serverTime).toBe(20); + expect(offline.current.notes['last']?.text).toBe('last'); + expect(isOutboxEmpty(offline.current)).toBe(true); + }); + + it('stops paginated sync if the server repeats its cursor', async () => { + const { api, transport, offline, engine } = build(); + transport.json(SESSION); + await api.login('me', 'password12'); + await offline.load(); + transport.json({ serverTime: 10, hasMore: true, progress: [], notes: [] }); + await engine.syncNow(); + expect(engine.status().state).toBe('error'); + expect(transport.requests.filter(request => request.url.includes('/sync?'))).toHaveLength(2); + }); + + it('retries a failed page from the last persisted cursor', async () => { + const { api, transport, offline, engine } = build(); + transport.json(SESSION); + await api.login('me', 'password12'); + await offline.load(); + transport.respondWith(request => { + if (request.url.includes('since=0')) return { status: 200, headers: {}, json: { serverTime: 10, hasMore: true, progress: [], notes: [] } }; + throw new Error('offline'); + }); + await engine.syncNow(); + expect(offline.current.serverTime).toBe(10); + transport.json({ serverTime: 20, hasMore: false, progress: [], notes: [] }); + await engine.syncNow(); + expect(transport.requests.at(-1)!.url).toContain('since=10'); + expect(offline.current.serverTime).toBe(20); + }); + it('degrades to offline without surfacing an error', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); diff --git a/web/test/tts-http.test.ts b/web/test/tts-http.test.ts index 077af68..1e66a5e 100644 --- a/web/test/tts-http.test.ts +++ b/web/test/tts-http.test.ts @@ -63,6 +63,8 @@ function makeEngine(options: { chunks: string[][]; baseUrl?: string; token?: string | null; + resourceToken?: string; + prepareRequest?: () => Promise | void; playFails?: boolean; }): { engine: HttpTtsEngine; audios: FakeAudio[]; spoken: string[]; errors: string[] } { const audios: FakeAudio[] = []; @@ -73,6 +75,8 @@ function makeEngine(options: { const engine = new HttpTtsEngine({ baseUrl: options.baseUrl ?? 'http://nas.local:8080', accessToken: () => options.token ?? null, + resourceToken: () => options.resourceToken ?? null, + ...(options.prepareRequest ? { prepareRequest: options.prepareRequest } : {}), createAudio: () => { const audio = new FakeAudio(); audios.push(audio); @@ -112,6 +116,13 @@ it('uses the current configured waiting limit for each sentence',async()=>{ }); describe('HttpTtsEngine', () => { + it('uses resource tickets for audio URLs without exposing the access token', async () => { + const { engine, audios } = makeEngine({ chunks: [['只读语音']], token: 'private-access', resourceToken: 'readonly-ticket' }); + await engine.play(0); + expect(audios[0]!.src).toContain('resource_token=readonly-ticket'); + expect(audios[0]!.src).not.toContain('private-access'); + engine.stop(); + }); it('asks the server one sentence at a time, with the token in the query', async () => { const { engine, audios } = makeEngine({ chunks: [['第一句。', '第二句。']], token: 'tok-123' }); @@ -202,6 +213,53 @@ describe('HttpTtsEngine', () => { expect(engine.active).toBe(false); }); + it('renews resource credentials before starting the next sentence', async () => { + let finishRenewal!: () => void; + const options = { + chunks: [['一。', '二。']], resourceToken: 'old-resource', + prepareRequest: () => new Promise(resolve => { finishRenewal = resolve; }), + }; + const { engine, audios } = makeEngine(options); + await engine.play(0); + expect(audios).toHaveLength(0); + options.resourceToken = 'new-resource'; + finishRenewal(); + await Promise.resolve(); + expect(audios).toHaveLength(1); + expect(new URL(audios[0]!.src).searchParams.get('resource_token')).toBe('new-resource'); + audios[0]!.fire('ended'); + expect(audios).toHaveLength(1); + finishRenewal(); + await Promise.resolve(); + expect(audios).toHaveLength(2); + engine.dispose(); + }); + + it('does not start stale playback after stopping during credential renewal', async () => { + let finishRenewal!: () => void; + const { engine, audios } = makeEngine({ + chunks: [['一。']], + prepareRequest: () => new Promise(resolve => { finishRenewal = resolve; }), + }); + await engine.play(0); + engine.stop(); + finishRenewal(); + await Promise.resolve(); + expect(audios).toHaveLength(0); + expect(engine.active).toBe(false); + }); + + it('reports credential renewal failures without playing expired audio', async () => { + const { engine, audios, errors } = makeEngine({ + chunks: [['一。']], prepareRequest: () => Promise.reject(new Error('offline')), + }); + await engine.play(0); + await Promise.resolve(); + expect(audios).toHaveLength(0); + expect(errors).toContain('朗读凭据续期失败,请重试'); + expect(engine.active).toBe(false); + }); + it('probes the server and answers null when it has no engine', async () => { const engine = new HttpTtsEngine({ baseUrl: 'http://nas.local:8080', accessToken: () => null }); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
影音服务:{state?labels[state]:'状态待确认'}
{selected==='modules'?'保存后立即生效,无需重启。关闭影音将停止播放及后台任务,保留全部影音数据。':'保存后生效,无需重启。进行中的任务保留原配置,新任务使用更新后的配置。'}