@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.
- package/LICENSE +74 -0
- package/README.md +170 -0
- package/bin/chat-hub.mjs +1794 -0
- package/cli/data-lifecycle.mjs +203 -0
- package/cli/deploy.mjs +569 -0
- package/cli/external-verification.mjs +219 -0
- package/cli/ip-certificate.mjs +331 -0
- package/cli/platform.mjs +173 -0
- package/cli/runtime.mjs +115 -0
- package/cli/server-maintenance.mjs +207 -0
- package/cli/server-verification.mjs +562 -0
- package/cli/windows-data-migration.mjs +145 -0
- package/dist/assets/AiSummaryPage-BUT43KuZ.js +1 -0
- package/dist/assets/index-BNCcoLmC.js +312 -0
- package/dist/assets/index-cxklu1ib.css +1 -0
- package/dist/index.html +14 -0
- package/dist/server/index.js +14796 -0
- package/package.json +118 -0
- package/scripts/install-platform-clis.mjs +163 -0
- package/skills/chat-hub-setup/scripts/gateway.mjs +360 -0
package/cli/platform.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export const platformServiceEnvironment = Object.freeze({
|
|
4
|
+
HOME: '/var/lib/chat-hub',
|
|
5
|
+
XDG_CONFIG_HOME: '/var/lib/chat-hub/.config',
|
|
6
|
+
XDG_DATA_HOME: '/var/lib/chat-hub/.local/share',
|
|
7
|
+
XDG_CACHE_HOME: '/var/lib/chat-hub/.cache',
|
|
8
|
+
DWS_CONFIG_DIR: '/var/lib/chat-hub/.dws',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const larkReadDomains = 'im,mail,minutes,task,approval,calendar,drive,base,sheets';
|
|
12
|
+
|
|
13
|
+
export class PlatformCommandError extends Error {
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'PlatformCommandError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function platformCliSteps({ platform, action, larkMode, appId, device = true }) {
|
|
22
|
+
if (!['lark', 'dingtalk'].includes(platform)) {
|
|
23
|
+
throw new PlatformCommandError('platform_invalid', '平台必须是 lark 或 dingtalk');
|
|
24
|
+
}
|
|
25
|
+
if (!['login', 'status'].includes(action)) {
|
|
26
|
+
throw new PlatformCommandError('platform_action_invalid', '平台操作必须是 login 或 status');
|
|
27
|
+
}
|
|
28
|
+
if (action === 'status') {
|
|
29
|
+
return platform === 'lark'
|
|
30
|
+
? [{ command: 'lark', args: ['whoami'], capture: true }]
|
|
31
|
+
: [{ command: 'dingtalk', args: ['auth', 'status', '--format', 'json'], capture: true }];
|
|
32
|
+
}
|
|
33
|
+
if (platform === 'dingtalk') {
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
command: 'dingtalk',
|
|
37
|
+
args: ['auth', 'login', ...(device ? ['--device'] : []), '--recommend'],
|
|
38
|
+
capture: false,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
if (larkMode === 'new') {
|
|
43
|
+
return [
|
|
44
|
+
{ command: 'lark', args: ['config', 'init', '--new'], capture: false },
|
|
45
|
+
{
|
|
46
|
+
command: 'lark',
|
|
47
|
+
args: ['auth', 'login', '--domain', larkReadDomains, '--recommend', '--json'],
|
|
48
|
+
capture: false,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
if (larkMode === 'existing') {
|
|
53
|
+
if (typeof appId !== 'string' || !/^cli_[A-Za-z0-9]+$/.test(appId)) {
|
|
54
|
+
throw new PlatformCommandError(
|
|
55
|
+
'lark_app_id_required',
|
|
56
|
+
'已有飞书应用必须通过 --app-id 提供有效 App ID;App Secret 随后从隐藏输入或 stdin 读取',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
command: 'lark',
|
|
62
|
+
args: ['config', 'init', '--app-id', appId, '--app-secret-stdin'],
|
|
63
|
+
capture: false,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
command: 'lark',
|
|
67
|
+
args: ['auth', 'login', '--domain', larkReadDomains, '--recommend', '--json'],
|
|
68
|
+
capture: false,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
if (larkMode === 'configured') {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
command: 'lark',
|
|
76
|
+
args: ['auth', 'login', '--domain', larkReadDomains, '--recommend', '--json'],
|
|
77
|
+
capture: false,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
throw new PlatformCommandError(
|
|
82
|
+
'lark_initialization_choice_required',
|
|
83
|
+
'飞书尚未配置时必须明确选择 --new-app,或使用 --app-id <App ID> 绑定已有应用',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function serviceUserInvocation(invocation) {
|
|
88
|
+
const environmentArguments = Object.entries(platformServiceEnvironment).map(
|
|
89
|
+
([key, value]) => `${key}=${value}`,
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
executable: 'runuser',
|
|
93
|
+
args: [
|
|
94
|
+
'--user',
|
|
95
|
+
'chat-hub',
|
|
96
|
+
'--',
|
|
97
|
+
'env',
|
|
98
|
+
...environmentArguments,
|
|
99
|
+
invocation.executable,
|
|
100
|
+
...invocation.prefixArgs,
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function summarizePlatformStatus(platform, output) {
|
|
106
|
+
let value;
|
|
107
|
+
try {
|
|
108
|
+
value = JSON.parse(output);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new PlatformCommandError(
|
|
111
|
+
'platform_status_invalid',
|
|
112
|
+
`${platform === 'lark' ? '飞书' : '钉钉'}状态输出无法识别`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (platform === 'lark') {
|
|
116
|
+
return {
|
|
117
|
+
platform,
|
|
118
|
+
configured: value.available !== false && Boolean(value.appId || value.profile),
|
|
119
|
+
authenticated: value.available === true && value.tokenStatus === 'ready',
|
|
120
|
+
displayName: cleanDisplayName(value.onBehalfOf?.userName),
|
|
121
|
+
status: value.tokenStatus ?? (value.available ? 'ready' : 'not_configured'),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
platform,
|
|
126
|
+
configured: true,
|
|
127
|
+
authenticated: value.authenticated === true && value.token_valid !== false,
|
|
128
|
+
displayName: cleanDisplayName(value.user_name),
|
|
129
|
+
status:
|
|
130
|
+
value.authenticated === true && value.token_valid !== false
|
|
131
|
+
? 'ready'
|
|
132
|
+
: value.authenticated === false
|
|
133
|
+
? 'not_authenticated'
|
|
134
|
+
: 'unknown',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function runPlatformCommand({
|
|
139
|
+
platform,
|
|
140
|
+
action,
|
|
141
|
+
larkMode,
|
|
142
|
+
appId,
|
|
143
|
+
device,
|
|
144
|
+
resolveInvocation,
|
|
145
|
+
execute = execFileSync,
|
|
146
|
+
}) {
|
|
147
|
+
const steps = platformCliSteps({ platform, action, larkMode, appId, device });
|
|
148
|
+
let captured = '';
|
|
149
|
+
for (const step of steps) {
|
|
150
|
+
const selected = resolveInvocation(step.command);
|
|
151
|
+
if (!selected) {
|
|
152
|
+
throw new PlatformCommandError(
|
|
153
|
+
'platform_cli_missing',
|
|
154
|
+
`缺少固定版本平台 CLI:${step.command}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const service = serviceUserInvocation(selected);
|
|
158
|
+
const options = step.capture
|
|
159
|
+
? { encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }
|
|
160
|
+
: { timeout: 10 * 60_000, stdio: 'inherit' };
|
|
161
|
+
const result = execute(service.executable, [...service.args, ...step.args], options);
|
|
162
|
+
if (step.capture) captured = String(result);
|
|
163
|
+
}
|
|
164
|
+
return action === 'status'
|
|
165
|
+
? summarizePlatformStatus(platform, captured)
|
|
166
|
+
: { platform, authenticated: true, status: 'login_completed' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cleanDisplayName(value) {
|
|
170
|
+
if (typeof value !== 'string') return undefined;
|
|
171
|
+
const trimmed = value.trim();
|
|
172
|
+
return trimmed ? trimmed.slice(0, 80) : undefined;
|
|
173
|
+
}
|
package/cli/runtime.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { startGateway } from '../skills/chat-hub-setup/scripts/gateway.mjs';
|
|
5
|
+
|
|
6
|
+
const configPath = process.argv[2];
|
|
7
|
+
if (!configPath || !existsSync(configPath)) throw new Error('缺少运行配置');
|
|
8
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
9
|
+
const statePath = join(config.runtimeDirectory, 'state.json');
|
|
10
|
+
let appProcess;
|
|
11
|
+
let gateway;
|
|
12
|
+
let closing = false;
|
|
13
|
+
|
|
14
|
+
function writeState(status, code) {
|
|
15
|
+
const temporary = `${statePath}.tmp`;
|
|
16
|
+
writeFileSync(
|
|
17
|
+
temporary,
|
|
18
|
+
`${JSON.stringify(
|
|
19
|
+
{
|
|
20
|
+
id: config.id,
|
|
21
|
+
runId: config.runId,
|
|
22
|
+
pid: process.pid,
|
|
23
|
+
appPid: appProcess?.pid,
|
|
24
|
+
status,
|
|
25
|
+
code,
|
|
26
|
+
updatedAt: new Date().toISOString(),
|
|
27
|
+
},
|
|
28
|
+
null,
|
|
29
|
+
2,
|
|
30
|
+
)}\n`,
|
|
31
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
32
|
+
);
|
|
33
|
+
renameSync(temporary, statePath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function close(code = 'stopped', failed = false) {
|
|
37
|
+
if (closing) return;
|
|
38
|
+
closing = true;
|
|
39
|
+
if (gateway) await gateway.close();
|
|
40
|
+
if (appProcess && appProcess.exitCode === null && appProcess.signalCode === null) {
|
|
41
|
+
await new Promise((resolvePromise) => {
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
appProcess.kill('SIGKILL');
|
|
44
|
+
resolvePromise();
|
|
45
|
+
}, 5_000);
|
|
46
|
+
appProcess.once('exit', () => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
resolvePromise();
|
|
49
|
+
});
|
|
50
|
+
appProcess.kill('SIGTERM');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
writeState(failed ? 'failed' : 'stopped', code);
|
|
54
|
+
process.exit(failed ? 1 : 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.on('SIGTERM', () => void close());
|
|
58
|
+
process.on('SIGINT', () => void close());
|
|
59
|
+
function reportFailure(code, error) {
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error ?? '未知错误');
|
|
61
|
+
console.error(`[chat-hub-runtime] ${code}: ${message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
process.on('uncaughtException', (error) => {
|
|
65
|
+
reportFailure('uncaught_exception', error);
|
|
66
|
+
void close('runtime_error', true);
|
|
67
|
+
});
|
|
68
|
+
process.on('unhandledRejection', (error) => {
|
|
69
|
+
reportFailure('unhandled_rejection', error);
|
|
70
|
+
void close('runtime_error', true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function waitForHealth() {
|
|
74
|
+
const deadline = Date.now() + 40_000;
|
|
75
|
+
while (Date.now() < deadline && !closing) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`http://127.0.0.1:${config.apiPort}/api/health`, {
|
|
78
|
+
signal: AbortSignal.timeout(1_000),
|
|
79
|
+
});
|
|
80
|
+
const body = await response.json();
|
|
81
|
+
if (response.ok && body.ok === true && body.data?.status === 'ready') return;
|
|
82
|
+
} catch {
|
|
83
|
+
// The application can still be opening SQLite or loading native modules.
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
|
|
86
|
+
}
|
|
87
|
+
throw new Error('health_timeout');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
writeState('starting');
|
|
92
|
+
appProcess = spawn(process.execPath, [join(config.packageRoot, 'dist', 'server', 'index.js')], {
|
|
93
|
+
cwd: config.runtimeDirectory,
|
|
94
|
+
env: { ...process.env, ...config.environment },
|
|
95
|
+
stdio: 'inherit',
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
shell: false,
|
|
98
|
+
});
|
|
99
|
+
appProcess.once('error', (error) => {
|
|
100
|
+
reportFailure('app_start_failed', error);
|
|
101
|
+
void close('app_start_failed', true);
|
|
102
|
+
});
|
|
103
|
+
appProcess.once('exit', (code, signal) => {
|
|
104
|
+
if (!closing) {
|
|
105
|
+
reportFailure('app_exited', `code=${code ?? 'null'}, signal=${signal ?? 'none'}`);
|
|
106
|
+
void close('app_exited', true);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
await waitForHealth();
|
|
110
|
+
gateway = await startGateway(config);
|
|
111
|
+
writeState('ready');
|
|
112
|
+
} catch (error) {
|
|
113
|
+
reportFailure('startup_failed', error);
|
|
114
|
+
await close('startup_failed', true);
|
|
115
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
15
|
+
import { deploymentPaths } from './deploy.mjs';
|
|
16
|
+
import { ipCertificateAutomationPaths } from './ip-certificate.mjs';
|
|
17
|
+
|
|
18
|
+
const caddyImport = 'import /etc/caddy/chat-hub.caddy';
|
|
19
|
+
|
|
20
|
+
export const purgeConfirmationPath = '/etc/chat-hub/purge-confirmation.json';
|
|
21
|
+
|
|
22
|
+
export function stageUninstall({ rootDirectory = '/' } = {}) {
|
|
23
|
+
const outputPath = (absolutePath) => rootedPath(rootDirectory, absolutePath);
|
|
24
|
+
const managedFiles = [
|
|
25
|
+
deploymentPaths.service,
|
|
26
|
+
deploymentPaths.caddySnippet,
|
|
27
|
+
deploymentPaths.runtimeConfig,
|
|
28
|
+
deploymentPaths.manifest,
|
|
29
|
+
ipCertificateAutomationPaths.service,
|
|
30
|
+
ipCertificateAutomationPaths.timer,
|
|
31
|
+
ipCertificateAutomationPaths.deployHook,
|
|
32
|
+
ipCertificateAutomationPaths.renewRunner,
|
|
33
|
+
];
|
|
34
|
+
const managedDirectories = [ipCertificateAutomationPaths.publishedDirectory];
|
|
35
|
+
const previous = new Map();
|
|
36
|
+
for (const absolutePath of [...managedFiles, deploymentPaths.caddyfile]) {
|
|
37
|
+
const target = outputPath(absolutePath);
|
|
38
|
+
previous.set(
|
|
39
|
+
absolutePath,
|
|
40
|
+
existsSync(target)
|
|
41
|
+
? { existed: true, content: readFileSync(target), mode: statSync(target).mode & 0o777 }
|
|
42
|
+
: { existed: false },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const quarantinedDirectories = new Map();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
for (const absolutePath of managedFiles) {
|
|
49
|
+
const target = outputPath(absolutePath);
|
|
50
|
+
if (existsSync(target)) unlinkSync(target);
|
|
51
|
+
}
|
|
52
|
+
for (const absolutePath of managedDirectories) {
|
|
53
|
+
const target = outputPath(absolutePath);
|
|
54
|
+
if (!existsSync(target)) continue;
|
|
55
|
+
const quarantine = `${target}.uninstall-${randomBytes(12).toString('hex')}`;
|
|
56
|
+
renameSync(target, quarantine);
|
|
57
|
+
quarantinedDirectories.set(target, quarantine);
|
|
58
|
+
}
|
|
59
|
+
const caddyfile = outputPath(deploymentPaths.caddyfile);
|
|
60
|
+
if (existsSync(caddyfile)) {
|
|
61
|
+
const filtered = readFileSync(caddyfile, 'utf8')
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.filter((line) => line.trim() !== caddyImport)
|
|
64
|
+
.join('\n');
|
|
65
|
+
writeAtomic(caddyfile, filtered ? `${filtered.replace(/\n+$/, '')}\n` : '', 0o644);
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
restoreDirectories(quarantinedDirectories);
|
|
69
|
+
restoreFiles(previous, outputPath);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let finalized = false;
|
|
74
|
+
return {
|
|
75
|
+
removedFiles: [...managedFiles, ...managedDirectories].map(outputPath),
|
|
76
|
+
preservedPaths: [
|
|
77
|
+
outputPath(deploymentPaths.data),
|
|
78
|
+
outputPath(deploymentPaths.credentials),
|
|
79
|
+
outputPath(deploymentPaths.backups),
|
|
80
|
+
],
|
|
81
|
+
rollback() {
|
|
82
|
+
if (finalized) return;
|
|
83
|
+
restoreDirectories(quarantinedDirectories);
|
|
84
|
+
restoreFiles(previous, outputPath);
|
|
85
|
+
finalized = true;
|
|
86
|
+
},
|
|
87
|
+
commit() {
|
|
88
|
+
for (const quarantine of quarantinedDirectories.values()) {
|
|
89
|
+
if (existsSync(quarantine)) rmSync(quarantine, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
finalized = true;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function restoreDirectories(quarantinedDirectories) {
|
|
97
|
+
for (const [target, quarantine] of [...quarantinedDirectories.entries()].reverse()) {
|
|
98
|
+
if (existsSync(quarantine)) renameSync(quarantine, target);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createPurgeConfirmation({
|
|
103
|
+
rootDirectory = '/',
|
|
104
|
+
now = Date.now(),
|
|
105
|
+
token = randomBytes(24).toString('base64url'),
|
|
106
|
+
} = {}) {
|
|
107
|
+
if (typeof token !== 'string' || token.length < 24 || /[\r\n\0]/.test(token)) {
|
|
108
|
+
throw new Error('purge 确认令牌强度不足');
|
|
109
|
+
}
|
|
110
|
+
const confirmation = rootedPath(rootDirectory, purgeConfirmationPath);
|
|
111
|
+
const configuration = rootedPath(rootDirectory, deploymentPaths.configuration);
|
|
112
|
+
mkdirSync(configuration, { recursive: true, mode: 0o700 });
|
|
113
|
+
chmodSync(configuration, 0o700);
|
|
114
|
+
const expiresAt = now + 10 * 60_000;
|
|
115
|
+
const paths = purgeTargets(rootDirectory);
|
|
116
|
+
writeAtomic(
|
|
117
|
+
confirmation,
|
|
118
|
+
`${JSON.stringify({ version: 1, token, createdAt: now, expiresAt, paths })}\n`,
|
|
119
|
+
0o600,
|
|
120
|
+
);
|
|
121
|
+
return { token, expiresAt, confirmationPath: confirmation, paths };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function consumePurgeConfirmation({ token, rootDirectory = '/', now = Date.now() }) {
|
|
125
|
+
const validated = validatePurgeConfirmation({ token, rootDirectory, now });
|
|
126
|
+
const { confirmation, expectedPaths } = validated;
|
|
127
|
+
// Consume first so an interrupted command cannot be replayed. Every target is a fixed,
|
|
128
|
+
// validated Chat Hub path; no caller-controlled filesystem path reaches rmSync.
|
|
129
|
+
unlinkSync(confirmation);
|
|
130
|
+
for (const path of expectedPaths) {
|
|
131
|
+
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
return { purged: true, removedPaths: expectedPaths };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function validatePurgeConfirmation({ token, rootDirectory = '/', now = Date.now() }) {
|
|
137
|
+
const confirmation = rootedPath(rootDirectory, purgeConfirmationPath);
|
|
138
|
+
if (!existsSync(confirmation) || lstatSync(confirmation).isSymbolicLink()) {
|
|
139
|
+
throw new Error('没有待确认的 purge 请求,请先运行 chat-hub purge');
|
|
140
|
+
}
|
|
141
|
+
const value = JSON.parse(readFileSync(confirmation, 'utf8'));
|
|
142
|
+
const expectedPaths = purgeTargets(rootDirectory);
|
|
143
|
+
if (
|
|
144
|
+
!value ||
|
|
145
|
+
value.version !== 1 ||
|
|
146
|
+
typeof value.token !== 'string' ||
|
|
147
|
+
!Number.isFinite(value.expiresAt) ||
|
|
148
|
+
JSON.stringify(value.paths) !== JSON.stringify(expectedPaths)
|
|
149
|
+
) {
|
|
150
|
+
throw new Error('purge 确认文件无效');
|
|
151
|
+
}
|
|
152
|
+
if (now > value.expiresAt) {
|
|
153
|
+
unlinkSync(confirmation);
|
|
154
|
+
throw new Error('purge 确认已过期,请重新运行 chat-hub purge');
|
|
155
|
+
}
|
|
156
|
+
if (!safeEqual(value.token, token)) throw new Error('purge 确认令牌不匹配');
|
|
157
|
+
return { confirmation, expectedPaths };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function purgeTargets(rootDirectory) {
|
|
161
|
+
return [
|
|
162
|
+
rootedPath(rootDirectory, deploymentPaths.data),
|
|
163
|
+
rootedPath(rootDirectory, deploymentPaths.backups),
|
|
164
|
+
rootedPath(rootDirectory, deploymentPaths.configuration),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function safeEqual(left, right) {
|
|
169
|
+
if (typeof right !== 'string') return false;
|
|
170
|
+
const leftBuffer = Buffer.from(left);
|
|
171
|
+
const rightBuffer = Buffer.from(right);
|
|
172
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function writeAtomic(path, content, mode) {
|
|
176
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
177
|
+
writeFileSync(temporary, content, { mode });
|
|
178
|
+
chmodSync(temporary, mode);
|
|
179
|
+
renameSync(temporary, path);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function restoreFiles(previous, outputPath) {
|
|
183
|
+
for (const [absolutePath, state] of [...previous.entries()].reverse()) {
|
|
184
|
+
const target = outputPath(absolutePath);
|
|
185
|
+
if (!state.existed) {
|
|
186
|
+
if (existsSync(target)) unlinkSync(target);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
190
|
+
writeFileSync(target, state.content, { mode: state.mode });
|
|
191
|
+
chmodSync(target, state.mode);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function rootedPath(rootDirectory, absolutePath) {
|
|
196
|
+
const root = resolve(rootDirectory);
|
|
197
|
+
if (!isAbsolute(rootDirectory) || !isAbsolute(absolutePath)) {
|
|
198
|
+
throw new Error('服务器维护路径必须是绝对路径');
|
|
199
|
+
}
|
|
200
|
+
if (root === resolve('/')) return absolutePath;
|
|
201
|
+
const target = resolve(root, absolutePath.replace(/^[/\\]+/, ''));
|
|
202
|
+
const pathFromRoot = relative(root, target);
|
|
203
|
+
if (pathFromRoot.startsWith('..') || isAbsolute(pathFromRoot)) {
|
|
204
|
+
throw new Error('服务器维护目标越出测试根目录');
|
|
205
|
+
}
|
|
206
|
+
return target;
|
|
207
|
+
}
|