From 7440d0779363c36f69121fff0d7eb7c9c99f0c79 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:36:36 +0100 Subject: [PATCH] feat: profile avatar + tombstone-restore fix + WebView2 nav race + sync summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 17 +- css/style.css | 21 ++ .../Handlers/PM.Handler.Entries.pas | 28 +- .../Handlers/PM.Handler.Settings.pas | 84 +++++ delphi-backend/Source/PM.Database.pas | 4 + delphi-backend/UMainForm.pas | 26 ++ delphi-backend/assets/assets.res | Bin 635104 -> 648092 bytes index.html | 21 +- js/app.js | 341 ++++++++++++++---- 9 files changed, 471 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b26fa5d..be59ce2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -489,7 +489,11 @@ device, never transmitted. auto-backup folder if enabled. 2. `webdav/get` → 404 = first sync, treat as empty remote. 3. Decrypt with `syncEncPwd` (reuses `encryptExportPayload` container). -4. POST remote tombstones → server hard-deletes local matches. +4. POST remote tombstones → server hard-deletes local matches, BUT + with resurrection arbitration : a remote tombstone (`{uuid, deleted_at}`) + is skipped if a local entry with that uuid has `updated_at > deleted_at` + (restored/edited after the delete → resurrection wins, no silent + re-kill). Ties + unparseable timestamps favour KEEP. 5. Folders : add missing ones additively (don't touch existing). 6. Entries : for each remote uuid → not in local = POST keeping uuid + restore attachments ; both sides have it = compare `updated_at`, @@ -501,6 +505,17 @@ device, never transmitted. Sensitive actions (export, change master pw, recovery code…) still require master pw via `askReauth` — sync never substitutes. +**Tombstone purge on (re)create** : `POST /entries` and +`POST /entries/bulk-import` both DELETE any `entry_tombstones` row +matching the inserted uuid (same transaction). Without this, restoring +a backup whose entries were previously hard-deleted would leave the +tombstone in place — the local `buildSyncSnapshot` would then re-push +it and the next pull would kill the just-restored entries. Purge-on- +insert + the resurrection arbitration (step 4) together make +restore-then-sync actually stick. A live `vault_entries` row can never +coexist with its tombstone (hard-delete removes the row), so `PUT` +needs no purge. + ## PIN unlock Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick diff --git a/css/style.css b/css/style.css index f423976..cda7190 100644 --- a/css/style.css +++ b/css/style.css @@ -751,6 +751,27 @@ input[type="range"]::-webkit-slider-thumb { transition: all var(--t-fast); } .user-chip:hover { background: var(--bg-elev-2); } +/* Round initials avatar. Background colour is set inline from a hash of + the username (deterministic — same user, same colour every render). + Falls back to a background-image when a custom picture is set. */ +.user-avatar { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; + margin-left: -4px; + border-radius: 50%; + font-size: 11px; font-weight: 600; + color: #fff; + text-transform: uppercase; + background-size: cover; background-position: center; + flex-shrink: 0; + user-select: none; +} +/* Larger preview variant shown in Settings → Account. */ +.user-avatar-lg { + width: 48px; height: 48px; + margin-left: 0; + font-size: 20px; +} .user-dropdown { position: absolute; top: calc(100% + 6px); right: 0; min-width: 180px; diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 6f11f44..fe2e7b8 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -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 diff --git a/delphi-backend/Handlers/PM.Handler.Settings.pas b/delphi-backend/Handlers/PM.Handler.Settings.pas index e1d7262..12e15c3 100644 --- a/delphi-backend/Handlers/PM.Handler.Settings.pas +++ b/delphi-backend/Handlers/PM.Handler.Settings.pas @@ -106,8 +106,92 @@ begin TJSONHelper.SendOK(AResponse); end; +// GET /avatar -> { avatar_b64: } +// 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); +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: } ('' clears it) +procedure HandleSetAvatar(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +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('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. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 4f78e1d..882b9a0 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -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; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index b68040a..b736f6c 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -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 := ''; diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 85b131c5e9cd550fa97e8dcf75ef0d94e4b3ba4e..0591097fa0455e3557892d7f190476542446c6f3 100644 GIT binary patch delta 12057 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 delta 2423 zcmZ8iZEO_P8tzPY&)F6zjke3R%JuMJy9K%}22mjuDik9cY(dl?#^mhI+1;_TGvk~Y z*7c%Y6l|-6uOhEX6Gfx(2NDd?iT)50jU;{`Mx%*esV2rmjc9Vwi1A+EGutl6CcArP z&NkF<>UJag6feXq5RpMGokc+=ah z<9j|_b?n?bS8s^d)zuxkD@?a)D+j}tytehH@Uu<&d&@?wgF1fe@f!=RgYn-!4nv1L zF*p2mH(4GZ9x`3#IVH(T1xE@N1g62brerDGVVgJHJh-`^WgT1k9ye{DIrfD(!|K4!B}_WZ z6H|Z+!7m6Vc~P(o&khAHD>;E>Fb7|IB@^Q`oFHR~3z4GZnXrAYoM!#_XJSkx(=RxI z&v^A>E|(IaJtxQ)*teDa%wJXzbD`M2bU{ZK^!7gbU0^9?n zwRtl}E?F2X@eZ!SQ~*8U8;zo`5efNwDfD*#SGBBMYE7Cq4%@K2H{g zmFLMr$?)(SWcNTgHiI5r7(UZOfA1L{2#_#O6dhkM-zjELmNrTtXL@q#DiMRI3FN!L zJ)_GImcYf_F0&%{vr4*A;!xia0g;-NjQTdLRmh^45uTD}QRx-`%cw)U!^b)94!eST zC>y7UYRd(F;8o>W19LIHG^^6(*jD+%ApHVIk!Jls8I4urLLV~A+;S00Byc<%{e?2N z4dpUl#Ztii*`OF$$fm(?9$krNDBukGX8K}Ezp_U4knNNh&!9y*r>=xiam<$|a+b)V z`SEHN3sC$F-Hs8a8{x_^WOmY~6do|>wO~J3PZlYuD>GE{pFW@ebdkF$)aMP%ulfN# z7)rDyl#P0`{EMNSHF|1`W2oU*8HnZ`QX!_galK7>F5Rhp*pD7bN>In*&%V zWZBM6mj>nIjKG*hE{El4334K&VkLtl4zXydW3wDLE#xFz@;LoOhc7GivSu~4$>)9` zQyl|=Wx@9q+Sja#9O{C(_y#lC3%xtFd2pg%Z-B=hrpu_B$ME6S{d9i2GOL$%dD(Kf z(aHW(Gu||cw7I(yem$cvgkMhUaoD$mUafiXz7qRnJ~0D++(Emb`5G+_S3XMD!1c5A z`LMb|n&3tCcseAP4CAHOc#f1^$Z+KLe1P&@p`t+Ir*h8JNwR=q$Oy}^^H}H{#{Ury z;uvNwCp;99QcHw|3b7*?RqL{WrzTN@(#+d9~3=p>KqC!(>u# zgju&GIu6`T6X15oD7-X6myVy-Vh3I&4e;#Y~D#@@V_&95?~iy5L2Nd4ygHlN;$`IMGUC@H5w2;nYcus9uo(z8&v#d_TPoDu?3~j((3nbDzL! zqCG;F!=6*|M0n3`I;O!br{c4rX)pZ>k{`rcfPE2bg7$qhAAY`%{;Bs=JD?1TMUI88 zw