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:
@@ -496,6 +496,17 @@ retirée** (site='' quand chiffré) — le client la fait. `folder` reste en cla
|
|||||||
(requête serveur de réassignation sur delete-folder). Reste en clair :
|
(requête serveur de réassignation sur delete-folder). Reste en clair :
|
||||||
`folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
|
`folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
|
||||||
|
|
||||||
|
**La corbeille voyage dans les sauvegardes, pas dans la sync** (2026-08-03) :
|
||||||
|
`buildBackupEntry` (js/app.import.js) est le point unique qui met en forme une
|
||||||
|
entrée pour un conteneur — partagé par l'export manuel **et** l'auto-backup, qui
|
||||||
|
en portaient deux copies. Les deux exportent `state.entries` **+**
|
||||||
|
`state.trashed`, avec `deleted` / `deleted_at` ; `deleted_at` est préservé pour
|
||||||
|
que l'auto-purge reprenne où elle en était au lieu de repartir à zéro (d'où son
|
||||||
|
ajout à l'écho de `GET /entries`). `buildSyncSnapshot` **n'utilise pas** ce
|
||||||
|
helper et n'envoie que le vivant : la corbeille est device-local, et republier
|
||||||
|
des lignes supprimées les dupliquerait (cf. le bug F8 où 30 entrées mises à la
|
||||||
|
corbeille revenaient vivantes, deux lignes pour un même uuid).
|
||||||
|
|
||||||
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours**
|
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours**
|
||||||
mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
|
mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
|
||||||
sur certaines actions :
|
sur certaines actions :
|
||||||
|
|||||||
@@ -191,6 +191,12 @@ begin
|
|||||||
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
|
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
|
||||||
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
|
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
|
||||||
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
|
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
|
||||||
|
// Echoed so a backup can carry a trashed entry WITH its original
|
||||||
|
// deletion date: restoring it must not restart the auto-purge clock.
|
||||||
|
if LQ.FieldByName('deleted_at').IsNull then
|
||||||
|
LObj.AddPair('deleted_at', TJSONNull.Create)
|
||||||
|
else
|
||||||
|
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
|
||||||
if LQ.FieldByName('accessed_at').IsNull then
|
if LQ.FieldByName('accessed_at').IsNull then
|
||||||
LObj.AddPair('accessed_at', TJSONNull.Create)
|
LObj.AddPair('accessed_at', TJSONNull.Create)
|
||||||
else
|
else
|
||||||
@@ -331,9 +337,9 @@ var
|
|||||||
LBody, LObj: TJSONObject;
|
LBody, LObj: TJSONObject;
|
||||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
||||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
|
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
|
||||||
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt,
|
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, LDeletedAt,
|
||||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||||
LIconCustom, LFavorite, LPinned: Boolean;
|
LIconCustom, LFavorite, LPinned, LDeleted: Boolean;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -389,6 +395,13 @@ begin
|
|||||||
// (soSave, duplicate) don't send them, so nothing changes there.
|
// (soSave, duplicate) don't send them, so nothing changes there.
|
||||||
LCreatedAt := Trim(LBody.GetValue<string>('created_at', ''));
|
LCreatedAt := Trim(LBody.GetValue<string>('created_at', ''));
|
||||||
LUpdatedAt := Trim(LBody.GetValue<string>('updated_at', ''));
|
LUpdatedAt := Trim(LBody.GetValue<string>('updated_at', ''));
|
||||||
|
// A backup carries trashed entries too, so a restore rebuilds the trash
|
||||||
|
// instead of silently dropping it. deleted_at is kept as-is so the
|
||||||
|
// auto-purge clock resumes where it was rather than restarting.
|
||||||
|
LDeleted := LBody.GetValue<Boolean>('deleted', False);
|
||||||
|
LDeletedAt := Trim(LBody.GetValue<string>('deleted_at', ''));
|
||||||
|
if LDeleted and (LDeletedAt = '') then LDeletedAt := NowUTCStr;
|
||||||
|
if not LDeleted then LDeletedAt := '';
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
@@ -418,10 +431,12 @@ begin
|
|||||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||||
' encrypted_password, iv, encryption_method, ' +
|
' encrypted_password, iv, encryption_method, ' +
|
||||||
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
||||||
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' +
|
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at,' +
|
||||||
|
' deleted, deleted_at) ' +
|
||||||
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
||||||
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
|
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
|
||||||
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
|
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2, :c,' +
|
||||||
|
' :del, :delat)';
|
||||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
// site/username/title/tags cleartext columns dropped — only *_enc written.
|
// site/username/title/tags cleartext columns dropped — only *_enc written.
|
||||||
BindNullable(LQ, 'uenc', LUserEnc);
|
BindNullable(LQ, 'uenc', LUserEnc);
|
||||||
@@ -471,6 +486,11 @@ begin
|
|||||||
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
||||||
LQ.ParamByName('c').AsString := LCreatedAt;
|
LQ.ParamByName('c').AsString := LCreatedAt;
|
||||||
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
||||||
|
if LDeleted then LQ.ParamByName('del').AsInteger := 1
|
||||||
|
else LQ.ParamByName('del').AsInteger := 0;
|
||||||
|
LQ.ParamByName('delat').DataType := ftString;
|
||||||
|
if LDeletedAt = '' then LQ.ParamByName('delat').Clear
|
||||||
|
else LQ.ParamByName('delat').AsString := LDeletedAt;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||||
|
|
||||||
@@ -1224,9 +1244,9 @@ var
|
|||||||
LArr, LIds: TJSONArray;
|
LArr, LIds: TJSONArray;
|
||||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
|
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
|
||||||
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid,
|
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid,
|
||||||
LCreatedAt, LUpdatedAt,
|
LCreatedAt, LUpdatedAt, LDeletedAt,
|
||||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||||
LIconCustom, LFavorite, LPinned: Boolean;
|
LIconCustom, LFavorite, LPinned, LDeleted: Boolean;
|
||||||
LQ, LTomb: TFDQuery;
|
LQ, LTomb: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -1283,10 +1303,12 @@ begin
|
|||||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||||
' encrypted_password, iv, encryption_method, ' +
|
' encrypted_password, iv, encryption_method, ' +
|
||||||
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
||||||
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at) ' +
|
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at,' +
|
||||||
|
' deleted, deleted_at) ' +
|
||||||
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
|
||||||
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
|
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
|
||||||
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2)';
|
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2,' +
|
||||||
|
' :del, :delat)';
|
||||||
// Declare optional param types ONCE — the prepared statement is
|
// Declare optional param types ONCE — the prepared statement is
|
||||||
// reused across every imported entry, and FireDAC needs the
|
// reused across every imported entry, and FireDAC needs the
|
||||||
// type set before the first .Clear call would otherwise fail
|
// type set before the first .Clear call would otherwise fail
|
||||||
@@ -1308,6 +1330,8 @@ begin
|
|||||||
LQ.ParamByName('tiv2').DataType := ftMemo;
|
LQ.ParamByName('tiv2').DataType := ftMemo;
|
||||||
LQ.ParamByName('genc').DataType := ftMemo;
|
LQ.ParamByName('genc').DataType := ftMemo;
|
||||||
LQ.ParamByName('giv').DataType := ftMemo;
|
LQ.ParamByName('giv').DataType := ftMemo;
|
||||||
|
// Same reason: a live entry clears it, so the type must be known.
|
||||||
|
LQ.ParamByName('delat').DataType := ftString;
|
||||||
|
|
||||||
for I := 0 to LArr.Count - 1 do
|
for I := 0 to LArr.Count - 1 do
|
||||||
begin
|
begin
|
||||||
@@ -1347,6 +1371,13 @@ begin
|
|||||||
if LCreatedAt = '' then LCreatedAt := LNow;
|
if LCreatedAt = '' then LCreatedAt := LNow;
|
||||||
LUpdatedAt := Trim(LEntry.GetValue<string>('updated_at', ''));
|
LUpdatedAt := Trim(LEntry.GetValue<string>('updated_at', ''));
|
||||||
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
||||||
|
// Trashed entries travel in backups so a restore rebuilds the
|
||||||
|
// trash. Their deletion date is preserved — restarting it would
|
||||||
|
// give every restored entry a fresh auto-purge delay.
|
||||||
|
LDeleted := LEntry.GetValue<Boolean>('deleted', False);
|
||||||
|
LDeletedAt := Trim(LEntry.GetValue<string>('deleted_at', ''));
|
||||||
|
if LDeleted and (LDeletedAt = '') then LDeletedAt := LNow;
|
||||||
|
if not LDeleted then LDeletedAt := '';
|
||||||
|
|
||||||
// Ciphertext is always required. Site is required only for
|
// Ciphertext is always required. Site is required only for
|
||||||
// logins — notes legitimately have no site (their body lives
|
// logins — notes legitimately have no site (their body lives
|
||||||
@@ -1391,6 +1422,10 @@ begin
|
|||||||
LQ.ParamByName('uuid').AsString := LUuid;
|
LQ.ParamByName('uuid').AsString := LUuid;
|
||||||
LQ.ParamByName('c').AsString := LCreatedAt;
|
LQ.ParamByName('c').AsString := LCreatedAt;
|
||||||
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
||||||
|
if LDeleted then LQ.ParamByName('del').AsInteger := 1
|
||||||
|
else LQ.ParamByName('del').AsInteger := 0;
|
||||||
|
if LDeletedAt = '' then LQ.ParamByName('delat').Clear
|
||||||
|
else LQ.ParamByName('delat').AsString := LDeletedAt;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||||
LIds.AddElement(TJSONNumber.Create(LNewId));
|
LIds.AddElement(TJSONNumber.Create(LNewId));
|
||||||
|
|||||||
+7
-44
@@ -175,54 +175,17 @@ async function runAutoBackupNow(silent) {
|
|||||||
avatar_b64: state.avatarDataUri || '',
|
avatar_b64: state.avatarDataUri || '',
|
||||||
entries: [],
|
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;
|
let _bkDone = 0;
|
||||||
const _bkTotal = state.entries.length;
|
const _bkTotal = toBackup.length;
|
||||||
for (const e of state.entries) {
|
for (const e of toBackup) {
|
||||||
_bkDone++;
|
_bkDone++;
|
||||||
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
|
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
|
||||||
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
|
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
|
||||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
payload.entries.push(await buildBackupEntry(e));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!silent) updateBusy('Encrypting backup…');
|
if (!silent) updateBusy('Encrypting backup…');
|
||||||
const container = await encryptExportPayload(payload, cfg.pwd);
|
const container = await encryptExportPayload(payload, cfg.pwd);
|
||||||
|
|||||||
+81
-55
@@ -421,6 +421,10 @@ function parseEntriesFromJSON(text) {
|
|||||||
pinned: e.pinned ? 1 : 0,
|
pinned: e.pinned ? 1 : 0,
|
||||||
created_at: String(e.created_at || '').trim(),
|
created_at: String(e.created_at || '').trim(),
|
||||||
updated_at: String(e.updated_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,
|
return { entries, skipped, columns: null, folders,
|
||||||
@@ -503,6 +507,10 @@ async function encryptImportEntry(plain) {
|
|||||||
// to now only when these are absent (foreign CSV imports).
|
// to now only when these are absent (foreign CSV imports).
|
||||||
created_at: normalizeImportTimestamp(plain.created_at),
|
created_at: normalizeImportTimestamp(plain.created_at),
|
||||||
updated_at: normalizeImportTimestamp(plain.updated_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');
|
if (attachCount > 0) toast(attachCount + ' attachment(s) restored');
|
||||||
|
|
||||||
await loadEntries();
|
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();
|
render();
|
||||||
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
|
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -811,6 +822,68 @@ async function doImport() {
|
|||||||
fileInput.click();
|
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() {
|
async function doExport() {
|
||||||
// Step 1: reauth — verifies the human in front of the screen is the
|
// Step 1: reauth — verifies the human in front of the screen is the
|
||||||
// vault owner before we hand them every plaintext password. Defense
|
// vault owner before we hand them every plaintext password. Defense
|
||||||
@@ -881,65 +954,18 @@ async function doExport() {
|
|||||||
})),
|
})),
|
||||||
entries: [],
|
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;
|
let _expDone = 0;
|
||||||
const _expTotal = state.entries.length;
|
const _expTotal = toExport.length;
|
||||||
for (const e of state.entries) {
|
for (const e of toExport) {
|
||||||
_expDone++;
|
_expDone++;
|
||||||
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
|
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
|
||||||
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
|
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
|
||||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
payload.entries.push(await buildBackupEntry(e));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachTotal = payload.entries.reduce(
|
const attachTotal = payload.entries.reduce(
|
||||||
|
|||||||
Reference in New Issue
Block a user