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/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { ONBOARDING_CODE_FILE, ONBOARDING_TTL_MS, createDevice, createHttpServer, listDevices, mintOnboardingCode, revokeDevice, DEFAULT_MAX_BYTES } from './http.js';
5
- import { clearAdminPassword, ensureSetupToken, isAdminInitialized, openDatabase, readMaxBytes } from './db.js';
5
+ import { generateAdminPassword, resetAdminPassword } from './admin-password.js';
6
+ import { ensureSetupToken, isAdminInitialized, openDatabase, readMaxBytes } from './db.js';
6
7
  function writeOnboardingCode(dataDir, code) {
7
8
  mkdirSync(dataDir, {
8
9
  recursive: true
@@ -34,13 +35,15 @@ function usage() {
34
35
  token list 列出存活设备
35
36
  token revoke <deviceId> 吊销某个设备 token
36
37
  admin status 查看初始化状态与配额
37
- admin reset 忘记管理密码时重置(重启 serve 后重新初始化)
38
+ admin reset-password 忘记管理密码时重置为新密码
39
+ (默认生成随机密码;--stdin 从标准输入读取自定义密码)
38
40
 
39
41
  选项:
40
42
  --data-dir <目录> 数据目录(默认 .tmp/dev-data)
41
43
  --host <地址> 绑定地址(默认 127.0.0.1)
42
44
  --port <端口> 监听端口(默认 8787)
43
45
  --max-bytes <n> 存储上限(默认 10 GiB)
46
+ --stdin admin reset-password 从标准输入读取新密码
44
47
  `);
45
48
  process.exit(2);
46
49
  }
@@ -50,6 +53,10 @@ function parse(argv) {
50
53
  for(let index = 0; index < argv.length; index += 1){
51
54
  const argument = argv[index];
52
55
  if (argument.startsWith('--')) {
56
+ if (argument === '--stdin') {
57
+ flags.set('stdin', '');
58
+ continue;
59
+ }
53
60
  const value = argv[index + 1];
54
61
  if (value === undefined || value.startsWith('--')) usage();
55
62
  flags.set(argument.slice(2), value);
@@ -99,10 +106,19 @@ async function main(parsed) {
99
106
  }, null, 2));
100
107
  return;
101
108
  }
102
- if (action === 'reset') {
103
- clearAdminPassword(db);
104
- clearOnboardingCode(dataDir);
105
- console.log('已清除管理密码。重启 server 后会在控制台打印新的初始化密钥。');
109
+ if (action === 'reset-password') {
110
+ const fromStdin = parsed.flags.has('stdin');
111
+ const password = fromStdin ? readFileSync(0, 'utf8').replace(/\r?\n$/u, '') : generateAdminPassword();
112
+ resetAdminPassword(db, password);
113
+ if (fromStdin) {
114
+ console.log('已重置管理密码。旧管理会话需要重启 server 后失效。');
115
+ } else {
116
+ console.log('已重置管理密码(只显示一次):');
117
+ console.log('');
118
+ console.log(password);
119
+ console.log('');
120
+ console.log('请登录管理台后立即修改;旧管理会话需要重启 server 后失效。');
121
+ }
106
122
  return;
107
123
  }
108
124
  usage();
@@ -0,0 +1,214 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { blobPath } from './blobs.js';
3
+ import { bumpRevision, DEFAULT_DATASET, withTransaction } from './db.js';
4
+ import { SyncError } from './shared/wire.js';
5
+ import { recordSessionEvent } from './session-history.js';
6
+ export const SESSION_RESET_TTL_MS = 24 * 60 * 60 * 1000;
7
+ function targetRow(db, sessionId, createdAt) {
8
+ return db.handle.prepare('SELECT session_id, created_at, deleted_at, purged_at, stored_bytes, last_seq, manifest_json FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
9
+ }
10
+ function requireTarget(db, sessionId, createdAt) {
11
+ const row = targetRow(db, sessionId, createdAt);
12
+ if (row === undefined) throw new SyncError('not-found', `没有会话 ${sessionId}`);
13
+ if (row.created_at !== createdAt) throw new SyncError('conflict', 'createdAt 与当前生命周期不符');
14
+ return row;
15
+ }
16
+ function manifestBlobRefs(manifest) {
17
+ const frames = Array.isArray(manifest.frames) ? manifest.frames : [];
18
+ const hashes = [];
19
+ for (const frame of frames){
20
+ if (frame === null || typeof frame !== 'object') continue;
21
+ const record = frame;
22
+ if (typeof record.blobSha256 === 'string' && record.blobSha256 !== '') hashes.push(record.blobSha256);
23
+ if (typeof record.attachmentIndexSha256 === 'string' && record.attachmentIndexSha256 !== '') {
24
+ hashes.push(record.attachmentIndexSha256);
25
+ }
26
+ if (Array.isArray(record.attachmentBlobs)) {
27
+ for (const sha of record.attachmentBlobs){
28
+ if (typeof sha === 'string' && sha !== '') hashes.push(sha);
29
+ }
30
+ }
31
+ }
32
+ return hashes;
33
+ }
34
+ function purgeSessionBlobs(db, dataDir, sessionId, manifestJson) {
35
+ let hashes;
36
+ try {
37
+ hashes = manifestBlobRefs(JSON.parse(manifestJson));
38
+ } catch {
39
+ throw new SyncError('conflict', '会话 manifest 损坏,拒绝清理');
40
+ }
41
+ const unique = [
42
+ ...new Set(hashes)
43
+ ];
44
+ if (unique.length === 0) return {
45
+ bytes: 0,
46
+ files: []
47
+ };
48
+ const otherHashes = new Set();
49
+ const otherRows = db.handle.prepare('SELECT manifest_json FROM sessions WHERE dataset = ? AND session_id <> ?').all(DEFAULT_DATASET, sessionId);
50
+ for (const row of otherRows){
51
+ try {
52
+ for (const hash of manifestBlobRefs(JSON.parse(row.manifest_json)))otherHashes.add(hash);
53
+ } catch {
54
+ for (const hash of unique)otherHashes.add(hash);
55
+ }
56
+ }
57
+ const vaultCheck = db.handle.prepare('SELECT 1 FROM vault_records WHERE blob_sha256 = ? LIMIT 1');
58
+ const deleteBlob = db.handle.prepare('DELETE FROM blobs WHERE sha256 = ?');
59
+ const blobInfo = db.handle.prepare('SELECT bytes FROM blobs WHERE sha256 = ?');
60
+ let bytes = 0;
61
+ const files = [];
62
+ for (const hash of unique){
63
+ if (otherHashes.has(hash) || vaultCheck.get(hash) !== undefined) continue;
64
+ const record = blobInfo.get(hash);
65
+ if (record === undefined) continue;
66
+ deleteBlob.run(hash);
67
+ bytes += Number(record.bytes ?? 0);
68
+ files.push(blobPath(dataDir, hash));
69
+ }
70
+ return {
71
+ bytes,
72
+ files
73
+ };
74
+ }
75
+ function removeFiles(files) {
76
+ for (const file of files){
77
+ try {
78
+ rmSync(file, {
79
+ force: true
80
+ });
81
+ } catch {}
82
+ }
83
+ }
84
+ export function archiveSession(db, sessionId, createdAt, now = Date.now()) {
85
+ withTransaction(db, ()=>{
86
+ const row = requireTarget(db, sessionId, createdAt);
87
+ if (row.deleted_at !== null) throw new SyncError('conflict', '会话已经删除/存档');
88
+ const revision = bumpRevision(db);
89
+ db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
90
+ db.handle.prepare(`INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by)
91
+ VALUES (?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, 'admin:archive');
92
+ db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
93
+ recordSessionEvent(db, {
94
+ sessionId,
95
+ at: now,
96
+ kind: 'archive',
97
+ actorKind: 'admin',
98
+ actorId: 'admin',
99
+ lastSeq: row.last_seq ?? null,
100
+ storedBytes: row.stored_bytes ?? null,
101
+ detail: {
102
+ createdAt
103
+ }
104
+ });
105
+ });
106
+ }
107
+ export function removeSession(db, dataDir, sessionId, createdAt, now = Date.now()) {
108
+ let purged;
109
+ withTransaction(db, ()=>{
110
+ const row = requireTarget(db, sessionId, createdAt);
111
+ if (row.purged_at !== null) throw new SyncError('conflict', '会话已经移除并释放过空间');
112
+ purged = purgeSessionBlobs(db, dataDir, sessionId, row.manifest_json);
113
+ const revision = bumpRevision(db);
114
+ let manifest = {};
115
+ try {
116
+ manifest = JSON.parse(row.manifest_json);
117
+ } catch {
118
+ manifest = {};
119
+ }
120
+ manifest.frames = [];
121
+ db.handle.prepare(`UPDATE sessions
122
+ SET deleted_at = COALESCE(deleted_at, ?), purged_at = ?, revision = ?, updated_at = ?,
123
+ stored_bytes = 0, manifest_json = ?
124
+ WHERE dataset = ? AND session_id = ?`).run(now, now, revision, now, JSON.stringify(manifest), DEFAULT_DATASET, sessionId);
125
+ db.handle.prepare(`INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by)
126
+ VALUES (?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, 'admin:remove');
127
+ db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
128
+ recordSessionEvent(db, {
129
+ sessionId,
130
+ at: now,
131
+ kind: 'remove',
132
+ actorKind: 'admin',
133
+ actorId: 'admin',
134
+ bytes: purged?.bytes ?? 0,
135
+ lastSeq: null,
136
+ storedBytes: 0,
137
+ detail: {
138
+ createdAt,
139
+ purgedBytes: purged?.bytes ?? 0
140
+ }
141
+ });
142
+ });
143
+ const result = purged ?? {
144
+ bytes: 0,
145
+ files: []
146
+ };
147
+ removeFiles(result.files);
148
+ return result.bytes;
149
+ }
150
+ export function resetSession(db, dataDir, sessionId, createdAt, deviceId, now = Date.now()) {
151
+ let purged;
152
+ withTransaction(db, ()=>{
153
+ const row = requireTarget(db, sessionId, createdAt);
154
+ purged = purgeSessionBlobs(db, dataDir, sessionId, row.manifest_json);
155
+ db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
156
+ db.handle.prepare('DELETE FROM tombstones WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
157
+ db.handle.prepare('DELETE FROM sessions WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
158
+ db.handle.prepare('DELETE FROM leases WHERE dataset = ? AND resource = ?').run(DEFAULT_DATASET, 'sync');
159
+ let workspaceKey = null;
160
+ try {
161
+ const manifest = JSON.parse(row.manifest_json);
162
+ if (typeof manifest.workspaceKey === 'string' && manifest.workspaceKey !== '') workspaceKey = manifest.workspaceKey;
163
+ } catch {
164
+ workspaceKey = null;
165
+ }
166
+ db.handle.prepare(`INSERT OR REPLACE INTO session_resets
167
+ (dataset, session_id, created_at, reset_at, expires_at, device_id, workspace_key)
168
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, now + SESSION_RESET_TTL_MS, deviceId, workspaceKey);
169
+ recordSessionEvent(db, {
170
+ sessionId,
171
+ at: now,
172
+ kind: 'reset',
173
+ actorKind: 'admin',
174
+ actorId: 'admin',
175
+ bytes: purged?.bytes ?? 0,
176
+ detail: {
177
+ createdAt,
178
+ deviceId,
179
+ purgedBytes: purged?.bytes ?? 0
180
+ }
181
+ });
182
+ });
183
+ const result = purged ?? {
184
+ bytes: 0,
185
+ files: []
186
+ };
187
+ removeFiles(result.files);
188
+ return result.bytes;
189
+ }
190
+ export function restoreSession(db, sessionId, createdAt, now = Date.now()) {
191
+ withTransaction(db, ()=>{
192
+ const row = requireTarget(db, sessionId, createdAt);
193
+ if (row.deleted_at === null) throw new SyncError('conflict', '会话没有存档');
194
+ if (row.purged_at !== null) throw new SyncError('conflict', '会话已经释放空间,不能恢复');
195
+ const revision = bumpRevision(db);
196
+ db.handle.prepare('UPDATE sessions SET deleted_at = NULL, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(revision, now, DEFAULT_DATASET, sessionId);
197
+ db.handle.prepare('DELETE FROM tombstones WHERE dataset = ? AND session_id = ? AND created_at = ?').run(DEFAULT_DATASET, sessionId, createdAt);
198
+ recordSessionEvent(db, {
199
+ sessionId,
200
+ at: now,
201
+ kind: 'restore',
202
+ actorKind: 'admin',
203
+ actorId: 'admin',
204
+ lastSeq: null,
205
+ storedBytes: row.stored_bytes ?? null,
206
+ detail: {
207
+ createdAt
208
+ }
209
+ });
210
+ });
211
+ }
212
+
213
+
214
+ //# sourceURL=session-admin.ts
@@ -0,0 +1,134 @@
1
+ import { DEFAULT_DATASET } from './db.js';
2
+ export function recordSessionEvent(db, input) {
3
+ db.handle.prepare(`INSERT INTO session_events
4
+ (dataset, session_id, at, kind, device_id, actor_kind, actor_id, events, bytes, seq_from, seq_to,
5
+ batch_count, last_seq, stored_bytes, detail)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, input.sessionId, input.at, input.kind, input.deviceId ?? null, input.actorKind, input.actorId, Math.max(0, Math.trunc(input.events ?? 0)), Math.max(0, Math.trunc(input.bytes ?? 0)), input.seqFrom ?? null, input.seqTo ?? null, Math.max(0, Math.trunc(input.batchCount ?? 0)), input.lastSeq ?? null, input.storedBytes ?? null, input.detail === undefined ? null : JSON.stringify(input.detail));
7
+ }
8
+ export function listSessionHistory(db, sessionId, options = {}) {
9
+ const limit = Math.min(200, Math.max(1, Math.trunc(options.limit ?? 100)));
10
+ const before = options.before !== undefined && Number.isSafeInteger(options.before) ? options.before : undefined;
11
+ const rows = db.handle.prepare(`SELECT e.id, e.session_id, e.at, e.kind, e.device_id, e.actor_kind, e.actor_id,
12
+ e.events, e.bytes, e.seq_from, e.seq_to, e.batch_count, e.last_seq, e.stored_bytes, e.detail,
13
+ d.label AS device_label, d.color AS device_color
14
+ FROM session_events e
15
+ LEFT JOIN devices d ON d.dataset = e.dataset AND d.device_id = e.device_id
16
+ WHERE e.dataset = ? AND e.session_id = ? ${before === undefined ? '' : 'AND e.id < ?'}
17
+ ORDER BY e.id DESC LIMIT ?`).all(...[
18
+ DEFAULT_DATASET,
19
+ sessionId,
20
+ ...before === undefined ? [] : [
21
+ before
22
+ ],
23
+ limit
24
+ ]);
25
+ const events = rows.map((row)=>({
26
+ id: row.id,
27
+ sessionId: row.session_id,
28
+ at: row.at,
29
+ kind: row.kind,
30
+ deviceId: row.device_id,
31
+ deviceLabel: row.device_label,
32
+ deviceColor: row.device_color,
33
+ actorKind: row.actor_kind,
34
+ actorId: row.actor_id,
35
+ actorLabel: row.actor_kind === 'admin' ? '管理台' : row.actor_kind === 'system' ? '系统' : row.device_label ?? row.actor_id,
36
+ events: Number(row.events ?? 0),
37
+ bytes: Number(row.bytes ?? 0),
38
+ seqFrom: row.seq_from,
39
+ seqTo: row.seq_to,
40
+ batchCount: Number(row.batch_count ?? 0),
41
+ lastSeq: row.last_seq,
42
+ storedBytes: row.stored_bytes,
43
+ detail: row.detail === null ? null : parseDetail(row.detail)
44
+ }));
45
+ const summaryRow = db.handle.prepare(`SELECT COALESCE(SUM(events), 0) AS events, COALESCE(SUM(bytes), 0) AS bytes,
46
+ COALESCE(SUM(batch_count), 0) AS batches,
47
+ COALESCE(SUM(CASE WHEN kind IN ('create', 'append') THEN 1 ELSE 0 END), 0) AS appends,
48
+ COUNT(DISTINCT device_id) AS devices,
49
+ MIN(at) AS first_at, MAX(at) AS last_at,
50
+ COALESCE(MAX(events), 0) AS max_batch_events
51
+ FROM session_events WHERE dataset = ? AND session_id = ?`).get(DEFAULT_DATASET, sessionId);
52
+ return {
53
+ events,
54
+ summary: {
55
+ events: Number(summaryRow?.events ?? 0),
56
+ bytes: Number(summaryRow?.bytes ?? 0),
57
+ batches: Number(summaryRow?.batches ?? 0),
58
+ appends: Number(summaryRow?.appends ?? 0),
59
+ devices: Number(summaryRow?.devices ?? 0),
60
+ firstAt: summaryRow?.first_at ?? null,
61
+ lastAt: summaryRow?.last_at ?? null,
62
+ maxBatchEvents: Number(summaryRow?.max_batch_events ?? 0)
63
+ },
64
+ nextBefore: rows.length === limit ? rows[rows.length - 1].id : null
65
+ };
66
+ }
67
+ export function listActivity(db, query) {
68
+ const offsetMs = Math.max(-14 * 60, Math.min(14 * 60, Math.trunc(query.tzOffset ?? 0))) * 60_000;
69
+ const where = [
70
+ 'e.dataset = ?',
71
+ "e.kind IN ('create', 'append')",
72
+ 'e.at >= ?',
73
+ 'e.at < ?'
74
+ ];
75
+ const params = [
76
+ DEFAULT_DATASET,
77
+ Math.trunc(query.from),
78
+ Math.trunc(query.to)
79
+ ];
80
+ if (query.deviceId !== undefined && query.deviceId !== '') {
81
+ where.push('e.device_id = ?');
82
+ params.push(query.deviceId);
83
+ }
84
+ if (query.sessionId !== undefined && query.sessionId !== '') {
85
+ where.push('e.session_id = ?');
86
+ params.push(query.sessionId);
87
+ }
88
+ const rows = db.handle.prepare(`SELECT date((e.at + ?) / 1000, 'unixepoch') AS day,
89
+ COALESCE(SUM(e.events), 0) AS events,
90
+ COALESCE(SUM(e.bytes), 0) AS bytes,
91
+ COUNT(*) AS appends,
92
+ COUNT(DISTINCT e.session_id) AS sessions,
93
+ COUNT(DISTINCT e.device_id) AS devices
94
+ FROM session_events e
95
+ WHERE ${where.join(' AND ')}
96
+ GROUP BY day ORDER BY day ASC`).all(offsetMs, ...params);
97
+ return {
98
+ days: rows.map((row)=>({
99
+ day: row.day,
100
+ events: Number(row.events ?? 0),
101
+ bytes: Number(row.bytes ?? 0),
102
+ appends: Number(row.appends ?? 0),
103
+ sessions: Number(row.sessions ?? 0),
104
+ devices: Number(row.devices ?? 0)
105
+ }))
106
+ };
107
+ }
108
+ export function listActivitySessions(db, query) {
109
+ const offsetMs = Math.max(-14 * 60, Math.min(14 * 60, Math.trunc(query.tzOffset ?? 0))) * 60_000;
110
+ const rows = db.handle.prepare(`SELECT e.session_id, COALESCE(SUM(e.events), 0) AS events,
111
+ COALESCE(SUM(e.bytes), 0) AS bytes, COUNT(*) AS appends
112
+ FROM session_events e
113
+ WHERE e.dataset = ? AND e.kind IN ('create', 'append')
114
+ AND date((e.at + ?) / 1000, 'unixepoch') = ?
115
+ GROUP BY e.session_id ORDER BY events DESC`).all(DEFAULT_DATASET, offsetMs, query.day);
116
+ return {
117
+ sessions: rows.map((row)=>({
118
+ sessionId: row.session_id,
119
+ events: Number(row.events ?? 0),
120
+ bytes: Number(row.bytes ?? 0),
121
+ appends: Number(row.appends ?? 0)
122
+ }))
123
+ };
124
+ }
125
+ function parseDetail(raw) {
126
+ try {
127
+ return JSON.parse(raw);
128
+ } catch {
129
+ return raw;
130
+ }
131
+ }
132
+
133
+
134
+ //# sourceURL=session-history.ts