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:
r-zakarya
2026-08-07 15:04:41 +01:00
parent 125cb11756
commit ebcd75fd2e
4 changed files with 142 additions and 107 deletions
+7 -44
View File
@@ -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);