Files
Password-Manager/js/app.favicon.js
T
r-zakarya 18fe13a861 feat(icons): preserve user-uploaded icons from bulk fetch/clear
Auto-fetched favicons and user uploads shared one icon_b64 column, so
"Clear cache" wiped custom uploads and "Re-fetch all" overwrote them — data
loss the user can't undo (a custom icon exists precisely because DDG can't
fetch that site).

New icon_custom flag (0=fetched cache, 1=user upload). Invariant: an
automatic action never touches a custom icon; only the user changes/removes
it per entry.

- Schema: icon_custom INTEGER on vault_entries.
- POST /entries/{id}/icon takes {custom}; GET /entries echoes icon_custom;
  DELETE /entries/icons/all filters COALESCE(icon_custom,0)=0.
- saveEntryIcon(id, uri, isCustom): upload→true, fetch/remove→false.
- backfillFavicons skips icon_custom even on force (Re-fetch all).
- clearAllFavicons clears only non-custom locally + refreshes the open editor
  (the icon preview didn't update on clear before).
- duplicate re-stamps a copied custom icon; import-overwrite carries the flag.

Not propagated across bulk-import / sync (the bulk INSERT doesn't read it) —
a restored icon counts as fetched. Documented; custom icons are typically
self-hosted sites DDG won't re-fetch anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:11:25 +01:00

146 lines
6.5 KiB
JavaScript

// ============================================================
// 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.
// isCustom = true for a user upload (preserved from automatic fetch/clear),
// false for an auto-fetched favicon or a clear. The server stores it in
// icon_custom so bulk fetch/clear can skip deliberate uploads.
async function saveEntryIcon(entryId, dataUri, isCustom) {
try {
const r = await fetch(API + '/entries/' + entryId + '/icon', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ icon_b64: dataUri || '', custom: !!isCustom }),
});
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');
return;
}
// Keep the in-memory row's provenance in sync so backfill / clear
// filters see the right value before the next reload.
const ent = state.entries.find(x => x.id === entryId);
if (ent) ent.icon_custom = (dataUri && isCustom) ? 1 : 0;
// Choke point for every icon write (favicon backfill, upload, remove,
// import overwrite) — refresh the editor if it's open on this entry.
editorAbsorbChange(entryId, { icon: dataUri || '' });
} 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;
// Never overwrite a user upload — not even on "Re-fetch all". Only the
// user changes a custom icon, per entry. Missing (no icon) still fetches.
const targets = eligible.filter(e => !e.icon_custom && (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;
}
// Only the auto-fetched cache is cleared locally — user uploads (custom)
// are preserved, mirroring the server's WHERE icon_custom = 0.
state.entries.forEach(e => { if (!e.icon_custom) e.icon_b64 = null; });
// Refresh the open editor's icon preview if it lost its icon.
if (soState && soState.id != null) {
const ent = state.entries.find(x => x.id === soState.id);
if (ent && !ent.icon_custom) editorAbsorbChange(ent.id, { icon: '' });
}
render();
toast('Cached icons cleared');
}