mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 13:04:08 +08:00
Merge branch 'perf/project-optimization'
This commit is contained in:
commit
6b85f7a0f0
49 files changed
+1152
-137
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-12
@@ -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=`。** 章节里的 `<img src>`、`<link href>` 是浏览器
|
||||
**子资源应携带 `?resource_token=`。** 为兼容旧客户端仍接受 `?access_token=`。章节里的 `<img src>`、`<link href>` 是浏览器
|
||||
自己发起的,没法带 `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,塞进 `<audio>` 里
|
||||
既没有声音也没有事件——这是最难排查的一种「成功」。
|
||||
- 这是唯一接受**查询串令牌**的接口之一(另一个是 `/books/:id/assets`、`/cover`、`/content`):
|
||||
`<audio src>` 无法携带 `Authorization` 头。因此它被精确地限制为 `GET /tts`,
|
||||
`<audio src>` 无法携带 `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=<ms>&bookId=<可选>`
|
||||
### `GET /sync?since=<cursor>&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": [...] }
|
||||
```
|
||||
|
||||
|
||||
@@ -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,保护登录凭据和访问令牌。
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+10
-10
@@ -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<AppContext,'config'> & {users:Pick<AppContext['users'],'byId'>}) {
|
||||
return async function preHandler(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||
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<AppContext,'config'> & {users:Pick<AppCon
|
||||
* narrow allowlist keeps the exposure to reads of immutable content, and those
|
||||
* URLs are only ever built by the client for that purpose.
|
||||
*/
|
||||
function bearerToken(request: FastifyRequest): string | null {
|
||||
function bearerToken(request: FastifyRequest): { token: string; resource: boolean } | null {
|
||||
const header = request.headers.authorization;
|
||||
if (header?.startsWith('Bearer ')) return header.slice('Bearer '.length).trim();
|
||||
if (header?.startsWith('Bearer ')) return { token: header.slice('Bearer '.length).trim(), resource: false };
|
||||
|
||||
if (!queryTokenAllowed(request)) return null;
|
||||
const query = (request.query ?? {}) as Record<string, unknown>;
|
||||
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 `<audio>` 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 {
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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<MediaRuntime,'request'|'close'>):void {
|
||||
@@ -39,7 +40,7 @@ export function registerMediaProxy(app:FastifyInstance,runtime:Pick<MediaRuntime
|
||||
return reply.send(stream);
|
||||
}catch(error){
|
||||
release();
|
||||
request.log.error({err:error,path:request.raw.url,method:request.method},'media proxy request failed');
|
||||
request.log.error({errorName:error instanceof Error?error.name:'Error',code:error instanceof AppError?error.code:undefined,path:requestLogPath(request.raw.url??''),method:request.method},'media proxy request failed');
|
||||
if(error instanceof AppError)throw error;
|
||||
throw new AppError(503,'MEDIA_REQUEST_UNCERTAIN','影音请求未完成;写入结果请查询后确认');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { AppContext } from '../context.ts';
|
||||
import { authenticate, currentUser } from '../auth.ts';
|
||||
import { badRequest } from '../../lib/errors.ts';
|
||||
import type { NoteRecord, ProgressRecord } from '../../services/sync.ts';
|
||||
import { validateSyncRecord, type NoteRecord, type ProgressRecord } from '../../services/sync.ts';
|
||||
|
||||
export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
const auth = authenticate(ctx);
|
||||
@@ -15,8 +15,8 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void
|
||||
app.get('/api/v1/sync', { preHandler: auth }, async (request) => {
|
||||
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<ProgressRecord>;
|
||||
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<NoteRecord>;
|
||||
validateSyncRecord(body);
|
||||
if (!body.bookId) throw badRequest('bookId is required');
|
||||
const note: NoteRecord = {
|
||||
id: body.id ?? crypto.randomUUID(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { RateLimitError } from '../lib/errors.ts';
|
||||
|
||||
export class AuthRateLimiter {
|
||||
private readonly buckets = new Map<string, { count: number; expiresAt: number }>();
|
||||
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<Result>(work: () => Promise<Result>): Promise<Result> {
|
||||
if (this.active >= this.capacity) throw new RateLimitError(1, 'AUTH_BUSY');
|
||||
this.active += 1;
|
||||
try { return await work(); }
|
||||
finally { this.active -= 1; }
|
||||
}
|
||||
}
|
||||
+51
-20
@@ -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<string, number> = {
|
||||
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<string, unknown>;
|
||||
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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<UserRow>('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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void>(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(); }
|
||||
});
|
||||
@@ -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> | void,
|
||||
): Promise<void> {
|
||||
const saved = new Map<string, string | undefined>();
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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<Db, 'run'>): 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(); }
|
||||
});
|
||||
@@ -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}});
|
||||
|
||||
@@ -179,7 +179,7 @@ export class ReaderApi {
|
||||
|
||||
/** Renew before expiry; transient failures leave the persisted login intact. */
|
||||
async renewSessionIfNeeded(): Promise<void> {
|
||||
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<SyncPushResult> {
|
||||
return this.call<SyncPushResult>('/api/v1/sync', 'POST', payload, options);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 <Suspense fallback={fallback}><Reader /></Suspense>; }
|
||||
export function MediaLibrariesPage() { return <Suspense fallback={fallback}><Libraries /></Suspense>; }
|
||||
@@ -63,3 +79,12 @@ export function SourceExtensionPage() { return <Suspense fallback={fallback}><So
|
||||
export function AdminUsersPage() { return <Suspense fallback={fallback}><AdminUsers /></Suspense>; }
|
||||
export function AdminRegistrationPage() { return <Suspense fallback={fallback}><AdminRegistration /></Suspense>; }
|
||||
export function AdminServicesPage() { return <Suspense fallback={fallback}><AdminServices /></Suspense>; }
|
||||
export function MediaDetailPage() { return <Suspense fallback={fallback}><MediaDetail /></Suspense>; }
|
||||
export function MediaPlayerPage() { return <Suspense fallback={fallback}><MediaPlayer /></Suspense>; }
|
||||
export function MediaFoldersPage() { return <Suspense fallback={fallback}><MediaFolders /></Suspense>; }
|
||||
export function MediaNarratorsPage() { return <Suspense fallback={fallback}><MediaNarrators /></Suspense>; }
|
||||
export function LibraryPage() { return <Suspense fallback={fallback}><Library /></Suspense>; }
|
||||
export function LibraryBrowsePage() { return <Suspense fallback={fallback}><LibraryBrowse /></Suspense>; }
|
||||
export function LibraryFilesPage() { return <Suspense fallback={fallback}><LibraryFiles /></Suspense>; }
|
||||
export function SourcesPage() { return <Suspense fallback={fallback}><Sources /></Suspense>; }
|
||||
export function SourceCatalogPage() { return <Suspense fallback={fallback}><SourceCatalog /></Suspense>; }
|
||||
@@ -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: <AppShell />, errorElement: <RouteErrorBoundary />, children: [
|
||||
@@ -107,4 +102,3 @@ export const routes = [{
|
||||
],
|
||||
}] satisfies RouteObject[];
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
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 });
|
||||
|
||||
@@ -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 <AdminFrame title="服务配置"><div className="user-management"><p role="status">影音服务:{state?labels[state]:'状态待确认'}</p><BusinessSettings api={runtime.api} showMedia={media} onSaved={()=>void runtime.refreshCapabilities().catch(()=>undefined)} /></div></AdminFrame>;
|
||||
return <AdminFrame title="服务配置"><div className="user-management"><BusinessSettings api={runtime.api} showMedia={media} modulesStatus={<p role="status">影音服务:{state?labels[state]:'状态待确认'}</p>} onSaved={()=>void runtime.refreshCapabilities().catch(()=>undefined)} /></div></AdminFrame>;
|
||||
}
|
||||
+10
-2
@@ -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<Response> {
|
||||
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);
|
||||
|
||||
+19
-21
@@ -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})`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ export interface SpeechFactoryOptions {
|
||||
kind: Exclude<SpeechEngineKind, 'auto'>;
|
||||
baseUrl: string;
|
||||
accessToken: () => string | null;
|
||||
resourceToken?: () => string | null;
|
||||
prepareRequest?: () => Promise<void> | 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);
|
||||
|
||||
@@ -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> | 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}`;
|
||||
}
|
||||
|
||||
@@ -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<T>(path?:string,method?:string,body?:unknown,signal?:AbortSignal):Promise<T>;
|
||||
@@ -13,7 +13,7 @@ interface SettingsView {
|
||||
group:string;label:string;revision:number;values:Record<string,string|number|boolean>;secrets:Record<string,boolean>;
|
||||
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<SettingsView[]>([]),[selected,setSelected]=useState(group||'tts');
|
||||
const [values,setValues]=useState<Record<string,string|number|boolean>>({}),[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&&<label className="user-manager-mode">配置分类<Select aria-label="配置分类" value={selected} disabled={busy} onChange={(event:ChangeEvent<HTMLSelectElement>)=>setSelected(event.currentTarget.value)}>{visibleGroups.map(item=><option key={item.group} value={item.group}>{item.label}</option>)}</Select></label>}
|
||||
{!current&&!busy&&<button onClick={()=>setRetry(value=>value+1)}>重新读取配置</button>}
|
||||
{current&&<>
|
||||
{selected==='modules'&&modulesStatus}
|
||||
<p className="muted">{selected==='modules'?'保存后立即生效,无需重启。关闭影音将停止播放及后台任务,保留全部影音数据。':'保存后生效,无需重启。进行中的任务保留原配置,新任务使用更新后的配置。'}</p>
|
||||
<form className="user-manager-form" onSubmit={event=>{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=><label key={field.key} className={field.type==='checkbox'?'business-setting-toggle':undefined}>{field.label}
|
||||
|
||||
@@ -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> | 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),
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<RuntimeContext.Provider value={runtime}>
|
||||
<MemoryRouter><AdminServicesPage /></MemoryRouter>
|
||||
</RuntimeContext.Provider>, root,
|
||||
));
|
||||
|
||||
afterEach(() => {
|
||||
act(() => render(null, root));
|
||||
useAuthStore.setState({ session: null, verifiedUser: null });
|
||||
dismissAllNotices();
|
||||
});
|
||||
|
||||
const selectGroup = (group: string) => act(() => {
|
||||
const select = root.querySelector<HTMLSelectElement>('[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();
|
||||
});
|
||||
@@ -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<string, string>).authorization, 'Bearer renewed');
|
||||
});
|
||||
|
||||
test('a 401 is retried once after a refresh, and the new token is used', async () => {
|
||||
const api = client();
|
||||
responder = (url) => {
|
||||
|
||||
@@ -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 = '<html xmlns="http://www.w3.org/1999/xhtml"><head><style>p{background:url(/api/v1/books/book/assets?ref=background)}</style></head><body><img src="/api/v1/books/book/assets?ref=image"/></body></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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -63,6 +63,8 @@ function makeEngine(options: {
|
||||
chunks: string[][];
|
||||
baseUrl?: string;
|
||||
token?: string | null;
|
||||
resourceToken?: string;
|
||||
prepareRequest?: () => Promise<void> | 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<void>(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<void>(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(
|
||||
|
||||
Reference in new issue
Block a user