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:
r-zakarya
2026-07-02 23:36:36 +01:00
parent 0a3372c151
commit 7440d07793
9 changed files with 471 additions and 71 deletions
+275 -66
View File
@@ -482,6 +482,9 @@ const state = {
csrf: sessionStorage.getItem('csrfToken') || '',
salt: sessionStorage.getItem('salt') || '',
username: sessionStorage.getItem('username') || '',
// Profile picture as a data URI. Loaded from the server at enterApp
// (users.avatar_b64). Empty → the initials avatar is shown instead.
avatarDataUri: '',
// KDF iteration count of the currently-logged-in user. Cached so reauth
// and on-the-fly verifier computations don't need a /login/challenge
// round trip every time. Refreshed from every auth response.
@@ -1890,6 +1893,7 @@ function lockVault() {
state.trashed = [];
state.locked = true;
state.justRecovered = false;
state.avatarDataUri = ''; // reloaded from server on next unlock
if (typeof authTickTimer !== 'undefined' && authTickTimer) {
clearInterval(authTickTimer); authTickTimer = null;
}
@@ -2241,6 +2245,124 @@ function filteredEntries() {
return list;
}
// Deterministic avatar colour: hash the username to a hue so the same
// account always gets the same background (no flicker across renders).
// Uses a fixed palette of pleasant saturated colours rather than raw
// HSL so every avatar reads well on the dark chrome.
const AVATAR_COLORS = [
'#e05a5a', '#e0895a', '#e0b45a', '#8bc34a', '#4caf82',
'#4aa3c3', '#5a7be0', '#7b5ae0', '#b45ae0', '#e05a9e',
];
function avatarColorFor(name) {
const s = String(name || '?');
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
// Paint the top-right user avatar: custom picture if one is set (data
// URI in state.avatarDataUri), otherwise the username's first letter on
// a deterministic colour.
function renderUserAvatar() {
const el = $('#userAvatar');
if (!el) return;
const pic = state.avatarDataUri || '';
if (pic) {
el.style.backgroundImage = 'url("' + pic + '")';
el.style.backgroundColor = 'transparent';
el.textContent = '';
} else {
el.style.backgroundImage = 'none';
el.style.backgroundColor = avatarColorFor(state.username);
el.textContent = (state.username || '?').trim().charAt(0) || '?';
}
// Keep the Settings preview (if the panel is open) in sync too.
const prev = $('#settingAvatarPreview');
if (prev) {
const pic2 = state.avatarDataUri || '';
if (pic2) {
prev.style.backgroundImage = 'url("' + pic2 + '")';
prev.style.backgroundColor = 'transparent';
prev.textContent = '';
} else {
prev.style.backgroundImage = 'none';
prev.style.backgroundColor = avatarColorFor(state.username);
prev.textContent = (state.username || '?').trim().charAt(0) || '?';
}
const rm = $('#settingAvatarRemove');
if (rm) rm.style.display = pic2 ? '' : 'none';
}
}
// Fetch the stored profile picture from the server and repaint.
async function loadUserAvatar() {
try {
const r = await api('/avatar', { headers: authHeaders() });
state.avatarDataUri = (r && r.avatar_b64) || '';
} catch (_) { state.avatarDataUri = ''; }
renderUserAvatar();
}
// Downscale + re-encode a picked image file to a small square JPEG data
// URI so we never store a multi-MB original. Returns a Promise<string>.
function processAvatarFile(file) {
return new Promise((resolve, reject) => {
// Read the file as a data: URI (not a blob: URL) — the app's CSP
// allows `img-src 'self' data:` but NOT blob:, so an <img> pointed
// at an object URL would fail to load.
const reader = new FileReader();
reader.onerror = () => reject(new Error('read failed'));
reader.onload = () => {
const img = new Image();
img.onload = () => {
const size = 128; // final square px
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
// Center-crop to a square, then draw scaled into 128×128.
const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2;
const sy = (img.height - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, size, size);
resolve(canvas.toDataURL('image/jpeg', 0.85));
};
img.onerror = () => reject(new Error('bad image'));
img.src = reader.result; // data:image/...;base64,...
};
reader.readAsDataURL(file);
});
}
async function uploadUserAvatar(file) {
if (!file || !/^image\//.test(file.type)) return toast('Pick an image file', 'error');
let dataUri;
try { dataUri = await processAvatarFile(file); }
catch (_) { return toast('Could not read that image', 'error'); }
try {
await api('/avatar', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ avatar_b64: dataUri }),
});
state.avatarDataUri = dataUri;
renderUserAvatar();
toast('Profile picture updated');
} catch (e) { toast(e.message || 'Upload failed', 'error'); }
}
async function removeUserAvatar() {
try {
await api('/avatar', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ avatar_b64: '' }),
});
state.avatarDataUri = '';
renderUserAvatar();
toast('Profile picture removed');
} catch (e) { toast(e.message || 'Failed', 'error'); }
}
function parseTags(s) {
if (!s) return [];
return s.split(',').map(t => t.trim()).filter(Boolean);
@@ -8209,7 +8331,8 @@ function parseEntriesFromJSON(text) {
icon_b64: String(e.icon_b64 || '').trim(),
});
}
return { entries, skipped, columns: null, folders };
return { entries, skipped, columns: null, folders,
avatar_b64: typeof data.avatar_b64 === 'string' ? data.avatar_b64 : '' };
}
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
@@ -8383,6 +8506,21 @@ async function doImport() {
}
}
// Restore the profile picture from the backup — only when the
// current account has none, so an import doesn't clobber a
// picture the user already set on this device.
if (parsed.avatar_b64 && !state.avatarDataUri) {
try {
await api('/avatar', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ avatar_b64: parsed.avatar_b64 }),
});
state.avatarDataUri = parsed.avatar_b64;
renderUserAvatar();
} catch (_) { /* non-critical */ }
}
// CSV imports (Bitwarden / KeePass / Chrome) don't carry a
// folders[] block — they just stamp a folder name on each row.
// Bulk-import stores the name but never creates the folders
@@ -8616,6 +8754,9 @@ async function doExport() {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
// Profile picture (data URI) so a restore brings the avatar
// back. Empty string when none set.
avatar_b64: state.avatarDataUri || '',
// Folder customisation (color, icon) so restoring on a fresh
// install brings the sidebar back the way the user had it,
// not the default gray + folder-icon. 'All' is synthetic and
@@ -9110,6 +9251,7 @@ function openSettings() {
});
}
$('#settingUser').textContent = state.username;
renderUserAvatar(); // sync the Account-section preview + Remove button
// Hide the version row entirely in the PHP/web frontend (no bridge).
if (Bridge.active) {
$('#settingVersionRow').style.display = '';
@@ -9342,6 +9484,8 @@ async function enterApp() {
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
renderUserAvatar();
loadUserAvatar(); // async — repaints the avatar once the pic arrives
// Server-side prefs override localStorage cache; runs before render so
// theme / view mode / mask flags are applied to the first paint.
await loadServerSettings();
@@ -9637,23 +9781,51 @@ async function applyRemoteSnapshot(remote) {
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0 };
let added = 0, updated = 0, deleted = 0, failed = 0;
// Push remote tombstones first — server will hard-delete any matching
// local entries AND remember them so they don't reappear from a future
// local push. Count only tombstones that actually deleted a live
// local entry this round (not the accumulated history that both
// sides already know about) so the toast reflects real user impact.
// Ensure state.entries reflects the live DB before we read updated_at
// for the resurrection arbitration below — a restore-then-sync must
// see the restored rows' fresh timestamps.
await loadEntries();
// Apply remote tombstones — but arbitrate against local resurrections.
// A remote tombstone says "this uuid was deleted at T". If the local
// entry with that uuid was updated AFTER T (e.g. restored from a
// backup since the deletion), the resurrection wins and the tombstone
// is skipped — otherwise a restore would be silently undone on the
// next sync. Entries the local side hasn't touched since T are
// deleted normally (standard delete propagation).
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
if (uuids.length > 0) {
// Snapshot local uuid → updated_at BEFORE any deletion.
const localTs = new Map();
for (const e of state.entries)
if (e.uuid) localTs.set(e.uuid, e.updated_at || '');
// A local entry beats the tombstone only if it exists AND is
// provably newer than deleted_at. Unparseable/missing timestamps
// favour KEEP (data-loss is worse than a stale entry the user can
// re-delete).
const isResurrected = (uuid, deletedAt) => {
if (!localTs.has(uuid)) return false; // not local → apply
const up = Date.parse(String(localTs.get(uuid)).replace(' ', 'T'));
const del = Date.parse(String(deletedAt || '').replace(' ', 'T'));
if (isNaN(up)) return true; // can't tell → keep
if (isNaN(del)) return false; // no delete time → apply
return up > del;
};
const toApply = remote.tombstones
.filter(t => t.uuid && !isResurrected(t.uuid, t.deleted_at))
.map(t => t.uuid);
if (toApply.length > 0) {
const preLocal = new Set();
for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid);
try {
await api('/entries/tombstones', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ uuids }),
body: JSON.stringify({ uuids: toApply }),
});
deleted = uuids.filter(u => preLocal.has(u)).length;
deleted = toApply.filter(u => preLocal.has(u)).length;
} catch (_) {}
}
}
@@ -9779,7 +9951,64 @@ async function runSyncNow() {
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
// Pre-sync backup — best effort, doesn't block sync on failure.
toast('Syncing…');
let merged = { added: 0, updated: 0, deleted: 0 };
// Fail-fast connectivity: hit the remote FIRST so a dead server /
// wrong URL aborts before any heavy local work (the pre-sync backup
// and buildSyncSnapshot are expensive on large vaults — no point
// running them if we can't reach the server).
syncStatus('Connecting…');
let pullResp;
try {
pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
} catch (e) {
syncStatus('');
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
}
if (pullResp.status === 0) {
syncStatus('');
return toast('Network error: ' + (pullResp.payload || 'unreachable'), 'error');
}
if (!(pullResp.status === 404 ||
(pullResp.status >= 200 && pullResp.status < 300))) {
syncStatus('');
return toast('Pull failed: HTTP ' + pullResp.status, 'error');
}
// Decrypt + cross-account guard BEFORE touching local state, so a
// wrong sync password or a foreign account aborts cleanly.
let remoteSnap = null;
if (pullResp.status !== 404 && pullResp.payload) {
try {
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
const container = JSON.parse(jsonText);
remoteSnap = await decryptExportContainer(container, cfg.encPwd);
} catch (e) {
syncStatus('');
return toast('Remote decrypt failed — wrong sync password?', 'error');
}
if (remoteSnap && remoteSnap.username && state.username &&
remoteSnap.username !== state.username) {
const ok = await confirmDialog({
title: 'Different account on remote',
message: 'The remote snapshot belongs to <b>' +
(remoteSnap.username + '').replace(/[<>&]/g, '') +
'</b>, but you are signed in as <b>' +
(state.username + '').replace(/[<>&]/g, '') +
'</b>. Merging would mix the two vaults. ' +
'Use a distinct sync URL per account.',
okText: 'Merge anyway',
cancelText: 'Cancel',
danger: true,
});
if (!ok) { syncStatus(''); return toast('Sync cancelled — account mismatch', 'warning'); }
}
}
// Server reachable + snapshot decrypted → NOW do the optional
// pre-sync backup (captures current local state before the merge
// mutates it). Best-effort; a failure here doesn't block the sync.
if (cfg.preBackup) {
try {
const ab = await loadAutoBackupConfig();
@@ -9796,60 +10025,15 @@ async function runSyncNow() {
} catch (_) { /* best-effort */ }
}
toast('Syncing…');
syncStatus('Pulling…');
let merged = { added: 0, updated: 0, deleted: 0 };
let pulled = false;
try {
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
if (r.status >= 200 && r.status < 300 && r.payload) {
let snap;
try {
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
const container = JSON.parse(jsonText);
snap = await decryptExportContainer(container, cfg.encPwd);
} catch (e) {
return toast('Remote decrypt failed — wrong sync password?', 'error');
}
// Cross-account guard: if the remote snapshot belongs to a
// different account than the one currently signed in, refuse
// to merge — otherwise a shared URL / same sync password
// between accounts silently pulls foreign entries into the
// current vault and pushes the polluted state back out.
if (snap && snap.username && state.username &&
snap.username !== state.username) {
const ok = await confirmDialog({
title: 'Different account on remote',
message: 'The remote snapshot belongs to <b>' +
(snap.username + '').replace(/[<>&]/g, '') +
'</b>, but you are signed in as <b>' +
(state.username + '').replace(/[<>&]/g, '') +
'</b>. Merging would mix the two vaults. ' +
'Use a distinct sync URL per account.',
okText: 'Merge anyway',
cancelText: 'Cancel',
danger: true,
});
if (!ok) return toast('Sync cancelled — account mismatch', 'warning');
}
syncStatus('Merging…');
try {
merged = await applyRemoteSnapshot(snap);
pulled = true;
} catch (e) {
syncStatus('');
return toast('Apply failed: ' + (e && e.message || e), 'error');
}
} else if (r.status === 404) {
// First sync — no remote yet, we'll just upload our state.
pulled = true;
} else if (r.status === 0) {
return toast('Network error: ' + (r.payload || 'unreachable'), 'error');
} else {
return toast('Pull failed: HTTP ' + r.status, 'error');
// Apply the remote snapshot (404 → nothing to merge, first sync).
if (remoteSnap) {
syncStatus('Merging…');
try {
merged = await applyRemoteSnapshot(remoteSnap);
} catch (e) {
syncStatus('');
return toast('Apply failed: ' + (e && e.message || e), 'error');
}
} catch (e) {
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
}
// Guard against data loss: if we failed to import ONE or more remote
@@ -9858,15 +10042,18 @@ async function runSyncNow() {
// remote file with a shrunken dataset. Abort the push and surface a
// clear error so the user can investigate + retry.
if (merged.failed && merged.failed > 0) {
syncStatus('');
return toast('Sync aborted — ' + merged.failed +
' remote entry(ies) failed to import locally. Push skipped to avoid overwriting remote data.',
'error');
}
// Push merged state back to the remote.
let pushedCount = 0;
try {
await loadEntries(); // pull latest after applying remote changes
const snap = await buildSyncSnapshot();
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0;
syncStatus('Encrypting…');
const container = await encryptExportPayload(snap, cfg.encPwd);
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
@@ -9886,10 +10073,17 @@ async function runSyncNow() {
const now = new Date().toISOString();
Bridge.setPref(SYNC_PREFS.last, now);
render();
// Bidirectional summary: the added/updated/deleted counts are what
// was pulled FROM the remote into this device; pushedCount is the
// total entries written back to the remote (so "0·0·0 · pushed 12"
// makes clear the vault is safely uploaded even when nothing new
// came down).
const summary =
merged.added + ' added · ' +
'pulled ' + merged.added + ' new · ' +
merged.updated + ' updated · ' +
merged.deleted + ' deleted';
merged.deleted + ' deleted · ' +
'pushed ' + pushedCount + ' ' +
(pushedCount === 1 ? 'entry' : 'entries');
toast('Sync complete — ' + summary);
}
@@ -11133,6 +11327,21 @@ async function init() {
$('#exportCsvBtn').addEventListener('click', doExportCSV);
$('#importBtn').addEventListener('click', doImport);
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
// Profile picture: "Change picture" opens the hidden file input;
// selecting a file downscales + uploads it; "Remove" clears it.
const avaUpload = $('#settingAvatarUpload');
const avaInput = $('#settingAvatarInput');
const avaRemove = $('#settingAvatarRemove');
if (avaUpload && avaInput) {
avaUpload.addEventListener('click', () => avaInput.click());
avaInput.addEventListener('change', async e => {
const f = e.target.files && e.target.files[0];
e.target.value = ''; // allow re-picking the same file later
if (f) await uploadUserAvatar(f);
});
}
if (avaRemove) avaRemove.addEventListener('click', removeUserAvatar);
$('#changeMasterForm').addEventListener('submit', e => {
e.preventDefault();
doChangeMasterPassword();