feat: profile avatar + tombstone-restore fix + WebView2 nav race + sync summary
Profile picture / avatar - users.avatar_b64 column (nullable, cosmetic, not encrypted) + GET/POST /avatar endpoints mirroring the settings handler pattern. - Top-right chip + Settings→Account show a round avatar: custom picture if set, otherwise the username's initial on a deterministic hash-picked colour (stable across renders). - Upload downscales + center-crops to a 128px JPEG via FileReader → data: URI (NOT blob:, which the CSP's `img-src 'self' data:` blocks) before POSTing. Remove button clears it. - Carried in the encrypted JSON export; restored on import only when the current account has no picture (never clobbers a local one). Tombstone restore-then-sync fix - POST /entries and POST /entries/bulk-import now DELETE any tombstone matching an inserted uuid (same transaction) so a restored backup isn't re-killed on the next sync by its own stale tombstone. - applyRemoteSnapshot arbitrates remote tombstones by timestamp: a tombstone is skipped when the local entry with that uuid is newer than deleted_at (resurrection wins). Ties / unparseable timestamps favour KEEP. loadEntries() up front so updated_at reflects the live rows. WebView2 navigation race - Black-window-on-cold-start fix: the 1.5s nav timer no longer consumes FPendingURL when WebView2 isn't initialised yet (it re-arms, bounded to ~10 retries). FBrowserInitialized flag set in OnInitialized; after the retry budget we Navigate best-effort rather than loop forever. Sync UX - Bidirectional toast: "pulled X new · Y updated · Z deleted · pushed N entries" so a 0/0/0 pull still shows the vault was uploaded. - FolderPOST/PUT: pre-declare ftString on color/icon params (fixes the earlier [SQLite]-335 on NULL bind, already in play for CSV import). Docs - CLAUDE.md sync section documents tombstone purge-on-insert + resurrection arbitration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -403,6 +403,15 @@ begin
|
||||
LQ.ParamByName('c2').AsString := LNow;
|
||||
LQ.ExecSQL;
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||
|
||||
// Clear any tombstone shadowing this uuid — a re-created entry
|
||||
// (sync restore keeping its identity, or an undo of a hard
|
||||
// delete) must not be silently re-killed on the next sync.
|
||||
LQ.SQL.Text :=
|
||||
'DELETE FROM entry_tombstones WHERE user_id = :uid AND uuid = :uuid';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('uuid').AsString := LUuid;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
@@ -1103,7 +1112,7 @@ var
|
||||
LArr, LIds: TJSONArray;
|
||||
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow,
|
||||
LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
|
||||
LQ: TFDQuery;
|
||||
LQ, LTomb: TFDQuery;
|
||||
begin
|
||||
try
|
||||
LUserId := Authenticate(ARequest, AResponse);
|
||||
@@ -1141,8 +1150,18 @@ begin
|
||||
DB.Connection.StartTransaction;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
// Reused across the batch to clear any tombstone shadowing an
|
||||
// imported uuid. Without this, restoring a backup whose entries
|
||||
// were previously hard-deleted (and tombstoned) would get those
|
||||
// entries wiped again on the next sync — the tombstone outlives
|
||||
// the resurrection. Purging here lets a restore actually stick.
|
||||
LTomb := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LTomb.Connection := DB.Connection;
|
||||
LTomb.SQL.Text :=
|
||||
'DELETE FROM entry_tombstones ' +
|
||||
'WHERE user_id = :uid AND uuid = :uuid';
|
||||
LQ.SQL.Text :=
|
||||
'INSERT INTO vault_entries ' +
|
||||
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
|
||||
@@ -1226,9 +1245,16 @@ begin
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||
LIds.AddElement(TJSONNumber.Create(LNewId));
|
||||
Inc(LImported);
|
||||
|
||||
// Clear any tombstone that would otherwise resurrect-then-kill
|
||||
// this uuid on the next sync.
|
||||
LTomb.ParamByName('uid').AsInteger := LUserId;
|
||||
LTomb.ParamByName('uuid').AsString := LUuid;
|
||||
LTomb.ExecSQL;
|
||||
end;
|
||||
finally
|
||||
LQ.Free;
|
||||
LTomb.Free;
|
||||
end;
|
||||
DB.Connection.Commit;
|
||||
except
|
||||
|
||||
@@ -106,8 +106,92 @@ begin
|
||||
TJSONHelper.SendOK(AResponse);
|
||||
end;
|
||||
|
||||
// GET /avatar -> { avatar_b64: <data-uri or ''> }
|
||||
// Fetched once at login (enterApp) so the image isn't re-sent on every
|
||||
// settings save.
|
||||
procedure HandleGetAvatar(ARequest: TIdHTTPRequestInfo;
|
||||
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||
var
|
||||
LUserId: Integer;
|
||||
LQ: TFDQuery;
|
||||
LObj: TJSONObject;
|
||||
LVal: string;
|
||||
begin
|
||||
LUserId := Authenticate(ARequest, AResponse);
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text := 'SELECT avatar_b64 FROM users WHERE id = :uid';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.Open;
|
||||
if LQ.IsEmpty then LVal := '' else LVal := LQ.FieldByName('avatar_b64').AsString;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
|
||||
LObj := TJSONObject.Create;
|
||||
LObj.AddPair('avatar_b64', LVal);
|
||||
TJSONHelper.SendJSON(AResponse, LObj);
|
||||
end;
|
||||
|
||||
// POST /avatar body: { avatar_b64: <data-uri> } ('' clears it)
|
||||
procedure HandleSetAvatar(ARequest: TIdHTTPRequestInfo;
|
||||
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||
var
|
||||
LUserId: Integer;
|
||||
LBody: TJSONObject;
|
||||
LVal: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
LUserId := Authenticate(ARequest, AResponse);
|
||||
RequireCSRF(ARequest, AResponse, LUserId);
|
||||
|
||||
LBody := TJSONHelper.ReadBody(ARequest);
|
||||
try
|
||||
LVal := LBody.GetValue<string>('avatar_b64', '');
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
|
||||
// Cap ~700 KB base64 (~512 KB raw) — the client downscales to a small
|
||||
// square before upload, so anything larger is a bug or an attack.
|
||||
if Length(LVal) > 720000 then
|
||||
begin
|
||||
TJSONHelper.SendError(AResponse, 413, 'Avatar too large');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
try
|
||||
LQ.Connection := DB.Connection;
|
||||
LQ.SQL.Text := 'UPDATE users SET avatar_b64 = :a WHERE id = :uid';
|
||||
LQ.ParamByName('a').DataType := ftMemo;
|
||||
if LVal = '' then LQ.ParamByName('a').Clear
|
||||
else LQ.ParamByName('a').Value := LVal;
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ExecSQL;
|
||||
finally
|
||||
LQ.Free;
|
||||
end;
|
||||
finally
|
||||
DB.Unlock;
|
||||
end;
|
||||
|
||||
TJSONHelper.SendOK(AResponse);
|
||||
end;
|
||||
|
||||
initialization
|
||||
Router.Register('GET', '/settings', HandleGetSettings);
|
||||
Router.Register('PUT', '/settings', HandlePutSettings);
|
||||
Router.Register('GET', '/avatar', HandleGetAvatar);
|
||||
Router.Register('POST', '/avatar', HandleSetAvatar);
|
||||
|
||||
end.
|
||||
|
||||
@@ -370,6 +370,10 @@ begin
|
||||
// toggles (quick-unlock DPAPI, Win32 autofill hotkey) intentionally stay
|
||||
// in localStorage and are NOT included here.
|
||||
AddColumnIfMissing('users', 'settings_json', 'TEXT DEFAULT ''{}''');
|
||||
// Profile picture: base64 data URI (nullable). Cosmetic, not encrypted.
|
||||
// Kept in its own column rather than settings_json so it isn't shipped
|
||||
// on every settings GET/PUT (an image is 5-50 KB).
|
||||
AddColumnIfMissing('users', 'avatar_b64', 'TEXT');
|
||||
AddColumnIfMissing('sessions', 'csrf_token', 'TEXT');
|
||||
end;
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ type
|
||||
FBridge: TPMBridge;
|
||||
FPendingURL: string;
|
||||
FNavTimer: TTimer;
|
||||
// Set once WebView2 fires OnInitialized. The nav timer only consumes
|
||||
// FPendingURL when this is True — otherwise a timer tick that lands
|
||||
// before the engine is ready would Navigate() into the void AND clear
|
||||
// FPendingURL, leaving OnInitialized nothing to do → permanent black
|
||||
// window on slow cold starts.
|
||||
FBrowserInitialized: Boolean;
|
||||
FNavRetries: Integer; // bounded retry count for the deferred nav timer
|
||||
FRequireAccessToken: Boolean;
|
||||
FRequireProcessCheck: Boolean;
|
||||
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
|
||||
@@ -288,6 +295,7 @@ begin
|
||||
// the engine is ready, so if a navigation is still pending here, do it
|
||||
// now. The timer either already ran (FPendingURL == '') or runs later
|
||||
// and no-ops on the empty string.
|
||||
FBrowserInitialized := True;
|
||||
FNavTimer.Enabled := False;
|
||||
if FPendingURL <> '' then
|
||||
begin
|
||||
@@ -435,6 +443,7 @@ begin
|
||||
if FServer.RequireAccessToken then
|
||||
FPendingURL := FPendingURL + '?pmt=' + FServer.AccessToken;
|
||||
LogLine('Will navigate embedded browser in ~1.5s to: ' + MaskAccessToken(FPendingURL));
|
||||
FNavRetries := 0;
|
||||
FNavTimer.Enabled := False;
|
||||
FNavTimer.Enabled := True;
|
||||
end;
|
||||
@@ -443,6 +452,23 @@ procedure TMainForm.NavTimerTick(Sender: TObject);
|
||||
begin
|
||||
FNavTimer.Enabled := False;
|
||||
if FPendingURL = '' then Exit;
|
||||
// Engine not ready yet: Navigate() would be silently dropped. Leave
|
||||
// FPendingURL intact and re-arm — either this timer catches the engine
|
||||
// once it's up, or OnInitialized fires first and does the nav. Whoever
|
||||
// wins clears FPendingURL so the other no-ops (no reload flash).
|
||||
// Bounded to ~10 retries (15 s): if OnInitialized never fires (missing /
|
||||
// broken WebView2 runtime), we stop deferring and attempt Navigate once
|
||||
// anyway — best effort beats an eternal retry loop on a blank window.
|
||||
if (not FBrowserInitialized) and (FNavRetries < 10) then
|
||||
begin
|
||||
Inc(FNavRetries);
|
||||
LogLine(Format('Nav deferred — WebView2 not initialised (retry %d/10).',
|
||||
[FNavRetries]));
|
||||
FNavTimer.Enabled := True;
|
||||
Exit;
|
||||
end;
|
||||
if not FBrowserInitialized then
|
||||
LogLine('WebView2 still not initialised after retries — attempting nav anyway.');
|
||||
LogLine('Navigating to: ' + MaskAccessToken(FPendingURL));
|
||||
WebBrowser.Navigate(FPendingURL);
|
||||
FPendingURL := '';
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user