feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle

- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server +
  JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with
  pull/merge/push flow, Settings UI, pre-sync backup option. Test
  connection now treats 404 as OK (snapshot not yet created) and 401/
  403 as auth failure with dedicated toast.
- Batch drag-drop: cards + table rows carry checked-set ids (CSV) when
  dragged from an active selection; folder + trash drop handlers parse
  and apply in batch via new moveEntriesToFolder helper that preserves
  TOTP / custom_fields / kind in the full PUT payload.
- Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the
  bridge message-only window; FormCloseQuery bypasses the tray-minimize
  intercept on system shutdown / restart / logoff so FireDAC closes the
  SQLite WAL cleanly instead of leaving -shm / -wal residue after a
  force-kill.
- Center-mode modal: blur+dim backdrop via body::before pseudo-element
  in editor-position=center, swallows clicks below the panel so the
  existing outside-click handlers reliably dismiss the slideover /
  settings panel.
- Batch bar state fixes: state.checked cleared before render in
  moveEntriesToFolder, emptyTrash, and per-card restoreEntry /
  permanentDelete / deleteEntry so the action bar disappears once the
  selection is fully processed.
- Save-then-discard duplicate fix: soState reset to null before
  openSlideOver re-opens the freshly saved entry, otherwise the dirty
  check fired on the soState.id=null → newId switch and a Cancel left
  the form in new-entry mode (second Save → POST duplicate).
- TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync
  with 2 real instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-30 00:32:12 +01:00
