@lasting-beauty/chat-hub 0.1.0-beta.1

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.
@@ -0,0 +1,562 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { X509Certificate } from 'node:crypto';
3
+ import { lstatSync, readFileSync, statSync } from 'node:fs';
4
+ import { request as httpRequest } from 'node:http';
5
+
6
+ import { deploymentPaths, verifiedPlatformCliVersionPatterns } from './deploy.mjs';
7
+ import { ipCertificateAutomationPaths } from './ip-certificate.mjs';
8
+
9
+ const internalPorts = new Map([
10
+ [4310, 'application'],
11
+ [8080, 'gateway'],
12
+ ]);
13
+
14
+ export async function verifyServerDeployment({
15
+ domain,
16
+ publicHost = domain,
17
+ publicScheme = 'https',
18
+ certificateMode = publicScheme === 'https' ? 'managed-domain' : 'none',
19
+ larkCliPath = 'lark-cli',
20
+ dingtalkCliPath = 'dws',
21
+ nodePath = process.execPath,
22
+ runCommand = defaultRunCommand,
23
+ fetchImpl = fetch,
24
+ internalFetchImpl = fetchInternalGateway,
25
+ fileSystem = { lstatSync, readFileSync, statSync },
26
+ environment = process.env,
27
+ now = Date.now(),
28
+ } = {}) {
29
+ if (!isValidEndpoint(publicHost, publicScheme)) throw new Error('服务器验收公网入口无效');
30
+ const publicPorts = new Map(
31
+ publicScheme === 'https'
32
+ ? [
33
+ [80, 'http'],
34
+ [443, 'https'],
35
+ ]
36
+ : [[80, 'http']],
37
+ );
38
+ const checks = [];
39
+
40
+ if (publicScheme === 'https') {
41
+ await record(checks, 'tls-validation-enabled', 'Node.js 未关闭 TLS 证书校验', () => {
42
+ if (environment.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
43
+ throw new Error('NODE_TLS_REJECT_UNAUTHORIZED=0 会使公网证书验收失真');
44
+ }
45
+ return 'enabled';
46
+ });
47
+ }
48
+
49
+ await record(checks, 'chat-hub-service', 'Chat Hub systemd 服务已启用并安全运行', () => {
50
+ const properties = parseProperties(
51
+ runCommand('systemctl', [
52
+ 'show',
53
+ 'chat-hub.service',
54
+ '--property=ActiveState,UnitFileState,User,Group,ProtectSystem,NoNewPrivileges',
55
+ ]),
56
+ );
57
+ assertEqual(properties.ActiveState, 'active', 'Chat Hub 服务未运行');
58
+ assertEnabled(properties.UnitFileState, 'Chat Hub 服务未启用开机启动');
59
+ assertEqual(properties.User, 'chat-hub', 'Chat Hub 服务用户不正确');
60
+ assertEqual(properties.Group, 'chat-hub', 'Chat Hub 服务组不正确');
61
+ assertEqual(properties.ProtectSystem, 'strict', 'ProtectSystem 未设为 strict');
62
+ assertEqual(properties.NoNewPrivileges, 'yes', 'NoNewPrivileges 未启用');
63
+ return properties;
64
+ });
65
+
66
+ await record(checks, 'caddy-service', 'Caddy 已启用并运行', () => {
67
+ const properties = parseProperties(
68
+ runCommand('systemctl', ['show', 'caddy.service', '--property=ActiveState,UnitFileState']),
69
+ );
70
+ assertEqual(properties.ActiveState, 'active', 'Caddy 服务未运行');
71
+ assertEnabled(properties.UnitFileState, 'Caddy 服务未启用开机启动');
72
+ return properties;
73
+ });
74
+
75
+ await record(checks, 'systemd-unit-valid', 'systemd 单元配置有效', () => {
76
+ runCommand('systemd-analyze', ['verify', deploymentPaths.service]);
77
+ return deploymentPaths.service;
78
+ });
79
+
80
+ await record(checks, 'caddy-config-valid', 'Caddy 配置有效', () => {
81
+ runCommand('caddy', [
82
+ 'validate',
83
+ '--config',
84
+ deploymentPaths.caddyfile,
85
+ '--adapter',
86
+ 'caddyfile',
87
+ ]);
88
+ return deploymentPaths.caddyfile;
89
+ });
90
+
91
+ if (certificateMode === 'ip-shortlived') {
92
+ await record(checks, 'ip-certificate', 'IP 证书覆盖当前公网 IP 且未进入危险窗口', () => {
93
+ const certificatePath = ipCertificateAutomationPaths.publishedFullchain;
94
+ const certificate = new X509Certificate(fileSystem.readFileSync(certificatePath));
95
+ if (!certificate.subjectAltName?.split(/,\s*/).includes(`IP Address:${publicHost}`)) {
96
+ throw new Error('当前证书没有覆盖部署公网 IP');
97
+ }
98
+ const expiresAt = Date.parse(certificate.validTo);
99
+ if (!Number.isFinite(expiresAt)) throw new Error('证书到期时间无法识别');
100
+ const remainingHours = (expiresAt - now) / 3_600_000;
101
+ if (remainingHours <= 24) throw new Error(`证书只剩 ${remainingHours.toFixed(1)} 小时`);
102
+ return { certificatePath, expiresAt: new Date(expiresAt).toISOString(), remainingHours };
103
+ });
104
+ await record(checks, 'certificate-renewal-timer', '短期证书续期 timer 已启用并运行', () => {
105
+ const properties = parseProperties(
106
+ runCommand('systemctl', [
107
+ 'show',
108
+ 'chat-hub-cert-renew.timer',
109
+ '--property=ActiveState,UnitFileState,LastTriggerUSec,NextElapseUSecRealtime',
110
+ ]),
111
+ );
112
+ assertEqual(properties.ActiveState, 'active', '证书续期 timer 未运行');
113
+ assertEnabled(properties.UnitFileState, '证书续期 timer 未启用');
114
+ return properties;
115
+ });
116
+ await record(checks, 'certificate-renewal-result', '证书续期验收最近成功', () => {
117
+ return validateCertificateRenewalResult(
118
+ fileSystem.readFileSync(ipCertificateAutomationPaths.result, 'utf8'),
119
+ now,
120
+ );
121
+ });
122
+ }
123
+
124
+ await record(checks, 'lark-cli-version', '飞书 CLI 版本经过验证', () => {
125
+ const invocation = cliInvocation(larkCliPath, nodePath);
126
+ const version = runCommand(invocation.executable, [...invocation.prefixArgs, '--version'])
127
+ .trim()
128
+ .split(/\r?\n/)[0];
129
+ if (!verifiedPlatformCliVersionPatterns['lark-cli'].test(version)) {
130
+ throw new Error(`未经验证的版本:${version}`);
131
+ }
132
+ return version;
133
+ });
134
+
135
+ await record(checks, 'dingtalk-cli-version', '钉钉 CLI 版本经过验证', () => {
136
+ const invocation = cliInvocation(dingtalkCliPath, nodePath);
137
+ const version = runCommand(invocation.executable, [...invocation.prefixArgs, '--version'])
138
+ .trim()
139
+ .split(/\r?\n/)[0];
140
+ if (!verifiedPlatformCliVersionPatterns.dws.test(version)) {
141
+ throw new Error(`未经验证的版本:${version}`);
142
+ }
143
+ return version;
144
+ });
145
+
146
+ for (const expected of expectedFileMetadata(certificateMode)) {
147
+ await record(checks, expected.id, expected.description, () => {
148
+ const metadata = fileSystem.lstatSync(expected.path);
149
+ if (metadata.isSymbolicLink()) throw new Error('不能是符号链接');
150
+ if (expected.type === 'file' && !metadata.isFile()) throw new Error('不是普通文件');
151
+ if (expected.type === 'directory' && !metadata.isDirectory()) throw new Error('不是目录');
152
+ const [owner, group, mode, size] = runCommand('stat', ['-c', '%U:%G:%a:%s', expected.path])
153
+ .trim()
154
+ .split(':');
155
+ assertEqual(owner, expected.owner, `所有者必须是 ${expected.owner}`);
156
+ assertEqual(group, expected.group, `所属组必须是 ${expected.group}`);
157
+ assertEqual(mode, expected.mode, `权限必须是 ${expected.mode}`);
158
+ if (expected.size !== undefined && Number(size) !== expected.size) {
159
+ throw new Error(`大小必须是 ${expected.size} 字节`);
160
+ }
161
+ return { path: expected.path, owner, group, mode, size: Number(size) };
162
+ });
163
+ }
164
+
165
+ await record(checks, 'listener-boundaries', '内部端口只监听回环且公网端口已监听', () => {
166
+ const sockets = parseListeningSockets(runCommand('ss', ['-ltnH']));
167
+ const details = {};
168
+ for (const [port, name] of internalPorts) {
169
+ const matches = sockets.filter((socket) => socket.port === port);
170
+ if (matches.length === 0) throw new Error(`${name} 端口 ${port} 未监听`);
171
+ if (matches.some((socket) => !isLoopback(socket.host))) {
172
+ throw new Error(`${name} 端口 ${port} 暴露在非回环地址`);
173
+ }
174
+ details[name] = matches.map((socket) => socket.host);
175
+ }
176
+ for (const [port, name] of publicPorts) {
177
+ const matches = sockets.filter((socket) => socket.port === port);
178
+ if (matches.length === 0) throw new Error(`公网 ${name} 端口 ${port} 未监听`);
179
+ details[name] = matches.map((socket) => socket.host);
180
+ }
181
+ return details;
182
+ });
183
+
184
+ await record(checks, 'internal-gateway-health', '内部安全网关健康', async () => {
185
+ const response = await fetchWithTimeout(internalFetchImpl, 'http://127.0.0.1:8080/api/health', {
186
+ headers: {
187
+ host: publicHost,
188
+ 'x-forwarded-for': '127.0.0.1',
189
+ 'x-forwarded-proto': publicScheme,
190
+ },
191
+ });
192
+ const body = await readJson(response);
193
+ if (!response.ok || body?.ok !== true || body.data?.status !== 'ready') {
194
+ throw new Error(`内部健康接口异常:HTTP ${response.status}`);
195
+ }
196
+ return { status: response.status, application: body.data.status };
197
+ });
198
+
199
+ if (publicScheme === 'https') {
200
+ await record(checks, 'http-redirect', '公网 HTTP 强制跳转到同域 HTTPS', async () => {
201
+ const response = await fetchWithTimeout(fetchImpl, `http://${publicHost}/api/health`, {
202
+ redirect: 'manual',
203
+ });
204
+ const location = response.headers.get('location');
205
+ if (![301, 302, 307, 308].includes(response.status)) {
206
+ throw new Error(`预期重定向,实际 HTTP ${response.status}`);
207
+ }
208
+ if (!location?.startsWith(`https://${publicHost}/`)) {
209
+ throw new Error('重定向目标不是同域 HTTPS');
210
+ }
211
+ return { status: response.status, location };
212
+ });
213
+ }
214
+
215
+ await record(
216
+ checks,
217
+ publicScheme === 'https' ? 'public-https-health' : 'public-http-health',
218
+ publicScheme === 'https'
219
+ ? '公网 HTTPS、证书和健康接口有效'
220
+ : '公网 HTTP 健康接口有效(传输未加密)',
221
+ async () => {
222
+ const response = await fetchWithTimeout(
223
+ fetchImpl,
224
+ `${publicScheme}://${publicHost}/api/health`,
225
+ );
226
+ const body = await readJson(response);
227
+ if (!response.ok || body?.ok !== true || body.data?.status !== 'ready') {
228
+ throw new Error(`公网健康接口异常:HTTP ${response.status}`);
229
+ }
230
+ return { status: response.status, application: body.data.status };
231
+ },
232
+ );
233
+
234
+ await record(checks, 'public-session-config', '公网管理员会话配置可读取', async () => {
235
+ const response = await fetchWithTimeout(
236
+ fetchImpl,
237
+ `${publicScheme}://${publicHost}/api/session/config`,
238
+ );
239
+ const body = await readJson(response);
240
+ if (
241
+ !response.ok ||
242
+ body?.ok !== true ||
243
+ body.data?.mode !== 'admin' ||
244
+ typeof body.data.initialized !== 'boolean'
245
+ ) {
246
+ throw new Error(`管理员会话配置异常:HTTP ${response.status}`);
247
+ }
248
+ return {
249
+ status: response.status,
250
+ mode: body.data.mode,
251
+ initialized: body.data.initialized,
252
+ };
253
+ });
254
+
255
+ let bootId;
256
+ await record(checks, 'boot-evidence', '记录当前 Linux 启动标识', () => {
257
+ bootId = fileSystem.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
258
+ if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(bootId)) {
259
+ throw new Error('无法读取有效 boot ID');
260
+ }
261
+ return bootId;
262
+ });
263
+
264
+ const failed = checks.filter((check) => check.status === 'fail').length;
265
+ return {
266
+ command: 'verify-server',
267
+ domain: publicHost,
268
+ publicHost,
269
+ publicScheme,
270
+ checkedAt: new Date(now).toISOString(),
271
+ bootId,
272
+ passed: failed === 0,
273
+ summary: { total: checks.length, passed: checks.length - failed, failed },
274
+ checks,
275
+ rebootEvidence: '服务器重启后再次运行本命令;新的 bootId 与全通过结果共同作为开机自启证据。',
276
+ };
277
+ }
278
+
279
+ export function validateCertificateRenewalResult(rawResult, now = Date.now()) {
280
+ let result;
281
+ try {
282
+ result = JSON.parse(String(rawResult));
283
+ } catch {
284
+ throw new Error('续期结果不是有效 JSON');
285
+ }
286
+ if (result?.status !== 'succeeded' || !Number.isFinite(Date.parse(result.completedAt))) {
287
+ throw new Error('续期结果缺少成功时间');
288
+ }
289
+ const ageHours = (now - Date.parse(result.completedAt)) / 3_600_000;
290
+ if (ageHours < 0 || ageHours > 96) {
291
+ throw new Error(`最近一次成功续期已过去 ${ageHours.toFixed(1)} 小时`);
292
+ }
293
+ return { status: result.status, completedAt: result.completedAt, ageHours };
294
+ }
295
+
296
+ function isValidEndpoint(publicHost, publicScheme) {
297
+ if (publicScheme === 'https') {
298
+ return isPublicDomain(publicHost) || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(publicHost ?? '');
299
+ }
300
+ return publicScheme === 'http' && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(publicHost ?? '');
301
+ }
302
+
303
+ export function parseListeningSockets(output) {
304
+ if (typeof output !== 'string') throw new Error('ss 输出必须是字符串');
305
+ return output
306
+ .split(/\r?\n/)
307
+ .map((line) => line.trim())
308
+ .filter(Boolean)
309
+ .flatMap((line) => {
310
+ const columns = line.split(/\s+/);
311
+ const local = columns[3];
312
+ if (!local) return [];
313
+ const match = local.match(/^(?:\[([^\]]+)\]|(.+)):(\d+)$/);
314
+ if (!match) return [];
315
+ return [{ host: (match[1] ?? match[2]).replace(/%.+$/, ''), port: Number(match[3]) }];
316
+ });
317
+ }
318
+
319
+ function expectedFileMetadata(certificateMode) {
320
+ const common = [
321
+ {
322
+ id: 'data-permissions',
323
+ description: '数据目录权限正确',
324
+ path: deploymentPaths.data,
325
+ type: 'directory',
326
+ owner: 'chat-hub',
327
+ group: 'chat-hub',
328
+ mode: '700',
329
+ },
330
+ {
331
+ id: 'backup-permissions',
332
+ description: '备份目录权限正确',
333
+ path: deploymentPaths.backups,
334
+ type: 'directory',
335
+ owner: 'chat-hub',
336
+ group: 'chat-hub',
337
+ mode: '700',
338
+ },
339
+ {
340
+ id: 'configuration-permissions',
341
+ description: '配置目录权限正确',
342
+ path: deploymentPaths.configuration,
343
+ type: 'directory',
344
+ owner: 'root',
345
+ group: 'chat-hub',
346
+ mode: '750',
347
+ },
348
+ {
349
+ id: 'credential-directory-permissions',
350
+ description: '凭证目录权限正确',
351
+ path: deploymentPaths.credentials,
352
+ type: 'directory',
353
+ owner: 'root',
354
+ group: 'root',
355
+ mode: '700',
356
+ },
357
+ {
358
+ id: 'master-key-permissions',
359
+ description: '主密钥是 root 独占的 32 字节普通文件',
360
+ path: deploymentPaths.masterKey,
361
+ type: 'file',
362
+ owner: 'root',
363
+ group: 'root',
364
+ mode: '600',
365
+ size: 32,
366
+ },
367
+ {
368
+ id: 'runtime-config-permissions',
369
+ description: '运行配置权限正确',
370
+ path: deploymentPaths.runtimeConfig,
371
+ type: 'file',
372
+ owner: 'root',
373
+ group: 'chat-hub',
374
+ mode: '640',
375
+ },
376
+ {
377
+ id: 'deployment-manifest-permissions',
378
+ description: '部署清单权限正确',
379
+ path: deploymentPaths.manifest,
380
+ type: 'file',
381
+ owner: 'root',
382
+ group: 'root',
383
+ mode: '644',
384
+ },
385
+ {
386
+ id: 'systemd-unit-permissions',
387
+ description: 'systemd 单元权限正确',
388
+ path: deploymentPaths.service,
389
+ type: 'file',
390
+ owner: 'root',
391
+ group: 'root',
392
+ mode: '644',
393
+ },
394
+ {
395
+ id: 'caddy-snippet-permissions',
396
+ description: 'Caddy 站点配置权限正确',
397
+ path: deploymentPaths.caddySnippet,
398
+ type: 'file',
399
+ owner: 'root',
400
+ group: 'root',
401
+ mode: '644',
402
+ },
403
+ ];
404
+ if (certificateMode !== 'ip-shortlived') return common;
405
+ return [
406
+ ...common,
407
+ {
408
+ id: 'ip-certificate-directory-permissions',
409
+ description: 'Caddy 的 IP 证书目录只允许 root 与 caddy 访问',
410
+ path: ipCertificateAutomationPaths.publishedDirectory,
411
+ type: 'directory',
412
+ owner: 'root',
413
+ group: 'caddy',
414
+ mode: '750',
415
+ },
416
+ {
417
+ id: 'ip-certificate-chain-permissions',
418
+ description: 'Caddy 可只读访问发布的 IP 证书链',
419
+ path: ipCertificateAutomationPaths.publishedFullchain,
420
+ type: 'file',
421
+ owner: 'root',
422
+ group: 'caddy',
423
+ mode: '640',
424
+ },
425
+ {
426
+ id: 'ip-certificate-key-permissions',
427
+ description: 'Caddy 可只读访问发布的 IP 私钥副本',
428
+ path: ipCertificateAutomationPaths.publishedPrivateKey,
429
+ type: 'file',
430
+ owner: 'root',
431
+ group: 'caddy',
432
+ mode: '640',
433
+ },
434
+ ];
435
+ }
436
+
437
+ async function record(checks, id, description, operation) {
438
+ try {
439
+ const evidence = await operation();
440
+ checks.push({ id, description, status: 'pass', evidence });
441
+ } catch (error) {
442
+ checks.push({
443
+ id,
444
+ description,
445
+ status: 'fail',
446
+ error: safeError(error),
447
+ });
448
+ }
449
+ }
450
+
451
+ function defaultRunCommand(executable, args) {
452
+ return execFileSync(executable, args, {
453
+ encoding: 'utf8',
454
+ timeout: 15_000,
455
+ maxBuffer: 1024 * 1024,
456
+ stdio: 'pipe',
457
+ env: { ...process.env, LC_ALL: 'C' },
458
+ });
459
+ }
460
+
461
+ async function fetchWithTimeout(fetchImpl, url, options = {}) {
462
+ return fetchImpl(url, {
463
+ ...options,
464
+ signal: AbortSignal.timeout(10_000),
465
+ });
466
+ }
467
+
468
+ export function fetchInternalGateway(url, options = {}) {
469
+ return new Promise((resolve, reject) => {
470
+ const request = httpRequest(
471
+ url,
472
+ {
473
+ headers: options.headers,
474
+ signal: options.signal,
475
+ },
476
+ (response) => {
477
+ const chunks = [];
478
+ let size = 0;
479
+ response.on('data', (chunk) => {
480
+ size += chunk.length;
481
+ if (size > 64 * 1024) {
482
+ request.destroy(new Error('验收接口响应超过 64 KiB'));
483
+ return;
484
+ }
485
+ chunks.push(chunk);
486
+ });
487
+ response.on('end', () => {
488
+ resolve(
489
+ new Response(Buffer.concat(chunks), {
490
+ status: response.statusCode ?? 500,
491
+ headers: response.headers,
492
+ }),
493
+ );
494
+ });
495
+ },
496
+ );
497
+ request.on('error', reject);
498
+ request.end();
499
+ });
500
+ }
501
+
502
+ async function readJson(response) {
503
+ const text = await response.text();
504
+ if (text.length > 64 * 1024) throw new Error('验收接口响应超过 64 KiB');
505
+ try {
506
+ return JSON.parse(text);
507
+ } catch {
508
+ throw new Error('验收接口未返回有效 JSON');
509
+ }
510
+ }
511
+
512
+ function parseProperties(output) {
513
+ return Object.fromEntries(
514
+ output
515
+ .split(/\r?\n/)
516
+ .filter((line) => line.includes('='))
517
+ .map((line) => {
518
+ const separator = line.indexOf('=');
519
+ return [line.slice(0, separator), line.slice(separator + 1)];
520
+ }),
521
+ );
522
+ }
523
+
524
+ function cliInvocation(executable, nodePath) {
525
+ return executable.toLowerCase().endsWith('.js')
526
+ ? { executable: nodePath, prefixArgs: [executable] }
527
+ : { executable, prefixArgs: [] };
528
+ }
529
+
530
+ function assertEqual(actual, expected, message) {
531
+ if (actual !== expected) throw new Error(`${message}(实际 ${actual ?? '缺失'})`);
532
+ }
533
+
534
+ function assertEnabled(value, message) {
535
+ if (value !== 'enabled' && value !== 'enabled-runtime') {
536
+ throw new Error(`${message}(实际 ${value ?? '缺失'})`);
537
+ }
538
+ }
539
+
540
+ function isLoopback(host) {
541
+ return host === '127.0.0.1' || host === '::1';
542
+ }
543
+
544
+ function isPublicDomain(domain) {
545
+ return (
546
+ typeof domain === 'string' &&
547
+ domain.length <= 253 &&
548
+ domain.includes('.') &&
549
+ /^[a-z0-9.-]+$/.test(domain) &&
550
+ !domain
551
+ .split('.')
552
+ .some(
553
+ (label) => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'),
554
+ ) &&
555
+ !/^\d+(?:\.\d+){3}$/.test(domain)
556
+ );
557
+ }
558
+
559
+ function safeError(error) {
560
+ const message = error instanceof Error ? error.message : String(error);
561
+ return message.replace(/[\r\n]+/g, ' ').slice(0, 500);
562
+ }
@@ -0,0 +1,145 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ } from 'node:fs';
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
14
+
15
+ export function migrateLegacyWindowsData({
16
+ legacyDirectory,
17
+ currentDirectory,
18
+ backupRoot,
19
+ now = Date.now(),
20
+ verifyCopy,
21
+ } = {}) {
22
+ for (const [name, value] of Object.entries({
23
+ legacyDirectory,
24
+ currentDirectory,
25
+ backupRoot,
26
+ })) {
27
+ if (typeof value !== 'string' || !isAbsolute(value)) {
28
+ throw new Error(`${name} 必须是绝对路径`);
29
+ }
30
+ }
31
+ const legacy = resolve(legacyDirectory);
32
+ const current = resolve(currentDirectory);
33
+ const backups = resolve(backupRoot);
34
+ assertSeparatePaths(legacy, current, backups);
35
+ const legacyExists = existsSync(legacy);
36
+ const currentExists = existsSync(current);
37
+ if (legacyExists && currentExists) {
38
+ throw new Error(`检测到新旧两个数据目录,已停止以避免打开错误数据库:${current};${legacy}`);
39
+ }
40
+ if (!legacyExists) return { migrated: false, currentDirectory: current };
41
+ if (!lstatSync(legacy).isDirectory() || lstatSync(legacy).isSymbolicLink()) {
42
+ throw new Error('旧版 Windows 数据路径不是普通目录,已停止自动迁移');
43
+ }
44
+ const sourceManifest = directoryManifest(legacy);
45
+ mkdirSync(dirname(current), { recursive: true });
46
+ mkdirSync(backups, { recursive: true });
47
+ const suffix = `${new Date(now).toISOString().replaceAll(':', '').replaceAll('.', '-')}-${randomUUID().slice(0, 8)}`;
48
+ const staged = `${current}.migration-${suffix}`;
49
+ const backup = join(backups, `legacy-${suffix}`);
50
+ try {
51
+ copyAndVerify(legacy, staged, sourceManifest, verifyCopy);
52
+ copyAndVerify(legacy, backup, sourceManifest, verifyCopy);
53
+ renameSync(staged, current);
54
+ try {
55
+ rmSync(legacy, { recursive: true, force: false });
56
+ } catch (error) {
57
+ throw new Error(
58
+ `新目录和备份已创建,但旧目录无法移除;为避免歧义,下次启动会停止。请核对 ${legacy}:${error instanceof Error ? error.message : '未知错误'}`,
59
+ );
60
+ }
61
+ return {
62
+ migrated: true,
63
+ currentDirectory: current,
64
+ legacyDirectory: legacy,
65
+ backupDirectory: backup,
66
+ fileCount: sourceManifest.length,
67
+ };
68
+ } catch (error) {
69
+ if (!existsSync(current)) rmSync(staged, { recursive: true, force: true });
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ function copyAndVerify(source, destination, expectedManifest, verifyCopy) {
75
+ cpSync(source, destination, {
76
+ recursive: true,
77
+ errorOnExist: true,
78
+ force: false,
79
+ preserveTimestamps: true,
80
+ });
81
+ const actualManifest = directoryManifest(destination);
82
+ if (JSON.stringify(actualManifest) !== JSON.stringify(expectedManifest)) {
83
+ rmSync(destination, { recursive: true, force: true });
84
+ throw new Error(`Windows 数据迁移副本校验失败:${basename(destination)}`);
85
+ }
86
+ try {
87
+ verifyCopy?.(destination);
88
+ } catch (error) {
89
+ rmSync(destination, { recursive: true, force: true });
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ function directoryManifest(directory) {
95
+ const entries = [];
96
+ const queue = [''];
97
+ while (queue.length > 0) {
98
+ const relativeDirectory = queue.shift();
99
+ const absoluteDirectory = join(directory, relativeDirectory);
100
+ const children = readdirSync(absoluteDirectory, { withFileTypes: true }).sort((a, b) =>
101
+ a.name.localeCompare(b.name),
102
+ );
103
+ for (const child of children) {
104
+ const relativePath = join(relativeDirectory, child.name);
105
+ const absolutePath = join(directory, relativePath);
106
+ const metadata = lstatSync(absolutePath);
107
+ if (metadata.isSymbolicLink()) {
108
+ throw new Error(`Windows 数据迁移不允许符号链接:${relativePath}`);
109
+ }
110
+ if (metadata.isDirectory()) {
111
+ entries.push({ path: relativePath, type: 'directory' });
112
+ queue.push(relativePath);
113
+ } else if (metadata.isFile()) {
114
+ entries.push({
115
+ path: relativePath,
116
+ type: 'file',
117
+ size: metadata.size,
118
+ sha256: createHash('sha256').update(readFileSync(absolutePath)).digest('hex'),
119
+ });
120
+ } else {
121
+ throw new Error(`Windows 数据迁移只接受普通文件和目录:${relativePath}`);
122
+ }
123
+ }
124
+ }
125
+ return entries.sort((left, right) => left.path.localeCompare(right.path));
126
+ }
127
+
128
+ function assertSeparatePaths(legacy, current, backups) {
129
+ const pairs = [
130
+ [legacy, current],
131
+ [legacy, backups],
132
+ [current, backups],
133
+ ];
134
+ for (const [left, right] of pairs) {
135
+ const leftToRight = relative(left, right);
136
+ const rightToLeft = relative(right, left);
137
+ if (
138
+ leftToRight === '' ||
139
+ (!leftToRight.startsWith('..') && !isAbsolute(leftToRight)) ||
140
+ (!rightToLeft.startsWith('..') && !isAbsolute(rightToLeft))
141
+ ) {
142
+ throw new Error('Windows 数据迁移的新、旧和备份目录不能互相包含');
143
+ }
144
+ }
145
+ }