dscs-server 0.1.2 → 0.1.4
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 +57 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +498 -78
- package/dist/index.js +23 -7
- package/dist/session-admin.js +200 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +103 -73
- package/dist/shared/chain.js +3 -0
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1836 -275
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,6 +361,40 @@ 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
|
}
|
|
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);
|
|
367
|
+
if (Number(result.changes) === 0) return undefined;
|
|
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);
|
|
369
|
+
if (row === undefined) return undefined;
|
|
370
|
+
return {
|
|
371
|
+
deviceId: row.device_id,
|
|
372
|
+
label: row.label,
|
|
373
|
+
createdAt: row.created_at,
|
|
374
|
+
...row.last_seen_at === null ? {} : {
|
|
375
|
+
lastSeenAt: row.last_seen_at
|
|
376
|
+
},
|
|
377
|
+
...row.color === null ? {} : {
|
|
378
|
+
color: row.color
|
|
379
|
+
}
|
|
380
|
+
};
|
|
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
|
+
function requireDeviceLabel(value) {
|
|
393
|
+
const label = typeof value === 'string' ? value.trim() : '';
|
|
394
|
+
if (label === '') throw new SyncError('bad-request', '设备名不能为空');
|
|
395
|
+
if (label.length > 64) throw new SyncError('bad-request', '设备名最长 64 个字符');
|
|
396
|
+
return label;
|
|
397
|
+
}
|
|
378
398
|
export function createHttpServer(options) {
|
|
379
399
|
const { db } = options;
|
|
380
400
|
const version = options.version ?? SERVER_VERSION;
|
|
@@ -456,14 +476,16 @@ export function createHttpServer(options) {
|
|
|
456
476
|
schemaVersion: SERVER_SCHEMA_VERSION,
|
|
457
477
|
capabilities: [
|
|
458
478
|
'session-summary',
|
|
459
|
-
'changes-cursor',
|
|
460
479
|
'conditional-get',
|
|
461
480
|
'idempotent-append',
|
|
462
481
|
'manifest-digest',
|
|
463
482
|
'batches',
|
|
464
483
|
'batch-upload',
|
|
465
484
|
'batch-download',
|
|
466
|
-
'zstd-frame'
|
|
485
|
+
'zstd-frame',
|
|
486
|
+
'me-device-info',
|
|
487
|
+
'me-counts',
|
|
488
|
+
'device-rename'
|
|
467
489
|
],
|
|
468
490
|
limits: {
|
|
469
491
|
batchBytes: MAX_BATCH_BODY_BYTES,
|
|
@@ -483,7 +505,8 @@ export function createHttpServer(options) {
|
|
|
483
505
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
484
506
|
const after = decodeSessionsCursor(url.searchParams.get('cursor'));
|
|
485
507
|
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
486
|
-
const
|
|
508
|
+
const includeDeleted = url.searchParams.get('includeDeleted') === '1';
|
|
509
|
+
const page = listV2Sessions(db, after, limit, includeDeleted);
|
|
487
510
|
return json(res, 200, {
|
|
488
511
|
ok: true,
|
|
489
512
|
value: {
|
|
@@ -496,30 +519,6 @@ export function createHttpServer(options) {
|
|
|
496
519
|
});
|
|
497
520
|
}
|
|
498
521
|
},
|
|
499
|
-
{
|
|
500
|
-
method: 'GET',
|
|
501
|
-
path: V2_CHANGES_PATH,
|
|
502
|
-
handler: async (req, res)=>{
|
|
503
|
-
requireDevice(db, req);
|
|
504
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
505
|
-
const cursor = decodeChangesCursor(url.searchParams.get('cursor'));
|
|
506
|
-
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
507
|
-
const page = listV2Changes(db, cursor.revision, cursor.tombstone, limit);
|
|
508
|
-
return json(res, 200, {
|
|
509
|
-
ok: true,
|
|
510
|
-
value: {
|
|
511
|
-
sessions: page.sessions,
|
|
512
|
-
tombstones: page.tombstones,
|
|
513
|
-
nextCursor: encodeCursor({
|
|
514
|
-
v: 2,
|
|
515
|
-
revision: page.nextRevision,
|
|
516
|
-
tombstone: page.nextTombstone
|
|
517
|
-
}),
|
|
518
|
-
hasMore: page.hasMore
|
|
519
|
-
}
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
},
|
|
523
522
|
{
|
|
524
523
|
method: 'GET',
|
|
525
524
|
path: V2_SUMMARY_PATH,
|
|
@@ -610,9 +609,19 @@ export function createHttpServer(options) {
|
|
|
610
609
|
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_DELETE_PATH);
|
|
611
610
|
const body = await readJson(req);
|
|
612
611
|
if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
|
|
612
|
+
const value = deleteSession(db, id, device.deviceId, clientId, Number(body.createdAt));
|
|
613
|
+
recordAudit(db, {
|
|
614
|
+
actorKind: 'device',
|
|
615
|
+
actorId: device.deviceId,
|
|
616
|
+
action: 'session.delete',
|
|
617
|
+
target: id,
|
|
618
|
+
detail: {
|
|
619
|
+
createdAt: Number(body.createdAt)
|
|
620
|
+
}
|
|
621
|
+
}, req);
|
|
613
622
|
return json(res, 200, {
|
|
614
623
|
ok: true,
|
|
615
|
-
value
|
|
624
|
+
value
|
|
616
625
|
});
|
|
617
626
|
}
|
|
618
627
|
},
|
|
@@ -967,9 +976,22 @@ export function createHttpServer(options) {
|
|
|
967
976
|
const password = typeof body.password === 'string' ? body.password : '';
|
|
968
977
|
if (!passwordMatches(password)) {
|
|
969
978
|
noteFailure(`login:${ip}`);
|
|
979
|
+
recordAudit(db, {
|
|
980
|
+
actorKind: 'admin',
|
|
981
|
+
actorId: 'admin',
|
|
982
|
+
action: 'login_failed',
|
|
983
|
+
detail: {
|
|
984
|
+
reason: '密码不正确'
|
|
985
|
+
}
|
|
986
|
+
}, req);
|
|
970
987
|
throw new SyncError('unauthorized', '密码不正确');
|
|
971
988
|
}
|
|
972
989
|
attemptsByKey.delete(`login:${ip}`);
|
|
990
|
+
recordAudit(db, {
|
|
991
|
+
actorKind: 'admin',
|
|
992
|
+
actorId: 'admin',
|
|
993
|
+
action: 'login'
|
|
994
|
+
}, req);
|
|
973
995
|
return json(res, 200, {
|
|
974
996
|
ok: true,
|
|
975
997
|
value: {
|
|
@@ -983,7 +1005,14 @@ export function createHttpServer(options) {
|
|
|
983
1005
|
path: /^\/admin\/logout$/u,
|
|
984
1006
|
handler: async (req, res)=>{
|
|
985
1007
|
const token = cookieValue(req, ADMIN_COOKIE);
|
|
986
|
-
if (token !== undefined)
|
|
1008
|
+
if (token !== undefined) {
|
|
1009
|
+
adminSessionsByKey.delete(hashToken(token));
|
|
1010
|
+
recordAudit(db, {
|
|
1011
|
+
actorKind: 'admin',
|
|
1012
|
+
actorId: 'admin',
|
|
1013
|
+
action: 'logout'
|
|
1014
|
+
}, req);
|
|
1015
|
+
}
|
|
987
1016
|
writeAdminCookie(res, '', 0);
|
|
988
1017
|
return json(res, 200, {
|
|
989
1018
|
ok: true,
|
|
@@ -993,6 +1022,48 @@ export function createHttpServer(options) {
|
|
|
993
1022
|
});
|
|
994
1023
|
}
|
|
995
1024
|
},
|
|
1025
|
+
{
|
|
1026
|
+
method: 'POST',
|
|
1027
|
+
path: /^\/admin\/password$/u,
|
|
1028
|
+
handler: async (req, res)=>{
|
|
1029
|
+
requireAdmin(req);
|
|
1030
|
+
const body = await readJson(req);
|
|
1031
|
+
const current = typeof body.currentPassword === 'string' ? body.currentPassword : '';
|
|
1032
|
+
if (!passwordMatches(current)) {
|
|
1033
|
+
recordAudit(db, {
|
|
1034
|
+
actorKind: 'admin',
|
|
1035
|
+
actorId: 'admin',
|
|
1036
|
+
action: 'password.change_failed',
|
|
1037
|
+
detail: {
|
|
1038
|
+
reason: '当前密码不正确'
|
|
1039
|
+
}
|
|
1040
|
+
}, req);
|
|
1041
|
+
throw new SyncError('bad-request', '当前密码不正确');
|
|
1042
|
+
}
|
|
1043
|
+
const problem = validateAdminPassword(body.newPassword);
|
|
1044
|
+
if (problem !== undefined) throw new SyncError('bad-request', problem);
|
|
1045
|
+
const salt = newSalt();
|
|
1046
|
+
setAdminPassword(db, deriveMasterKey(String(body.newPassword), salt).toString('hex'), salt.toString('hex'));
|
|
1047
|
+
const token = cookieValue(req, ADMIN_COOKIE);
|
|
1048
|
+
const currentKey = token === undefined ? undefined : hashToken(token);
|
|
1049
|
+
for (const key of [
|
|
1050
|
+
...adminSessionsByKey.keys()
|
|
1051
|
+
]){
|
|
1052
|
+
if (key !== currentKey) adminSessionsByKey.delete(key);
|
|
1053
|
+
}
|
|
1054
|
+
recordAudit(db, {
|
|
1055
|
+
actorKind: 'admin',
|
|
1056
|
+
actorId: 'admin',
|
|
1057
|
+
action: 'password.change'
|
|
1058
|
+
}, req);
|
|
1059
|
+
return json(res, 200, {
|
|
1060
|
+
ok: true,
|
|
1061
|
+
value: {
|
|
1062
|
+
changed: true
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
},
|
|
996
1067
|
{
|
|
997
1068
|
method: 'GET',
|
|
998
1069
|
path: /^\/setup\/state$/u,
|
|
@@ -1050,6 +1121,11 @@ export function createHttpServer(options) {
|
|
|
1050
1121
|
if (problem !== undefined) throw new SyncError('bad-request', problem);
|
|
1051
1122
|
const salt = newSalt();
|
|
1052
1123
|
setAdminPassword(db, deriveMasterKey(String(body.password), salt).toString('hex'), salt.toString('hex'));
|
|
1124
|
+
recordAudit(db, {
|
|
1125
|
+
actorKind: 'system',
|
|
1126
|
+
actorId: 'setup',
|
|
1127
|
+
action: 'setup.password'
|
|
1128
|
+
}, req);
|
|
1053
1129
|
return json(res, 200, {
|
|
1054
1130
|
ok: true,
|
|
1055
1131
|
value: {
|
|
@@ -1069,6 +1145,14 @@ export function createHttpServer(options) {
|
|
|
1069
1145
|
const maxBytes = Number(body.maxBytes);
|
|
1070
1146
|
if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_MAX_BYTES) throw new SyncError('bad-request', '配额超出允许范围');
|
|
1071
1147
|
writeMaxBytes(db, maxBytes);
|
|
1148
|
+
recordAudit(db, {
|
|
1149
|
+
actorKind: 'system',
|
|
1150
|
+
actorId: 'setup',
|
|
1151
|
+
action: 'setup.quota',
|
|
1152
|
+
detail: {
|
|
1153
|
+
maxBytes
|
|
1154
|
+
}
|
|
1155
|
+
}, req);
|
|
1072
1156
|
return json(res, 200, {
|
|
1073
1157
|
ok: true,
|
|
1074
1158
|
value: {
|
|
@@ -1085,9 +1169,19 @@ export function createHttpServer(options) {
|
|
|
1085
1169
|
if (isAdminInitialized(db)) throw new SyncError('conflict', '已经完成初始化');
|
|
1086
1170
|
const body = await readJson(req);
|
|
1087
1171
|
const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
|
|
1172
|
+
const created = createDevice(db, label);
|
|
1173
|
+
recordAudit(db, {
|
|
1174
|
+
actorKind: 'system',
|
|
1175
|
+
actorId: 'setup',
|
|
1176
|
+
action: 'setup.device',
|
|
1177
|
+
target: created.device.deviceId,
|
|
1178
|
+
detail: {
|
|
1179
|
+
label
|
|
1180
|
+
}
|
|
1181
|
+
}, req);
|
|
1088
1182
|
return json(res, 201, {
|
|
1089
1183
|
ok: true,
|
|
1090
|
-
value:
|
|
1184
|
+
value: created
|
|
1091
1185
|
});
|
|
1092
1186
|
}
|
|
1093
1187
|
},
|
|
@@ -1105,6 +1199,11 @@ export function createHttpServer(options) {
|
|
|
1105
1199
|
force: true
|
|
1106
1200
|
});
|
|
1107
1201
|
writeSetupCookie(res, '', 0);
|
|
1202
|
+
recordAudit(db, {
|
|
1203
|
+
actorKind: 'system',
|
|
1204
|
+
actorId: 'setup',
|
|
1205
|
+
action: 'setup.complete'
|
|
1206
|
+
}, req);
|
|
1108
1207
|
return json(res, 200, {
|
|
1109
1208
|
ok: true,
|
|
1110
1209
|
value: {
|
|
@@ -1123,7 +1222,17 @@ export function createHttpServer(options) {
|
|
|
1123
1222
|
if (!Number.isSafeInteger(body.maxBytes)) throw new SyncError('bad-request', '配额必须是整数');
|
|
1124
1223
|
const maxBytes = Number(body.maxBytes);
|
|
1125
1224
|
if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_MAX_BYTES) throw new SyncError('bad-request', '配额超出允许范围');
|
|
1225
|
+
const from = effectiveMaxBytes();
|
|
1126
1226
|
writeMaxBytes(db, maxBytes);
|
|
1227
|
+
recordAudit(db, {
|
|
1228
|
+
actorKind: 'admin',
|
|
1229
|
+
actorId: 'admin',
|
|
1230
|
+
action: 'quota.change',
|
|
1231
|
+
detail: {
|
|
1232
|
+
from,
|
|
1233
|
+
to: maxBytes
|
|
1234
|
+
}
|
|
1235
|
+
}, req);
|
|
1127
1236
|
return json(res, 200, {
|
|
1128
1237
|
ok: true,
|
|
1129
1238
|
value: {
|
|
@@ -1143,7 +1252,6 @@ export function createHttpServer(options) {
|
|
|
1143
1252
|
value: {
|
|
1144
1253
|
version,
|
|
1145
1254
|
protocol: PROTOCOL_VERSION,
|
|
1146
|
-
revision: readRevision(db),
|
|
1147
1255
|
devices: listDevices(db).length,
|
|
1148
1256
|
sessions: counts.total,
|
|
1149
1257
|
deletedSessions: counts.deleted,
|
|
@@ -1171,9 +1279,19 @@ export function createHttpServer(options) {
|
|
|
1171
1279
|
requireAdmin(req);
|
|
1172
1280
|
const body = await readJson(req);
|
|
1173
1281
|
const label = typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : 'device';
|
|
1282
|
+
const created = createDevice(db, label);
|
|
1283
|
+
recordAudit(db, {
|
|
1284
|
+
actorKind: 'admin',
|
|
1285
|
+
actorId: 'admin',
|
|
1286
|
+
action: 'device.create',
|
|
1287
|
+
target: created.device.deviceId,
|
|
1288
|
+
detail: {
|
|
1289
|
+
label
|
|
1290
|
+
}
|
|
1291
|
+
}, req);
|
|
1174
1292
|
return json(res, 201, {
|
|
1175
1293
|
ok: true,
|
|
1176
|
-
value:
|
|
1294
|
+
value: created
|
|
1177
1295
|
});
|
|
1178
1296
|
}
|
|
1179
1297
|
},
|
|
@@ -1184,13 +1302,136 @@ export function createHttpServer(options) {
|
|
|
1184
1302
|
requireAdmin(req);
|
|
1185
1303
|
const match = ADMIN_DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1186
1304
|
if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1187
|
-
|
|
1305
|
+
const before = listDevices(db).find((device)=>device.deviceId === match[1]);
|
|
1306
|
+
if (!revokeDevice(db, match[1])) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1307
|
+
recordAudit(db, {
|
|
1308
|
+
actorKind: 'admin',
|
|
1309
|
+
actorId: 'admin',
|
|
1310
|
+
action: 'device.revoke',
|
|
1311
|
+
target: match[1],
|
|
1312
|
+
detail: {
|
|
1313
|
+
label: before?.label
|
|
1314
|
+
}
|
|
1315
|
+
}, req);
|
|
1316
|
+
return json(res, 200, {
|
|
1188
1317
|
ok: true,
|
|
1189
1318
|
value: {
|
|
1190
1319
|
deviceId: match[1],
|
|
1191
1320
|
revoked: true
|
|
1192
1321
|
}
|
|
1193
|
-
})
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
method: 'PATCH',
|
|
1327
|
+
path: ADMIN_DEVICE_PATH,
|
|
1328
|
+
handler: async (req, res)=>{
|
|
1329
|
+
requireAdmin(req);
|
|
1330
|
+
const match = ADMIN_DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1331
|
+
if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1332
|
+
const body = await readJson(req);
|
|
1333
|
+
const label = body.label === undefined ? undefined : requireDeviceLabel(body.label);
|
|
1334
|
+
const color = body.color === undefined ? undefined : requireDeviceColor(body.color);
|
|
1335
|
+
if (label === undefined && color === undefined) throw new SyncError('bad-request', '需要 label 或 color');
|
|
1336
|
+
const before = listDevices(db).find((device)=>device.deviceId === match[1]);
|
|
1337
|
+
const device = updateDevice(db, match[1], {
|
|
1338
|
+
...label === undefined ? {} : {
|
|
1339
|
+
label
|
|
1340
|
+
},
|
|
1341
|
+
...color === undefined ? {} : {
|
|
1342
|
+
color
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
if (device === undefined) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1346
|
+
if (label !== undefined) {
|
|
1347
|
+
recordAudit(db, {
|
|
1348
|
+
actorKind: 'admin',
|
|
1349
|
+
actorId: 'admin',
|
|
1350
|
+
action: 'device.rename',
|
|
1351
|
+
target: match[1],
|
|
1352
|
+
detail: {
|
|
1353
|
+
from: before?.label,
|
|
1354
|
+
to: label
|
|
1355
|
+
}
|
|
1356
|
+
}, req);
|
|
1357
|
+
}
|
|
1358
|
+
return json(res, 200, {
|
|
1359
|
+
ok: true,
|
|
1360
|
+
value: {
|
|
1361
|
+
device
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
method: 'GET',
|
|
1368
|
+
path: /^\/admin\/activity$/u,
|
|
1369
|
+
handler: async (req, res)=>{
|
|
1370
|
+
requireAdmin(req);
|
|
1371
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1372
|
+
const now = Date.now();
|
|
1373
|
+
const to = intParam(url.searchParams.get('to'), now, 1, Number.MAX_SAFE_INTEGER);
|
|
1374
|
+
const from = intParam(url.searchParams.get('from'), to - 90 * 24 * 60 * 60 * 1000, 0, Number.MAX_SAFE_INTEGER);
|
|
1375
|
+
if (to <= from) throw new SyncError('bad-request', 'to 必须大于 from');
|
|
1376
|
+
const tzOffset = intParam(url.searchParams.get('tzOffset'), 0, -14 * 60, 14 * 60);
|
|
1377
|
+
const deviceId = url.searchParams.get('deviceId') ?? undefined;
|
|
1378
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
1379
|
+
return json(res, 200, {
|
|
1380
|
+
ok: true,
|
|
1381
|
+
value: listActivity(db, {
|
|
1382
|
+
from,
|
|
1383
|
+
to,
|
|
1384
|
+
tzOffset,
|
|
1385
|
+
...deviceId === undefined ? {} : {
|
|
1386
|
+
deviceId
|
|
1387
|
+
},
|
|
1388
|
+
...sessionId === undefined ? {} : {
|
|
1389
|
+
sessionId
|
|
1390
|
+
}
|
|
1391
|
+
})
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
method: 'GET',
|
|
1397
|
+
path: /^\/admin\/activity\/sessions$/u,
|
|
1398
|
+
handler: async (req, res)=>{
|
|
1399
|
+
requireAdmin(req);
|
|
1400
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1401
|
+
const day = url.searchParams.get('day') ?? '';
|
|
1402
|
+
if (!/^\d{4}-\d{2}-\d{2}$/u.test(day)) throw new SyncError('bad-request', 'day 必须是 YYYY-MM-DD');
|
|
1403
|
+
const tzOffset = intParam(url.searchParams.get('tzOffset'), 0, -14 * 60, 14 * 60);
|
|
1404
|
+
return json(res, 200, {
|
|
1405
|
+
ok: true,
|
|
1406
|
+
value: listActivitySessions(db, {
|
|
1407
|
+
day,
|
|
1408
|
+
tzOffset
|
|
1409
|
+
})
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
method: 'GET',
|
|
1415
|
+
path: /^\/admin\/audit$/u,
|
|
1416
|
+
handler: async (req, res)=>{
|
|
1417
|
+
requireAdmin(req);
|
|
1418
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1419
|
+
const limit = intParam(url.searchParams.get('limit'), 50, 1, 200);
|
|
1420
|
+
const beforeRaw = url.searchParams.get('before');
|
|
1421
|
+
const before = beforeRaw === null ? undefined : intParam(beforeRaw, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1422
|
+
const action = url.searchParams.get('action') ?? undefined;
|
|
1423
|
+
return json(res, 200, {
|
|
1424
|
+
ok: true,
|
|
1425
|
+
value: listAudit(db, {
|
|
1426
|
+
limit,
|
|
1427
|
+
...before === undefined ? {} : {
|
|
1428
|
+
before
|
|
1429
|
+
},
|
|
1430
|
+
...action === undefined ? {} : {
|
|
1431
|
+
action
|
|
1432
|
+
}
|
|
1433
|
+
})
|
|
1434
|
+
});
|
|
1194
1435
|
}
|
|
1195
1436
|
},
|
|
1196
1437
|
{
|
|
@@ -1204,6 +1445,154 @@ export function createHttpServer(options) {
|
|
|
1204
1445
|
});
|
|
1205
1446
|
}
|
|
1206
1447
|
},
|
|
1448
|
+
{
|
|
1449
|
+
method: 'GET',
|
|
1450
|
+
path: ADMIN_SESSION_HISTORY_PATH,
|
|
1451
|
+
handler: async (req, res)=>{
|
|
1452
|
+
requireAdmin(req);
|
|
1453
|
+
const match = ADMIN_SESSION_HISTORY_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1454
|
+
if (match === null) return json(res, 404, errorBody('not-found', '没有这个会话'));
|
|
1455
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1456
|
+
const limit = intParam(url.searchParams.get('limit'), 100, 1, 200);
|
|
1457
|
+
const beforeRaw = url.searchParams.get('before');
|
|
1458
|
+
const before = beforeRaw === null ? undefined : intParam(beforeRaw, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1459
|
+
const sessionId = decodeURIComponent(match[1]);
|
|
1460
|
+
return json(res, 200, {
|
|
1461
|
+
ok: true,
|
|
1462
|
+
value: listSessionHistory(db, sessionId, {
|
|
1463
|
+
limit,
|
|
1464
|
+
...before === undefined ? {} : {
|
|
1465
|
+
before
|
|
1466
|
+
}
|
|
1467
|
+
})
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
method: 'POST',
|
|
1473
|
+
path: ADMIN_SESSION_ACTION_PATH,
|
|
1474
|
+
handler: async (req, res)=>{
|
|
1475
|
+
requireAdmin(req);
|
|
1476
|
+
const match = ADMIN_SESSION_ACTION_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
1477
|
+
if (match === null) return json(res, 404, errorBody('not-found', '没有这个会话'));
|
|
1478
|
+
const sessionId = decodeURIComponent(match[1]);
|
|
1479
|
+
const action = match[2];
|
|
1480
|
+
const body = await readJson(req);
|
|
1481
|
+
if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
|
|
1482
|
+
const createdAt = Number(body.createdAt);
|
|
1483
|
+
if (action === 'clear-reset') {
|
|
1484
|
+
const result = db.handle.prepare('DELETE FROM session_resets WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
1485
|
+
if (Number(result.changes) === 0) throw new SyncError('not-found', '没有等待重建的重置记录');
|
|
1486
|
+
recordAudit(db, {
|
|
1487
|
+
actorKind: 'admin',
|
|
1488
|
+
actorId: 'admin',
|
|
1489
|
+
action: 'session.reset_cleared',
|
|
1490
|
+
target: sessionId
|
|
1491
|
+
}, req);
|
|
1492
|
+
return json(res, 200, {
|
|
1493
|
+
ok: true,
|
|
1494
|
+
value: {
|
|
1495
|
+
sessionId,
|
|
1496
|
+
action,
|
|
1497
|
+
cleared: true
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
if (action === 'reset') {
|
|
1502
|
+
const requested = body.deviceId;
|
|
1503
|
+
if (requested !== undefined && requested !== null && typeof requested !== 'string') {
|
|
1504
|
+
throw new SyncError('bad-request', 'deviceId 必须是字符串或 null');
|
|
1505
|
+
}
|
|
1506
|
+
if (typeof requested === 'string' && requested !== '') {
|
|
1507
|
+
const device = db.handle.prepare('SELECT 1 FROM devices WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL').get(DEFAULT_DATASET, requested);
|
|
1508
|
+
if (device === undefined) throw new SyncError('bad-request', '指定设备不存在或已吊销');
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
if (action === 'archive') {
|
|
1512
|
+
archiveSession(db, sessionId, createdAt);
|
|
1513
|
+
recordAudit(db, {
|
|
1514
|
+
actorKind: 'admin',
|
|
1515
|
+
actorId: 'admin',
|
|
1516
|
+
action: 'session.archive',
|
|
1517
|
+
target: sessionId,
|
|
1518
|
+
detail: {
|
|
1519
|
+
createdAt
|
|
1520
|
+
}
|
|
1521
|
+
}, req);
|
|
1522
|
+
return json(res, 200, {
|
|
1523
|
+
ok: true,
|
|
1524
|
+
value: {
|
|
1525
|
+
sessionId,
|
|
1526
|
+
action,
|
|
1527
|
+
archived: true
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
if (action === 'restore') {
|
|
1532
|
+
restoreSession(db, sessionId, createdAt);
|
|
1533
|
+
recordAudit(db, {
|
|
1534
|
+
actorKind: 'admin',
|
|
1535
|
+
actorId: 'admin',
|
|
1536
|
+
action: 'session.restore',
|
|
1537
|
+
target: sessionId,
|
|
1538
|
+
detail: {
|
|
1539
|
+
createdAt
|
|
1540
|
+
}
|
|
1541
|
+
}, req);
|
|
1542
|
+
return json(res, 200, {
|
|
1543
|
+
ok: true,
|
|
1544
|
+
value: {
|
|
1545
|
+
sessionId,
|
|
1546
|
+
action,
|
|
1547
|
+
restored: true
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
if (action === 'remove') {
|
|
1552
|
+
const purgedBytes = removeSession(db, options.dataDir, sessionId, createdAt);
|
|
1553
|
+
recordAudit(db, {
|
|
1554
|
+
actorKind: 'admin',
|
|
1555
|
+
actorId: 'admin',
|
|
1556
|
+
action: 'session.remove',
|
|
1557
|
+
target: sessionId,
|
|
1558
|
+
detail: {
|
|
1559
|
+
createdAt,
|
|
1560
|
+
purgedBytes
|
|
1561
|
+
}
|
|
1562
|
+
}, req);
|
|
1563
|
+
return json(res, 200, {
|
|
1564
|
+
ok: true,
|
|
1565
|
+
value: {
|
|
1566
|
+
sessionId,
|
|
1567
|
+
action,
|
|
1568
|
+
purgedBytes
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
const deviceId = typeof body.deviceId === 'string' && body.deviceId !== '' ? body.deviceId : null;
|
|
1573
|
+
const purgedBytes = resetSession(db, options.dataDir, sessionId, createdAt, deviceId);
|
|
1574
|
+
recordAudit(db, {
|
|
1575
|
+
actorKind: 'admin',
|
|
1576
|
+
actorId: 'admin',
|
|
1577
|
+
action: 'session.reset',
|
|
1578
|
+
target: sessionId,
|
|
1579
|
+
detail: {
|
|
1580
|
+
createdAt,
|
|
1581
|
+
purgedBytes,
|
|
1582
|
+
deviceId
|
|
1583
|
+
}
|
|
1584
|
+
}, req);
|
|
1585
|
+
return json(res, 200, {
|
|
1586
|
+
ok: true,
|
|
1587
|
+
value: {
|
|
1588
|
+
sessionId,
|
|
1589
|
+
action,
|
|
1590
|
+
purgedBytes,
|
|
1591
|
+
resetDeviceId: deviceId
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
1207
1596
|
{
|
|
1208
1597
|
method: 'GET',
|
|
1209
1598
|
path: ME_PATH,
|
|
@@ -1216,11 +1605,42 @@ export function createHttpServer(options) {
|
|
|
1216
1605
|
role: 'admin'
|
|
1217
1606
|
}
|
|
1218
1607
|
});
|
|
1608
|
+
const counts = sessionCounts(db);
|
|
1609
|
+
const workspaceRow = db.handle.prepare("SELECT COUNT(DISTINCT json_extract(manifest_json, '$.workspaceKey')) AS n FROM sessions WHERE dataset = ? AND deleted_at IS NULL AND json_extract(manifest_json, '$.workspaceKey') IS NOT NULL").get(DEFAULT_DATASET);
|
|
1219
1610
|
return json(res, 200, {
|
|
1220
1611
|
ok: true,
|
|
1221
1612
|
value: {
|
|
1222
1613
|
role: 'device',
|
|
1223
|
-
device: auth.device
|
|
1614
|
+
device: auth.device,
|
|
1615
|
+
server: {
|
|
1616
|
+
version,
|
|
1617
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1618
|
+
quotaBytes: effectiveMaxBytes(),
|
|
1619
|
+
usedBytes: usedBlobBytes(db)
|
|
1620
|
+
},
|
|
1621
|
+
counts: {
|
|
1622
|
+
workspaces: Number(workspaceRow?.n ?? 0),
|
|
1623
|
+
sessions: counts.total - counts.deleted
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
method: 'PATCH',
|
|
1631
|
+
path: ME_PATH,
|
|
1632
|
+
handler: async (req, res)=>{
|
|
1633
|
+
const auth = authenticate(db, req);
|
|
1634
|
+
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '未知 token'));
|
|
1635
|
+
if (auth.role !== 'device') return json(res, 401, errorBody('unauthorized', '需要设备 token'));
|
|
1636
|
+
const body = await readJson(req);
|
|
1637
|
+
const label = requireDeviceLabel(body.label);
|
|
1638
|
+
const device = renameDevice(db, auth.device.deviceId, label);
|
|
1639
|
+
if (device === undefined) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
1640
|
+
return json(res, 200, {
|
|
1641
|
+
ok: true,
|
|
1642
|
+
value: {
|
|
1643
|
+
device
|
|
1224
1644
|
}
|
|
1225
1645
|
});
|
|
1226
1646
|
}
|