a7ad81c708
Sixth slice of the app.js split. Moves the favicon fetch/cache section
(faviconHost, saveEntryIcon, ensureEntryFavicon, backfillFavicons,
clearAllFavicons) to js/app.favicon.js. Pure declarations, no top-level
side effects → loads before app.js.
- Code moved byte-for-byte; no duplicate const; syntax OK on all 7 app parts.
- NEW: js/tests/favicon.test.js — 7 tests for faviconHost, the pure
URL→validated-hostname function that decides which domain is sent to the
DuckDuckGo proxy (a bug there leaks the wrong host). Covers scheme/www/
path/port stripping, non-hostname rejection, malformed dotting, unsafe
chars, and the 253-char DNS cap.
- Fixed an inaccurate source comment surfaced by the tests: it claimed raw
IPs "stay valid", but the TLD rule /\.[a-z]{2,}$/ rejects a numeric final
label, so IPs get no favicon lookup (fine). Test pins the real behaviour.
- Suite: 55 → 62 tests, all green. Assets regenerated (8 ordered JS files).
app.js: 11936 → 9790 lines (6 modules extracted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
5.1 KiB
JavaScript
126 lines
5.1 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.
|
|
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');
|
|
}
|
|
|