diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index d3152b3..85b131c 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 777f534..1e1c7a5 100644 --- a/index.html +++ b/index.html @@ -707,6 +707,7 @@
+
diff --git a/js/app.js b/js/app.js index 6be184a..17424b0 100644 --- a/js/app.js +++ b/js/app.js @@ -8165,7 +8165,16 @@ function parseEntriesFromJSON(text) { // Notes legitimately have no `site` — their "content" lives in // password (the note body). Logins still need both site + pwd. if (kind === 'login' && (!site || !pwd)) { skipped++; continue; } - if (kind === 'note' && !pwd) { skipped++; continue; } + // Notes with a template (credit-card, ssh-key, etc.) carry data + // in custom_fields — an empty body is legitimate as long as at + // least one custom field has content. Only skip a note if BOTH + // the body AND every custom field are empty. + if (kind === 'note' && !pwd) { + const cfList = Array.isArray(e.custom_fields) ? e.custom_fields : []; + const anyFieldFilled = cfList.some(f => + f && (String(f.value || '').trim() !== '')); + if (!anyFieldFilled) { skipped++; continue; } + } const tagsVal = e.tags; const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',') @@ -8230,6 +8239,13 @@ async function encryptImportEntry(plain) { cfIv = c.iv; } catch (e) { /* drop silently */ } } + // tags may arrive as a comma-separated string (CSV / our own JSON + // export) or as a real array (buildSyncSnapshot uses parseTags → []). + // The server's HandleCreateEntry does GetValue which throws + // "TJSONArray → string non supporté" on an array — normalise here. + const tagsStr = Array.isArray(plain.tags) + ? plain.tags.filter(Boolean).join(',') + : (plain.tags || ''); return { uuid: plain.uuid || '', site: plain.site, @@ -8238,7 +8254,7 @@ async function encryptImportEntry(plain) { encrypted_password: pw.encrypted, iv: pw.iv, folder: plain.folder || 'All', - tags: plain.tags || '', + tags: tagsStr, totp_secret: totpEnc, totp_iv: totpIv, kind: plain.kind === 'note' ? 'note' : 'login', @@ -8332,8 +8348,9 @@ async function doImport() { }); if (!confirmed) return; - const parsedAtt = parsed.entries.reduce( - (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); + // parsedAtt is computed AFTER dedup — see below where + // dedupedEntries is defined. + let parsedAtt = 0; // Apply folder customisation (color, icon) from the payload — // additive only: existing local folders are left untouched so the @@ -8394,20 +8411,84 @@ async function doImport() { if (createdMissing > 0) await loadFolders(); } - toast('Encrypting ' + parsed.entries.length + ' entries…'); + // Dedupe by uuid: split parsed rows into "fresh" (uuid absent + // locally, safe to bulk-insert) and "overlapping" (uuid already + // exists — the user is either re-importing a backup or rolling + // back to an earlier version). Ask what to do with overlapping + // entries so a restore isn't silently blocked by the dedup. + const localByUuid = new Map(); + for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e); + const fresh = []; + const overlaps = []; + for (const e of parsed.entries) { + if (e.uuid && localByUuid.has(e.uuid)) overlaps.push(e); + else fresh.push(e); + } + let overwriteOverlaps = false; + if (overlaps.length > 0) { + overwriteOverlaps = await confirmDialog({ + title: overlaps.length + ' entries already in vault', + message: '' + overlaps.length + ' entries in this file ' + + 'already exist locally (same UUID).

' + + 'Choose Overwrite to replace the local version with the ' + + 'file\'s (rolls back edits made since the backup was taken).

' + + 'Choose Skip to keep the current local version and only ' + + 'import genuinely new entries.', + okText: 'Overwrite', + cancelText: 'Skip', + danger: true, + }); + } + if (fresh.length === 0 && !overwriteOverlaps) { + return toast('Nothing new to import', 'warning'); + } + parsedAtt = fresh.reduce( + (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); + + toast('Encrypting ' + fresh.length + ' entries…'); const encrypted = []; - for (const e of parsed.entries) { + for (const e of fresh) { encrypted.push(await encryptImportEntry(e)); } + // Overwriting overlaps: PUT each existing entry with the file's + // content. Attachments on the local entry stay in place — the + // user typically wants to roll back credentials, not lose + // manually-uploaded files. Adjust if that assumption changes. + let overwritten = 0; + if (overwriteOverlaps) { + for (const src of overlaps) { + try { + const local = localByUuid.get(src.uuid); + if (!local) continue; + const enc = await encryptImportEntry(src); + await api('/entries/' + local.id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(enc), + }); + overwritten++; + } catch (_) { /* skip the single row on failure */ } + } + } + // Use dedupedEntries as an alias for fresh so downstream code + // (attachments loop) keeps working without a second rename. + const dedupedEntries = fresh; + try { - const r = await api('/entries/bulk-import', { - method: 'POST', - headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ entries: encrypted }), - }); - toast('Imported ' + r.imported + ' entries'); + let r = { imported: 0, ids: [] }; + if (encrypted.length > 0) { + r = await api('/entries/bulk-import', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ entries: encrypted }), + }); + } + const tailMsg = overwritten > 0 + ? ' · ' + overwritten + ' overwritten' + : ''; + toast('Imported ' + r.imported + ' entries' + tailMsg); // Restore attachments. ids[] is parallel-indexed with the // input (-1 = server skipped this row), so we can map back @@ -8417,9 +8498,9 @@ async function doImport() { toast(parsedAtt + ' attachment(s) skipped — server missing /ids response', 'warning'); } let attachCount = 0; - for (let i = 0; i < parsed.entries.length; i++) { + for (let i = 0; i < dedupedEntries.length; i++) { const newId = ids[i]; - const atts = parsed.entries[i].attachments; + const atts = dedupedEntries[i].attachments; if (typeof newId !== 'number' || newId <= 0) continue; if (!Array.isArray(atts) || atts.length === 0) continue; for (const a of atts) { @@ -9486,8 +9567,17 @@ async function buildSyncSnapshot() { entries: [], tombstones: [], }; - for (const e of state.entries) { - if (!e.uuid) continue; // legacy row that missed the backfill — skip + // Progress feedback: buildSyncSnapshot dominates runtime (per-entry + // decrypt + attachment fetch). Report N/total as we go so the user + // doesn't think the app froze on large vaults. + const eligible = state.entries.filter(e => e && e.uuid); + const total = eligible.length; + let done = 0; + for (const e of eligible) { + done++; + if (total > 5 && (done === 1 || done % 5 === 0 || done === total)) { + syncStatus('Preparing… ' + done + '/' + total); + } const plain = await decryptPwd(e.encrypted_password, e.iv); let plainTotp = ''; if (e.totp_secret && e.totp_iv) { @@ -9544,22 +9634,26 @@ async function buildSyncSnapshot() { } async function applyRemoteSnapshot(remote) { - if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0 }; - let added = 0, updated = 0, deleted = 0; + 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. + // 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. if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) { const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean); if (uuids.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 }), }); - deleted = uuids.length; + deleted = uuids.filter(u => preLocal.has(u)).length; } catch (_) {} } } @@ -9569,6 +9663,18 @@ async function applyRemoteSnapshot(remote) { const byUuid = new Map(); for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e); + // Local tombstones must veto ANY remote entry with a matching uuid — + // otherwise a perm-delete on this device gets undone by the next + // pull ("48 added" after wiping the vault, because the remote + // snapshot still carries the pre-delete state). The tombstones are + // pushed back to the remote at the next push, propagating the + // delete cleanly. + const localTombstones = new Set(); + try { + const ts = await api('/entries/tombstones', { headers: authHeaders() }); + (Array.isArray(ts) ? ts : []).forEach(t => t.uuid && localTombstones.add(t.uuid)); + } catch (_) {} + // Folders — add missing ones with the remote's color/icon. Existing // folders are left untouched (user's local customisation wins). if (Array.isArray(remote.folders)) { @@ -9591,6 +9697,9 @@ async function applyRemoteSnapshot(remote) { // Per-entry merge. for (const r of remote.entries) { if (!r.uuid) continue; + // Skip entries this device has already tombstoned — never + // resurrect a deleted entry. + if (localTombstones.has(r.uuid)) continue; const local = byUuid.get(r.uuid); if (!local) { // New entry on remote — encrypt locally + POST keeping the uuid. @@ -9622,7 +9731,7 @@ async function applyRemoteSnapshot(remote) { } catch (_) {} } } - } catch (_) {} + } catch (_) { failed++; } } else { // Both sides have it — keep the newer one (lexical ISO sort). const remoteWins = (r.updated_at || '') > (local.updated_at || ''); @@ -9638,10 +9747,29 @@ async function applyRemoteSnapshot(remote) { body: JSON.stringify(enc), }); updated++; - } catch (_) {} + } catch (_) { failed++; } } } - return { added, updated, deleted }; + return { added, updated, deleted, failed }; +} + +// Update the inline sync-status label next to the button. Empty string +// hides it (end of sync). Called from every phase so the user sees +// where the runtime is spent (buildSyncSnapshot is by far the slowest, +// hence the per-entry counter). +function syncStatus(text) { + const el = document.getElementById('syncStatus'); + if (el) { + if (text) { el.textContent = text; el.style.display = ''; } + else { el.textContent = ''; el.style.display = 'none'; } + } + // Lock the Sync/Test buttons while a run is in flight so the user + // can't double-click a second concurrent sync. + const active = !!text; + ['syncNowBtn', 'syncTestBtn'].forEach(id => { + const b = document.getElementById(id); + if (b) b.disabled = active; + }); } async function runSyncNow() { @@ -9657,6 +9785,7 @@ async function runSyncNow() { const ab = await loadAutoBackupConfig(); const dir = (ab && ab.dir) ? ab.dir : null; if (dir) { + syncStatus('Local backup…'); const snap = await buildSyncSnapshot(); const container = await encryptExportPayload(snap, cfg.encPwd); const ts = new Date().toISOString() @@ -9668,19 +9797,48 @@ async function runSyncNow() { } 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); - const snap = await decryptExportContainer(container, cfg.encPwd); + 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 ' + + (snap.username + '').replace(/[<>&]/g, '') + + ', but you are signed in as ' + + (state.username + '').replace(/[<>&]/g, '') + + '. 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) { - return toast('Remote decrypt failed — wrong sync password?', 'error'); + 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. @@ -9694,21 +9852,37 @@ async function runSyncNow() { return toast('Sync pull failed: ' + (e && e.message || e), 'error'); } + // Guard against data loss: if we failed to import ONE or more remote + // entries locally (server rejected them, encryption failed, etc.), + // pushing the current local snapshot would silently overwrite the + // 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) { + 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. try { await loadEntries(); // pull latest after applying remote changes const snap = await buildSyncSnapshot(); + syncStatus('Encrypting…'); const container = await encryptExportPayload(snap, cfg.encPwd); const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); + syncStatus('Pushing…'); const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, bytesToBase64(bodyBytes)); if (!(r.status >= 200 && r.status < 300)) { + syncStatus(''); return toast('Push failed: HTTP ' + r.status, 'error'); } } catch (e) { + syncStatus(''); return toast('Sync push failed: ' + (e && e.message || e), 'error'); } + syncStatus(''); const now = new Date().toISOString(); Bridge.setPref(SYNC_PREFS.last, now); render();