diff --git a/CLAUDE.md b/CLAUDE.md index b4168f0..9262929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 : `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** mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement sur certaines actions : diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index c55c8f0..4f3d3d1 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -191,6 +191,12 @@ begin LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString); LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_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 LObj.AddPair('accessed_at', TJSONNull.Create) else @@ -331,9 +337,9 @@ var LBody, LObj: TJSONObject; LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid, - LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, + LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, LDeletedAt, LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; - LIconCustom, LFavorite, LPinned: Boolean; + LIconCustom, LFavorite, LPinned, LDeleted: Boolean; LQ: TFDQuery; begin try @@ -389,6 +395,13 @@ begin // (soSave, duplicate) don't send them, so nothing changes there. LCreatedAt := Trim(LBody.GetValue('created_at', '')); LUpdatedAt := Trim(LBody.GetValue('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('deleted', False); + LDeletedAt := Trim(LBody.GetValue('deleted_at', '')); + if LDeleted and (LDeletedAt = '') then LDeletedAt := NowUTCStr; + if not LDeleted then LDeletedAt := ''; finally LBody.Free; end; @@ -418,10 +431,12 @@ begin ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' encrypted_password, iv, encryption_method, ' + ' 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, ' + ' :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; // site/username/title/tags cleartext columns dropped — only *_enc written. BindNullable(LQ, 'uenc', LUserEnc); @@ -471,6 +486,11 @@ begin if LUpdatedAt = '' then LUpdatedAt := LNow; LQ.ParamByName('c').AsString := LCreatedAt; 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; LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); @@ -1224,9 +1244,9 @@ var LArr, LIds: TJSONArray; LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid, - LCreatedAt, LUpdatedAt, + LCreatedAt, LUpdatedAt, LDeletedAt, LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; - LIconCustom, LFavorite, LPinned: Boolean; + LIconCustom, LFavorite, LPinned, LDeleted: Boolean; LQ, LTomb: TFDQuery; begin try @@ -1283,10 +1303,12 @@ begin ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' encrypted_password, iv, encryption_method, ' + ' 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, ' + ' :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 // reused across every imported entry, and FireDAC needs the // type set before the first .Clear call would otherwise fail @@ -1308,6 +1330,8 @@ begin LQ.ParamByName('tiv2').DataType := ftMemo; LQ.ParamByName('genc').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 begin @@ -1347,6 +1371,13 @@ begin if LCreatedAt = '' then LCreatedAt := LNow; LUpdatedAt := Trim(LEntry.GetValue('updated_at', '')); 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('deleted', False); + LDeletedAt := Trim(LEntry.GetValue('deleted_at', '')); + if LDeleted and (LDeletedAt = '') then LDeletedAt := LNow; + if not LDeleted then LDeletedAt := ''; // Ciphertext is always required. Site is required only for // logins — notes legitimately have no site (their body lives @@ -1391,6 +1422,10 @@ begin LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('c').AsString := LCreatedAt; 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; LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); LIds.AddElement(TJSONNumber.Create(LNewId)); diff --git a/js/app.backup.js b/js/app.backup.js index 4c02a9f..88b9eb3 100644 --- a/js/app.backup.js +++ b/js/app.backup.js @@ -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); diff --git a/js/app.import.js b/js/app.import.js index aff3d7b..8672a5e 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -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(