From 0a3372c151892cac5df18b1637b136a5db66b5a3 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:24:35 +0100 Subject: [PATCH] feat: sync guards + import restore mode + inline progress + uncategorised polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync engine - Cross-account guard: refuse to merge a remote snapshot whose username differs from the currently-signed-in one (confirm dialog, Cancel by default) so a shared WebDAV URL / same sync password between accounts stops silently mixing vaults. - Local tombstones veto: skip any remote entry whose uuid is already in the local entry_tombstones table — otherwise a perm-delete on this device was getting undone on the next pull. - "deleted" counter fixed: report only tombstones that actually removed a live local entry this round, not the accumulated history the toast used to inflate ("73 deleted" for 48 real deletes). - Push failure surfaces syncStatus reset + toast so state doesn't get stuck on a stale phase label. Sync progress feedback - Inline #syncStatus label next to the Sync button reports each phase: Local backup… → Pulling… → Merging… → Preparing… N/total (per-entry counter during the slow buildSyncSnapshot decrypt loop) → Encrypting… → Pushing…, then clears. - Sync now / Test connection buttons are disabled while any run is in flight so double-clicks can't kick off a concurrent sync. Import (JSON only — CSV out of scope) - Uuid-aware dedup: split parsed rows into fresh (new uuid) vs overlaps (uuid already present locally). - Overlaps prompt: confirm dialog offers Overwrite (restore/roll back) or Skip. Overwrite PUTs the file's payload over each match; Skip drops them and only imports fresh. Prevents the "re-import doubles everything" regression while still allowing restore. - Bulk-import call is skipped entirely when there's nothing fresh to send (avoids a POST with an empty entries array). - Template notes with empty body but populated custom_fields (credit-card, ssh-key, etc.) no longer skipped as "note body required" — kept as long as at least one custom field has a value. Sidebar / uncategorised view - "(no folder)" pseudo-entry stays visible whenever the vault has any real folder, so it always works as a drag target for uncategorising — and doesn't vanish mid-action when the user is currently viewing it. - Header title reads "(no folder)" for state.view === 'folder:All' instead of the ambiguous "All". - Dedicated empty-state copy for the uncategorised view. Rebuild assets required. Co-Authored-By: Claude Opus 4.7 --- delphi-backend/assets/assets.res | Bin 628488 -> 635104 bytes index.html | 1 + js/app.js | 224 +++++++++++++++++++++++++++---- 3 files changed, 200 insertions(+), 25 deletions(-) diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index d3152b30dcaebc40dbf74aa73332fbcefd72a9f0..85b131c5e9cd550fa97e8dcf75ef0d94e4b3ba4e 100644 GIT binary patch delta 5905 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 + 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();