@musnows/scriverse 0.6.8 → 0.6.10
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/ai-tool-results.js +13 -1
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +76 -27
- package/dist/ai.js.map +1 -1
- package/dist/app.js +94 -6
- package/dist/app.js.map +1 -1
- package/dist/database.js +295 -5
- package/dist/database.js.map +1 -1
- package/dist/hybrid-search.js +2 -1
- package/dist/hybrid-search.js.map +1 -1
- package/dist/logger.js +2 -2
- package/dist/logger.js.map +1 -1
- package/dist/public/app.js +368 -15
- package/dist/public/display-labels.js +2 -1
- package/dist/public/global-search.d.ts +3 -1
- package/dist/public/global-search.js +31 -0
- package/dist/public/index.html +48 -3
- package/dist/public/s3-backup-ui.d.ts +17 -0
- package/dist/public/s3-backup-ui.js +28 -0
- package/dist/public/styles.css +86 -2
- package/dist/s3-backup.js +654 -0
- package/dist/s3-backup.js.map +1 -0
- package/dist/security.js +25 -0
- package/dist/security.js.map +1 -1
- package/dist/store.js +74 -15
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +6 -2
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DeleteObjectsCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
4
|
+
import { AppError } from "./errors.js";
|
|
5
|
+
import { logger, sanitizeError } from "./logger.js";
|
|
6
|
+
const sensitiveResponseKey = /(?:authorization|credential|accesskey|secret|securitytoken|set-cookie)/iu;
|
|
7
|
+
function redactedString(value, secrets) {
|
|
8
|
+
let result = value;
|
|
9
|
+
for (const secret of secrets) {
|
|
10
|
+
if (secret)
|
|
11
|
+
result = result.replaceAll(secret, "[REDACTED]");
|
|
12
|
+
}
|
|
13
|
+
return result.slice(0, 20_000);
|
|
14
|
+
}
|
|
15
|
+
function serializableValue(value, secrets, key = "", depth = 0, seen = new WeakSet()) {
|
|
16
|
+
if (sensitiveResponseKey.test(key))
|
|
17
|
+
return "[REDACTED]";
|
|
18
|
+
if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean")
|
|
19
|
+
return value;
|
|
20
|
+
if (typeof value === "string")
|
|
21
|
+
return redactedString(value, secrets);
|
|
22
|
+
if (typeof value === "bigint")
|
|
23
|
+
return value.toString();
|
|
24
|
+
if (value instanceof Date)
|
|
25
|
+
return value.toISOString();
|
|
26
|
+
if (Buffer.isBuffer(value))
|
|
27
|
+
return { byteLength: value.byteLength };
|
|
28
|
+
if (typeof value !== "object")
|
|
29
|
+
return String(value);
|
|
30
|
+
if (seen.has(value))
|
|
31
|
+
return "[CIRCULAR]";
|
|
32
|
+
if (depth >= 6)
|
|
33
|
+
return "[TRUNCATED]";
|
|
34
|
+
seen.add(value);
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return value.slice(0, 1_000).map((item) => serializableValue(item, secrets, "", depth + 1, seen));
|
|
37
|
+
return Object.fromEntries(Object.entries(value)
|
|
38
|
+
.filter(([entryKey]) => entryKey !== "$response" && entryKey !== "stack")
|
|
39
|
+
.slice(0, 1_000)
|
|
40
|
+
.map(([entryKey, entryValue]) => [entryKey, serializableValue(entryValue, secrets, entryKey, depth + 1, seen)]));
|
|
41
|
+
}
|
|
42
|
+
export function s3FailureServerResponse(error, secrets = []) {
|
|
43
|
+
const result = error instanceof Error
|
|
44
|
+
? { name: error.name, message: redactedString(error.message, secrets) }
|
|
45
|
+
: { message: redactedString(String(error), secrets) };
|
|
46
|
+
if (!error || typeof error !== "object")
|
|
47
|
+
return result;
|
|
48
|
+
const record = error;
|
|
49
|
+
for (const [key, value] of Object.entries(record)) {
|
|
50
|
+
if (key === "$response" || key === "stack")
|
|
51
|
+
continue;
|
|
52
|
+
result[key] = serializableValue(value, secrets, key);
|
|
53
|
+
}
|
|
54
|
+
const response = record.$response;
|
|
55
|
+
if (response && typeof response === "object") {
|
|
56
|
+
const responseRecord = response;
|
|
57
|
+
result.httpResponse = {
|
|
58
|
+
statusCode: serializableValue(responseRecord.statusCode, secrets, "statusCode"),
|
|
59
|
+
statusMessage: serializableValue(responseRecord.statusMessage, secrets, "statusMessage"),
|
|
60
|
+
headers: serializableValue(responseRecord.headers, secrets, "headers")
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
class AwsS3ObjectClient {
|
|
66
|
+
client;
|
|
67
|
+
constructor(connection) {
|
|
68
|
+
this.client = new S3Client({
|
|
69
|
+
endpoint: connection.endpoint,
|
|
70
|
+
region: connection.region,
|
|
71
|
+
credentials: {
|
|
72
|
+
accessKeyId: connection.accessKeyId,
|
|
73
|
+
secretAccessKey: connection.secretAccessKey
|
|
74
|
+
},
|
|
75
|
+
forcePathStyle: connection.forcePathStyle,
|
|
76
|
+
maxAttempts: 3,
|
|
77
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
78
|
+
responseChecksumValidation: "WHEN_REQUIRED"
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async objectExists(bucket, key) {
|
|
82
|
+
try {
|
|
83
|
+
await this.client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const candidate = error;
|
|
88
|
+
if (candidate.$metadata?.httpStatusCode === 404 || candidate.name === "NotFound" || candidate.name === "NoSuchKey")
|
|
89
|
+
return false;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async putObject(input) {
|
|
94
|
+
await this.client.send(new PutObjectCommand({
|
|
95
|
+
Bucket: input.bucket,
|
|
96
|
+
Key: input.key,
|
|
97
|
+
Body: input.body,
|
|
98
|
+
ContentLength: input.body.byteLength,
|
|
99
|
+
ContentType: input.contentType,
|
|
100
|
+
Metadata: input.metadata
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
async listObjects(bucket, prefix) {
|
|
104
|
+
const objects = [];
|
|
105
|
+
let continuationToken;
|
|
106
|
+
do {
|
|
107
|
+
const page = await this.client.send(new ListObjectsV2Command({
|
|
108
|
+
Bucket: bucket,
|
|
109
|
+
Prefix: prefix,
|
|
110
|
+
ContinuationToken: continuationToken
|
|
111
|
+
}));
|
|
112
|
+
for (const item of page.Contents ?? []) {
|
|
113
|
+
if (!item.Key)
|
|
114
|
+
continue;
|
|
115
|
+
objects.push({ key: item.Key, lastModified: item.LastModified ?? null });
|
|
116
|
+
}
|
|
117
|
+
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
|
|
118
|
+
} while (continuationToken);
|
|
119
|
+
return objects;
|
|
120
|
+
}
|
|
121
|
+
async deleteObjects(bucket, keys) {
|
|
122
|
+
for (let offset = 0; offset < keys.length; offset += 1_000) {
|
|
123
|
+
const batch = keys.slice(offset, offset + 1_000);
|
|
124
|
+
if (!batch.length)
|
|
125
|
+
continue;
|
|
126
|
+
const result = await this.client.send(new DeleteObjectsCommand({
|
|
127
|
+
Bucket: bucket,
|
|
128
|
+
Delete: { Objects: batch.map((Key) => ({ Key })), Quiet: true }
|
|
129
|
+
}));
|
|
130
|
+
if (result.Errors?.length) {
|
|
131
|
+
throw new AppError(502, "S3_DELETE_OBJECTS_FAILED", "S3 服务未能删除部分过期数据库备份", {
|
|
132
|
+
errors: result.Errors.map((item) => ({ key: item.Key, code: item.Code, message: item.Message }))
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
close() {
|
|
138
|
+
this.client.destroy();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function requiredString(row, key) {
|
|
142
|
+
const value = row[key];
|
|
143
|
+
if (typeof value !== "string")
|
|
144
|
+
throw new AppError(500, "BACKUP_TARGET_INVALID", `S3 备份配置字段 ${key} 无效`);
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
function nullableString(row, key) {
|
|
148
|
+
const value = row[key];
|
|
149
|
+
return typeof value === "string" ? value : null;
|
|
150
|
+
}
|
|
151
|
+
function encryptedSecret(row, prefix) {
|
|
152
|
+
return {
|
|
153
|
+
encrypted: requiredString(row, `${prefix}_encrypted`),
|
|
154
|
+
iv: requiredString(row, `${prefix}_iv`),
|
|
155
|
+
tag: requiredString(row, `${prefix}_tag`)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function normalizeS3BasePath(value = "") {
|
|
159
|
+
return value.trim().replace(/^\/+|\/+$/gu, "").replace(/\/{2,}/gu, "/");
|
|
160
|
+
}
|
|
161
|
+
export function s3BackupRootPrefix(basePath = "") {
|
|
162
|
+
const normalized = normalizeS3BasePath(basePath);
|
|
163
|
+
return normalized ? `${normalized}/scriverse` : "scriverse";
|
|
164
|
+
}
|
|
165
|
+
function normalizedEndpoint(value) {
|
|
166
|
+
const url = new URL(value);
|
|
167
|
+
url.pathname = url.pathname.replace(/\/+$/gu, "") || "/";
|
|
168
|
+
return url.toString().replace(/\/$/u, "");
|
|
169
|
+
}
|
|
170
|
+
export class S3BackupManager {
|
|
171
|
+
database;
|
|
172
|
+
vault;
|
|
173
|
+
store;
|
|
174
|
+
attachmentStorage;
|
|
175
|
+
clientFactory;
|
|
176
|
+
snapshotDatabase;
|
|
177
|
+
masterKey;
|
|
178
|
+
requestTimeoutMs;
|
|
179
|
+
validateEndpoint;
|
|
180
|
+
now;
|
|
181
|
+
log;
|
|
182
|
+
queuedTargetIds = new Set();
|
|
183
|
+
executionChain = Promise.resolve();
|
|
184
|
+
schedulerTimer = null;
|
|
185
|
+
disposed = false;
|
|
186
|
+
constructor(database, vault, store, attachmentStorage, options = {}) {
|
|
187
|
+
this.database = database;
|
|
188
|
+
this.vault = vault;
|
|
189
|
+
this.store = store;
|
|
190
|
+
this.attachmentStorage = attachmentStorage;
|
|
191
|
+
this.clientFactory = options.clientFactory ?? ((connection) => new AwsS3ObjectClient(connection));
|
|
192
|
+
this.snapshotDatabase = options.snapshotDatabase ?? (() => this.database.createSnapshotBuffer());
|
|
193
|
+
this.masterKey = options.masterKey === undefined
|
|
194
|
+
? null
|
|
195
|
+
: Buffer.isBuffer(options.masterKey) ? Buffer.from(options.masterKey) : Buffer.from(options.masterKey, "utf8");
|
|
196
|
+
const requestTimeoutMs = Number(options.requestTimeoutMs ?? 30_000);
|
|
197
|
+
this.requestTimeoutMs = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 30_000;
|
|
198
|
+
this.validateEndpoint = options.validateEndpoint;
|
|
199
|
+
this.now = options.now ?? (() => new Date());
|
|
200
|
+
this.log = options.logger ?? logger;
|
|
201
|
+
}
|
|
202
|
+
listTargets() {
|
|
203
|
+
return this.database.all("SELECT * FROM s3_backup_targets ORDER BY sort_order, created_at, id").map((row) => this.mapTarget(row));
|
|
204
|
+
}
|
|
205
|
+
getTarget(targetId) {
|
|
206
|
+
return this.mapTarget(this.requireTargetRow(targetId));
|
|
207
|
+
}
|
|
208
|
+
createTarget(input) {
|
|
209
|
+
const targetId = randomUUID();
|
|
210
|
+
const timestamp = this.now().toISOString();
|
|
211
|
+
const accessKey = this.vault.encrypt(input.accessKeyId);
|
|
212
|
+
const secretKey = this.vault.encrypt(input.secretAccessKey);
|
|
213
|
+
this.database.transaction(() => {
|
|
214
|
+
const sortOrder = Number(this.database.get("SELECT COALESCE(MAX(sort_order), -1) + 1 AS value FROM s3_backup_targets")?.value ?? 0);
|
|
215
|
+
this.database.run(`INSERT INTO s3_backup_targets (
|
|
216
|
+
id, name, endpoint, region, bucket, base_path,
|
|
217
|
+
access_key_encrypted, access_key_iv, access_key_tag,
|
|
218
|
+
secret_key_encrypted, secret_key_iv, secret_key_tag,
|
|
219
|
+
force_path_style, enabled, backup_images, schedule_time, retention_count, sort_order,
|
|
220
|
+
created_at, updated_at
|
|
221
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, targetId, input.name.trim(), normalizedEndpoint(input.endpoint), input.region?.trim() || "us-east-1", input.bucket.trim(), normalizeS3BasePath(input.basePath), accessKey.encrypted, accessKey.iv, accessKey.tag, secretKey.encrypted, secretKey.iv, secretKey.tag, input.forcePathStyle === false ? 0 : 1, input.enabled === true ? 1 : 0, input.backupImages === false ? 0 : 1, input.scheduleTime ?? "03:00", input.retentionCount ?? 7, sortOrder, timestamp, timestamp);
|
|
222
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-target.created", "s3-backup-target", targetId, {
|
|
223
|
+
...this.sanitizedInput(input),
|
|
224
|
+
rootPrefix: s3BackupRootPrefix(input.basePath)
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
return this.getTarget(targetId);
|
|
228
|
+
}
|
|
229
|
+
updateTarget(targetId, input) {
|
|
230
|
+
const current = this.requireTargetRow(targetId);
|
|
231
|
+
const accessKey = input.accessKeyId === undefined ? encryptedSecret(current, "access_key") : this.vault.encrypt(input.accessKeyId);
|
|
232
|
+
const secretKey = input.secretAccessKey === undefined ? encryptedSecret(current, "secret_key") : this.vault.encrypt(input.secretAccessKey);
|
|
233
|
+
const timestamp = this.now().toISOString();
|
|
234
|
+
this.database.transaction(() => {
|
|
235
|
+
this.database.run(`UPDATE s3_backup_targets SET
|
|
236
|
+
name = ?, endpoint = ?, region = ?, bucket = ?, base_path = ?,
|
|
237
|
+
access_key_encrypted = ?, access_key_iv = ?, access_key_tag = ?,
|
|
238
|
+
secret_key_encrypted = ?, secret_key_iv = ?, secret_key_tag = ?,
|
|
239
|
+
force_path_style = ?, enabled = ?, backup_images = ?, schedule_time = ?, retention_count = ?, updated_at = ?
|
|
240
|
+
WHERE id = ?`, input.name?.trim() ?? requiredString(current, "name"), input.endpoint === undefined ? requiredString(current, "endpoint") : normalizedEndpoint(input.endpoint), input.region?.trim() ?? requiredString(current, "region"), input.bucket?.trim() ?? requiredString(current, "bucket"), input.basePath === undefined ? requiredString(current, "base_path") : normalizeS3BasePath(input.basePath), accessKey.encrypted, accessKey.iv, accessKey.tag, secretKey.encrypted, secretKey.iv, secretKey.tag, input.forcePathStyle === undefined ? Number(current.force_path_style) : input.forcePathStyle ? 1 : 0, input.enabled === undefined ? Number(current.enabled) : input.enabled ? 1 : 0, input.backupImages === undefined ? Number(current.backup_images) : input.backupImages ? 1 : 0, input.scheduleTime ?? requiredString(current, "schedule_time"), input.retentionCount ?? Number(current.retention_count), timestamp, targetId);
|
|
241
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-target.updated", "s3-backup-target", targetId, {
|
|
242
|
+
fields: Object.keys(input).filter((key) => key !== "accessKeyId" && key !== "secretAccessKey"),
|
|
243
|
+
credentialsUpdated: input.accessKeyId !== undefined || input.secretAccessKey !== undefined
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
return this.getTarget(targetId);
|
|
247
|
+
}
|
|
248
|
+
deleteTarget(targetId) {
|
|
249
|
+
const current = this.requireTargetRow(targetId);
|
|
250
|
+
this.database.transaction(() => {
|
|
251
|
+
this.database.run("DELETE FROM s3_backup_targets WHERE id = ?", targetId);
|
|
252
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-target.deleted", "s3-backup-target", targetId, {
|
|
253
|
+
name: requiredString(current, "name"),
|
|
254
|
+
endpoint: requiredString(current, "endpoint"),
|
|
255
|
+
bucket: requiredString(current, "bucket"),
|
|
256
|
+
basePath: requiredString(current, "base_path")
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
getRun(runId) {
|
|
261
|
+
const row = this.database.get("SELECT rowid AS sequence, * FROM s3_backup_runs WHERE id = ?", runId);
|
|
262
|
+
if (!row)
|
|
263
|
+
throw new AppError(404, "BACKUP_RUN_NOT_FOUND", "S3 备份记录不存在");
|
|
264
|
+
return this.mapRun(row);
|
|
265
|
+
}
|
|
266
|
+
listRuns(options = {}) {
|
|
267
|
+
const limit = Math.min(100, Math.max(1, options.limit ?? 30));
|
|
268
|
+
const rows = options.afterSequence === undefined
|
|
269
|
+
? this.database.all("SELECT rowid AS sequence, * FROM s3_backup_runs ORDER BY rowid DESC LIMIT ?", limit)
|
|
270
|
+
: this.database.all("SELECT rowid AS sequence, * FROM s3_backup_runs WHERE rowid > ? ORDER BY rowid LIMIT ?", options.afterSequence, limit);
|
|
271
|
+
const latestSequence = Number(this.database.get("SELECT COALESCE(MAX(rowid), 0) AS value FROM s3_backup_runs")?.value ?? 0);
|
|
272
|
+
return { items: rows.map((row) => this.mapRun(row)), latestSequence };
|
|
273
|
+
}
|
|
274
|
+
enabledTargetIds() {
|
|
275
|
+
return this.database.all("SELECT id FROM s3_backup_targets WHERE enabled = 1 ORDER BY sort_order, created_at, id").map((row) => String(row.id));
|
|
276
|
+
}
|
|
277
|
+
enqueueTargets(targetIds, trigger) {
|
|
278
|
+
if (this.disposed)
|
|
279
|
+
throw new AppError(503, "BACKUP_MANAGER_STOPPED", "S3 备份服务正在停止");
|
|
280
|
+
const uniqueIds = [...new Set(targetIds)];
|
|
281
|
+
const targetRows = uniqueIds.map((targetId) => this.requireTargetRow(targetId));
|
|
282
|
+
const disabledTargetIds = trigger === "manual"
|
|
283
|
+
? targetRows.filter((row) => Number(row.enabled) !== 1).map((row) => requiredString(row, "id"))
|
|
284
|
+
: [];
|
|
285
|
+
if (disabledTargetIds.length > 0) {
|
|
286
|
+
throw new AppError(400, "BACKUP_TARGET_DISABLED", "停用的 S3 备份目标不能手动执行", { targetIds: disabledTargetIds });
|
|
287
|
+
}
|
|
288
|
+
const acceptedTargetIds = uniqueIds.filter((targetId) => !this.queuedTargetIds.has(targetId));
|
|
289
|
+
const skippedTargetIds = uniqueIds.filter((targetId) => this.queuedTargetIds.has(targetId));
|
|
290
|
+
for (const targetId of acceptedTargetIds)
|
|
291
|
+
this.queuedTargetIds.add(targetId);
|
|
292
|
+
if (acceptedTargetIds.length) {
|
|
293
|
+
const execute = async () => {
|
|
294
|
+
for (const targetId of acceptedTargetIds) {
|
|
295
|
+
try {
|
|
296
|
+
if (!this.disposed)
|
|
297
|
+
await this.runTarget(targetId, trigger);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
this.log.error("backup.queue.target_failed", { targetId, trigger, error: sanitizeError(error) });
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
this.queuedTargetIds.delete(targetId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
this.executionChain = this.executionChain.then(execute, execute).catch((error) => {
|
|
308
|
+
this.log.error("backup.queue.failed", { trigger, error: sanitizeError(error) });
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return { acceptedTargetIds, skippedTargetIds };
|
|
312
|
+
}
|
|
313
|
+
enqueueEnabledTargets(trigger) {
|
|
314
|
+
return this.enqueueTargets(this.enabledTargetIds(), trigger);
|
|
315
|
+
}
|
|
316
|
+
enqueueDueTargets(value = this.now()) {
|
|
317
|
+
const currentTime = `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
|
|
318
|
+
const dateKey = this.localDateKey(value);
|
|
319
|
+
const dueTargetIds = this.database.all(`SELECT target.id, target.schedule_time,
|
|
320
|
+
(SELECT run.started_at FROM s3_backup_runs run
|
|
321
|
+
WHERE run.target_id = target.id AND run.trigger = 'scheduled'
|
|
322
|
+
ORDER BY run.rowid DESC LIMIT 1) AS last_scheduled_at
|
|
323
|
+
FROM s3_backup_targets target
|
|
324
|
+
WHERE target.enabled = 1
|
|
325
|
+
ORDER BY target.sort_order, target.created_at, target.id`).filter((row) => {
|
|
326
|
+
if (requiredString(row, "schedule_time") > currentTime)
|
|
327
|
+
return false;
|
|
328
|
+
const lastScheduledAt = nullableString(row, "last_scheduled_at");
|
|
329
|
+
return !lastScheduledAt || this.localDateKey(new Date(lastScheduledAt)) !== dateKey;
|
|
330
|
+
}).map((row) => requiredString(row, "id"));
|
|
331
|
+
return this.enqueueTargets(dueTargetIds, "scheduled");
|
|
332
|
+
}
|
|
333
|
+
startScheduler() {
|
|
334
|
+
if (this.schedulerTimer || this.disposed)
|
|
335
|
+
return;
|
|
336
|
+
const poll = () => {
|
|
337
|
+
try {
|
|
338
|
+
this.enqueueDueTargets();
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
this.log.error("backup.scheduler.poll_failed", { error: sanitizeError(error) });
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
poll();
|
|
345
|
+
this.schedulerTimer = setInterval(poll, 30_000);
|
|
346
|
+
this.schedulerTimer.unref();
|
|
347
|
+
}
|
|
348
|
+
async waitForIdle() {
|
|
349
|
+
await this.executionChain;
|
|
350
|
+
}
|
|
351
|
+
dispose() {
|
|
352
|
+
this.disposed = true;
|
|
353
|
+
if (this.schedulerTimer)
|
|
354
|
+
clearInterval(this.schedulerTimer);
|
|
355
|
+
this.schedulerTimer = null;
|
|
356
|
+
}
|
|
357
|
+
async runEnabledTargets(trigger) {
|
|
358
|
+
const targetIds = this.database.all("SELECT id FROM s3_backup_targets WHERE enabled = 1 ORDER BY sort_order, created_at, id").map((row) => String(row.id));
|
|
359
|
+
const runs = [];
|
|
360
|
+
for (const targetId of targetIds)
|
|
361
|
+
runs.push(await this.runTarget(targetId, trigger));
|
|
362
|
+
return runs;
|
|
363
|
+
}
|
|
364
|
+
async runTarget(targetId, trigger) {
|
|
365
|
+
const targetRow = this.requireTargetRow(targetId);
|
|
366
|
+
const target = this.runtimeTarget(targetRow);
|
|
367
|
+
const runId = randomUUID();
|
|
368
|
+
const started = this.now();
|
|
369
|
+
const startedAt = started.toISOString();
|
|
370
|
+
this.database.transaction(() => {
|
|
371
|
+
this.database.run(`INSERT INTO s3_backup_runs (id, target_id, target_name, trigger, status, started_at)
|
|
372
|
+
VALUES (?, ?, ?, ?, 'running', ?)`, runId, target.id, target.name, trigger, startedAt);
|
|
373
|
+
this.database.run("UPDATE s3_backup_targets SET last_started_at = ?, last_error = NULL, updated_at = ? WHERE id = ?", startedAt, startedAt, target.id);
|
|
374
|
+
});
|
|
375
|
+
let client = null;
|
|
376
|
+
let databaseKey = null;
|
|
377
|
+
let imagesUploaded = 0;
|
|
378
|
+
let imagesSkipped = 0;
|
|
379
|
+
let databasesDeleted = 0;
|
|
380
|
+
try {
|
|
381
|
+
await this.validateEndpoint?.(target.endpoint);
|
|
382
|
+
client = this.clientFactory({
|
|
383
|
+
endpoint: target.endpoint,
|
|
384
|
+
region: target.region,
|
|
385
|
+
accessKeyId: target.accessKeyId,
|
|
386
|
+
secretAccessKey: target.secretAccessKey,
|
|
387
|
+
forcePathStyle: target.forcePathStyle
|
|
388
|
+
});
|
|
389
|
+
const databaseSnapshot = await this.snapshotDatabase();
|
|
390
|
+
databaseKey = `${target.rootPrefix}/db/scriverse-${this.snapshotTimestamp(started)}-${runId.replaceAll("-", "").slice(0, 8)}.db`;
|
|
391
|
+
if (!this.masterKey)
|
|
392
|
+
throw new AppError(500, "BACKUP_MASTER_KEY_UNAVAILABLE", "S3 备份缺少 CredentialVault 恢复密钥");
|
|
393
|
+
await this.withRequestTimeout(client.putObject({
|
|
394
|
+
bucket: target.bucket,
|
|
395
|
+
key: `${target.rootPrefix}/master.key`,
|
|
396
|
+
body: this.masterKey,
|
|
397
|
+
contentType: "application/octet-stream"
|
|
398
|
+
}), "上传 CredentialVault 恢复密钥");
|
|
399
|
+
await this.withRequestTimeout(client.putObject({
|
|
400
|
+
bucket: target.bucket,
|
|
401
|
+
key: databaseKey,
|
|
402
|
+
body: databaseSnapshot,
|
|
403
|
+
contentType: "application/vnd.sqlite3"
|
|
404
|
+
}), "上传数据库快照");
|
|
405
|
+
if (target.backupImages) {
|
|
406
|
+
for (const source of this.backupImageSources(target.rootPrefix)) {
|
|
407
|
+
if (await this.withRequestTimeout(client.objectExists(target.bucket, source.objectKey), "检查远端图片")) {
|
|
408
|
+
imagesSkipped += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const body = await source.read();
|
|
412
|
+
await this.withRequestTimeout(client.putObject({
|
|
413
|
+
bucket: target.bucket,
|
|
414
|
+
key: source.objectKey,
|
|
415
|
+
body,
|
|
416
|
+
contentType: source.contentType,
|
|
417
|
+
metadata: { sha256: source.sha256 }
|
|
418
|
+
}), "上传图片");
|
|
419
|
+
imagesUploaded += 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
databasesDeleted = await this.enforceDatabaseRetention(client, target);
|
|
423
|
+
const finishedAt = this.now().toISOString();
|
|
424
|
+
this.database.transaction(() => {
|
|
425
|
+
this.database.run(`UPDATE s3_backup_runs SET status = 'succeeded', database_key = ?, images_uploaded = ?, images_skipped = ?,
|
|
426
|
+
databases_deleted = ?, finished_at = ? WHERE id = ?`, databaseKey, imagesUploaded, imagesSkipped, databasesDeleted, finishedAt, runId);
|
|
427
|
+
this.database.run("UPDATE s3_backup_targets SET last_success_at = ?, last_error = NULL, updated_at = ? WHERE id = ?", finishedAt, finishedAt, target.id);
|
|
428
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup.succeeded", "s3-backup-target", target.id, {
|
|
429
|
+
runId,
|
|
430
|
+
trigger,
|
|
431
|
+
databaseKey,
|
|
432
|
+
imagesUploaded,
|
|
433
|
+
imagesSkipped,
|
|
434
|
+
databasesDeleted
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
this.log.info("backup.s3_target.succeeded", {
|
|
438
|
+
target: this.targetForLog(target),
|
|
439
|
+
runId,
|
|
440
|
+
trigger,
|
|
441
|
+
databaseKey,
|
|
442
|
+
imagesUploaded,
|
|
443
|
+
imagesSkipped,
|
|
444
|
+
databasesDeleted
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
const serverResponse = s3FailureServerResponse(error, [target.accessKeyId, target.secretAccessKey]);
|
|
449
|
+
const errorMessage = typeof serverResponse.message === "string" ? serverResponse.message : "S3 备份失败";
|
|
450
|
+
const finishedAt = this.now().toISOString();
|
|
451
|
+
this.database.transaction(() => {
|
|
452
|
+
this.database.run(`UPDATE s3_backup_runs SET status = 'failed', database_key = ?, images_uploaded = ?, images_skipped = ?,
|
|
453
|
+
databases_deleted = ?, error_message = ?, server_response_json = ?, finished_at = ? WHERE id = ?`, databaseKey, imagesUploaded, imagesSkipped, databasesDeleted, errorMessage, JSON.stringify(serverResponse), finishedAt, runId);
|
|
454
|
+
this.database.run("UPDATE s3_backup_targets SET last_failure_at = ?, last_error = ?, updated_at = ? WHERE id = ?", finishedAt, errorMessage, finishedAt, target.id);
|
|
455
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup.failed", "s3-backup-target", target.id, {
|
|
456
|
+
runId,
|
|
457
|
+
trigger,
|
|
458
|
+
databaseKey,
|
|
459
|
+
errorMessage
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
this.log.error("backup.s3_target.failed", {
|
|
463
|
+
target: this.targetForLog(target),
|
|
464
|
+
runId,
|
|
465
|
+
trigger,
|
|
466
|
+
databaseKey,
|
|
467
|
+
error: sanitizeError(new Error(errorMessage)),
|
|
468
|
+
serverResponse
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
try {
|
|
473
|
+
client?.close();
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
this.log.warn("backup.s3_client.close_failed", {
|
|
477
|
+
target: this.targetForLog(target),
|
|
478
|
+
runId,
|
|
479
|
+
error: sanitizeError(error)
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return this.getRun(runId);
|
|
484
|
+
}
|
|
485
|
+
requireTargetRow(targetId) {
|
|
486
|
+
const row = this.database.get("SELECT * FROM s3_backup_targets WHERE id = ?", targetId);
|
|
487
|
+
if (!row)
|
|
488
|
+
throw new AppError(404, "BACKUP_TARGET_NOT_FOUND", "S3 备份目标不存在");
|
|
489
|
+
return row;
|
|
490
|
+
}
|
|
491
|
+
runtimeTarget(row) {
|
|
492
|
+
return {
|
|
493
|
+
...this.mapTarget(row),
|
|
494
|
+
accessKeyId: this.vault.decrypt(encryptedSecret(row, "access_key")),
|
|
495
|
+
secretAccessKey: this.vault.decrypt(encryptedSecret(row, "secret_key"))
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
mapRun(row) {
|
|
499
|
+
let serverResponse = null;
|
|
500
|
+
const responseJson = nullableString(row, "server_response_json");
|
|
501
|
+
if (responseJson) {
|
|
502
|
+
try {
|
|
503
|
+
const parsed = JSON.parse(responseJson);
|
|
504
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
505
|
+
serverResponse = parsed;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
serverResponse = { message: "S3 服务返回结果无法解析" };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const status = requiredString(row, "status");
|
|
512
|
+
const trigger = requiredString(row, "trigger");
|
|
513
|
+
if (status !== "running" && status !== "succeeded" && status !== "failed") {
|
|
514
|
+
throw new AppError(500, "BACKUP_RUN_INVALID", "S3 备份记录状态无效");
|
|
515
|
+
}
|
|
516
|
+
if (trigger !== "manual" && trigger !== "scheduled") {
|
|
517
|
+
throw new AppError(500, "BACKUP_RUN_INVALID", "S3 备份触发类型无效");
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
sequence: Number(row.sequence),
|
|
521
|
+
id: requiredString(row, "id"),
|
|
522
|
+
targetId: nullableString(row, "target_id"),
|
|
523
|
+
targetName: requiredString(row, "target_name"),
|
|
524
|
+
trigger,
|
|
525
|
+
status,
|
|
526
|
+
databaseKey: nullableString(row, "database_key"),
|
|
527
|
+
imagesUploaded: Number(row.images_uploaded),
|
|
528
|
+
imagesSkipped: Number(row.images_skipped),
|
|
529
|
+
databasesDeleted: Number(row.databases_deleted),
|
|
530
|
+
errorMessage: nullableString(row, "error_message"),
|
|
531
|
+
serverResponse,
|
|
532
|
+
startedAt: requiredString(row, "started_at"),
|
|
533
|
+
finishedAt: nullableString(row, "finished_at")
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
backupImageSources(rootPrefix) {
|
|
537
|
+
const sources = new Map();
|
|
538
|
+
const addDatabaseImages = (table) => {
|
|
539
|
+
for (const row of this.database.all(`SELECT mime_type, content, sha256 FROM ${table} ORDER BY sha256`)) {
|
|
540
|
+
const contentType = requiredString(row, "mime_type");
|
|
541
|
+
const sha256 = requiredString(row, "sha256");
|
|
542
|
+
const content = Buffer.from(row.content);
|
|
543
|
+
const objectKey = `${rootPrefix}/img/${sha256.slice(0, 2)}/${sha256}.${this.imageExtension(contentType)}`;
|
|
544
|
+
sources.set(objectKey, { objectKey, contentType, sha256, read: async () => content });
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
addDatabaseImages("work_covers");
|
|
548
|
+
addDatabaseImages("user_avatars");
|
|
549
|
+
for (const row of this.database.all("SELECT DISTINCT storage_key, stored_mime_type, stored_sha256 FROM attachments ORDER BY storage_key")) {
|
|
550
|
+
const storageKey = requiredString(row, "storage_key");
|
|
551
|
+
const contentType = requiredString(row, "stored_mime_type");
|
|
552
|
+
const sha256 = requiredString(row, "stored_sha256");
|
|
553
|
+
const objectKey = `${rootPrefix}/img/${storageKey}`;
|
|
554
|
+
if (!sources.has(objectKey)) {
|
|
555
|
+
sources.set(objectKey, {
|
|
556
|
+
objectKey,
|
|
557
|
+
contentType,
|
|
558
|
+
sha256,
|
|
559
|
+
read: () => this.attachmentStorage.read(storageKey)
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return [...sources.values()];
|
|
564
|
+
}
|
|
565
|
+
async enforceDatabaseRetention(client, target) {
|
|
566
|
+
const prefix = `${target.rootPrefix}/db/`;
|
|
567
|
+
const snapshotNamePattern = /^scriverse-\d{8}T\d{9}Z-[0-9a-f]{8}\.db$/u;
|
|
568
|
+
const backups = (await this.withRequestTimeout(client.listObjects(target.bucket, prefix), "列出数据库快照"))
|
|
569
|
+
.filter((item) => item.key.startsWith(prefix) && snapshotNamePattern.test(item.key.slice(prefix.length)))
|
|
570
|
+
.sort((left, right) => {
|
|
571
|
+
const timestampDelta = (left.lastModified?.getTime() ?? 0) - (right.lastModified?.getTime() ?? 0);
|
|
572
|
+
return timestampDelta || left.key.localeCompare(right.key, "en");
|
|
573
|
+
});
|
|
574
|
+
const excessCount = Math.max(0, backups.length - target.retentionCount);
|
|
575
|
+
const keys = backups.slice(0, excessCount).map((item) => item.key);
|
|
576
|
+
if (keys.length)
|
|
577
|
+
await this.withRequestTimeout(client.deleteObjects(target.bucket, keys), "清理过期数据库快照");
|
|
578
|
+
return keys.length;
|
|
579
|
+
}
|
|
580
|
+
async withRequestTimeout(operation, action) {
|
|
581
|
+
let timer = null;
|
|
582
|
+
const timeout = new Promise((_, reject) => {
|
|
583
|
+
timer = setTimeout(() => reject(new AppError(504, "S3_REQUEST_TIMEOUT", `${action}超时`)), this.requestTimeoutMs);
|
|
584
|
+
});
|
|
585
|
+
try {
|
|
586
|
+
return await Promise.race([operation, timeout]);
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
if (timer)
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
imageExtension(contentType) {
|
|
594
|
+
if (contentType === "image/jpeg")
|
|
595
|
+
return "jpg";
|
|
596
|
+
if (contentType === "image/png")
|
|
597
|
+
return "png";
|
|
598
|
+
if (contentType === "image/webp")
|
|
599
|
+
return "webp";
|
|
600
|
+
if (contentType === "image/gif")
|
|
601
|
+
return "gif";
|
|
602
|
+
throw new AppError(500, "BACKUP_IMAGE_TYPE_INVALID", "待备份图片的类型无效");
|
|
603
|
+
}
|
|
604
|
+
snapshotTimestamp(value) {
|
|
605
|
+
return value.toISOString().replace(/[-:.]/gu, "");
|
|
606
|
+
}
|
|
607
|
+
localDateKey(value) {
|
|
608
|
+
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
609
|
+
}
|
|
610
|
+
targetForLog(target) {
|
|
611
|
+
const { accessKeyId: _accessKeyId, secretAccessKey: _secretAccessKey, ...safeTarget } = target;
|
|
612
|
+
return safeTarget;
|
|
613
|
+
}
|
|
614
|
+
mapTarget(row) {
|
|
615
|
+
const basePath = requiredString(row, "base_path");
|
|
616
|
+
return {
|
|
617
|
+
id: requiredString(row, "id"),
|
|
618
|
+
name: requiredString(row, "name"),
|
|
619
|
+
endpoint: requiredString(row, "endpoint"),
|
|
620
|
+
region: requiredString(row, "region"),
|
|
621
|
+
bucket: requiredString(row, "bucket"),
|
|
622
|
+
basePath,
|
|
623
|
+
rootPrefix: s3BackupRootPrefix(basePath),
|
|
624
|
+
forcePathStyle: Number(row.force_path_style) === 1,
|
|
625
|
+
enabled: Number(row.enabled) === 1,
|
|
626
|
+
backupImages: Number(row.backup_images) === 1,
|
|
627
|
+
scheduleTime: requiredString(row, "schedule_time"),
|
|
628
|
+
retentionCount: Number(row.retention_count),
|
|
629
|
+
sortOrder: Number(row.sort_order),
|
|
630
|
+
credentialsConfigured: true,
|
|
631
|
+
lastStartedAt: nullableString(row, "last_started_at"),
|
|
632
|
+
lastSuccessAt: nullableString(row, "last_success_at"),
|
|
633
|
+
lastFailureAt: nullableString(row, "last_failure_at"),
|
|
634
|
+
lastError: nullableString(row, "last_error"),
|
|
635
|
+
createdAt: requiredString(row, "created_at"),
|
|
636
|
+
updatedAt: requiredString(row, "updated_at")
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
sanitizedInput(input) {
|
|
640
|
+
return {
|
|
641
|
+
name: input.name.trim(),
|
|
642
|
+
endpoint: normalizedEndpoint(input.endpoint),
|
|
643
|
+
region: input.region?.trim() || "us-east-1",
|
|
644
|
+
bucket: input.bucket.trim(),
|
|
645
|
+
basePath: normalizeS3BasePath(input.basePath),
|
|
646
|
+
forcePathStyle: input.forcePathStyle !== false,
|
|
647
|
+
enabled: input.enabled === true,
|
|
648
|
+
backupImages: input.backupImages !== false,
|
|
649
|
+
scheduleTime: input.scheduleTime ?? "03:00",
|
|
650
|
+
retentionCount: input.retentionCount ?? 7
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
//# sourceMappingURL=s3-backup.js.map
|