From 327bcd3385df779f4a9ba0841e26811e247c6164 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 14 Jun 2026 19:53:38 +0200 Subject: [PATCH] fix(quick-save): Drain idempotent (srcId-Dedup) + isSafeUrl-Gate Zwei Befunde aus der Integrations-Review: - Race: der Page-Drain und der Worker machen je ein read-modify-write auf 'quicksave_pending' ohne kontextuebergreifende Atomizitaet. Ein Worker-Append im await-Fenster des Drains konnte einen bereits gedrainten Eintrag in der Queue belassen, den ein Folge-Drain erneut in die Inbox schrieb (Duplikat). Jede eingespielte Bookmark traegt jetzt die Pending-id als srcId; ein erneut auftauchender Eintrag wird uebersprungen statt doppelt eingefuegt. boards-Write bleibt vor der Queue-Bereinigung -> kein Verlust. - Validierung: der Drain hat e.url ohne isSafeUrl gepusht, anders als jeder andere Bookmark-Schreibpfad. isSafeUrl (jetzt im DOM-freien quicksave-core, http/https/ftp) filtert unsichere/leere Protokolle vor dem Schreiben ins Board. --- src/js/app.js | 15 ++++++++++++--- src/js/quicksave-core.js | 12 ++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/js/app.js b/src/js/app.js index 32967a4..3dc6c5c 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -250,10 +250,19 @@ async function drainQuickSavePending() { const drained = pending.slice(); const drainedIds = new Set(drained.map(e => e && e.id).filter(Boolean)); const inbox = await ensureInboxBoard(); // legt die Inbox an, falls noetig; gibt das Board zurueck + // Idempotenz gegen den Worker/Drain-Race auf 'quicksave_pending': jede eingespielte Inbox- + // Bookmark traegt die Pending-id ihres Ursprungs als srcId. Taucht ein bereits gedrainter + // Eintrag durch einen gleichzeitigen Worker-Append erneut in der Queue auf, wird er hier + // uebersprungen statt doppelt eingefuegt — kein Duplikat, und kein Verlust (boards-Write + // bleibt vor der Queue-Bereinigung, daher keine umgekehrte Verlustgefahr). + const seenSrc = new Set(inbox.bookmarks.map(b => b && b.srcId).filter(Boolean)); for (const e of drained) { - if (e && typeof e.url === 'string' && e.url) { - inbox.bookmarks.push(normalizeBookmark({ title: e.title, url: e.url })); - } + if (!e || !e.id || seenSrc.has(e.id)) continue; // schon eingespielt + if (typeof e.url !== 'string' || !e.url || !isSafeUrl(e.url)) continue; // leeres/unsicheres Protokoll verwerfen + const bm = normalizeBookmark({ title: e.title, url: e.url }); + bm.srcId = e.id; // Herkunft fuer kuenftige Dedup + inbox.bookmarks.push(bm); + seenSrc.add(e.id); } await saveBoards(); // NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten. diff --git a/src/js/quicksave-core.js b/src/js/quicksave-core.js index 00706a1..296e505 100644 --- a/src/js/quicksave-core.js +++ b/src/js/quicksave-core.js @@ -34,6 +34,17 @@ return inbox; } + // Sicheres URL-Protokoll (http/https/ftp). Inhaltlich identisch zur data.js-Variante, aber + // DOM-frei und auf globalThis, damit der Quick-Save-Drain (app.js) dieselbe Validierung nutzt + // wie jeder andere Bookmark-Schreibpfad. URL ist in Worker UND Seite verfuegbar. + function isSafeUrl(url) { + try { + return ['http:', 'https:', 'ftp:'].includes(new URL(url).protocol); + } catch (_) { + return false; + } + } + // Normalisiert eine Bookmark in die kanonische Form { id, title, url, desc }. // title-Fallback auf url, desc auf ''. Begrenzt Laengen wie data.js (200/500), // damit Quick-Save-Eintraege das gleiche Schema wie Import/Manuell haben. @@ -52,6 +63,7 @@ root.INBOX_ID = INBOX_ID; root.uid = uid; + root.isSafeUrl = isSafeUrl; root.ensureInbox = ensureInbox; root.normalizeBookmark = normalizeBookmark; })(typeof globalThis !== 'undefined' ? globalThis : self);