Fix manual backup manifest filename handling

This commit is contained in:
Youzini-afk
2026-04-10 19:49:10 +08:00
parent cde747ce56
commit 53672fa751
4 changed files with 384 additions and 57 deletions

View File

@@ -13123,6 +13123,7 @@ async function onManageServerBackups() {
async function onDeleteServerBackupEntry(payload = {}) {
const chatId = String(payload?.chatId || "").trim();
const filename = String(payload?.filename || "").trim();
const serverPath = String(payload?.serverPath || "").trim();
if (!chatId) {
return {
deleted: false,
@@ -13138,12 +13139,27 @@ async function onDeleteServerBackupEntry(payload = {}) {
buildBmeSyncRuntimeOptions({
reason: "delete-backup",
trigger: "panel:delete-backup",
filename,
serverPath,
}),
);
const currentChatId = getCurrentChatId();
if (
deleteResult?.deleted &&
currentChatId &&
normalizeChatIdCandidate(currentChatId) ===
normalizeChatIdCandidate(chatId)
) {
await syncIndexedDbMetaToPersistenceState(chatId, {
syncState: "idle",
lastSyncError: "",
});
}
return {
...deleteResult,
filename,
filename: deleteResult?.filename || filename,
handledToast: true,
skipDashboardRefresh: true,
};

View File

@@ -368,56 +368,207 @@ async function upsertBackupManifestEntry(entry, options = {}) {
await writeBackupManifest(filteredEntries, options);
}
function normalizeSelectedBackupFilename(filename) {
const normalized = String(filename ?? "")
.trim()
.replace(/^\/+/, "");
if (
!normalized
|| normalized === BME_BACKUP_MANIFEST_FILENAME
|| /[\\/]/.test(normalized)
) {
return "";
}
return normalized;
}
function normalizeSelectedBackupServerPath(serverPath, fallbackFilename = "") {
const normalizedPath = String(serverPath ?? "")
.trim()
.replace(/\\/g, "/")
.replace(/^\/+/, "");
if (normalizedPath && !normalizedPath.includes("..")) {
return `/${normalizedPath}`;
}
const normalizedFilename = normalizeSelectedBackupFilename(fallbackFilename);
return normalizedFilename ? `/user/files/${normalizedFilename}` : "";
}
function sortBackupManifestEntries(entries = []) {
return [...entries].sort((left, right) => {
const timeDelta =
normalizeTimestamp(right.backupTime, 0) -
normalizeTimestamp(left.backupTime, 0);
if (timeDelta !== 0) return timeDelta;
const modifiedDelta =
normalizeTimestamp(right.lastModified, 0) -
normalizeTimestamp(left.lastModified, 0);
if (modifiedDelta !== 0) return modifiedDelta;
return String(left.filename || "").localeCompare(
String(right.filename || ""),
);
});
}
async function resolveBackupLookupContext(chatId, options = {}) {
const normalizedChatId = normalizeChatId(chatId);
const explicitFilename = normalizeSelectedBackupFilename(
options.filename || options.backupFilename,
);
const explicitServerPath = normalizeSelectedBackupServerPath(
options.serverPath,
explicitFilename,
);
let manifestEntries = [];
let manifestError = null;
try {
manifestEntries = sortBackupManifestEntries(await fetchBackupManifest(options));
} catch (error) {
manifestError = error;
}
const candidates = [];
const candidateIndexByFilename = new Map();
const pushCandidate = (filename, serverPath = "") => {
const normalizedFilename = normalizeSelectedBackupFilename(filename);
if (!normalizedFilename) return;
const normalizedServerPath = normalizeSelectedBackupServerPath(
serverPath,
normalizedFilename,
);
const existingIndex = candidateIndexByFilename.get(normalizedFilename);
if (existingIndex != null) {
if (
normalizedServerPath &&
!candidates[existingIndex].serverPath
) {
candidates[existingIndex].serverPath = normalizedServerPath;
}
return;
}
candidateIndexByFilename.set(normalizedFilename, candidates.length);
candidates.push({
filename: normalizedFilename,
serverPath: normalizedServerPath,
});
};
pushCandidate(explicitFilename, explicitServerPath);
if (explicitFilename) {
for (const entry of manifestEntries) {
if (entry.filename === explicitFilename) {
pushCandidate(entry.filename, entry.serverPath);
}
}
}
for (const entry of manifestEntries) {
if (normalizeChatId(entry.chatId) === normalizedChatId) {
pushCandidate(entry.filename, entry.serverPath);
}
}
pushCandidate(buildBackupFilename(normalizedChatId));
return {
explicitFilename,
manifestEntries,
manifestError,
candidates,
};
}
async function readBackupEnvelope(chatId, options = {}) {
const normalizedChatId = normalizeChatId(chatId);
const backupFilename = buildBackupFilename(normalizedChatId);
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
const fetchImpl = getFetch(options);
const fallbackFilename = buildBackupFilename(normalizedChatId);
let lastMissingFilename = lookup.candidates[0]?.filename || fallbackFilename;
for (const candidate of lookup.candidates) {
try {
const response = await fetchImpl(
`${candidate.serverPath || `/user/files/${encodeURIComponent(candidate.filename)}`}?t=${Date.now()}`,
{
method: "GET",
cache: "no-store",
},
);
if (response.status === 404) {
lastMissingFilename = candidate.filename;
continue;
}
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
return {
exists: false,
filename: candidate.filename,
envelope: null,
reason: "backup-read-error",
error: new Error(errorText || `HTTP ${response.status}`),
};
}
const payload = await response.json();
const envelope = normalizeBackupEnvelope(payload, normalizedChatId);
if (!envelope) {
return {
exists: false,
filename: candidate.filename,
envelope: null,
reason: "invalid-backup",
};
}
return {
exists: true,
filename: candidate.filename,
envelope,
reason: "ok",
};
} catch (error) {
return {
exists: false,
filename: candidate.filename,
envelope: null,
reason: "backup-read-error",
error,
};
}
}
return {
exists: false,
filename: lastMissingFilename,
envelope: null,
reason: "not-found",
manifestError: lookup.manifestError,
};
}
async function syncDeletedBackupMeta(chatId, remainingEntry, options = {}) {
try {
const response = await fetchImpl(
`/user/files/${encodeURIComponent(backupFilename)}?t=${Date.now()}`,
{
method: "GET",
cache: "no-store",
},
);
if (response.status === 404) {
return {
exists: false,
filename: backupFilename,
envelope: null,
reason: "not-found",
};
}
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
throw new Error(errorText || `HTTP ${response.status}`);
}
const payload = await response.json();
const envelope = normalizeBackupEnvelope(payload, normalizedChatId);
if (!envelope) {
return {
exists: false,
filename: backupFilename,
envelope: null,
reason: "invalid-backup",
};
}
return {
exists: true,
filename: backupFilename,
envelope,
reason: "ok",
};
} catch (error) {
return {
exists: false,
filename: backupFilename,
envelope: null,
reason: "backup-read-error",
error,
};
const db = await getDb(chatId, options);
await patchDbMeta(db, {
lastBackupUploadedAt: remainingEntry
? normalizeTimestamp(
remainingEntry.backupTime || remainingEntry.lastModified,
0,
)
: 0,
lastBackupFilename: remainingEntry
? String(remainingEntry.filename || "")
: "",
});
return true;
} catch {
return false;
}
}
@@ -1891,7 +2042,18 @@ export async function deleteServerBackup(chatId, options = {}) {
};
}
const filename = buildBackupFilename(normalizedChatId);
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
const targetCandidate = lookup.candidates[0] || {
filename: buildBackupFilename(normalizedChatId),
serverPath: normalizeSelectedBackupServerPath(
"",
buildBackupFilename(normalizedChatId),
),
};
const filename = targetCandidate.filename;
const serverPath =
targetCandidate.serverPath ||
normalizeSelectedBackupServerPath("", filename);
const fetchImpl = getFetch(options);
try {
@@ -1902,7 +2064,7 @@ export async function deleteServerBackup(chatId, options = {}) {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: `/user/files/${filename}`,
path: serverPath,
}),
});
@@ -1912,11 +2074,33 @@ export async function deleteServerBackup(chatId, options = {}) {
}
try {
const existingEntries = await fetchBackupManifest(options);
const existingEntries =
lookup.manifestError == null
? lookup.manifestEntries
: await fetchBackupManifest(options);
const filteredEntries = existingEntries.filter(
(entry) => entry.filename !== filename,
);
await writeBackupManifest(filteredEntries, options);
const remainingEntry =
sortBackupManifestEntries(
filteredEntries.filter(
(entry) => normalizeChatId(entry.chatId) === normalizedChatId,
),
)[0] || null;
const localMetaUpdated = await syncDeletedBackupMeta(
normalizedChatId,
remainingEntry,
options,
);
return {
deleted: true,
chatId: normalizedChatId,
filename,
localMetaUpdated,
};
} catch (manifestError) {
return {
deleted: false,
@@ -1927,12 +2111,6 @@ export async function deleteServerBackup(chatId, options = {}) {
error: manifestError,
};
}
return {
deleted: true,
chatId: normalizedChatId,
filename,
};
} catch (error) {
console.warn("[ST-BME] 删除服务端备份失败:", error);
return {

View File

@@ -714,12 +714,15 @@ async function testManualBackupAndRestoreFlow() {
const deleteResult = await deleteServerBackup("chat-backup-flow", runtime);
assert.equal(deleteResult.deleted, true);
assert.equal(deleteResult.localMetaUpdated, true);
const manifestAfterDelete = await listServerBackups(runtime);
assert.equal(manifestAfterDelete.entries.length, 0);
assert.equal(
Array.from(remoteFiles.keys()).some((key) => key.startsWith("ST-BME_backup_")),
false,
);
assert.equal(db.meta.get("lastBackupUploadedAt"), 0);
assert.equal(db.meta.get("lastBackupFilename"), "");
}
async function testBackupManifestReadFailureDoesNotOverwriteManifest() {
@@ -806,6 +809,130 @@ async function testRestoreValidationDoesNotCreateSafetySnapshot() {
assert.equal(safetyStatus.exists, false);
}
async function testRestoreUsesManifestFilenameWhenCurrentFilenameDrifts() {
const { fetch, remoteFiles } = createMockFetchEnvironment();
const dbByChatId = new Map();
const db = new FakeDb("chat-filename-drift");
const safetyDb = new FakeDb(buildRestoreSafetyChatId("chat-filename-drift"));
dbByChatId.set("chat-filename-drift", db);
const legacyFilename = "ST-BME_backup_chat-filename-drift-legacy.json";
remoteFiles.set(legacyFilename, {
kind: "st-bme-backup",
version: 1,
chatId: "chat-filename-drift",
createdAt: 123,
sourceDeviceId: "remote-device",
snapshot: {
meta: {
schemaVersion: 1,
chatId: "chat-filename-drift",
revision: 7,
lastModified: 70,
deviceId: "remote-device",
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [{ id: "restored-from-drift", updatedAt: 70 }],
edges: [],
tombstones: [],
state: {
lastProcessedFloor: 5,
extractionCount: 2,
},
},
});
remoteFiles.set("ST-BME_BackupManifest.json", [
{
filename: legacyFilename,
serverPath: `user/files/${legacyFilename}`,
chatId: "chat-filename-drift",
revision: 7,
lastModified: 70,
backupTime: 123,
size: 256,
schemaVersion: 1,
},
]);
const runtime = {
...buildRuntimeOptions({ dbByChatId, fetch }),
getSafetyDb: async () => safetyDb,
};
const restoreResult = await restoreFromServer("chat-filename-drift", runtime);
assert.equal(restoreResult.restored, true);
assert.equal(restoreResult.filename, legacyFilename);
assert.equal(db.snapshot.nodes[0].id, "restored-from-drift");
}
async function testDeleteUsesExplicitManifestFilenameAndClearsLocalBackupMeta() {
const { fetch, remoteFiles } = createMockFetchEnvironment();
const dbByChatId = new Map();
const db = new FakeDb("chat-delete-drift");
db.meta.set("lastBackupUploadedAt", 999);
db.meta.set("lastBackupFilename", "ST-BME_backup_chat-delete-drift-stale.json");
dbByChatId.set("chat-delete-drift", db);
const driftFilename = "ST-BME_backup_chat-delete-drift-legacy.json";
remoteFiles.set(driftFilename, {
kind: "st-bme-backup",
version: 1,
chatId: "chat-delete-drift",
createdAt: 321,
sourceDeviceId: "remote-device",
snapshot: {
meta: {
schemaVersion: 1,
chatId: "chat-delete-drift",
revision: 3,
lastModified: 30,
deviceId: "remote-device",
nodeCount: 0,
edgeCount: 0,
tombstoneCount: 0,
},
nodes: [],
edges: [],
tombstones: [],
state: {
lastProcessedFloor: -1,
extractionCount: 0,
},
},
});
remoteFiles.set("ST-BME_BackupManifest.json", [
{
filename: driftFilename,
serverPath: `user/files/${driftFilename}`,
chatId: "chat-delete-drift",
revision: 3,
lastModified: 30,
backupTime: 321,
size: 128,
schemaVersion: 1,
},
]);
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
const deleteResult = await deleteServerBackup("chat-delete-drift", {
...runtime,
filename: driftFilename,
serverPath: `user/files/${driftFilename}`,
});
assert.equal(deleteResult.deleted, true);
assert.equal(deleteResult.filename, driftFilename);
assert.equal(deleteResult.localMetaUpdated, true);
assert.equal(remoteFiles.has(driftFilename), false);
assert.equal(db.meta.get("lastBackupUploadedAt"), 0);
assert.equal(db.meta.get("lastBackupFilename"), "");
const manifestResult = await listServerBackups(runtime);
assert.equal(manifestResult.entries.length, 0);
}
async function testSyncNowLockAndAutoSync() {
const { fetch, remoteFiles, logs } = createMockFetchEnvironment();
const dbByChatId = new Map();
@@ -1079,6 +1206,8 @@ async function main() {
await testManualBackupAndRestoreFlow();
await testBackupManifestReadFailureDoesNotOverwriteManifest();
await testRestoreValidationDoesNotCreateSafetySnapshot();
await testRestoreUsesManifestFilenameWhenCurrentFilenameDrifts();
await testDeleteUsesExplicitManifestFilenameAndClearsLocalBackupMeta();
await testSyncNowLockAndAutoSync();
await testDeleteRemoteSyncFile();
await testDeleteRemoteSyncFileFallsBackToLegacyFilename();

View File

@@ -9889,12 +9889,13 @@ function _buildCloudBackupManagerHtml(state = {}) {
</div>
<div class="bme-cloud-backup-card__filename">${_escHtml(filename)}</div>
<div class="bme-cloud-backup-card__actions">
<button
<button
type="button"
class="bme-cloud-backup-modal__btn bme-cloud-backup-card__danger"
data-bme-backup-action="delete"
data-chat-id="${_escHtml(chatId)}"
data-filename="${_escHtml(filename)}"
data-server-path="${_escHtml(String(entry?.serverPath || ""))}"
${state.busy ? "disabled" : ""}
>
<i class="fa-solid fa-trash-can"></i>
@@ -9970,7 +9971,7 @@ async function _openServerBackupManagerModal() {
}
};
const deleteEntry = async (chatId, filename) => {
const deleteEntry = async (chatId, filename, serverPath = "") => {
if (typeof _actionHandlers.deleteServerBackupEntry !== "function") {
toastr.error("\u5f53\u524d\u8fd0\u884c\u65f6\u6ca1\u6709\u63a5\u5165\u5220\u9664\u670d\u52a1\u5668\u5907\u4efd\u5165\u53e3", "ST-BME");
return;
@@ -9986,6 +9987,7 @@ async function _openServerBackupManagerModal() {
const result = await _actionHandlers.deleteServerBackupEntry({
chatId,
filename,
serverPath,
});
if (!result?.deleted) {
const message =
@@ -10005,6 +10007,7 @@ async function _openServerBackupManagerModal() {
} finally {
state.busy = false;
render();
_refreshRuntimeStatus();
void _refreshCloudBackupManualUi();
}
};
@@ -10021,6 +10024,7 @@ async function _openServerBackupManagerModal() {
await deleteEntry(
String(button.dataset.chatId || "").trim(),
String(button.dataset.filename || "").trim(),
String(button.dataset.serverPath || "").trim(),
);
}
});