diff --git a/CLAUDE.md b/CLAUDE.md index 29c15fd..7bec40c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -844,7 +844,11 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien. chargé à `enterApp`. Pas de photo → initiale sur couleur déterministe (`avatarColorFor(username)`). Upload downscale 128px JPEG via `FileReader` → `data:` URI (PAS `blob:` — la CSP `img-src 'self' data:` bloque blob). - Inclus dans l'export JSON (`avatar_b64`), restauré à l'import si absent. + Inclus dans l'export JSON, l'auto-backup ET le snapshot de sync + (`avatar_b64`). Restauré **additivement** (seulement si le device n'a pas + déjà d'avatar) à l'import et dans `applyRemoteSnapshot` — pas de timestamp + par avatar, donc on n'écrase jamais une photo locale (changer un avatar + existant ne se propage pas ; cosmétique, assumé). - **Quick search — modes fill** (Ctrl+Shift+Q) : Enter/clic-gauche = password seul, Shift+Enter/clic-droit = full (user + Tab + pwd), Ctrl+Enter/ Ctrl+clic = username seul. `Bridge.executeAutofill(u, p, hide, 'user')` diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index f342fb4..6b80146 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -305,8 +305,10 @@ cf. la checklist "Entry payload" de CLAUDE.md). - 🔵 **Combobox custom fields** : navigation clavier (flèches ↑↓ + Enter) dans le menu déroulant, pas seulement souris. - 🔵 **Auto-lock** : afficher le temps restant avant lock dans un coin. -- 🔵 **Avatar** : l'inclure dans le sync snapshot + auto-backup (actuellement - seulement dans l'export manuel) pour cohérence multi-device. +- ~~🔵 **Avatar** : l'inclure dans le sync snapshot + auto-backup~~ — ✅ fait : + `avatar_b64` dans `buildSyncSnapshot` + payload auto-backup ; restauré + additivement dans `applyRemoteSnapshot` (adopté seulement si le device n'a + pas d'avatar, jamais écrasé). +2 tests merge. --- diff --git a/js/app.backup.js b/js/app.backup.js index bd2c8ee..ee65f42 100644 --- a/js/app.backup.js +++ b/js/app.backup.js @@ -146,6 +146,9 @@ async function runAutoBackupNow(silent) { color: f.color || '', icon: f.icon || '', })), + // Same as the manual export container — keep the avatar so a + // restore from an auto-backup brings the profile picture back. + avatar_b64: state.avatarDataUri || '', entries: [], }; let _bkDone = 0; diff --git a/js/app.sync.js b/js/app.sync.js index 6770508..869d603 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -197,6 +197,9 @@ async function buildSyncSnapshot() { folders: (state.folders || []) .filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })), + // Cosmetic, unencrypted — travels so a new device picks up the avatar + // (adopted only if that device has none; see applyRemoteSnapshot). + avatar_b64: state.avatarDataUri || '', entries: [], tombstones: [], }; @@ -355,6 +358,21 @@ async function applyRemoteSnapshot(remote) { await loadFolders(); } + // Avatar (cosmetic, unencrypted) — adopt the remote one only if this + // device has none, mirroring the import path. Additive so we never fight + // over / clobber a locally-set picture (no per-avatar timestamp to + // arbitrate). A device that already has an avatar keeps it. + if (typeof remote.avatar_b64 === 'string' && remote.avatar_b64 && !state.avatarDataUri) { + try { + await api('/avatar', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ avatar_b64: remote.avatar_b64 }), + }); + state.avatarDataUri = remote.avatar_b64; + } catch (_) {} + } + // Per-entry merge. for (const r of remote.entries) { if (!r.uuid) continue; diff --git a/js/tests/merge.test.js b/js/tests/merge.test.js index b4340ba..c8223e2 100644 --- a/js/tests/merge.test.js +++ b/js/tests/merge.test.js @@ -49,6 +49,10 @@ async function freshMerge() { db.folders.push({ name: body.name, color: body.color || '', icon: body.icon || '' }); return { ok: true }; } + if (path === '/avatar' && method === 'POST') { + db.avatar = body.avatar_b64 || ''; + return { ok: true }; + } if (path === '/entries/tombstones' && method === 'GET') { return db.tombstones.map(t => Object.assign({}, t)); } @@ -220,6 +224,28 @@ test('merge: missing remote folders are added additively', async () => { assert.equal(added.color, '#0f0'); }); +test('merge: remote avatar is adopted when the local device has none', async () => { + const { ctx, T, db } = await freshMerge(); + ctx.__test.state.avatarDataUri = ''; // this device has no picture + await T.applyRemoteSnapshot({ + entries: [], tombstones: [], folders: [], + avatar_b64: 'data:image/jpeg;base64,AAAA', + }); + assert.equal(db.avatar, 'data:image/jpeg;base64,AAAA', 'avatar POSTed to server'); + assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,AAAA'); +}); + +test('merge: remote avatar does NOT clobber an existing local avatar (additive)', async () => { + const { ctx, T, db } = await freshMerge(); + ctx.__test.state.avatarDataUri = 'data:image/jpeg;base64,LOCAL'; + await T.applyRemoteSnapshot({ + entries: [], tombstones: [], folders: [], + avatar_b64: 'data:image/jpeg;base64,REMOTE', + }); + assert.equal(db.avatar, undefined, 'no /avatar POST when a local avatar exists'); + assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,LOCAL'); +}); + test('merge: empty/invalid remote snapshot is a no-op', async () => { const { T, db } = await freshMerge(); db.seedEntry({ uuid: 'u1' });