parent b00da43ab0
commit 6869b7c692
10 changed files with 1132 additions and 25 deletions
+174 -7
View File
@@ -28,6 +28,21 @@ begin
if Result = '' then Result := ADefault;
end;
// RFC 4122 v4 UUID — lowercase canonical hex with dashes, no braces.
// Used as the cross-device stable identity for vault_entries.
function NewUUIDv4: string;
var
G: TGUID;
S: string;
begin
CreateGUID(G);
S := GUIDToString(G);
// Strip surrounding braces RTL adds, lowercase the rest.
if (Length(S) > 0) and (S[1] = '{') then
S := Copy(S, 2, Length(S) - 2);
Result := LowerCase(S);
end;
// SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString
// would format in system locale (DD/MM/YYYY in French). Force ISO format
// 'yyyy-mm-dd hh:nn:ss' which is what api.php / SQLite text storage uses and
@@ -132,6 +147,8 @@ begin
LObj.AddPair('template', TJSONNull.Create)
else
LObj.AddPair('template', LQ.FieldByName('template').AsString);
// Stable cross-device identity (always populated post-migration).
LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString);
// Custom fields: opaque ciphertext + IV, treated identically to
// password / totp_secret. NULL → JSON null so the client can
// distinguish "never set" from "empty array stored".
@@ -165,6 +182,117 @@ begin
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== GET /entries/tombstones ==============================================
// Sync helper — returns the uuid + deleted_at of every permanently-removed
// entry so the merge engine can propagate deletes to other devices.
procedure HandleGetTombstones(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LQ: TFDQuery;
LArr: TJSONArray;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LArr := TJSONArray.Create;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT uuid, deleted_at FROM entry_tombstones ' +
'WHERE user_id = :uid ORDER BY deleted_at DESC';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
while not LQ.Eof do
begin
LObj := TJSONObject.Create;
LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString);
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== POST /entries/tombstones =============================================
// Sync helper — body {uuids: ["x","y", ...]} adds tombstones for entries
// deleted on another device. Idempotent (UNIQUE constraint).
procedure HandlePostTombstones(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, I, LAdded: Integer;
LBody: TJSONObject;
LArr: TJSONArray;
LQ: TFDQuery;
LUuid: string;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
LAdded := 0;
try
LArr := LBody.GetValue<TJSONArray>('uuids');
if (LArr = nil) or (LArr.Count = 0) then
begin
TJSONHelper.SendOK(AResponse, 'No tombstones');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'VALUES (:uid, :u)';
for I := 0 to LArr.Count - 1 do
begin
LUuid := Trim(LArr.Items[I].Value);
if LUuid = '' then Continue;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('u').AsString := LUuid;
LQ.ExecSQL;
if LQ.RowsAffected > 0 then Inc(LAdded);
end;
// Hard-delete any local entries whose UUID just received a
// tombstone — propagates remote deletes during sync pull.
LQ.SQL.Text :=
'DELETE FROM vault_entries WHERE user_id = :uid AND uuid IN ' +
' (SELECT uuid FROM entry_tombstones WHERE user_id = :uid)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
finally
LBody.Free;
end;
TJSONHelper.SendOK(AResponse, IntToStr(LAdded) + ' tombstones added');
end;
// ===== POST /entries =========================================================
procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
@@ -173,7 +301,7 @@ var
LUserId, LNewId: Integer;
LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv, LIcon, LTemplate: string;
LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
LQ: TFDQuery;
begin
try
@@ -200,6 +328,10 @@ begin
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
LIcon := LBody.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LBody.GetValue<string>('template', ''));
// Caller may bring its own UUID (sync restore / import preserving
// identity). Otherwise the server mints a fresh one.
LUuid := Trim(LBody.GetValue<string>('uuid', ''));
if LUuid = '' then LUuid := NewUUIDv4;
finally
LBody.Free;
end;
@@ -229,9 +361,9 @@ begin
'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, created_at, updated_at, password_changed_at) ' +
' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tpl, :c, :c2, :c)';
' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle;
@@ -266,6 +398,7 @@ begin
LQ.ParamByName('tpl').DataType := ftString;
if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate;
LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL;
@@ -280,6 +413,7 @@ begin
LogAudit(LUserId, 'add_entry', GetClientIP(ARequest));
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LNewId));
LObj.AddPair('uuid', LUuid);
LObj.AddPair('site', LSite);
LObj.AddPair('title', LTitle);
LObj.AddPair('username', LUser);
@@ -475,7 +609,19 @@ begin
try
LQ.Connection := DB.Connection;
if LPermanent then
LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid'
begin
// Record a tombstone BEFORE the delete so the sync engine can
// propagate this removal to other devices. UPSERT semantics —
// re-deleting an already-tombstoned uuid is a no-op.
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'SELECT user_id, uuid FROM vault_entries ' +
'WHERE id = :id AND user_id = :uid AND uuid IS NOT NULL';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid';
end
else
LQ.SQL.Text :=
'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' +
@@ -870,6 +1016,16 @@ begin
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// Tombstones FIRST so the sync engine can propagate the purge.
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'SELECT user_id, uuid FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 AND uuid IS NOT NULL ' +
' AND deleted_at IS NOT NULL ' +
' AND (julianday(''now'') - julianday(deleted_at)) >= :d';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('d').AsInteger := LDays;
LQ.ExecSQL;
LQ.SQL.Text :=
'DELETE FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 ' +
@@ -914,6 +1070,12 @@ begin
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'SELECT user_id, uuid FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 AND uuid IS NOT NULL';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
LQ.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
@@ -940,7 +1102,7 @@ var
LBody, LObj, LEntry: TJSONObject;
LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow,
LKind, LCf, LCfIv, LIcon, LTemplate: string;
LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
LQ: TFDQuery;
begin
try
@@ -985,9 +1147,9 @@ begin
'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, created_at, updated_at) ' +
' icon_b64, template, uuid, created_at, updated_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tpl, :c, :c2)';
' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)';
// 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
@@ -1021,6 +1183,8 @@ begin
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
if LUuid = '' then LUuid := NewUUIDv4;
// Ciphertext is always required. Site is required only for
// logins — notes legitimately have no site (their body lives
@@ -1055,6 +1219,7 @@ begin
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate;
LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL;
@@ -1136,6 +1301,8 @@ initialization
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
Router.Register('GET', '/entries/tombstones', HandleGetTombstones);
Router.Register('POST', '/entries/tombstones', HandlePostTombstones);
Router.Register('POST', '/entries/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);