feat(sync): include avatar in sync snapshot + auto-backup (multi-device)

The profile picture (users.avatar_b64, cosmetic/unencrypted) travelled only
in the manual export. Now it's also in buildSyncSnapshot and the auto-backup
container, so a new device / a restore picks it up.

- Restore is ADDITIVE (mirrors the import path): applyRemoteSnapshot adopts
  remote.avatar_b64 only when the local device has no avatar — never clobbers
  a locally-set picture. No per-avatar timestamp to arbitrate, so changing an
  existing avatar doesn't propagate (cosmetic, accepted).
- +2 merge tests (adopt-when-empty, don't-clobber-when-set). 65/65 green.
- Server /avatar endpoint unchanged (already accepts {avatar_b64}).

Closes the avatar item of CODE_AUDIT §4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-08 20:06:31 +01:00
parent dd86b2bd23
commit c58424d58c
5 changed files with 56 additions and 3 deletions
+5 -1
View File
@@ -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 chargé à `enterApp`. Pas de photo → initiale sur couleur déterministe
(`avatarColorFor(username)`). Upload downscale 128px JPEG via `FileReader` (`avatarColorFor(username)`). Upload downscale 128px JPEG via `FileReader`
`data:` URI (PAS `blob:` — la CSP `img-src 'self' data:` bloque blob). `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 = - **Quick search — modes fill** (Ctrl+Shift+Q) : Enter/clic-gauche =
password seul, Shift+Enter/clic-droit = full (user + Tab + pwd), Ctrl+Enter/ password seul, Shift+Enter/clic-droit = full (user + Tab + pwd), Ctrl+Enter/
Ctrl+clic = username seul. `Bridge.executeAutofill(u, p, hide, 'user')` Ctrl+clic = username seul. `Bridge.executeAutofill(u, p, hide, 'user')`
+4 -2
View File
@@ -305,8 +305,10 @@ cf. la checklist "Entry payload" de CLAUDE.md).
- 🔵 **Combobox custom fields** : navigation clavier (flèches ↑↓ + Enter) - 🔵 **Combobox custom fields** : navigation clavier (flèches ↑↓ + Enter)
dans le menu déroulant, pas seulement souris. dans le menu déroulant, pas seulement souris.
- 🔵 **Auto-lock** : afficher le temps restant avant lock dans un coin. - 🔵 **Auto-lock** : afficher le temps restant avant lock dans un coin.
- 🔵 **Avatar** : l'inclure dans le sync snapshot + auto-backup (actuellement - ~~🔵 **Avatar** : l'inclure dans le sync snapshot + auto-backup~~ — ✅ fait :
seulement dans l'export manuel) pour cohérence multi-device. `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.
--- ---
+3
View File
@@ -146,6 +146,9 @@ async function runAutoBackupNow(silent) {
color: f.color || '', color: f.color || '',
icon: f.icon || '', 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: [], entries: [],
}; };
let _bkDone = 0; let _bkDone = 0;
+18
View File
@@ -197,6 +197,9 @@ async function buildSyncSnapshot() {
folders: (state.folders || []) folders: (state.folders || [])
.filter(f => f && f.name && f.name !== 'All') .filter(f => f && f.name && f.name !== 'All')
.map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })), .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: [], entries: [],
tombstones: [], tombstones: [],
}; };
@@ -355,6 +358,21 @@ async function applyRemoteSnapshot(remote) {
await loadFolders(); 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. // Per-entry merge.
for (const r of remote.entries) { for (const r of remote.entries) {
if (!r.uuid) continue; if (!r.uuid) continue;
+26
View File
@@ -49,6 +49,10 @@ async function freshMerge() {
db.folders.push({ name: body.name, color: body.color || '', icon: body.icon || '' }); db.folders.push({ name: body.name, color: body.color || '', icon: body.icon || '' });
return { ok: true }; return { ok: true };
} }
if (path === '/avatar' && method === 'POST') {
db.avatar = body.avatar_b64 || '';
return { ok: true };
}
if (path === '/entries/tombstones' && method === 'GET') { if (path === '/entries/tombstones' && method === 'GET') {
return db.tombstones.map(t => Object.assign({}, t)); 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'); 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 () => { test('merge: empty/invalid remote snapshot is a no-op', async () => {
const { T, db } = await freshMerge(); const { T, db } = await freshMerge();
db.seedEntry({ uuid: 'u1' }); db.seedEntry({ uuid: 'u1' });