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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user