dscs-server 0.1.3 → 0.1.5
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/README.md +59 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +433 -85
- package/dist/index.js +23 -7
- package/dist/session-admin.js +214 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +133 -73
- package/dist/shared/blob-batch.js +4 -2
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1833 -297
package/dist/http.js
CHANGED
|
@@ -7,10 +7,13 @@ import { SyncError, statusOf } from './shared/wire.js';
|
|
|
7
7
|
import { BLOB_BATCH_CONTENT_TYPE, MAX_BATCH_BLOBS, MAX_BATCH_BODY_BYTES, decodeBlobBatch, encodeBlobBatch } from './shared/blob-batch.js';
|
|
8
8
|
import { deriveMasterKey, newSalt } from './shared/crypto.js';
|
|
9
9
|
import { hasBlob, putBlob, readBlob } from './blobs.js';
|
|
10
|
-
import { DEFAULT_DATASET, SERVER_SCHEMA_VERSION, adminPassword, blobRecord, isAdminInitialized, markAdminInitialized, readMaxBytes, readMeta, recordBlob,
|
|
11
|
-
import { MAX_APPEND_BODY_BYTES, adminSessions, appendFrames, deleteSession, getManifest, getV2SessionSummary, listV2Batches,
|
|
10
|
+
import { DEFAULT_DATASET, SERVER_SCHEMA_VERSION, adminPassword, blobRecord, isAdminInitialized, markAdminInitialized, nextDeviceColor, readMaxBytes, readMeta, recordBlob, setAdminPassword, usedBlobBytes, writeMaxBytes } from './db.js';
|
|
11
|
+
import { MAX_APPEND_BODY_BYTES, adminSessions, appendFrames, deleteSession, getManifest, getV2SessionSummary, listV2Batches, listV2Sessions, sessionCounts } from './sessions.js';
|
|
12
12
|
import { acquireLease, releaseLease, SYNC_RESOURCE, vaultResource } from './leases.js';
|
|
13
13
|
import { appendVaultRecord, readVaultCursor, readVaultStream, validVaultWriter } from './vault.js';
|
|
14
|
+
import { listAudit, recordAudit } from './audit.js';
|
|
15
|
+
import { archiveSession, removeSession, resetSession, restoreSession } from './session-admin.js';
|
|
16
|
+
import { listActivity, listActivitySessions, listSessionHistory } from './session-history.js';
|
|
14
17
|
function readServerVersion() {
|
|
15
18
|
try {
|
|
16
19
|
const raw = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
@@ -87,16 +90,26 @@ function authenticate(db, req) {
|
|
|
87
90
|
if (setupToken !== undefined && token === setupToken) return {
|
|
88
91
|
role: 'admin'
|
|
89
92
|
};
|
|
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));
|
|
93
|
+
const row = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at, revoked_at, color FROM devices WHERE dataset = ? AND token_hash = ?').get(DEFAULT_DATASET, hashToken(token));
|
|
91
94
|
if (row === undefined || row.revoked_at !== null) return undefined;
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
let lastSeenAt = row.last_seen_at;
|
|
97
|
+
try {
|
|
98
|
+
const result = db.handle.prepare(`UPDATE devices SET last_seen_at = ?
|
|
99
|
+
WHERE dataset = ? AND device_id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`).run(now, DEFAULT_DATASET, row.device_id, now - DEVICE_LAST_SEEN_THROTTLE_MS);
|
|
100
|
+
if (Number(result.changes) > 0) lastSeenAt = now;
|
|
101
|
+
} catch {}
|
|
92
102
|
return {
|
|
93
103
|
role: 'device',
|
|
94
104
|
device: {
|
|
95
105
|
deviceId: row.device_id,
|
|
96
106
|
label: row.label,
|
|
97
107
|
createdAt: row.created_at,
|
|
98
|
-
...
|
|
99
|
-
lastSeenAt
|
|
108
|
+
...lastSeenAt === null ? {} : {
|
|
109
|
+
lastSeenAt
|
|
110
|
+
},
|
|
111
|
+
...row.color === null ? {} : {
|
|
112
|
+
color: row.color
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
};
|
|
@@ -163,40 +176,6 @@ function decodeSessionsCursor(raw) {
|
|
|
163
176
|
}
|
|
164
177
|
return value['after'];
|
|
165
178
|
}
|
|
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
179
|
function etagMatches(header, digest) {
|
|
201
180
|
const raw = Array.isArray(header) ? header.join(',') : header;
|
|
202
181
|
if (raw === undefined) return false;
|
|
@@ -266,7 +245,6 @@ function intParam(value, fallback, min, max) {
|
|
|
266
245
|
}
|
|
267
246
|
const SYNC_LEASE_PATH = /^\/v2\/sync\/lease$/u;
|
|
268
247
|
const V2_SESSIONS_PATH = /^\/v2\/sessions$/u;
|
|
269
|
-
const V2_CHANGES_PATH = /^\/v2\/changes$/u;
|
|
270
248
|
const V2_SUMMARY_PATH = /^\/v2\/sessions\/([^/]+)\/summary$/u;
|
|
271
249
|
const V2_BATCHES_PATH = /^\/v2\/sessions\/([^/]+)\/batches$/u;
|
|
272
250
|
const V2_MANIFEST_PATH = /^\/v2\/sessions\/([^/]+)\/manifest$/u;
|
|
@@ -283,6 +261,8 @@ const VAULT_LEASE_PATH = /^\/v2\/vault\/lease$/u;
|
|
|
283
261
|
const VAULT_CURSOR_PATH = /^\/v2\/vault\/cursor$/u;
|
|
284
262
|
const VAULT_STREAM_PATH = /^\/v2\/vault\/stream\/([^/]+)$/u;
|
|
285
263
|
const ADMIN_DEVICE_PATH = /^\/admin\/devices\/([0-9a-f]+)$/u;
|
|
264
|
+
const ADMIN_SESSION_ACTION_PATH = /^\/admin\/sessions\/([^/]+)\/(archive|remove|reset|restore|clear-reset)$/u;
|
|
265
|
+
const ADMIN_SESSION_HISTORY_PATH = /^\/admin\/sessions\/([^/]+)\/history$/u;
|
|
286
266
|
function withBatchFields(value) {
|
|
287
267
|
if (value === null || typeof value !== 'object') return value;
|
|
288
268
|
const record = value;
|
|
@@ -303,6 +283,7 @@ function withBatchFields(value) {
|
|
|
303
283
|
const ADMIN_COOKIE = 'dsh_sync_admin';
|
|
304
284
|
const SETUP_COOKIE = 'dsh_sync_setup';
|
|
305
285
|
const ADMIN_TTL_MS = 12 * 60 * 60 * 1000;
|
|
286
|
+
const DEVICE_LAST_SEEN_THROTTLE_MS = 60 * 1000;
|
|
306
287
|
const SETUP_TTL_MS = 15 * 60 * 1000;
|
|
307
288
|
export const ONBOARDING_TTL_MS = 10 * 60 * 1000;
|
|
308
289
|
export const ONBOARDING_CODE_FILE = 'onboarding-code.json';
|
|
@@ -350,24 +331,29 @@ export function createDevice(db, label) {
|
|
|
350
331
|
const deviceId = randomBytes(8).toString('hex');
|
|
351
332
|
const token = mintToken();
|
|
352
333
|
const now = Date.now();
|
|
353
|
-
|
|
334
|
+
const color = nextDeviceColor(db);
|
|
335
|
+
db.handle.prepare('INSERT INTO devices (dataset, device_id, label, token_hash, created_at, color) VALUES (?, ?, ?, ?, ?, ?)').run(DEFAULT_DATASET, deviceId, label, hashToken(token), now, color);
|
|
354
336
|
return {
|
|
355
337
|
device: {
|
|
356
338
|
deviceId,
|
|
357
339
|
label,
|
|
358
|
-
createdAt: now
|
|
340
|
+
createdAt: now,
|
|
341
|
+
color
|
|
359
342
|
},
|
|
360
343
|
token
|
|
361
344
|
};
|
|
362
345
|
}
|
|
363
346
|
export function listDevices(db) {
|
|
364
|
-
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
|
+
const rows = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at, color FROM devices WHERE dataset = ? AND revoked_at IS NULL ORDER BY created_at').all(DEFAULT_DATASET);
|
|
365
348
|
return rows.map((row)=>({
|
|
366
349
|
deviceId: row.device_id,
|
|
367
350
|
label: row.label,
|
|
368
351
|
createdAt: row.created_at,
|
|
369
352
|
...row.last_seen_at === null ? {} : {
|
|
370
353
|
lastSeenAt: row.last_seen_at
|
|
354
|
+
},
|
|
355
|
+
...row.color === null ? {} : {
|
|
356
|
+
color: row.color
|
|
371
357
|
}
|
|
372
358
|
}));
|
|
373
359
|
}
|
|
@@ -375,10 +361,11 @@ export function revokeDevice(db, deviceId) {
|
|
|
375
361
|
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);
|
|
376
362
|
return Number(result.changes) > 0;
|
|
377
363
|
}
|
|
378
|
-
export function
|
|
379
|
-
const result = db.handle.prepare(
|
|
364
|
+
export function updateDevice(db, deviceId, changes) {
|
|
365
|
+
const result = db.handle.prepare(`UPDATE devices SET label = COALESCE(?, label), color = COALESCE(?, color)
|
|
366
|
+
WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL`).run(changes.label ?? null, changes.color ?? null, DEFAULT_DATASET, deviceId);
|
|
380
367
|
if (Number(result.changes) === 0) return undefined;
|
|
381
|
-
const row = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at FROM devices WHERE dataset = ? AND device_id = ?').get(DEFAULT_DATASET, deviceId);
|
|
368
|
+
const row = db.handle.prepare('SELECT device_id, label, created_at, last_seen_at, color FROM devices WHERE dataset = ? AND device_id = ?').get(DEFAULT_DATASET, deviceId);
|
|
382
369
|
if (row === undefined) return undefined;
|
|
383
370
|
return {
|
|
384
371
|
deviceId: row.device_id,
|
|
@@ -386,9 +373,22 @@ export function renameDevice(db, deviceId, label) {
|
|
|
386
373
|
createdAt: row.created_at,
|
|
387
374
|
...row.last_seen_at === null ? {} : {
|
|
388
375
|
lastSeenAt: row.last_seen_at
|
|
376
|
+
},
|
|
377
|
+
...row.color === null ? {} : {
|
|
378
|
+
color: row.color
|
|
389
379
|
}
|
|
390
380
|
};
|
|
391
381
|
}
|
|
382
|
+
export function renameDevice(db, deviceId, label) {
|
|
383
|
+
return updateDevice(db, deviceId, {
|
|
384
|
+
label
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function requireDeviceColor(value) {
|
|
388
|
+
const color = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
389
|
+
if (!/^#[0-9a-f]{6}$/u.test(color)) throw new SyncError('bad-request', '颜色必须是 #rrggbb');
|
|
390
|
+
return color;
|
|
391
|
+
}
|
|
392
392
|
function requireDeviceLabel(value) {
|
|
393
393
|
const label = typeof value === 'string' ? value.trim() : '';
|
|
394
394
|
if (label === '') throw new SyncError('bad-request', '设备名不能为空');
|
|
@@ -476,7 +476,6 @@ export function createHttpServer(options) {
|
|
|
476
476
|
schemaVersion: SERVER_SCHEMA_VERSION,
|
|
477
477
|
capabilities: [
|
|
478
478
|
'session-summary',
|
|
479
|
-
'changes-cursor',
|
|
480
479
|
'conditional-get',
|
|
481
480
|
'idempotent-append',
|
|
482
481
|
'manifest-digest',
|
|
@@ -486,7 +485,8 @@ export function createHttpServer(options) {
|
|
|
486
485
|
'zstd-frame',
|
|
487
486
|
'me-device-info',
|
|
488
487
|
'me-counts',
|
|
489
|
-
'device-rename'
|
|
488
|
+
'device-rename',
|
|
489
|
+
'attachment-index'
|
|
490
490
|
],
|
|
491
491
|
limits: {
|
|
492
492
|
batchBytes: MAX_BATCH_BODY_BYTES,
|
|
@@ -506,7 +506,8 @@ export function createHttpServer(options) {
|
|
|
506
506
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
507
507
|
const after = decodeSessionsCursor(url.searchParams.get('cursor'));
|
|
508
508
|
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
509
|
-
const
|
|
509
|
+
const includeDeleted = url.searchParams.get('includeDeleted') === '1';
|
|
510
|
+
const page = listV2Sessions(db, after, limit, includeDeleted);
|
|
510
511
|
return json(res, 200, {
|
|
511
512
|
ok: true,
|
|
512
513
|
value: {
|
|
@@ -519,30 +520,6 @@ export function createHttpServer(options) {
|
|
|
519
520
|
});
|
|
520
521
|
}
|
|
521
522
|
},
|
|
522
|
-
{
|
|
523
|
-
method: 'GET',
|
|
524
|
-
path: V2_CHANGES_PATH,
|
|
525
|
-
handler: async (req, res)=>{
|
|
526
|
-
requireDevice(db, req);
|
|
527
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
528
|
-
const cursor = decodeChangesCursor(url.searchParams.get('cursor'));
|
|
529
|
-
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
530
|
-
const page = listV2Changes(db, cursor.revision, cursor.tombstone, limit);
|
|
531
|
-
return json(res, 200, {
|
|
532
|
-
ok: true,
|
|
533
|
-
value: {
|
|
534
|
-
sessions: page.sessions,
|
|
535
|
-
tombstones: page.tombstones,
|
|
536
|
-
nextCursor: encodeCursor({
|
|
537
|
-
v: 2,
|
|
538
|
-
revision: page.nextRevision,
|
|
539
|
-
tombstone: page.nextTombstone
|
|
540
|
-
}),
|
|
541
|
-
hasMore: page.hasMore
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
523
|
{
|
|
547
524
|
method: 'GET',
|
|
548
525
|
path: V2_SUMMARY_PATH,
|
|
@@ -633,9 +610,19 @@ export function createHttpServer(options) {
|
|
|
633
610
|
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_DELETE_PATH);
|
|
634
611
|
const body = await readJson(req);
|
|
635
612
|
if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
|
|
613
|
+
const value = deleteSession(db, id, device.deviceId, clientId, Number(body.createdAt));
|
|
614
|
+
recordAudit(db, {
|
|
615
|
+
actorKind: 'device',
|
|
616
|
+
actorId: device.deviceId,
|
|
617
|
+
action: 'session.delete',
|
|
618
|
+
target: id,
|
|
619
|
+
detail: {
|
|
620
|
+
createdAt: Number(body.createdAt)
|
|
621
|
+
}
|
|
622
|
+
}, req);
|
|
636
623
|
return json(res, 200, {
|
|
637
624
|
ok: true,
|
|
638
|
-
value
|
|
625
|
+
value
|
|
639
626
|
});
|
|
640
627
|
}
|
|
641
628
|
},
|
|
@@ -759,7 +746,7 @@ export function createHttpServer(options) {
|
|
|
759
746
|
if (!await hasBlob(options.dataDir, sha256)) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
|
|
760
747
|
const record = blobRecord(db, sha256);
|
|
761
748
|
if (record === undefined) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
|
|
762
|
-
if (record.kind !== 'frame' && record.kind !== 'attachment' && record.kind !== 'vault') {
|
|
749
|
+
if (record.kind !== 'frame' && record.kind !== 'attachment' && record.kind !== 'attachment-index' && record.kind !== 'vault') {
|
|
763
750
|
throw new SyncError('bad-request', `blob 类别非法:${record.kind}`);
|
|
764
751
|
}
|
|
765
752
|
kinds.push(record.kind);
|
|
@@ -792,8 +779,8 @@ export function createHttpServer(options) {
|
|
|
792
779
|
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '需要设备或 setup token'));
|
|
793
780
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
794
781
|
const kind = url.searchParams.get('kind') ?? 'frame';
|
|
795
|
-
if (kind !== 'frame' && kind !== 'attachment' && kind !== 'vault') {
|
|
796
|
-
return json(res, 400, errorBody('bad-request', 'kind 只能是 frame、attachment 或 vault'));
|
|
782
|
+
if (kind !== 'frame' && kind !== 'attachment' && kind !== 'attachment-index' && kind !== 'vault') {
|
|
783
|
+
return json(res, 400, errorBody('bad-request', 'kind 只能是 frame、attachment、attachment-index 或 vault'));
|
|
797
784
|
}
|
|
798
785
|
const bytes = await readRaw(req, maxBlobBytes);
|
|
799
786
|
if (bytes.length === 0) return json(res, 400, errorBody('bad-request', 'blob 不能为空'));
|
|
@@ -990,9 +977,22 @@ export function createHttpServer(options) {
|
|
|
990
977
|
const password = typeof body.password === 'string' ? body.password : '';
|
|
991
978
|
if (!passwordMatches(password)) {
|
|
992
979
|
noteFailure(`login:${ip}`);
|
|
980
|
+
recordAudit(db, {
|
|
981
|
+
actorKind: 'admin',
|
|
982
|
+
actorId: 'admin',
|
|
983
|
+
action: 'login_failed',
|
|
984
|
+
detail: {
|
|
985
|
+
reason: '密码不正确'
|
|
986
|
+
}
|
|
987
|
+
}, req);
|
|
993
988
|
throw new SyncError('unauthorized', '密码不正确');
|
|
994
989
|
}
|
|
995
990
|
attemptsByKey.delete(`login:${ip}`);
|
|
991
|
+
recordAudit(db, {
|
|
992
|
+
actorKind: 'admin',
|
|
993
|
+
actorId: 'admin',
|
|
994
|
+
action: 'login'
|
|
995
|
+
}, req);
|
|
996
996
|
return json(res, 200, {
|
|
997
997
|
ok: true,
|
|
998
998
|
value: {
|
|
@@ -1006,7 +1006,14 @@ export function createHttpServer(options) {
|
|
|
1006
1006
|
path: /^\/admin\/logout$/u,
|
|
1007
1007
|
handler: async (req, res)=>{
|
|
1008
1008
|
const token = cookieValue(req, ADMIN_COOKIE);
|
|
1009
|
-
if (token !== undefined)
|
|
1009
|
+
if (token !== undefined) {
|
|
1010
|
+
adminSessionsByKey.delete(hashToken(token));
|
|
1011
|
+
recordAudit(db, {
|
|
1012
|
+
actorKind: 'admin',
|
|
1013
|
+
actorId: 'admin',
|
|
1014
|
+
action: 'logout'
|
|
1015
|
+
}, req);
|
|
1016
|
+
}
|
|
1010
1017
|
writeAdminCookie(res, '', 0);
|
|
1011
1018
|
return json(res, 200, {
|
|
1012
1019
|
ok: true,
|
|
@@ -1016,6 +1023,48 @@ export function createHttpServer(options) {
|
|
|
1016
1023
|
});
|
|
1017
1024
|
}
|
|
1018
1025
|
},
|
|
1026
|
+
{
|
|
1027
|
+
method: 'POST',
|
|
1028
|
+
path: /^\/admin\/password$/u,
|
|
1029
|
+
handler: async (req, res)=>{
|
|
1030
|
+
requireAdmin(req);
|
|
1031
|
+
const body = await readJson(req);
|
|
1032
|
+
const current = typeof body.currentPassword === 'string' ? body.currentPassword : '';
|
|
1033
|
+
if (!passwordMatches(current)) {
|
|
1034
|
+
recordAudit(db, {
|
|
1035
|
+
actorKind: 'admin',
|
|
1036
|
+
actorId: 'admin',
|
|
1037
|
+
action: 'password.change_failed',
|
|
1038
|
+
detail: {
|
|
1039
|
+
reason: '当前密码不正确'
|
|
1040
|
+
}
|
|
1041
|
+
}, req);
|
|
1042
|
+
throw new SyncError('bad-request', '当前密码不正确');
|
|
1043
|
+
}
|
|
1044
|
+
const problem = validateAdminPassword(body.newPassword);
|
|
1045
|
+
if (problem !== undefined) throw new SyncError('bad-request', problem);
|
|
1046
|
+
const salt = newSalt();
|
|
1047
|
+
setAdminPassword(db, deriveMasterKey(String(body.newPassword), salt).toString('hex'), salt.toString('hex'));
|
|
1048
|
+
const token = cookieValue(req, ADMIN_COOKIE);
|
|
1049
|
+
const currentKey = token === undefined ? undefined : hashToken(token);
|
|
1050
|
+
for (const key of [
|
|
1051
|
+
...adminSessionsByKey.keys()
|
|
1052
|
+
]){
|
|
1053
|
+
if (key !== currentKey) adminSessionsByKey.delete(key);
|
|
1054
|
+
}
|
|
1055
|
+
recordAudit(db, {
|
|
1056
|
+
actorKind: 'admin',
|
|
1057
|
+
actorId: 'admin',
|
|
1058
|
+
action: 'password.change'
|
|
1059
|
+
}, req);
|
|
1060
|
+
return json(res, 200, {
|
|
1061
|
+
ok: true,
|
|
1062
|
+
value: {
|
|
1063
|
+
changed: true
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1019
1068
|
{
|
|
1020
1069
|
method: 'GET',
|
|
1021
1070
|
path: /^\/setup\/state$/u,
|
|
@@ -1073,6 +1122,11 @@ export function createHttpServer(options) {
|
|
|
1073
1122
|
if (problem !== undefined) throw new SyncError('bad-request', problem);
|
|
1074
1123
|
const salt = newSalt();
|
|
1075
1124
|
setAdminPassword(db, deriveMasterKey(String(body.password), salt).toString('hex'), salt.toString('hex'));
|
|
1125
|
+
recordAudit(db, {
|
|
1126
|
+
actorKind: 'system',
|
|
1127
|
+
actorId: 'setup',
|
|
1128
|
+
action: 'setup.password'
|
|
1129
|
+
}, req);
|
|
1076
1130
|
return json(res, 200, {
|
|
1077
1131
|
ok: true,
|
|
1078
1132
|
value: {
|
|
@@ -1092,6 +1146,14 @@ export function createHttpServer(options) {
|
|
|
1092
1146
|
const maxBytes = Number(body.maxBytes);
|
|
1093
1147
|
if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_MAX_BYTES) throw new SyncError('bad-request', '配额超出允许范围');
|
|
1094
1148
|
writeMaxBytes(db, maxBytes);
|
|
1149
|
+
recordAudit(db, {
|
|
1150
|
+
actorKind: 'system',
|
|
1151
|
+
actorId: 'setup',
|
|
1152
|
+
action: 'setup.quota',
|
|
1153
|
+
detail: {
|
|
1154
|
+
maxBytes
|
|
1155
|
+
}
|
|
1156
|
+
}, req);
|
|
1095
1157
|
return json(res, 200, {
|
|
1096
1158
|
ok: true,
|
|
1097
1159
|
value: {
|
|
@@ -1108,9 +1170,19 @@ export function createHttpServer(options) {
|
|
|
1108
1170
|
if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
|
|
1109
1171
|
const body = await readJson(req);
|
|
1110
1172
|
const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
|
|
1173
|
+
const created = createDevice(db, label);
|
|
1174
|
+
recordAudit(db, {
|
|
1175
|
+
actorKind: 'system',
|
|
1176
|
+
actorId: 'setup',
|
|
1177
|
+
action: 'setup.device',
|
|
1178
|
+
target: created.device.deviceId,
|
|
1179
|
+
detail: {
|
|
1180
|
+
label
|
|
1181
|
+
}
|
|
1182
|
+
}, req);
|
|
1111
1183
|
return json(res, 201, {
|
|
1112
1184
|
ok: true,
|
|
1113
|
-
value:
|
|
1185
|
+
value: created
|
|
1114
1186
|
});
|
|
1115
1187
|
}
|
|
1116
1188
|
},
|
|
@@ -1128,6 +1200,11 @@ export function createHttpServer(options) {
|
|
|
1128
1200
|
force: true
|
|
1129
1201
|
});
|
|
1130
1202
|
writeSetupCookie(res, '', 0);
|
|
1203
|
+
recordAudit(db, {
|
|
1204
|
+
actorKind: 'system',
|
|
1205
|
+
actorId: 'setup',
|
|
1206
|
+
action: 'setup.complete'
|
|
1207
|
+
}, req);
|
|
1131
1208
|
return json(res, 200, {
|
|
1132
1209
|
ok: true,
|
|
1133
1210
|
value: {
|
|
@@ -1146,7 +1223,17 @@ export function createHttpServer(options) {
|
|
|
1146
1223
|
if (!Number.isSafeInteger(body.maxBytes)) throw new SyncError('bad-request', '配额必须是整数');
|
|
1147
1224
|
const maxBytes = Number(body.maxBytes);
|
|
1148
1225
|
if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_MAX_BYTES) throw new SyncError('bad-request', '配额超出允许范围');
|
|
1226
|
+
const from = effectiveMaxBytes();
|
|
1149
1227
|
writeMaxBytes(db, maxBytes);
|
|
1228
|
+
recordAudit(db, {
|
|
1229
|
+
actorKind: 'admin',
|
|
1230
|
+
actorId: 'admin',
|
|
1231
|
+
action: 'quota.change',
|
|
1232
|
+
detail: {
|
|
1233
|
+
from,
|
|
1234
|
+
to: maxBytes
|
|
1235
|
+
}
|
|
1236
|
+
}, req);
|
|
1150
1237
|
return json(res, 200, {
|
|
1151
1238
|
ok: true,
|
|
1152
1239
|
value: {
|
|
@@ -1166,7 +1253,6 @@ export function createHttpServer(options) {
|
|
|
1166
1253
|
value: {
|
|
1167
1254
|
version,
|
|
1168
1255
|
protocol: PROTOCOL_VERSION,
|
|
1169
|
-
revision: readRevision(db),
|
|
1170
1256
|
devices: listDevices(db).length,
|
|
1171
1257
|
sessions: counts.total,
|
|
1172
1258
|
deletedSessions: counts.deleted,
|
|
@@ -1194,9 +1280,19 @@ export function createHttpServer(options) {
|
|
|
1194
1280
|
requireAdmin(req);
|
|
1195
1281
|
const body = await readJson(req);
|
|
1196
1282
|
const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
|
|
1283
|
+
const created = createDevice(db, label);
|
|
1284
|
+
recordAudit(db, {
|
|
1285
|
+
actorKind: 'admin',
|
|
1286
|
+
actorId: 'admin',
|
|
1287
|
+
action: 'device.create',
|
|
1288
|
+
target: created.device.deviceId,
|
|
1289
|
+
detail: {
|
|
1290
|
+
label
|
|
1291
|
+
}
|
|
1292
|
+
}, req);
|
|
1197
1293
|
return json(res, 201, {
|
|
1198
1294
|
ok: true,
|
|
1199
|
-
value:
|
|
1295
|
+
value: created
|
|
1200
1296
|
});
|
|
1201
1297
|
}
|
|
1202
1298
|
},
|
|
@@ -1207,13 +1303,24 @@ export function createHttpServer(options) {
|
|
|
1207
1303
|
requireAdmin(req);
|
|
1208
1304
|
const match = ADMIN_DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1209
1305
|
if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1210
|
-
|
|
1306
|
+
const before = listDevices(db).find((device)=>device.deviceId === match[1]);
|
|
1307
|
+
if (!revokeDevice(db, match[1])) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1308
|
+
recordAudit(db, {
|
|
1309
|
+
actorKind: 'admin',
|
|
1310
|
+
actorId: 'admin',
|
|
1311
|
+
action: 'device.revoke',
|
|
1312
|
+
target: match[1],
|
|
1313
|
+
detail: {
|
|
1314
|
+
label: before?.label
|
|
1315
|
+
}
|
|
1316
|
+
}, req);
|
|
1317
|
+
return json(res, 200, {
|
|
1211
1318
|
ok: true,
|
|
1212
1319
|
value: {
|
|
1213
1320
|
deviceId: match[1],
|
|
1214
1321
|
revoked: true
|
|
1215
1322
|
}
|
|
1216
|
-
})
|
|
1323
|
+
});
|
|
1217
1324
|
}
|
|
1218
1325
|
},
|
|
1219
1326
|
{
|
|
@@ -1224,9 +1331,31 @@ export function createHttpServer(options) {
|
|
|
1224
1331
|
const match = ADMIN_DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1225
1332
|
if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1226
1333
|
const body = await readJson(req);
|
|
1227
|
-
const label = requireDeviceLabel(body.label);
|
|
1228
|
-
const
|
|
1334
|
+
const label = body.label === undefined ? undefined : requireDeviceLabel(body.label);
|
|
1335
|
+
const color = body.color === undefined ? undefined : requireDeviceColor(body.color);
|
|
1336
|
+
if (label === undefined && color === undefined) throw new SyncError('bad-request', '需要 label 或 color');
|
|
1337
|
+
const before = listDevices(db).find((device)=>device.deviceId === match[1]);
|
|
1338
|
+
const device = updateDevice(db, match[1], {
|
|
1339
|
+
...label === undefined ? {} : {
|
|
1340
|
+
label
|
|
1341
|
+
},
|
|
1342
|
+
...color === undefined ? {} : {
|
|
1343
|
+
color
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1229
1346
|
if (device === undefined) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1347
|
+
if (label !== undefined) {
|
|
1348
|
+
recordAudit(db, {
|
|
1349
|
+
actorKind: 'admin',
|
|
1350
|
+
actorId: 'admin',
|
|
1351
|
+
action: 'device.rename',
|
|
1352
|
+
target: match[1],
|
|
1353
|
+
detail: {
|
|
1354
|
+
from: before?.label,
|
|
1355
|
+
to: label
|
|
1356
|
+
}
|
|
1357
|
+
}, req);
|
|
1358
|
+
}
|
|
1230
1359
|
return json(res, 200, {
|
|
1231
1360
|
ok: true,
|
|
1232
1361
|
value: {
|
|
@@ -1235,6 +1364,77 @@ export function createHttpServer(options) {
|
|
|
1235
1364
|
});
|
|
1236
1365
|
}
|
|
1237
1366
|
},
|
|
1367
|
+
{
|
|
1368
|
+
method: 'GET',
|
|
1369
|
+
path: /^\/admin\/activity$/u,
|
|
1370
|
+
handler: async (req, res)=>{
|
|
1371
|
+
requireAdmin(req);
|
|
1372
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1373
|
+
const now = Date.now();
|
|
1374
|
+
const to = intParam(url.searchParams.get('to'), now, 1, Number.MAX_SAFE_INTEGER);
|
|
1375
|
+
const from = intParam(url.searchParams.get('from'), to - 90 * 24 * 60 * 60 * 1000, 0, Number.MAX_SAFE_INTEGER);
|
|
1376
|
+
if (to <= from) throw new SyncError('bad-request', 'to 必须大于 from');
|
|
1377
|
+
const tzOffset = intParam(url.searchParams.get('tzOffset'), 0, -14 * 60, 14 * 60);
|
|
1378
|
+
const deviceId = url.searchParams.get('deviceId') ?? undefined;
|
|
1379
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
1380
|
+
return json(res, 200, {
|
|
1381
|
+
ok: true,
|
|
1382
|
+
value: listActivity(db, {
|
|
1383
|
+
from,
|
|
1384
|
+
to,
|
|
1385
|
+
tzOffset,
|
|
1386
|
+
...deviceId === undefined ? {} : {
|
|
1387
|
+
deviceId
|
|
1388
|
+
},
|
|
1389
|
+
...sessionId === undefined ? {} : {
|
|
1390
|
+
sessionId
|
|
1391
|
+
}
|
|
1392
|
+
})
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
method: 'GET',
|
|
1398
|
+
path: /^\/admin\/activity\/sessions$/u,
|
|
1399
|
+
handler: async (req, res)=>{
|
|
1400
|
+
requireAdmin(req);
|
|
1401
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1402
|
+
const day = url.searchParams.get('day') ?? '';
|
|
1403
|
+
if (!/^\d{4}-\d{2}-\d{2}$/u.test(day)) throw new SyncError('bad-request', 'day 必须是 YYYY-MM-DD');
|
|
1404
|
+
const tzOffset = intParam(url.searchParams.get('tzOffset'), 0, -14 * 60, 14 * 60);
|
|
1405
|
+
return json(res, 200, {
|
|
1406
|
+
ok: true,
|
|
1407
|
+
value: listActivitySessions(db, {
|
|
1408
|
+
day,
|
|
1409
|
+
tzOffset
|
|
1410
|
+
})
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
method: 'GET',
|
|
1416
|
+
path: /^\/admin\/audit$/u,
|
|
1417
|
+
handler: async (req, res)=>{
|
|
1418
|
+
requireAdmin(req);
|
|
1419
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1420
|
+
const limit = intParam(url.searchParams.get('limit'), 50, 1, 200);
|
|
1421
|
+
const beforeRaw = url.searchParams.get('before');
|
|
1422
|
+
const before = beforeRaw === null ? undefined : intParam(beforeRaw, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1423
|
+
const action = url.searchParams.get('action') ?? undefined;
|
|
1424
|
+
return json(res, 200, {
|
|
1425
|
+
ok: true,
|
|
1426
|
+
value: listAudit(db, {
|
|
1427
|
+
limit,
|
|
1428
|
+
...before === undefined ? {} : {
|
|
1429
|
+
before
|
|
1430
|
+
},
|
|
1431
|
+
...action === undefined ? {} : {
|
|
1432
|
+
action
|
|
1433
|
+
}
|
|
1434
|
+
})
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1238
1438
|
{
|
|
1239
1439
|
method: 'GET',
|
|
1240
1440
|
path: /^\/admin\/sessions$/u,
|
|
@@ -1246,6 +1446,154 @@ export function createHttpServer(options) {
|
|
|
1246
1446
|
});
|
|
1247
1447
|
}
|
|
1248
1448
|
},
|
|
1449
|
+
{
|
|
1450
|
+
method: 'GET',
|
|
1451
|
+
path: ADMIN_SESSION_HISTORY_PATH,
|
|
1452
|
+
handler: async (req, res)=>{
|
|
1453
|
+
requireAdmin(req);
|
|
1454
|
+
const match = ADMIN_SESSION_HISTORY_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1455
|
+
if (match === null) return json(res, 404, errorBody('not-found', '没有这个会话'));
|
|
1456
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1457
|
+
const limit = intParam(url.searchParams.get('limit'), 100, 1, 200);
|
|
1458
|
+
const beforeRaw = url.searchParams.get('before');
|
|
1459
|
+
const before = beforeRaw === null ? undefined : intParam(beforeRaw, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1460
|
+
const sessionId = decodeURIComponent(match[1]);
|
|
1461
|
+
return json(res, 200, {
|
|
1462
|
+
ok: true,
|
|
1463
|
+
value: listSessionHistory(db, sessionId, {
|
|
1464
|
+
limit,
|
|
1465
|
+
...before === undefined ? {} : {
|
|
1466
|
+
before
|
|
1467
|
+
}
|
|
1468
|
+
})
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
method: 'POST',
|
|
1474
|
+
path: ADMIN_SESSION_ACTION_PATH,
|
|
1475
|
+
handler: async (req, res)=>{
|
|
1476
|
+
requireAdmin(req);
|
|
1477
|
+
const match = ADMIN_SESSION_ACTION_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1478
|
+
if (match === null) return json(res, 404, errorBody('not-found', '没有这个会话'));
|
|
1479
|
+
const sessionId = decodeURIComponent(match[1]);
|
|
1480
|
+
const action = match[2];
|
|
1481
|
+
const body = await readJson(req);
|
|
1482
|
+
if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
|
|
1483
|
+
const createdAt = Number(body.createdAt);
|
|
1484
|
+
if (action === 'clear-reset') {
|
|
1485
|
+
const result = db.handle.prepare('DELETE FROM session_resets WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
1486
|
+
if (Number(result.changes) === 0) throw new SyncError('not-found', '没有等待重建的重置记录');
|
|
1487
|
+
recordAudit(db, {
|
|
1488
|
+
actorKind: 'admin',
|
|
1489
|
+
actorId: 'admin',
|
|
1490
|
+
action: 'session.reset_cleared',
|
|
1491
|
+
target: sessionId
|
|
1492
|
+
}, req);
|
|
1493
|
+
return json(res, 200, {
|
|
1494
|
+
ok: true,
|
|
1495
|
+
value: {
|
|
1496
|
+
sessionId,
|
|
1497
|
+
action,
|
|
1498
|
+
cleared: true
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
if (action === 'reset') {
|
|
1503
|
+
const requested = body.deviceId;
|
|
1504
|
+
if (requested !== undefined && requested !== null && typeof requested !== 'string') {
|
|
1505
|
+
throw new SyncError('bad-request', 'deviceId 必须是字符串或 null');
|
|
1506
|
+
}
|
|
1507
|
+
if (typeof requested === 'string' && requested !== '') {
|
|
1508
|
+
const device = db.handle.prepare('SELECT 1 FROM devices WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL').get(DEFAULT_DATASET, requested);
|
|
1509
|
+
if (device === undefined) throw new SyncError('bad-request', '指定设备不存在或已吊销');
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (action === 'archive') {
|
|
1513
|
+
archiveSession(db, sessionId, createdAt);
|
|
1514
|
+
recordAudit(db, {
|
|
1515
|
+
actorKind: 'admin',
|
|
1516
|
+
actorId: 'admin',
|
|
1517
|
+
action: 'session.archive',
|
|
1518
|
+
target: sessionId,
|
|
1519
|
+
detail: {
|
|
1520
|
+
createdAt
|
|
1521
|
+
}
|
|
1522
|
+
}, req);
|
|
1523
|
+
return json(res, 200, {
|
|
1524
|
+
ok: true,
|
|
1525
|
+
value: {
|
|
1526
|
+
sessionId,
|
|
1527
|
+
action,
|
|
1528
|
+
archived: true
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
if (action === 'restore') {
|
|
1533
|
+
restoreSession(db, sessionId, createdAt);
|
|
1534
|
+
recordAudit(db, {
|
|
1535
|
+
actorKind: 'admin',
|
|
1536
|
+
actorId: 'admin',
|
|
1537
|
+
action: 'session.restore',
|
|
1538
|
+
target: sessionId,
|
|
1539
|
+
detail: {
|
|
1540
|
+
createdAt
|
|
1541
|
+
}
|
|
1542
|
+
}, req);
|
|
1543
|
+
return json(res, 200, {
|
|
1544
|
+
ok: true,
|
|
1545
|
+
value: {
|
|
1546
|
+
sessionId,
|
|
1547
|
+
action,
|
|
1548
|
+
restored: true
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
if (action === 'remove') {
|
|
1553
|
+
const purgedBytes = removeSession(db, options.dataDir, sessionId, createdAt);
|
|
1554
|
+
recordAudit(db, {
|
|
1555
|
+
actorKind: 'admin',
|
|
1556
|
+
actorId: 'admin',
|
|
1557
|
+
action: 'session.remove',
|
|
1558
|
+
target: sessionId,
|
|
1559
|
+
detail: {
|
|
1560
|
+
createdAt,
|
|
1561
|
+
purgedBytes
|
|
1562
|
+
}
|
|
1563
|
+
}, req);
|
|
1564
|
+
return json(res, 200, {
|
|
1565
|
+
ok: true,
|
|
1566
|
+
value: {
|
|
1567
|
+
sessionId,
|
|
1568
|
+
action,
|
|
1569
|
+
purgedBytes
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
const deviceId = typeof body.deviceId === 'string' && body.deviceId !== '' ? body.deviceId : null;
|
|
1574
|
+
const purgedBytes = resetSession(db, options.dataDir, sessionId, createdAt, deviceId);
|
|
1575
|
+
recordAudit(db, {
|
|
1576
|
+
actorKind: 'admin',
|
|
1577
|
+
actorId: 'admin',
|
|
1578
|
+
action: 'session.reset',
|
|
1579
|
+
target: sessionId,
|
|
1580
|
+
detail: {
|
|
1581
|
+
createdAt,
|
|
1582
|
+
purgedBytes,
|
|
1583
|
+
deviceId
|
|
1584
|
+
}
|
|
1585
|
+
}, req);
|
|
1586
|
+
return json(res, 200, {
|
|
1587
|
+
ok: true,
|
|
1588
|
+
value: {
|
|
1589
|
+
sessionId,
|
|
1590
|
+
action,
|
|
1591
|
+
purgedBytes,
|
|
1592
|
+
resetDeviceId: deviceId
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
},
|
|
1249
1597
|
{
|
|
1250
1598
|
method: 'GET',
|
|
1251
1599
|
path: ME_PATH,
|