// ============================================================ // app.favicon.js — FAVICONS module (extracted from app.js, §3.1) // ============================================================ // // Website favicon fetch/cache (opt-in, via the Delphi DuckDuckGo proxy). // faviconHost() is a pure URL→validated-hostname function (unit-tested); // the rest are api/Bridge/render calls. Pure declarations, no top-level // side effects → loads BEFORE app.js. Uses state, api, Bridge, render via // shared global scope at call time. // // ============================================================ // FAVICONS (opt-in, cached server-side as base64 data URI) // ============================================================ // Extract a usable host from entry.site (we accept anything user-typed). // Returns '' for values that don't look like real hostnames — common case // is users storing a brand label ("Gitea", "Work GitHub") to help the // autofill matcher. Sending those to DDG would leak meaningless tokens // without ever producing an icon. function faviconHost(siteRaw) { if (!siteRaw) return ''; let s = String(siteRaw).trim().toLowerCase(); s = s.replace(/^https?:\/\//, '').replace(/^www\./, ''); s = s.split('/')[0].split(':')[0]; // Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2 // LETTERS. Rejects "Gitea", "my work pwd", and raw IPs like "1.2.3.4" // (numeric final label fails the [a-z]{2,} TLD rule — no favicon for a // bare IP, which is fine). 253-char overall cap mirrors the DNS spec. if (!s || s.length > 253) return ''; if (!/^[a-z0-9.-]+$/.test(s)) return ''; if (s.indexOf('.') < 1) return ''; if (!/\.[a-z]{2,}$/.test(s)) return ''; if (s.startsWith('.') || s.endsWith('.')) return ''; if (s.indexOf('..') >= 0) return ''; return s; } // Save the icon for one entry via the dedicated endpoint (no full PUT, // no re-encryption). Fire-and-forget: failures are silent so a flaky // network doesn't break the user's flow. async function saveEntryIcon(entryId, dataUri) { try { const r = await fetch(API + '/entries/' + entryId + '/icon', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ icon_b64: dataUri || '' }), }); if (!r.ok) { // Surface the server's reason so silent persistence failures // (size cap, auth) stop being invisible bugs. let msg = 'HTTP ' + r.status; try { const b = await r.json(); if (b && b.error) msg = b.error; } catch (_) {} toast('Icon NOT saved: ' + msg, 'error'); } } catch (e) { toast('Icon save failed: ' + (e && e.message || e), 'error'); } } // Fetch + save the favicon for one entry. Updates state.entries in-place // so the next render() picks it up. No-op if the entry already has one. // opts: { force: bypass "already has icon" skip, manual: bypass the global // faviconsEnabled toggle (for explicit user actions like the Refresh button) } async function ensureEntryFavicon(entry, opts) { opts = opts || {}; if (!Bridge.active) return; if (!opts.manual && !state.faviconsEnabled) return; if (!opts.force && entry.icon_b64) return; const host = faviconHost(entry.site); if (!host) return; const dataUri = await Bridge.fetchFavicon(host); if (!dataUri) return; entry.icon_b64 = dataUri; await saveEntryIcon(entry.id, dataUri); // Full render() — patching the avatar in place is fragile because // the avatar also contains the checkbox overlay. render(); } // Backfill: walk state.entries, fetch missing icons one at a time so we // don't hammer the upstream. Used by the "Refresh icons" button. async function backfillFavicons(force) { if (!Bridge.active) return; const all = state.entries; const eligible = all.filter(e => faviconHost(e.site)); const skipped = all.length - eligible.length; const targets = eligible.filter(e => force || !e.icon_b64); if (targets.length === 0) { if (skipped > 0) { toast('No icons to fetch — ' + skipped + ' entries have a non-domain site (e.g. "Gitea")', 'warning'); } else { toast('No icons to fetch'); } return; } toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…'); let ok = 0; for (const e of targets) { // Explicit user action — bypass the global toggle so the buttons // work even when "Fetch website icons" is OFF (the toggle only // gates auto-fetch on save). await ensureEntryFavicon(e, { force: !!force, manual: true }); if (e.icon_b64) ok++; } toast('Fetched ' + ok + ' / ' + targets.length + ' icons'); render(); } async function clearAllFavicons() { try { await fetch(API + '/entries/icons/all', { method: 'DELETE', headers: authHeaders(), }); } catch (e) { toast('Failed to clear icons', 'error'); return; } state.entries.forEach(e => { e.icon_b64 = null; }); render(); toast('Cached icons cleared'); }