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/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, 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';
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
- ...row.last_seen_at === null ? {} : {
99
- lastSeenAt: row.last_seen_at
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
- db.handle.prepare('INSERT INTO devices (dataset, device_id, label, token_hash, created_at) VALUES (?, ?, ?, ?, ?)').run(DEFAULT_DATASET, deviceId, label, hashToken(token), now);
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 renameDevice(db, deviceId, label) {
379
- const result = db.handle.prepare('UPDATE devices SET label = ? WHERE dataset = ? AND device_id = ? AND revoked_at IS NULL').run(label, DEFAULT_DATASET, deviceId);
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 page = listV2Sessions(db, after, limit);
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: deleteSession(db, id, device.deviceId, clientId, Number(body.createdAt))
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) adminSessionsByKey.delete(hashToken(token));
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: createDevice(db, label)
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: createDevice(db, label)
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
- return revokeDevice(db, match[1]) ? json(res, 200, {
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
- }) : json(res, 404, errorBody('not-found', '没有这个设备'));
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 device = renameDevice(db, match[1], label);
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,