dscs-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js ADDED
@@ -0,0 +1,1281 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { createServer } from 'node:http';
5
+ import { PROTOCOL_VERSION, errorBody } from './shared/protocol.js';
6
+ import { SyncError, statusOf } from './shared/wire.js';
7
+ import { BLOB_BATCH_CONTENT_TYPE, MAX_BATCH_BLOBS, MAX_BATCH_BODY_BYTES, decodeBlobBatch, encodeBlobBatch } from './shared/blob-batch.js';
8
+ import { deriveMasterKey, newSalt } from './shared/crypto.js';
9
+ import { hasBlob, putBlob, readBlob } from './blobs.js';
10
+ import { DEFAULT_DATASET, SERVER_SCHEMA_VERSION, adminPassword, blobRecord, isAdminInitialized, markAdminInitialized, readMaxBytes, readMeta, recordBlob, readRevision, setAdminPassword, usedBlobBytes, writeMaxBytes } from './db.js';
11
+ import { MAX_APPEND_BODY_BYTES, adminSessions, appendFrames, deleteSession, getManifest, getV2SessionSummary, listV2Batches, listV2Changes, listV2Sessions, sessionCounts } from './sessions.js';
12
+ import { acquireLease, releaseLease, SYNC_RESOURCE, vaultResource } from './leases.js';
13
+ import { appendVaultRecord, readVaultCursor, readVaultStream, validVaultWriter } from './vault.js';
14
+ function readServerVersion() {
15
+ try {
16
+ const raw = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
17
+ return typeof raw.version === 'string' && raw.version !== '' ? raw.version : '0.0.0';
18
+ } catch {
19
+ return '0.0.0';
20
+ }
21
+ }
22
+ export const SERVER_VERSION = readServerVersion();
23
+ export const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 * 1024;
24
+ export const DEFAULT_MAX_BLOB_BYTES = 32 * 1024 * 1024;
25
+ function readAsset(url) {
26
+ try {
27
+ return readFileSync(url);
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ const LOGO_SVG = readAsset(new URL('../../../assets/logo-dark.svg', import.meta.url));
33
+ const FAVICON_SVG = readAsset(new URL('../../../assets/favicon.svg', import.meta.url));
34
+ function readText(url, label) {
35
+ try {
36
+ return readFileSync(url, 'utf8');
37
+ } catch {
38
+ throw new Error(`缺少页面文件:${label}`);
39
+ }
40
+ }
41
+ const ADMIN_PAGE = readText(new URL('../web/admin.html', import.meta.url), 'admin.html');
42
+ const SETUP_PAGE = readText(new URL('../web/setup.html', import.meta.url), 'setup.html');
43
+ export function hashToken(token) {
44
+ return createHash('sha256').update(token).digest('hex');
45
+ }
46
+ export function mintToken() {
47
+ return randomBytes(32).toString('base64url');
48
+ }
49
+ function json(res, status, body) {
50
+ res.statusCode = status;
51
+ res.setHeader('content-type', 'application/json; charset=utf-8');
52
+ res.setHeader('cache-control', 'no-store');
53
+ res.end(JSON.stringify(body));
54
+ }
55
+ async function readJson(req, limit = 64 * 1024) {
56
+ const chunks = [];
57
+ let size = 0;
58
+ for await (const chunk of req){
59
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
60
+ size += buffer.length;
61
+ if (size > limit) throw new SyncError('bad-request', `请求体超过上限 ${limit} 字节`);
62
+ chunks.push(buffer);
63
+ }
64
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
65
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
66
+ throw new SyncError('bad-request', '请求体必须是 JSON 对象');
67
+ }
68
+ return parsed;
69
+ }
70
+ async function readRaw(req, limit) {
71
+ const chunks = [];
72
+ let size = 0;
73
+ for await (const chunk of req){
74
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
75
+ size += buffer.length;
76
+ if (size > limit) throw new SyncError('bad-request', `请求体超过上限 ${limit} 字节`);
77
+ chunks.push(buffer);
78
+ }
79
+ return Buffer.concat(chunks);
80
+ }
81
+ function authenticate(db, req) {
82
+ const header = String(req.headers.authorization ?? '');
83
+ if (!header.startsWith('Bearer ')) return undefined;
84
+ const token = header.slice('Bearer '.length).trim();
85
+ if (token === '') return undefined;
86
+ const setupToken = readMeta(db, 'setup_token');
87
+ if (setupToken !== undefined && token === setupToken) return {
88
+ role: 'admin'
89
+ };
90
+ const row = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at, revoked_at FROM devices WHERE dataset = ? AND token_hash = ?').get(DEFAULT_DATASET, hashToken(token));
91
+ if (row === undefined || row.revoked_at !== null) return undefined;
92
+ return {
93
+ role: 'device',
94
+ device: {
95
+ deviceId: row.device_id,
96
+ label: row.label,
97
+ createdAt: row.created_at,
98
+ ...row.last_seen_at === null ? {} : {
99
+ lastSeenAt: row.last_seen_at
100
+ }
101
+ }
102
+ };
103
+ }
104
+ function requireDevice(db, req) {
105
+ const auth = authenticate(db, req);
106
+ if (auth === undefined) throw new SyncError('unauthorized', '未知 token');
107
+ if (auth.role !== 'device') throw new SyncError('unauthorized', '需要设备 token');
108
+ const declared = req.headers['x-device-id'];
109
+ if (declared !== undefined && String(declared) !== auth.device.deviceId) {
110
+ throw new SyncError('unauthorized', 'X-Device-Id 与 token 不匹配');
111
+ }
112
+ return auth.device;
113
+ }
114
+ function clientIdFrom(req) {
115
+ const raw = req.headers['x-client-id'];
116
+ const value = Array.isArray(raw) ? raw[0] ?? '' : String(raw ?? '');
117
+ if (!/^[0-9a-f]{16,64}$/u.test(value)) {
118
+ throw new SyncError('bad-request', '需要 16–64 位十六进制 X-Client-Id');
119
+ }
120
+ return value;
121
+ }
122
+ function idempotencyKeyFrom(req) {
123
+ const raw = req.headers['idempotency-key'];
124
+ const value = Array.isArray(raw) ? raw[0] ?? '' : String(raw ?? '');
125
+ if (!/^[A-Za-z0-9._:-]{1,255}$/u.test(value)) {
126
+ throw new SyncError('bad-request', '需要 1–255 位合法 Idempotency-Key');
127
+ }
128
+ return value;
129
+ }
130
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
131
+ function sessionIdFrom(pathname, pattern) {
132
+ const match = pattern.exec(pathname);
133
+ if (match === null) throw new SyncError('bad-request', '会话 id 缺失');
134
+ const id = decodeURIComponent(match[1]);
135
+ if (!SESSION_ID_PATTERN.test(id)) throw new SyncError('bad-request', `会话 id 非法:${id}`);
136
+ return id;
137
+ }
138
+ const MAX_CURSOR_LENGTH = 512;
139
+ function encodeCursor(value) {
140
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
141
+ }
142
+ function decodeCursorObject(raw) {
143
+ if (raw === null || raw === '') return undefined;
144
+ if (raw.length > MAX_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/u.test(raw)) {
145
+ throw new SyncError('bad-request', 'cursor 非法');
146
+ }
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
150
+ } catch {
151
+ throw new SyncError('bad-request', 'cursor 非法');
152
+ }
153
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
154
+ throw new SyncError('bad-request', 'cursor 非法');
155
+ }
156
+ return parsed;
157
+ }
158
+ function decodeSessionsCursor(raw) {
159
+ const value = decodeCursorObject(raw);
160
+ if (value === undefined) return undefined;
161
+ if (value['v'] !== 2 || typeof value['after'] !== 'string' || !SESSION_ID_PATTERN.test(value['after'])) {
162
+ throw new SyncError('bad-request', 'cursor 非法');
163
+ }
164
+ return value['after'];
165
+ }
166
+ function decodeChangesCursor(raw) {
167
+ const value = decodeCursorObject(raw);
168
+ if (value === undefined) {
169
+ return {
170
+ revision: 0,
171
+ tombstone: {
172
+ deletedAt: 0,
173
+ sessionId: '',
174
+ createdAt: 0
175
+ }
176
+ };
177
+ }
178
+ const revision = value['revision'];
179
+ const tombstone = value['tombstone'];
180
+ if (value['v'] !== 2 || typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0 || tombstone === null || typeof tombstone !== 'object' || Array.isArray(tombstone)) {
181
+ throw new SyncError('bad-request', 'cursor 非法');
182
+ }
183
+ const record = tombstone;
184
+ const deletedAt = record['deletedAt'];
185
+ const sessionId = record['sessionId'];
186
+ const createdAt = record['createdAt'];
187
+ const empty = deletedAt === 0 && sessionId === '' && createdAt === 0;
188
+ if (typeof deletedAt !== 'number' || !Number.isSafeInteger(deletedAt) || deletedAt < 0 || typeof sessionId !== 'string' || !empty && !SESSION_ID_PATTERN.test(sessionId) || typeof createdAt !== 'number' || !Number.isSafeInteger(createdAt) || createdAt < 0) {
189
+ throw new SyncError('bad-request', 'cursor 非法');
190
+ }
191
+ return {
192
+ revision,
193
+ tombstone: {
194
+ deletedAt,
195
+ sessionId,
196
+ createdAt
197
+ }
198
+ };
199
+ }
200
+ function etagMatches(header, digest) {
201
+ const raw = Array.isArray(header) ? header.join(',') : header;
202
+ if (raw === undefined) return false;
203
+ const target = `"${digest}"`;
204
+ return raw.split(',').some((entry)=>{
205
+ const value = entry.trim();
206
+ return value === '*' || value === target || value === `W/${target}`;
207
+ });
208
+ }
209
+ function parseAppend(body) {
210
+ if (!Number.isSafeInteger(body.baseIndex)) throw new SyncError('bad-request', 'baseIndex 必须是整数');
211
+ const baseChainHash = body.baseChainHash;
212
+ if (baseChainHash !== null && (typeof baseChainHash !== 'string' || !/^[0-9a-f]{64}$/u.test(baseChainHash))) {
213
+ throw new SyncError('bad-request', 'baseChainHash 必须是 64 位十六进制或 null');
214
+ }
215
+ if (!Number.isSafeInteger(body.createdAt) || Number(body.createdAt) < 0) throw new SyncError('bad-request', 'createdAt 必须是非负整数');
216
+ if (!Number.isSafeInteger(body.formatVersion) || Number(body.formatVersion) < 1) throw new SyncError('bad-request', 'formatVersion 必须是正整数');
217
+ const refs = body.batches;
218
+ if (!Array.isArray(refs)) throw new SyncError('bad-request', 'batches 必须是数组');
219
+ if (body.tailOpen !== undefined && typeof body.tailOpen !== 'boolean') throw new SyncError('bad-request', 'tailOpen 必须是布尔值');
220
+ if (body.safeThroughSeq !== undefined && (!Number.isSafeInteger(body.safeThroughSeq) || Number(body.safeThroughSeq) < -1)) {
221
+ throw new SyncError('bad-request', 'safeThroughSeq 必须是不小于 -1 的整数');
222
+ }
223
+ if (body.workspaceKey !== undefined && (typeof body.workspaceKey !== 'string' || !/^[A-Za-z0-9_-]{16,64}$/u.test(body.workspaceKey))) {
224
+ throw new SyncError('bad-request', 'workspaceKey 非法');
225
+ }
226
+ return {
227
+ baseIndex: Number(body.baseIndex),
228
+ baseChainHash: baseChainHash,
229
+ createdAt: Number(body.createdAt),
230
+ formatVersion: Number(body.formatVersion),
231
+ frames: refs,
232
+ ...body.tailOpen === undefined ? {} : {
233
+ tailOpen: body.tailOpen
234
+ },
235
+ ...body.safeThroughSeq === undefined ? {} : {
236
+ safeThroughSeq: Number(body.safeThroughSeq)
237
+ },
238
+ ...body.workspaceKey === undefined ? {} : {
239
+ workspaceKey: body.workspaceKey
240
+ }
241
+ };
242
+ }
243
+ function intParam(value, fallback, min, max) {
244
+ if (value === null) return fallback;
245
+ const parsed = Number(value);
246
+ if (!Number.isSafeInteger(parsed)) throw new SyncError('bad-request', `查询参数必须是整数:${value}`);
247
+ return Math.min(max, Math.max(min, parsed));
248
+ }
249
+ const SYNC_LEASE_PATH = /^\/v2\/sync\/lease$/u;
250
+ const V2_SESSIONS_PATH = /^\/v2\/sessions$/u;
251
+ const V2_CHANGES_PATH = /^\/v2\/changes$/u;
252
+ const V2_SUMMARY_PATH = /^\/v2\/sessions\/([^/]+)\/summary$/u;
253
+ const V2_BATCHES_PATH = /^\/v2\/sessions\/([^/]+)\/batches$/u;
254
+ const V2_MANIFEST_PATH = /^\/v2\/sessions\/([^/]+)\/manifest$/u;
255
+ const V2_APPEND_PATH = /^\/v2\/sessions\/([^/]+)\/append$/u;
256
+ const V2_DELETE_PATH = /^\/v2\/sessions\/([^/]+)\/delete$/u;
257
+ const DEVICES_PATH = /^\/v2\/devices$/u;
258
+ const DEVICE_PATH = /^\/v2\/devices\/([0-9a-f]+)$/u;
259
+ const BLOB_PATH = /^\/v2\/blobs\/([0-9a-f]{64})$/u;
260
+ const BLOB_BATCH_PATH = /^\/v2\/blobs\/batch$/u;
261
+ const BLOB_BATCH_READ_PATH = /^\/v2\/blobs\/batch\/read$/u;
262
+ const ME_PATH = /^\/v2\/me$/u;
263
+ const VAULT_APPEND_PATH = /^\/v2\/vault\/append$/u;
264
+ const VAULT_LEASE_PATH = /^\/v2\/vault\/lease$/u;
265
+ const VAULT_CURSOR_PATH = /^\/v2\/vault\/cursor$/u;
266
+ const VAULT_STREAM_PATH = /^\/v2\/vault\/stream\/([^/]+)$/u;
267
+ const ADMIN_DEVICE_PATH = /^\/admin\/devices\/([0-9a-f]+)$/u;
268
+ function withBatchFields(value) {
269
+ if (value === null || typeof value !== 'object') return value;
270
+ const record = value;
271
+ const result = {
272
+ ...record
273
+ };
274
+ const refs = Array.isArray(record['batches']) ? record['batches'] : record['frames'];
275
+ if (Array.isArray(refs)) {
276
+ result['batches'] = refs;
277
+ if (typeof record['batchCount'] !== 'number') result['batchCount'] = refs.length;
278
+ delete result['frames'];
279
+ }
280
+ if (record['manifest'] !== null && typeof record['manifest'] === 'object') {
281
+ result['manifest'] = withBatchFields(record['manifest']);
282
+ }
283
+ return result;
284
+ }
285
+ const ADMIN_COOKIE = 'dsh_sync_admin';
286
+ const SETUP_COOKIE = 'dsh_sync_setup';
287
+ const ADMIN_TTL_MS = 12 * 60 * 60 * 1000;
288
+ const SETUP_TTL_MS = 15 * 60 * 1000;
289
+ export const ONBOARDING_TTL_MS = 10 * 60 * 1000;
290
+ export const ONBOARDING_CODE_FILE = 'onboarding-code.json';
291
+ const ONBOARDING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
292
+ const MIN_MAX_BYTES = 16 * 1024 * 1024;
293
+ const MAX_MAX_BYTES = 1024 * 1024 * 1024 * 1024;
294
+ export function mintOnboardingCode() {
295
+ const bytes = randomBytes(8);
296
+ let raw = '';
297
+ for (const byte of bytes)raw += ONBOARDING_ALPHABET[byte % ONBOARDING_ALPHABET.length];
298
+ return `${raw.slice(0, 4)}-${raw.slice(4)}`;
299
+ }
300
+ function normalizeOnboardingCode(raw) {
301
+ return raw.toUpperCase().replace(/[^A-Z0-9]/gu, '');
302
+ }
303
+ export function validateAdminPassword(password) {
304
+ if (typeof password !== 'string') return '密码不能为空';
305
+ if (password.length < 6) return '密码至少 6 位';
306
+ if (password.length > 128) return '密码不能超过 128 位';
307
+ const classes = [
308
+ /[A-Za-z]/u,
309
+ /[0-9]/u,
310
+ /[^A-Za-z0-9]/u
311
+ ].filter((pattern)=>pattern.test(password)).length;
312
+ if (classes < 2) return '密码需要包含字母、数字、特殊字符中的至少两类';
313
+ return undefined;
314
+ }
315
+ function cookieValue(req, name) {
316
+ const header = req.headers.cookie;
317
+ if (typeof header !== 'string') return undefined;
318
+ for (const part of header.split(';')){
319
+ const separator = part.indexOf('=');
320
+ if (separator === -1) continue;
321
+ if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
322
+ }
323
+ return undefined;
324
+ }
325
+ function writeAdminCookie(res, token, maxAgeSeconds) {
326
+ res.appendHeader('set-cookie', `${ADMIN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeSeconds}`);
327
+ }
328
+ function writeSetupCookie(res, token, maxAgeSeconds) {
329
+ res.appendHeader('set-cookie', `${SETUP_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/setup; Max-Age=${maxAgeSeconds}`);
330
+ }
331
+ export function createDevice(db, label) {
332
+ const deviceId = randomBytes(8).toString('hex');
333
+ const token = mintToken();
334
+ const now = Date.now();
335
+ db.handle.prepare('INSERT INTO devices (dataset, device_id, label, token_hash, created_at) VALUES (?, ?, ?, ?, ?)').run(DEFAULT_DATASET, deviceId, label, hashToken(token), now);
336
+ return {
337
+ device: {
338
+ deviceId,
339
+ label,
340
+ createdAt: now
341
+ },
342
+ token
343
+ };
344
+ }
345
+ export function listDevices(db) {
346
+ const rows = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at FROM devices WHERE dataset = ? AND revoked_at IS NULL ORDER BY created_at').all(DEFAULT_DATASET);
347
+ return rows.map((row)=>({
348
+ deviceId: row.device_id,
349
+ label: row.label,
350
+ createdAt: row.created_at,
351
+ ...row.last_seen_at === null ? {} : {
352
+ lastSeenAt: row.last_seen_at
353
+ }
354
+ }));
355
+ }
356
+ export function revokeDevice(db, deviceId) {
357
+ const result = db.handle.prepare('UPDATE devices SET revoked_at = ? WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL').run(Date.now(), DEFAULT_DATASET, deviceId);
358
+ return Number(result.changes) > 0;
359
+ }
360
+ export function createHttpServer(options) {
361
+ const { db } = options;
362
+ const version = options.version ?? SERVER_VERSION;
363
+ const maxBlobBytes = options.maxBlobBytes ?? DEFAULT_MAX_BLOB_BYTES;
364
+ const defaultMaxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
365
+ const effectiveMaxBytes = ()=>readMaxBytes(db, defaultMaxBytes);
366
+ const adminSessionsByKey = new Map();
367
+ const setupSessionsByKey = new Map();
368
+ const attemptsByKey = new Map();
369
+ const onboarding = {
370
+ codeHash: options.onboardingCode === undefined ? undefined : hashToken(normalizeOnboardingCode(options.onboardingCode)),
371
+ expiresAt: Date.now() + ONBOARDING_TTL_MS
372
+ };
373
+ function requireAdmin(req) {
374
+ const token = cookieValue(req, ADMIN_COOKIE);
375
+ if (token === undefined) throw new SyncError('unauthorized', '需要登录管理台');
376
+ const key = hashToken(token);
377
+ const expiresAt = adminSessionsByKey.get(key);
378
+ if (expiresAt === undefined || expiresAt <= Date.now()) {
379
+ adminSessionsByKey.delete(key);
380
+ throw new SyncError('unauthorized', '管理台会话已过期');
381
+ }
382
+ }
383
+ function requireSetup(req) {
384
+ const token = cookieValue(req, SETUP_COOKIE);
385
+ if (token === undefined) throw new SyncError('unauthorized', '请先验证初始化密钥');
386
+ const key = hashToken(token);
387
+ const expiresAt = setupSessionsByKey.get(key);
388
+ if (expiresAt === undefined || expiresAt <= Date.now()) {
389
+ setupSessionsByKey.delete(key);
390
+ throw new SyncError('unauthorized', '初始化会话已过期,请重新验证密钥');
391
+ }
392
+ }
393
+ function issueAdminSession(res) {
394
+ const token = randomBytes(32).toString('base64url');
395
+ const expiresAt = Date.now() + ADMIN_TTL_MS;
396
+ adminSessionsByKey.set(hashToken(token), expiresAt);
397
+ writeAdminCookie(res, token, Math.floor(ADMIN_TTL_MS / 1000));
398
+ return expiresAt;
399
+ }
400
+ function guardAttempts(key) {
401
+ const now = Date.now();
402
+ const entry = attemptsByKey.get(key);
403
+ if (entry === undefined || entry.resetAt <= now) {
404
+ attemptsByKey.set(key, {
405
+ count: 0,
406
+ resetAt: now + 10 * 60 * 1000
407
+ });
408
+ return;
409
+ }
410
+ if (entry.count >= 10) throw new SyncError('rate-limited', '尝试过于频繁,请稍后再试');
411
+ }
412
+ function noteFailure(key) {
413
+ const entry = attemptsByKey.get(key);
414
+ if (entry !== undefined) entry.count += 1;
415
+ }
416
+ function passwordMatches(submitted) {
417
+ const stored = adminPassword(db);
418
+ if (stored === undefined || submitted.length === 0) return false;
419
+ const derived = deriveMasterKey(submitted, Buffer.from(stored.salt, 'hex'));
420
+ const actual = Buffer.from(stored.hash, 'hex');
421
+ return derived.length === actual.length && timingSafeEqual(derived, actual);
422
+ }
423
+ const routes = [
424
+ {
425
+ method: 'GET',
426
+ path: /^\/health$/,
427
+ handler: async (_req, res)=>{
428
+ json(res, 200, {
429
+ ok: true,
430
+ server: {
431
+ version,
432
+ apiVersions: [
433
+ 2
434
+ ],
435
+ protocols: [
436
+ PROTOCOL_VERSION
437
+ ],
438
+ schemaVersion: SERVER_SCHEMA_VERSION,
439
+ capabilities: [
440
+ 'session-summary',
441
+ 'changes-cursor',
442
+ 'conditional-get',
443
+ 'idempotent-append',
444
+ 'manifest-digest',
445
+ 'batches',
446
+ 'batch-upload',
447
+ 'batch-download',
448
+ 'zstd-frame'
449
+ ],
450
+ limits: {
451
+ batchBytes: MAX_BATCH_BODY_BYTES,
452
+ batchBlobs: MAX_BATCH_BLOBS,
453
+ blobBytes: maxBlobBytes
454
+ }
455
+ },
456
+ now: Date.now()
457
+ });
458
+ }
459
+ },
460
+ {
461
+ method: 'GET',
462
+ path: V2_SESSIONS_PATH,
463
+ handler: async (req, res)=>{
464
+ requireDevice(db, req);
465
+ const url = new URL(req.url ?? '/', 'http://localhost');
466
+ const after = decodeSessionsCursor(url.searchParams.get('cursor'));
467
+ const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
468
+ const page = listV2Sessions(db, after, limit);
469
+ return json(res, 200, {
470
+ ok: true,
471
+ value: {
472
+ sessions: page.sessions,
473
+ nextCursor: page.nextSessionId === null ? null : encodeCursor({
474
+ v: 2,
475
+ after: page.nextSessionId
476
+ })
477
+ }
478
+ });
479
+ }
480
+ },
481
+ {
482
+ method: 'GET',
483
+ path: V2_CHANGES_PATH,
484
+ handler: async (req, res)=>{
485
+ requireDevice(db, req);
486
+ const url = new URL(req.url ?? '/', 'http://localhost');
487
+ const cursor = decodeChangesCursor(url.searchParams.get('cursor'));
488
+ const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
489
+ const page = listV2Changes(db, cursor.revision, cursor.tombstone, limit);
490
+ return json(res, 200, {
491
+ ok: true,
492
+ value: {
493
+ sessions: page.sessions,
494
+ tombstones: page.tombstones,
495
+ nextCursor: encodeCursor({
496
+ v: 2,
497
+ revision: page.nextRevision,
498
+ tombstone: page.nextTombstone
499
+ }),
500
+ hasMore: page.hasMore
501
+ }
502
+ });
503
+ }
504
+ },
505
+ {
506
+ method: 'GET',
507
+ path: V2_SUMMARY_PATH,
508
+ handler: async (req, res)=>{
509
+ requireDevice(db, req);
510
+ const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_SUMMARY_PATH);
511
+ const summary = getV2SessionSummary(db, id);
512
+ if (summary === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
513
+ res.setHeader('etag', `"${summary.manifestDigest}"`);
514
+ if (etagMatches(req.headers['if-none-match'], summary.manifestDigest)) {
515
+ res.statusCode = 304;
516
+ res.end();
517
+ return;
518
+ }
519
+ return json(res, 200, {
520
+ ok: true,
521
+ value: summary
522
+ });
523
+ }
524
+ },
525
+ {
526
+ method: 'GET',
527
+ path: V2_BATCHES_PATH,
528
+ handler: async (req, res)=>{
529
+ requireDevice(db, req);
530
+ const url = new URL(req.url ?? '/', 'http://localhost');
531
+ const id = sessionIdFrom(url.pathname, V2_BATCHES_PATH);
532
+ const from = intParam(url.searchParams.get('from'), 0, 0, Number.MAX_SAFE_INTEGER);
533
+ const limit = intParam(url.searchParams.get('limit'), 500, 1, 1000);
534
+ const expected = url.searchParams.get('expectedManifestDigest');
535
+ if (expected !== null && !/^[0-9a-f]{64}$/u.test(expected)) {
536
+ throw new SyncError('bad-request', 'expectedManifestDigest 必须是 64 位十六进制');
537
+ }
538
+ const page = listV2Batches(db, id, from, limit);
539
+ if (page === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
540
+ if (expected !== null && expected !== page.manifestDigest) {
541
+ throw new SyncError('conflict', 'manifest 已变化,请重新读取摘要');
542
+ }
543
+ return json(res, 200, {
544
+ ok: true,
545
+ value: page
546
+ });
547
+ }
548
+ },
549
+ {
550
+ method: 'POST',
551
+ path: V2_APPEND_PATH,
552
+ handler: async (req, res)=>{
553
+ const device = requireDevice(db, req);
554
+ const clientId = clientIdFrom(req);
555
+ const idempotencyKey = idempotencyKeyFrom(req);
556
+ const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_APPEND_PATH);
557
+ const body = await readJson(req, MAX_APPEND_BODY_BYTES);
558
+ const parsed = parseAppend(body);
559
+ const value = appendFrames(db, options.dataDir, {
560
+ sessionId: id,
561
+ deviceId: device.deviceId,
562
+ clientId,
563
+ idempotencyKey,
564
+ ...parsed
565
+ });
566
+ return json(res, 200, {
567
+ ok: true,
568
+ value
569
+ });
570
+ }
571
+ },
572
+ {
573
+ method: 'GET',
574
+ path: V2_MANIFEST_PATH,
575
+ handler: async (req, res)=>{
576
+ requireDevice(db, req);
577
+ const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_MANIFEST_PATH);
578
+ const found = getManifest(db, id);
579
+ if (found === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
580
+ return json(res, 200, {
581
+ ok: true,
582
+ value: withBatchFields(found)
583
+ });
584
+ }
585
+ },
586
+ {
587
+ method: 'POST',
588
+ path: V2_DELETE_PATH,
589
+ handler: async (req, res)=>{
590
+ const device = requireDevice(db, req);
591
+ const clientId = clientIdFrom(req);
592
+ const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_DELETE_PATH);
593
+ const body = await readJson(req);
594
+ if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
595
+ return json(res, 200, {
596
+ ok: true,
597
+ value: deleteSession(db, id, device.deviceId, clientId, Number(body.createdAt))
598
+ });
599
+ }
600
+ },
601
+ {
602
+ method: 'POST',
603
+ path: DEVICES_PATH,
604
+ handler: async (req, res)=>{
605
+ const auth = authenticate(db, req);
606
+ if (auth?.role !== 'admin') return json(res, 401, errorBody('unauthorized', '需要 setup token'));
607
+ const body = await readJson(req);
608
+ const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
609
+ const created = createDevice(db, label);
610
+ return json(res, 201, {
611
+ ok: true,
612
+ value: created
613
+ });
614
+ }
615
+ },
616
+ {
617
+ method: 'GET',
618
+ path: DEVICES_PATH,
619
+ handler: async (req, res)=>{
620
+ const auth = authenticate(db, req);
621
+ if (auth?.role !== 'admin') return json(res, 401, errorBody('unauthorized', '需要 setup token'));
622
+ return json(res, 200, {
623
+ ok: true,
624
+ value: listDevices(db)
625
+ });
626
+ }
627
+ },
628
+ {
629
+ method: 'DELETE',
630
+ path: DEVICE_PATH,
631
+ handler: async (req, res)=>{
632
+ const auth = authenticate(db, req);
633
+ if (auth?.role !== 'admin') return json(res, 401, errorBody('unauthorized', '需要 setup token'));
634
+ const match = DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
635
+ if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
636
+ const revoked = revokeDevice(db, match[1]);
637
+ return revoked ? json(res, 200, {
638
+ ok: true,
639
+ value: {
640
+ deviceId: match[1],
641
+ revoked: true
642
+ }
643
+ }) : json(res, 404, errorBody('not-found', '没有这个设备'));
644
+ }
645
+ },
646
+ {
647
+ method: 'POST',
648
+ path: BLOB_BATCH_PATH,
649
+ handler: async (req, res)=>{
650
+ requireDevice(db, req);
651
+ const body = await readRaw(req, MAX_BATCH_BODY_BYTES);
652
+ const entries = decodeBlobBatch(body);
653
+ const results = [];
654
+ const fresh = new Map();
655
+ for (const entry of entries){
656
+ const actual = createHash('sha256').update(entry.data).digest('hex');
657
+ if (actual !== entry.sha256) {
658
+ throw new SyncError('bad-request', `批量 blob 的哈希不匹配:${entry.sha256}`);
659
+ }
660
+ const existing = blobRecord(db, actual);
661
+ if (existing !== undefined || fresh.has(actual)) {
662
+ results.push({
663
+ sha256: actual,
664
+ bytes: entry.data.length,
665
+ dedup: true
666
+ });
667
+ continue;
668
+ }
669
+ fresh.set(actual, {
670
+ kind: entry.kind,
671
+ data: entry.data
672
+ });
673
+ results.push({
674
+ sha256: actual,
675
+ bytes: entry.data.length,
676
+ dedup: false
677
+ });
678
+ }
679
+ const newBytes = [
680
+ ...fresh.values()
681
+ ].reduce((sum, entry)=>sum + entry.data.length, 0);
682
+ const used = usedBlobBytes(db);
683
+ const limit = effectiveMaxBytes();
684
+ if (used + newBytes > limit) {
685
+ throw new SyncError('quota', `存储超额:已用 ${used} 字节,本批新增 ${newBytes} 字节,上限 ${limit} 字节`);
686
+ }
687
+ for (const [sha256, entry] of fresh){
688
+ await putBlob(options.dataDir, entry.data);
689
+ recordBlob(db, sha256, entry.data.length, entry.kind);
690
+ }
691
+ return json(res, 200, {
692
+ ok: true,
693
+ value: {
694
+ blobs: results
695
+ }
696
+ });
697
+ }
698
+ },
699
+ {
700
+ method: 'POST',
701
+ path: BLOB_BATCH_READ_PATH,
702
+ handler: async (req, res)=>{
703
+ requireDevice(db, req);
704
+ const body = await readJson(req);
705
+ const raw = body.sha256;
706
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_BATCH_BLOBS) {
707
+ throw new SyncError('bad-request', `sha256 必须是 1–${MAX_BATCH_BLOBS} 个数组项`);
708
+ }
709
+ const hashes = [];
710
+ for (const value of raw){
711
+ if (typeof value !== 'string' || !/^[0-9a-f]{64}$/u.test(value)) {
712
+ throw new SyncError('bad-request', 'sha256 必须是 64 位小写十六进制');
713
+ }
714
+ hashes.push(value);
715
+ }
716
+ const kinds = [];
717
+ for (const sha256 of hashes){
718
+ if (!await hasBlob(options.dataDir, sha256)) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
719
+ const record = blobRecord(db, sha256);
720
+ if (record === undefined) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
721
+ if (record.kind !== 'frame' && record.kind !== 'attachment' && record.kind !== 'vault') {
722
+ throw new SyncError('bad-request', `blob 类别非法:${record.kind}`);
723
+ }
724
+ kinds.push(record.kind);
725
+ }
726
+ const blobs = [];
727
+ for(let index = 0; index < hashes.length; index += 1){
728
+ blobs.push({
729
+ kind: kinds[index],
730
+ data: await readBlob(options.dataDir, hashes[index])
731
+ });
732
+ }
733
+ let encoded;
734
+ try {
735
+ encoded = encodeBlobBatch(blobs);
736
+ } catch (error) {
737
+ throw new SyncError('bad-request', error instanceof Error ? error.message : String(error));
738
+ }
739
+ res.statusCode = 200;
740
+ res.setHeader('content-type', BLOB_BATCH_CONTENT_TYPE);
741
+ res.setHeader('content-length', encoded.length);
742
+ res.setHeader('cache-control', 'no-store');
743
+ res.end(encoded);
744
+ }
745
+ },
746
+ {
747
+ method: 'POST',
748
+ path: /^\/v2\/blobs$/,
749
+ handler: async (req, res)=>{
750
+ const auth = authenticate(db, req);
751
+ if (auth === undefined) return json(res, 401, errorBody('unauthorized', '需要设备或 setup token'));
752
+ const url = new URL(req.url ?? '/', 'http://localhost');
753
+ const kind = url.searchParams.get('kind') ?? 'frame';
754
+ if (kind !== 'frame' && kind !== 'attachment' && kind !== 'vault') {
755
+ return json(res, 400, errorBody('bad-request', 'kind 只能是 frame、attachment 或 vault'));
756
+ }
757
+ const bytes = await readRaw(req, maxBlobBytes);
758
+ if (bytes.length === 0) return json(res, 400, errorBody('bad-request', 'blob 不能为空'));
759
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
760
+ const existing = blobRecord(db, sha256);
761
+ if (existing !== undefined) return json(res, 200, {
762
+ ok: true,
763
+ value: {
764
+ sha256,
765
+ bytes: existing.bytes,
766
+ dedup: true
767
+ }
768
+ });
769
+ const used = usedBlobBytes(db);
770
+ const limit = effectiveMaxBytes();
771
+ if (used + bytes.length > limit) {
772
+ throw new SyncError('quota', `存储超额:已用 ${used} 字节,上限 ${limit} 字节`);
773
+ }
774
+ const stored = await putBlob(options.dataDir, bytes);
775
+ recordBlob(db, stored.sha256, stored.bytes, kind);
776
+ return json(res, 201, {
777
+ ok: true,
778
+ value: {
779
+ sha256: stored.sha256,
780
+ bytes: stored.bytes,
781
+ dedup: false
782
+ }
783
+ });
784
+ }
785
+ },
786
+ {
787
+ method: 'HEAD',
788
+ path: BLOB_PATH,
789
+ handler: async (req, res)=>{
790
+ const auth = authenticate(db, req);
791
+ if (auth === undefined) {
792
+ res.statusCode = 401;
793
+ res.end();
794
+ return;
795
+ }
796
+ const match = BLOB_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
797
+ res.statusCode = match !== null && await hasBlob(options.dataDir, match[1]) ? 200 : 404;
798
+ res.end();
799
+ }
800
+ },
801
+ {
802
+ method: 'GET',
803
+ path: BLOB_PATH,
804
+ handler: async (req, res)=>{
805
+ const auth = authenticate(db, req);
806
+ if (auth === undefined) return json(res, 401, errorBody('unauthorized', '需要设备或 setup token'));
807
+ const match = BLOB_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
808
+ if (match === null) return json(res, 404, errorBody('not-found', '没有这个 blob'));
809
+ const sha256 = match[1];
810
+ if (!await hasBlob(options.dataDir, sha256)) return json(res, 404, errorBody('not-found', '没有这个 blob'));
811
+ const bytes = await readBlob(options.dataDir, sha256);
812
+ res.statusCode = 200;
813
+ res.setHeader('content-type', 'application/octet-stream');
814
+ res.setHeader('content-length', bytes.length);
815
+ res.end(bytes);
816
+ }
817
+ },
818
+ {
819
+ method: 'POST',
820
+ path: SYNC_LEASE_PATH,
821
+ handler: async (req, res)=>{
822
+ const device = requireDevice(db, req);
823
+ const clientId = clientIdFrom(req);
824
+ return json(res, 200, {
825
+ ok: true,
826
+ value: acquireLease(db, SYNC_RESOURCE, device.deviceId, clientId)
827
+ });
828
+ }
829
+ },
830
+ {
831
+ method: 'DELETE',
832
+ path: SYNC_LEASE_PATH,
833
+ handler: async (req, res)=>{
834
+ const device = requireDevice(db, req);
835
+ const clientId = clientIdFrom(req);
836
+ return json(res, 200, {
837
+ ok: true,
838
+ value: {
839
+ released: releaseLease(db, SYNC_RESOURCE, device.deviceId, clientId)
840
+ }
841
+ });
842
+ }
843
+ },
844
+ {
845
+ method: 'POST',
846
+ path: VAULT_LEASE_PATH,
847
+ handler: async (req, res)=>{
848
+ const device = requireDevice(db, req);
849
+ const clientId = clientIdFrom(req);
850
+ const resource = vaultResource(device.deviceId);
851
+ return json(res, 200, {
852
+ ok: true,
853
+ value: acquireLease(db, resource, device.deviceId, clientId)
854
+ });
855
+ }
856
+ },
857
+ {
858
+ method: 'DELETE',
859
+ path: VAULT_LEASE_PATH,
860
+ handler: async (req, res)=>{
861
+ const device = requireDevice(db, req);
862
+ const clientId = clientIdFrom(req);
863
+ const resource = vaultResource(device.deviceId);
864
+ return json(res, 200, {
865
+ ok: true,
866
+ value: {
867
+ released: releaseLease(db, resource, device.deviceId, clientId)
868
+ }
869
+ });
870
+ }
871
+ },
872
+ {
873
+ method: 'POST',
874
+ path: VAULT_APPEND_PATH,
875
+ handler: async (req, res)=>{
876
+ const device = requireDevice(db, req);
877
+ const clientId = clientIdFrom(req);
878
+ const body = await readJson(req);
879
+ const writer = typeof body.writer === 'string' ? body.writer : device.deviceId;
880
+ if (writer !== device.deviceId) throw new SyncError('bad-request', 'writer 必须等于当前 deviceId');
881
+ const sealedRaw = body.sealed;
882
+ if (sealedRaw === null || typeof sealedRaw !== 'object' || Array.isArray(sealedRaw)) {
883
+ throw new SyncError('bad-request', 'sealed 必须是对象');
884
+ }
885
+ const sealed = sealedRaw;
886
+ const result = appendVaultRecord(db, options.dataDir, {
887
+ writer,
888
+ deviceId: device.deviceId,
889
+ clientId,
890
+ seq: Number(body.seq),
891
+ prevHash: body.prevHash === null ? null : String(body.prevHash),
892
+ blobSha256: typeof body.blobSha256 === 'string' ? body.blobSha256 : '',
893
+ bytes: Number(body.bytes),
894
+ createdAt: Number(body.createdAt),
895
+ sealed: {
896
+ formatVersion: Number(sealed.formatVersion),
897
+ keyId: typeof sealed.keyId === 'string' ? sealed.keyId : ''
898
+ }
899
+ });
900
+ return json(res, 200, {
901
+ ok: true,
902
+ value: {
903
+ ...result.record,
904
+ dedup: result.dedup
905
+ }
906
+ });
907
+ }
908
+ },
909
+ {
910
+ method: 'GET',
911
+ path: VAULT_CURSOR_PATH,
912
+ handler: async (req, res)=>{
913
+ requireDevice(db, req);
914
+ const url = new URL(req.url ?? '/', 'http://localhost');
915
+ const since = intParam(url.searchParams.get('since'), 0, 0, Number.MAX_SAFE_INTEGER);
916
+ const limit = intParam(url.searchParams.get('limit'), 200, 1, 500);
917
+ return json(res, 200, {
918
+ ok: true,
919
+ value: readVaultCursor(db, since, limit)
920
+ });
921
+ }
922
+ },
923
+ {
924
+ method: 'GET',
925
+ path: VAULT_STREAM_PATH,
926
+ handler: async (req, res)=>{
927
+ requireDevice(db, req);
928
+ const url = new URL(req.url ?? '/', 'http://localhost');
929
+ const match = VAULT_STREAM_PATH.exec(url.pathname);
930
+ if (match === null) throw new SyncError('bad-request', '缺少 writer');
931
+ const writer = decodeURIComponent(match[1]);
932
+ if (!validVaultWriter(writer)) throw new SyncError('bad-request', 'writer 非法');
933
+ const from = intParam(url.searchParams.get('from'), 1, 1, Number.MAX_SAFE_INTEGER);
934
+ const limit = intParam(url.searchParams.get('limit'), 200, 1, 500);
935
+ return json(res, 200, {
936
+ ok: true,
937
+ value: readVaultStream(db, writer, from, limit)
938
+ });
939
+ }
940
+ },
941
+ {
942
+ method: 'POST',
943
+ path: /^\/admin\/login$/u,
944
+ handler: async (req, res)=>{
945
+ if (!isAdminInitialized(db)) throw new SyncError('conflict', '请先完成初始化');
946
+ const ip = req.socket.remoteAddress ?? 'unknown';
947
+ guardAttempts(`login:${ip}`);
948
+ const body = await readJson(req);
949
+ const password = typeof body.password === 'string' ? body.password : '';
950
+ if (!passwordMatches(password)) {
951
+ noteFailure(`login:${ip}`);
952
+ throw new SyncError('unauthorized', '密码不正确');
953
+ }
954
+ attemptsByKey.delete(`login:${ip}`);
955
+ return json(res, 200, {
956
+ ok: true,
957
+ value: {
958
+ expiresAt: issueAdminSession(res)
959
+ }
960
+ });
961
+ }
962
+ },
963
+ {
964
+ method: 'POST',
965
+ path: /^\/admin\/logout$/u,
966
+ handler: async (req, res)=>{
967
+ const token = cookieValue(req, ADMIN_COOKIE);
968
+ if (token !== undefined) adminSessionsByKey.delete(hashToken(token));
969
+ writeAdminCookie(res, '', 0);
970
+ return json(res, 200, {
971
+ ok: true,
972
+ value: {
973
+ loggedOut: true
974
+ }
975
+ });
976
+ }
977
+ },
978
+ {
979
+ method: 'GET',
980
+ path: /^\/setup\/state$/u,
981
+ handler: async (_req, res)=>{
982
+ return json(res, 200, {
983
+ ok: true,
984
+ value: {
985
+ initialized: isAdminInitialized(db),
986
+ hasPassword: adminPassword(db) !== undefined,
987
+ hasQuota: readMeta(db, 'max_bytes') !== undefined,
988
+ devices: listDevices(db).length
989
+ }
990
+ });
991
+ }
992
+ },
993
+ {
994
+ method: 'POST',
995
+ path: /^\/setup\/verify$/u,
996
+ handler: async (req, res)=>{
997
+ if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
998
+ const ip = req.socket.remoteAddress ?? 'unknown';
999
+ guardAttempts(`verify:${ip}`);
1000
+ if (onboarding.codeHash === undefined || Date.now() > onboarding.expiresAt) {
1001
+ throw new SyncError('unauthorized', '初始化密钥已过期,请重启服务');
1002
+ }
1003
+ const body = await readJson(req);
1004
+ const submitted = normalizeOnboardingCode(typeof body.code === 'string' ? body.code : '');
1005
+ const actual = Buffer.from(hashToken(submitted), 'hex');
1006
+ const expected = Buffer.from(onboarding.codeHash, 'hex');
1007
+ if (submitted.length === 0 || actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
1008
+ noteFailure(`verify:${ip}`);
1009
+ throw new SyncError('unauthorized', '初始化密钥不正确');
1010
+ }
1011
+ attemptsByKey.delete(`verify:${ip}`);
1012
+ const token = randomBytes(32).toString('base64url');
1013
+ const expiresAt = Date.now() + SETUP_TTL_MS;
1014
+ setupSessionsByKey.set(hashToken(token), expiresAt);
1015
+ writeSetupCookie(res, token, Math.floor(SETUP_TTL_MS / 1000));
1016
+ return json(res, 200, {
1017
+ ok: true,
1018
+ value: {
1019
+ expiresAt
1020
+ }
1021
+ });
1022
+ }
1023
+ },
1024
+ {
1025
+ method: 'POST',
1026
+ path: /^\/setup\/password$/u,
1027
+ handler: async (req, res)=>{
1028
+ requireSetup(req);
1029
+ if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
1030
+ const body = await readJson(req);
1031
+ const problem = validateAdminPassword(body.password);
1032
+ if (problem !== undefined) throw new SyncError('bad-request', problem);
1033
+ const salt = newSalt();
1034
+ setAdminPassword(db, deriveMasterKey(String(body.password), salt).toString('hex'), salt.toString('hex'));
1035
+ return json(res, 200, {
1036
+ ok: true,
1037
+ value: {
1038
+ passwordSet: true
1039
+ }
1040
+ });
1041
+ }
1042
+ },
1043
+ {
1044
+ method: 'POST',
1045
+ path: /^\/setup\/quota$/u,
1046
+ handler: async (req, res)=>{
1047
+ requireSetup(req);
1048
+ if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
1049
+ const body = await readJson(req);
1050
+ if (!Number.isSafeInteger(body.maxBytes)) throw new SyncError('bad-request', '配额必须是整数');
1051
+ const maxBytes = Number(body.maxBytes);
1052
+ if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_MAX_BYTES) throw new SyncError('bad-request', '配额超出允许范围');
1053
+ writeMaxBytes(db, maxBytes);
1054
+ return json(res, 200, {
1055
+ ok: true,
1056
+ value: {
1057
+ maxBytes
1058
+ }
1059
+ });
1060
+ }
1061
+ },
1062
+ {
1063
+ method: 'POST',
1064
+ path: /^\/setup\/device$/u,
1065
+ handler: async (req, res)=>{
1066
+ requireSetup(req);
1067
+ if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
1068
+ const body = await readJson(req);
1069
+ const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
1070
+ return json(res, 201, {
1071
+ ok: true,
1072
+ value: createDevice(db, label)
1073
+ });
1074
+ }
1075
+ },
1076
+ {
1077
+ method: 'POST',
1078
+ path: /^\/setup\/complete$/u,
1079
+ handler: async (req, res)=>{
1080
+ requireSetup(req);
1081
+ if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
1082
+ if (adminPassword(db) === undefined) throw new SyncError('conflict', '请先设置密码');
1083
+ markAdminInitialized(db);
1084
+ onboarding.expiresAt = 0;
1085
+ setupSessionsByKey.clear();
1086
+ rmSync(join(options.dataDir, ONBOARDING_CODE_FILE), {
1087
+ force: true
1088
+ });
1089
+ writeSetupCookie(res, '', 0);
1090
+ return json(res, 200, {
1091
+ ok: true,
1092
+ value: {
1093
+ redirect: '/admin',
1094
+ expiresAt: issueAdminSession(res)
1095
+ }
1096
+ });
1097
+ }
1098
+ },
1099
+ {
1100
+ method: 'POST',
1101
+ path: /^\/admin\/quota$/u,
1102
+ handler: async (req, res)=>{
1103
+ requireAdmin(req);
1104
+ const body = await readJson(req);
1105
+ if (!Number.isSafeInteger(body.maxBytes)) throw new SyncError('bad-request', '配额必须是整数');
1106
+ const maxBytes = Number(body.maxBytes);
1107
+ if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_MAX_BYTES) throw new SyncError('bad-request', '配额超出允许范围');
1108
+ writeMaxBytes(db, maxBytes);
1109
+ return json(res, 200, {
1110
+ ok: true,
1111
+ value: {
1112
+ maxBytes
1113
+ }
1114
+ });
1115
+ }
1116
+ },
1117
+ {
1118
+ method: 'GET',
1119
+ path: /^\/admin\/overview$/u,
1120
+ handler: async (req, res)=>{
1121
+ requireAdmin(req);
1122
+ const counts = sessionCounts(db);
1123
+ return json(res, 200, {
1124
+ ok: true,
1125
+ value: {
1126
+ version,
1127
+ protocol: PROTOCOL_VERSION,
1128
+ revision: readRevision(db),
1129
+ devices: listDevices(db).length,
1130
+ sessions: counts.total,
1131
+ deletedSessions: counts.deleted,
1132
+ usedBytes: usedBlobBytes(db),
1133
+ maxBytes: effectiveMaxBytes()
1134
+ }
1135
+ });
1136
+ }
1137
+ },
1138
+ {
1139
+ method: 'GET',
1140
+ path: /^\/admin\/devices$/u,
1141
+ handler: async (req, res)=>{
1142
+ requireAdmin(req);
1143
+ return json(res, 200, {
1144
+ ok: true,
1145
+ value: listDevices(db)
1146
+ });
1147
+ }
1148
+ },
1149
+ {
1150
+ method: 'POST',
1151
+ path: /^\/admin\/devices$/u,
1152
+ handler: async (req, res)=>{
1153
+ requireAdmin(req);
1154
+ const body = await readJson(req);
1155
+ const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
1156
+ return json(res, 201, {
1157
+ ok: true,
1158
+ value: createDevice(db, label)
1159
+ });
1160
+ }
1161
+ },
1162
+ {
1163
+ method: 'DELETE',
1164
+ path: ADMIN_DEVICE_PATH,
1165
+ handler: async (req, res)=>{
1166
+ requireAdmin(req);
1167
+ const match = ADMIN_DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
1168
+ if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
1169
+ return revokeDevice(db, match[1]) ? json(res, 200, {
1170
+ ok: true,
1171
+ value: {
1172
+ deviceId: match[1],
1173
+ revoked: true
1174
+ }
1175
+ }) : json(res, 404, errorBody('not-found', '没有这个设备'));
1176
+ }
1177
+ },
1178
+ {
1179
+ method: 'GET',
1180
+ path: /^\/admin\/sessions$/u,
1181
+ handler: async (req, res)=>{
1182
+ requireAdmin(req);
1183
+ return json(res, 200, {
1184
+ ok: true,
1185
+ value: adminSessions(db)
1186
+ });
1187
+ }
1188
+ },
1189
+ {
1190
+ method: 'GET',
1191
+ path: ME_PATH,
1192
+ handler: async (req, res)=>{
1193
+ const auth = authenticate(db, req);
1194
+ if (auth === undefined) return json(res, 401, errorBody('unauthorized', '未知 token'));
1195
+ if (auth.role === 'admin') return json(res, 200, {
1196
+ ok: true,
1197
+ value: {
1198
+ role: 'admin'
1199
+ }
1200
+ });
1201
+ return json(res, 200, {
1202
+ ok: true,
1203
+ value: {
1204
+ role: 'device',
1205
+ device: auth.device
1206
+ }
1207
+ });
1208
+ }
1209
+ }
1210
+ ];
1211
+ return createServer((req, res)=>{
1212
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
1213
+ if (path === '/' && req.method === 'GET') {
1214
+ res.writeHead(302, {
1215
+ location: '/admin'
1216
+ });
1217
+ res.end();
1218
+ return;
1219
+ }
1220
+ if ((path === '/admin' || path === '/admin/') && req.method === 'GET') {
1221
+ if (!isAdminInitialized(db)) {
1222
+ res.writeHead(302, {
1223
+ location: '/setup'
1224
+ });
1225
+ res.end();
1226
+ return;
1227
+ }
1228
+ res.writeHead(200, {
1229
+ 'content-type': 'text/html; charset=utf-8',
1230
+ 'cache-control': 'no-store'
1231
+ });
1232
+ res.end(ADMIN_PAGE);
1233
+ return;
1234
+ }
1235
+ if ((path === '/setup' || path === '/setup/') && req.method === 'GET') {
1236
+ if (isAdminInitialized(db)) {
1237
+ res.writeHead(302, {
1238
+ location: '/admin'
1239
+ });
1240
+ res.end();
1241
+ return;
1242
+ }
1243
+ res.writeHead(200, {
1244
+ 'content-type': 'text/html; charset=utf-8',
1245
+ 'cache-control': 'no-store'
1246
+ });
1247
+ res.end(SETUP_PAGE);
1248
+ return;
1249
+ }
1250
+ if (path === '/logo.svg' && LOGO_SVG !== undefined) {
1251
+ res.writeHead(200, {
1252
+ 'content-type': 'image/svg+xml',
1253
+ 'cache-control': 'public, max-age=3600'
1254
+ });
1255
+ res.end(LOGO_SVG);
1256
+ return;
1257
+ }
1258
+ if (path === '/favicon.svg' && FAVICON_SVG !== undefined) {
1259
+ res.writeHead(200, {
1260
+ 'content-type': 'image/svg+xml',
1261
+ 'cache-control': 'public, max-age=86400'
1262
+ });
1263
+ res.end(FAVICON_SVG);
1264
+ return;
1265
+ }
1266
+ const route = routes.find((candidate)=>candidate.method === req.method && candidate.path.test(path));
1267
+ if (route === undefined) {
1268
+ json(res, 404, errorBody('not-found', `没有 ${req.method ?? 'GET'} ${path} 这条路由`));
1269
+ return;
1270
+ }
1271
+ void route.handler(req, res).catch((error)=>{
1272
+ const sync = error instanceof SyncError ? error : undefined;
1273
+ const code = sync?.code ?? 'bad-request';
1274
+ const message = sync?.message ?? (error instanceof Error ? error.message : String(error));
1275
+ json(res, statusOf(code), errorBody(code, message));
1276
+ });
1277
+ });
1278
+ }
1279
+
1280
+
1281
+ //# sourceURL=http.ts