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
+43 -8
View File
@@ -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<string>('created_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
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<string>('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<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
// 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));