feat(backup): include the trash in exports and auto-backups
A backup that silently drops the trash is not a backup: export, empty the vault, import, and the trashed entries were gone for good with nothing said. Both containers now carry state.trashed with deleted/deleted_at, and deleted_at is preserved so a restore resumes the auto-purge clock instead of restarting it - hence its addition to the GET echo. The sync snapshot deliberately does NOT: the trash is device-local, and re-publishing deleted rows is what produced the duplicate-uuid bug. The two identical export loops were folded into buildBackupEntry first, so the trash handling exists once.
This commit is contained in:
+7
-44
@@ -175,54 +175,17 @@ async function runAutoBackupNow(silent) {
|
||||
avatar_b64: state.avatarDataUri || '',
|
||||
entries: [],
|
||||
};
|
||||
// Same container as the manual export, trash included — see
|
||||
// buildBackupEntry (app.import.js), which both paths share.
|
||||
await loadTrash();
|
||||
const toBackup = state.entries.concat(state.trashed || []);
|
||||
let _bkDone = 0;
|
||||
const _bkTotal = state.entries.length;
|
||||
for (const e of state.entries) {
|
||||
const _bkTotal = toBackup.length;
|
||||
for (const e of toBackup) {
|
||||
_bkDone++;
|
||||
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
|
||||
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
let plainCustom = [];
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
try { plainCustom = await decryptCustomFields(
|
||||
e.custom_fields, e.custom_fields_iv); }
|
||||
catch (_) { plainCustom = []; }
|
||||
}
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
{ headers: authHeaders() });
|
||||
for (const m of (metas || [])) {
|
||||
const full = await api('/attachments/' + m.id,
|
||||
{ headers: authHeaders() });
|
||||
const bytes = await decryptBlobBytes(
|
||||
full.encrypted_blob, full.iv);
|
||||
attachments.push({
|
||||
filename: m.filename,
|
||||
mime: m.mime,
|
||||
size_bytes: m.size_bytes,
|
||||
content_b64: bytesToBase64(bytes),
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
payload.entries.push({
|
||||
uuid: e.uuid || '',
|
||||
site: e.site, title: e.title || '', username: e.username,
|
||||
password: plain, folder: e.folder, tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite, pinned: !!e.pinned, totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments: attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
icon_custom: e.icon_custom ? 1 : 0,
|
||||
created_at: e.created_at, updated_at: e.updated_at,
|
||||
});
|
||||
payload.entries.push(await buildBackupEntry(e));
|
||||
}
|
||||
if (!silent) updateBusy('Encrypting backup…');
|
||||
const container = await encryptExportPayload(payload, cfg.pwd);
|
||||
|
||||
+81
-55
@@ -421,6 +421,10 @@ function parseEntriesFromJSON(text) {
|
||||
pinned: e.pinned ? 1 : 0,
|
||||
created_at: String(e.created_at || '').trim(),
|
||||
updated_at: String(e.updated_at || '').trim(),
|
||||
// Trash state, so a restore rebuilds the trash rather than
|
||||
// resurrecting everything into the live list. Absent → live.
|
||||
deleted: e.deleted ? 1 : 0,
|
||||
deleted_at: String(e.deleted_at || '').trim(),
|
||||
});
|
||||
}
|
||||
return { entries, skipped, columns: null, folders,
|
||||
@@ -503,6 +507,10 @@ async function encryptImportEntry(plain) {
|
||||
// to now only when these are absent (foreign CSV imports).
|
||||
created_at: normalizeImportTimestamp(plain.created_at),
|
||||
updated_at: normalizeImportTimestamp(plain.updated_at),
|
||||
// Same passthrough contract: our containers carry the trash, the sync
|
||||
// snapshot never does, a foreign CSV has no such notion → 0 = live.
|
||||
deleted: plain.deleted ? 1 : 0,
|
||||
deleted_at: plain.deleted ? normalizeImportTimestamp(plain.deleted_at) : '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -801,6 +809,9 @@ async function doImport() {
|
||||
if (attachCount > 0) toast(attachCount + ' attachment(s) restored');
|
||||
|
||||
await loadEntries();
|
||||
// Trash too: a container can now restore trashed entries, and
|
||||
// without this the Trash count stays stale until the user opens it.
|
||||
await loadTrash();
|
||||
render();
|
||||
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
|
||||
} catch (err) {
|
||||
@@ -811,6 +822,68 @@ async function doImport() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
// One entry, decrypted and shaped for a backup container. Shared by the manual
|
||||
// export and the auto-backup, which carried two copies of this loop — and would
|
||||
// now need the trash handling added twice.
|
||||
// `deleted` rides along so a restore rebuilds the trash instead of dropping it;
|
||||
// the sync snapshot deliberately does NOT use this (see buildSyncSnapshot: the
|
||||
// trash is device-local, and re-creating trashed rows duplicates them).
|
||||
async function buildBackupEntry(e) {
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
let plainCustom = [];
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
try {
|
||||
plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
|
||||
} catch (_) { plainCustom = []; }
|
||||
}
|
||||
// Attachments cost one extra GET per entry that has them, so vaults
|
||||
// without any pay nothing. A failure here degrades to a partial export
|
||||
// rather than losing the whole file.
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
{ headers: authHeaders() });
|
||||
for (const m of (metas || [])) {
|
||||
const full = await api('/attachments/' + m.id, { headers: authHeaders() });
|
||||
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
|
||||
attachments.push({
|
||||
filename: m.filename,
|
||||
mime: m.mime,
|
||||
size_bytes: m.size_bytes,
|
||||
content_b64: bytesToBase64(bytes),
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
uuid: e.uuid || '',
|
||||
site: e.site,
|
||||
title: e.title || '',
|
||||
username: e.username,
|
||||
password: plain,
|
||||
folder: e.folder,
|
||||
tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite,
|
||||
pinned: !!e.pinned,
|
||||
totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments: attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
icon_custom: e.icon_custom ? 1 : 0,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
deleted: e.deleted ? 1 : 0,
|
||||
deleted_at: e.deleted_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
// Step 1: reauth — verifies the human in front of the screen is the
|
||||
// vault owner before we hand them every plaintext password. Defense
|
||||
@@ -881,65 +954,18 @@ async function doExport() {
|
||||
})),
|
||||
entries: [],
|
||||
};
|
||||
// Trash included: a backup that silently drops it is not a backup.
|
||||
// loadTrash first — state.trashed is only populated when the user has
|
||||
// visited the trash view.
|
||||
await loadTrash();
|
||||
const toExport = state.entries.concat(state.trashed || []);
|
||||
let _expDone = 0;
|
||||
const _expTotal = state.entries.length;
|
||||
for (const e of state.entries) {
|
||||
const _expTotal = toExport.length;
|
||||
for (const e of toExport) {
|
||||
_expDone++;
|
||||
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
|
||||
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
let plainTotp = '';
|
||||
if (e.totp_secret && e.totp_iv) {
|
||||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||
}
|
||||
let plainCustom = [];
|
||||
if (e.custom_fields && e.custom_fields_iv) {
|
||||
try {
|
||||
plainCustom = await decryptCustomFields(
|
||||
e.custom_fields, e.custom_fields_iv);
|
||||
} catch (_) { plainCustom = []; }
|
||||
}
|
||||
// Attachments are fetched separately (one extra GET per entry
|
||||
// that has them) so this branch stays cheap on vaults without.
|
||||
let attachments = [];
|
||||
try {
|
||||
const metas = await api('/entries/' + e.id + '/attachments',
|
||||
{ headers: authHeaders() });
|
||||
for (const m of (metas || [])) {
|
||||
const full = await api('/attachments/' + m.id,
|
||||
{ headers: authHeaders() });
|
||||
const bytes = await decryptBlobBytes(
|
||||
full.encrypted_blob, full.iv);
|
||||
attachments.push({
|
||||
filename: m.filename,
|
||||
mime: m.mime,
|
||||
size_bytes: m.size_bytes,
|
||||
content_b64: bytesToBase64(bytes),
|
||||
});
|
||||
}
|
||||
} catch (_) { /* partial export beats a failed one */ }
|
||||
|
||||
payload.entries.push({
|
||||
uuid: e.uuid || '',
|
||||
site: e.site,
|
||||
title: e.title || '',
|
||||
username: e.username,
|
||||
password: plain,
|
||||
folder: e.folder,
|
||||
tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite,
|
||||
pinned: !!e.pinned,
|
||||
totp_secret: plainTotp,
|
||||
kind: e.kind || 'login',
|
||||
template: e.template || '',
|
||||
custom_fields: plainCustom,
|
||||
attachments: attachments,
|
||||
icon_b64: e.icon_b64 || '',
|
||||
icon_custom: e.icon_custom ? 1 : 0,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
});
|
||||
payload.entries.push(await buildBackupEntry(e));
|
||||
}
|
||||
|
||||
const attachTotal = payload.entries.reduce(
|
||||
|
||||
Reference in New Issue
Block a user