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 85b131c..0591097 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index 1e1c7a5..5b5ed65 100644 --- a/index.html +++ b/index.html @@ -350,6 +350,7 @@