@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,203 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
16
+ import { deploymentPaths } from './deploy.mjs';
17
+
18
+ export function createStoppedBackup({
19
+ rootDirectory = '/',
20
+ kind = 'manual',
21
+ now = Date.now(),
22
+ } = {}) {
23
+ if (!['manual', 'upgrade', 'restore-safety'].includes(kind)) {
24
+ throw new Error('备份类型无效');
25
+ }
26
+ const dataDirectory = rootedPath(rootDirectory, deploymentPaths.data);
27
+ const backupRoot = rootedPath(rootDirectory, deploymentPaths.backups);
28
+ if (!existsSync(dataDirectory) || !statSync(dataDirectory).isDirectory()) {
29
+ throw new Error('Chat Hub 数据目录不存在');
30
+ }
31
+ assertNoSymbolicLinks(dataDirectory);
32
+ mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
33
+ chmodSync(backupRoot, 0o700);
34
+ const stamp = new Date(now).toISOString().replaceAll(':', '').replaceAll('.', '-');
35
+ const backupDirectory = join(backupRoot, `${kind}-${stamp}-${randomUUID().slice(0, 8)}`);
36
+ mkdirSync(backupDirectory, { mode: 0o700 });
37
+ const copiedData = join(backupDirectory, 'data');
38
+ try {
39
+ cpSync(dataDirectory, copiedData, { recursive: true, errorOnExist: true, force: false });
40
+ const databasePath = join(copiedData, 'chat-hub.sqlite');
41
+ const manifest = {
42
+ version: 1,
43
+ kind,
44
+ createdAt: new Date(now).toISOString(),
45
+ sourceDataDirectory: deploymentPaths.data,
46
+ database: existsSync(databasePath)
47
+ ? { relativePath: 'data/chat-hub.sqlite', sha256: sha256File(databasePath) }
48
+ : null,
49
+ includesMasterKey: false,
50
+ };
51
+ writeFileSync(
52
+ join(backupDirectory, 'manifest.json'),
53
+ `${JSON.stringify(manifest, null, 2)}\n`,
54
+ {
55
+ mode: 0o600,
56
+ },
57
+ );
58
+ return { backupDirectory, manifest };
59
+ } catch (error) {
60
+ rmSync(backupDirectory, { recursive: true, force: true });
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export function validateBackup(backup, rootDirectory = '/') {
66
+ const backupRoot = rootedPath(rootDirectory, deploymentPaths.backups);
67
+ const requested = isAbsolute(backup) ? resolve(backup) : resolve(backupRoot, backup);
68
+ const pathFromRoot = relative(backupRoot, requested);
69
+ if (
70
+ !pathFromRoot ||
71
+ pathFromRoot.startsWith('..') ||
72
+ isAbsolute(pathFromRoot) ||
73
+ pathFromRoot.includes('/') ||
74
+ pathFromRoot.includes('\\')
75
+ ) {
76
+ throw new Error('恢复目标必须是备份目录中的一个直接子目录');
77
+ }
78
+ const manifestPath = join(requested, 'manifest.json');
79
+ const dataDirectory = join(requested, 'data');
80
+ if (!existsSync(manifestPath) || !existsSync(dataDirectory)) throw new Error('备份内容不完整');
81
+ assertNoSymbolicLinks(requested);
82
+ const manifest = parseManifest(readFileSync(manifestPath, 'utf8'));
83
+ if (manifest.database) {
84
+ const databasePath = join(requested, manifest.database.relativePath);
85
+ if (!existsSync(databasePath) || sha256File(databasePath) !== manifest.database.sha256) {
86
+ throw new Error('备份数据库校验和不匹配');
87
+ }
88
+ const header = readFileSync(databasePath).subarray(0, 16).toString('utf8');
89
+ if (header !== 'SQLite format 3\0') throw new Error('备份数据库不是有效的 SQLite 文件');
90
+ }
91
+ return { backupDirectory: requested, dataDirectory, manifest };
92
+ }
93
+
94
+ export function stageRestore({ backup, rootDirectory = '/' }) {
95
+ const validated = validateBackup(backup, rootDirectory);
96
+ const dataDirectory = rootedPath(rootDirectory, deploymentPaths.data);
97
+ const parent = dirname(dataDirectory);
98
+ const suffix = randomUUID();
99
+ const staged = join(parent, `.chat-hub.restore-${suffix}`);
100
+ const previous = join(parent, `.chat-hub.previous-${suffix}`);
101
+ if (!existsSync(dataDirectory)) throw new Error('当前 Chat Hub 数据目录不存在');
102
+ cpSync(validated.dataDirectory, staged, { recursive: true, errorOnExist: true, force: false });
103
+ try {
104
+ renameSync(dataDirectory, previous);
105
+ renameSync(staged, dataDirectory);
106
+ } catch (error) {
107
+ if (existsSync(previous) && !existsSync(dataDirectory)) renameSync(previous, dataDirectory);
108
+ rmSync(staged, { recursive: true, force: true });
109
+ throw error;
110
+ }
111
+ let finalized = false;
112
+ return {
113
+ backupDirectory: validated.backupDirectory,
114
+ dataDirectory,
115
+ rollback() {
116
+ if (finalized) return;
117
+ const failed = join(parent, `.chat-hub.failed-${suffix}`);
118
+ if (existsSync(dataDirectory)) renameSync(dataDirectory, failed);
119
+ if (existsSync(previous)) renameSync(previous, dataDirectory);
120
+ rmSync(failed, { recursive: true, force: true });
121
+ rmSync(staged, { recursive: true, force: true });
122
+ finalized = true;
123
+ },
124
+ commit() {
125
+ if (finalized) return;
126
+ rmSync(previous, { recursive: true, force: true });
127
+ rmSync(staged, { recursive: true, force: true });
128
+ finalized = true;
129
+ },
130
+ };
131
+ }
132
+
133
+ export function pruneUpgradeBackups(rootDirectory = '/', retain = 3) {
134
+ if (!Number.isInteger(retain) || retain < 1) throw new Error('升级备份保留数量无效');
135
+ const backupRoot = rootedPath(rootDirectory, deploymentPaths.backups);
136
+ if (!existsSync(backupRoot)) return [];
137
+ const candidates = readdirSync(backupRoot, { withFileTypes: true })
138
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('upgrade-'))
139
+ .map((entry) => ({
140
+ name: entry.name,
141
+ path: join(backupRoot, entry.name),
142
+ modifiedAt: statSync(join(backupRoot, entry.name)).mtimeMs,
143
+ }))
144
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
145
+ const removed = [];
146
+ for (const entry of candidates.slice(retain)) {
147
+ const checked = validateBackup(entry.name, rootDirectory);
148
+ rmSync(checked.backupDirectory, { recursive: true, force: true });
149
+ removed.push(entry.path);
150
+ }
151
+ return removed;
152
+ }
153
+
154
+ function parseManifest(serialized) {
155
+ const value = JSON.parse(serialized);
156
+ if (!value || typeof value !== 'object' || value.version !== 1) {
157
+ throw new Error('备份清单版本无效');
158
+ }
159
+ if (!['manual', 'upgrade', 'restore-safety'].includes(value.kind)) {
160
+ throw new Error('备份清单类型无效');
161
+ }
162
+ if (typeof value.createdAt !== 'string' || value.includesMasterKey !== false) {
163
+ throw new Error('备份清单字段无效');
164
+ }
165
+ if (
166
+ value.database !== null &&
167
+ (!value.database ||
168
+ value.database.relativePath !== 'data/chat-hub.sqlite' ||
169
+ typeof value.database.sha256 !== 'string' ||
170
+ !/^[a-f0-9]{64}$/.test(value.database.sha256))
171
+ ) {
172
+ throw new Error('备份数据库清单无效');
173
+ }
174
+ return value;
175
+ }
176
+
177
+ function sha256File(path) {
178
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
179
+ }
180
+
181
+ function assertNoSymbolicLinks(directory) {
182
+ const queue = [directory];
183
+ while (queue.length > 0) {
184
+ const current = queue.pop();
185
+ const metadata = lstatSync(current);
186
+ if (metadata.isSymbolicLink())
187
+ throw new Error(`数据或备份中不允许符号链接:${basename(current)}`);
188
+ if (!metadata.isDirectory()) continue;
189
+ for (const entry of readdirSync(current)) queue.push(join(current, entry));
190
+ }
191
+ }
192
+
193
+ function rootedPath(rootDirectory, absolutePath) {
194
+ const root = resolve(rootDirectory);
195
+ if (!isAbsolute(absolutePath)) throw new Error('生命周期目标路径必须是绝对路径');
196
+ if (root === resolve('/')) return absolutePath;
197
+ const target = resolve(root, absolutePath.replace(/^[/\\]+/, ''));
198
+ const pathFromRoot = relative(root, target);
199
+ if (pathFromRoot.startsWith('..') || isAbsolute(pathFromRoot)) {
200
+ throw new Error('生命周期目标越出测试根目录');
201
+ }
202
+ return target;
203
+ }