feat: website favicons + vault health dashboard

Favicons
- PM.Favicon (new): THTTPClient/WinHTTP proxy to icons.duckduckgo.com.
  Native Windows TLS — no OpenSSL DLLs to ship (Indy would fail
  silently without them). 5 s timeout, max 3 redirects, 64 KB cap,
  magic-byte MIME sniffing.
- DB: vault_entries.icon_b64 TEXT (idempotent migration).
- Endpoints: POST /entries/{id}/icon stores a cached data URI without
  forcing a full PUT (which would re-encrypt the password). DELETE
  /entries/icons/all purges the cache.
- Bridge cmd://favicon/fetch?host=X&reqId=Y runs in an anonymous thread
  so the up-to-5 s HTTP GET doesn't block the main thread; result
  shipped back via Bridge.onFaviconResult(reqId, host, dataUri).
- Hostname validated on both sides (JS faviconHost + Delphi
  NormalizeHost) so brand labels like "Gitea" never leak upstream.
- Settings: opt-in "Fetch website icons" toggle (synced), three explicit
  actions (Fetch missing / Re-fetch all / Clear cache) that bypass the
  toggle — manual user actions always work.
- Entry card avatar shows <img> when cached, falls back to initials.
  onerror handler recovers silently from a corrupt data URI.

Vault health
- New sidebar Tools → "Vault health" view. Four category cards:
  Weak (strength < 50), Reused (same plaintext on ≥ 2 entries), Old
  (updated_at > 365d), Pwned (HIBP cache).
- Score 0-100 with colour band (Good/Fair/At risk/Critical).
- One-shot computation cached per session (healthCache), invalidated
  on lockVault, entry save, and the explicit "Recompute" button.
- "Fix" button on each item opens the slideover for the affected
  entry, unmasks the password, focuses it, and pulses the dice button
  — full context preserved, user decides how to fix.
- Click handler stopPropagation prevents the document-level
  "click outside slideover" listener from closing the panel that
  we just opened in the same click event.

Fixes
- openSlideover typo (lowercase O) → openSlideOver across all call
  sites. Was silently breaking the Authenticator card click and the
  Vault health Fix button.
- W1050 WideChar warning in PM.Favicon — replaced set-membership
  with explicit Ord-style range comparisons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:05:54 +01:00
parent 33e4b4b614
commit ad5fb21a18
11 changed files with 967 additions and 5 deletions
+425 -4
View File
@@ -15,6 +15,8 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
// Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {};
let autoStartResolver = null;
const faviconResolvers = {};
let _faviconReqSeq = 0;
const Bridge = (() => {
const active = (API === '');
@@ -218,6 +220,33 @@ const Bridge = (() => {
const cb = document.getElementById('settingAutoStart');
if (cb) cb.checked = !!enabled;
},
// ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ----------
// Returns a Promise<dataUri|''>. Multiple in-flight requests for
// distinct hosts are tracked per reqId so they can't collide.
fetchFavicon(host) {
if (!active) return Promise.resolve('');
if (!host) return Promise.resolve('');
const reqId = 'fav_' + (++_faviconReqSeq);
return new Promise(resolve => {
faviconResolvers[reqId] = resolve;
cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (faviconResolvers[reqId]) {
delete faviconResolvers[reqId];
resolve('');
}
}, 8000);
});
},
onFaviconResult(reqId, host, dataUri) {
const r = faviconResolvers[reqId];
if (r) {
delete faviconResolvers[reqId];
r(dataUri || '');
}
},
};
})();
@@ -280,6 +309,10 @@ const state = {
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
'{"folders":false,"tags":false,"tools":false}'),
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
// default — opt-in because it sends each entry's domain to a third
// party (DuckDuckGo). Synced because it's a portable preference.
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1',
};
// ============================================================
@@ -506,6 +539,111 @@ async function decryptTotpSecret(encB64, ivB64) {
return await decryptPwd(encB64, ivB64);
}
// ============================================================
// 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", IP-like "1.2.3.4" stays
// valid (DDG handles IPs gracefully). 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 {
await fetch(API + '/entries/' + entryId + '/icon', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ icon_b64: dataUri || '' }),
});
} catch (e) { /* silent */ }
}
// 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');
}
// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
function randomBase32Secret(numBytes) {
@@ -1046,6 +1184,7 @@ function lockVault() {
if (typeof totpToolTimer !== 'undefined' && totpToolTimer) {
clearInterval(totpToolTimer); totpToolTimer = null;
}
if (typeof healthCache !== 'undefined') healthCache = null;
showAuth();
// Two UI variants for the auth screen:
@@ -1212,6 +1351,7 @@ function viewTitle() {
if (state.view === 'favorites') return 'Favorites';
if (state.view === 'trash') return 'Trash';
if (state.view === 'authenticator') return 'Authenticator';
if (state.view === 'health') return 'Vault health';
if (state.view.startsWith('folder:')) return state.view.slice(7);
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
return 'Items';
@@ -1386,6 +1526,21 @@ async function addTagToEntry(id, tag) {
function renderGrid() {
$('#contentTitle').textContent = viewTitle();
// Vault health dashboard: bypass the standard list rendering entirely.
if (state.view === 'health') {
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
renderBatchBar();
$('#contentMeta').textContent = state.entries.length +
(state.entries.length === 1 ? ' entry analysed' : ' entries analysed');
const grid = $('#entryGrid');
grid.className = 'entry-grid is-health';
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
renderHealthDashboard(grid);
return;
}
// Authenticator view: bypass the standard pipeline — render a dedicated
// grid of TOTP cards (only entries that have a TOTP secret configured).
if (state.view === 'authenticator') {
@@ -1496,7 +1651,7 @@ function renderAuthenticatorGrid(grid, entries) {
wrap.appendChild(barWrap);
// Click anywhere on card (outside copy) opens the entry detail.
wrap.addEventListener('click', () => openSlideover(e.id));
wrap.addEventListener('click', () => openSlideOver(e.id));
return { entry: e, wrap, codeEl, bar, secret: null };
});
@@ -1546,6 +1701,214 @@ function renderAuthenticatorGrid(grid, entries) {
})();
}
// ============================================================
// VAULT HEALTH dashboard
// ============================================================
//
// One-shot computation per session — decrypting every entry is the
// expensive part, so we cache the result and clear it on lock / entry
// edit / view re-entry (Tools → Vault health).
let healthCache = null;
const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak
const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year
function entryAgeDays(e) {
const ts = e.updated_at || e.created_at;
if (!ts) return 0;
// ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking.
const d = new Date(ts.replace(' ', 'T'));
if (isNaN(d)) return 0;
return Math.floor((Date.now() - d.getTime()) / 86400000);
}
async function computeHealthCache() {
const weak = [], old = [], pwned = [];
const byPwd = new Map(); // plaintext → [entries]
for (const e of state.entries) {
const ageD = entryAgeDays(e);
if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD });
const pwn = state.hibpResults.get(e.id);
if (typeof pwn === 'number' && pwn > 0)
pwned.push({ entry: e, count: pwn });
// Decrypt for strength + reuse detection. '[ERROR]' bubbles up
// from decryptPwd for corrupted ciphertext — skip those silently.
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') continue;
const score = computeStrength(plain);
if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score });
if (!byPwd.has(plain)) byPwd.set(plain, []);
byPwd.get(plain).push(e);
}
// Reuse: groups of ≥2 entries sharing the same plaintext password.
const reused = [];
for (const [, entries] of byPwd) {
if (entries.length >= 2) reused.push(entries);
}
// Score: start at 100, subtract per issue (capped at 0). Weights
// chosen so a single pwned password dominates over a single old one.
let score = 100;
score -= Math.min(40, weak.length * 5);
score -= Math.min(30, reused.length * 10);
score -= Math.min(20, old.length * 2);
score -= Math.min(50, pwned.length * 15);
if (score < 0) score = 0;
return { weak, reused, old, pwned, score };
}
function healthScoreBand(score) {
if (score >= 80) return { label: 'Good', cls: 'is-ok' };
if (score >= 50) return { label: 'Fair', cls: 'is-fair' };
if (score >= 25) return { label: 'At risk', cls: 'is-warn' };
return { label: 'Critical', cls: 'is-danger' };
}
// Open the entry slideover, unmask the password, focus it, and pulse the
// generator button. The user keeps full context (which entry they're
// fixing) and decides whether to type a new password, click the dice, or
// dismiss. Auto-opening the generator modal hid the entry context and
// forced an extra Save click — worse UX than this lighter nudge.
async function openEntryForFix(entryId) {
await openSlideOver(entryId);
const pwd = document.getElementById('soPassword');
if (pwd) {
pwd.type = 'text'; // unmask so the user sees what they're replacing
pwd.focus();
pwd.select();
}
const genBtn = document.querySelector('.so-pw-row button[title="Generate"]');
if (genBtn) {
genBtn.classList.add('is-pulse');
setTimeout(() => genBtn.classList.remove('is-pulse'), 2000);
}
}
async function renderHealthDashboard(grid) {
// Recompute on demand. The "Recompute" button below also triggers it.
if (!healthCache) {
grid.appendChild(el('div', { class: 'health-loading' },
'Analysing ' + state.entries.length + ' entries…'));
healthCache = await computeHealthCache();
grid.innerHTML = '';
}
const h = healthCache;
const band = healthScoreBand(h.score);
// Header: big score + recompute action
const header = el('div', { class: 'health-header' });
const scoreEl = el('div', { class: 'health-score ' + band.cls });
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
header.appendChild(scoreEl);
const intro = el('div', { class: 'health-intro' });
intro.appendChild(el('h3', null, 'How healthy is your vault?'));
intro.appendChild(el('p', null,
'A summary of weak, reused, old and breached passwords. ' +
'Click any item to open it and rotate the password.'));
const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
recompute.appendChild(icon('i-rotate-ccw'));
recompute.appendChild(document.createTextNode(' Recompute'));
recompute.addEventListener('click', () => {
healthCache = null;
render();
});
intro.appendChild(recompute);
header.appendChild(intro);
grid.appendChild(header);
// Four category cards
grid.appendChild(renderHealthSection({
title: 'Weak passwords',
hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD +
'/100 (short / few character classes).',
items: h.weak,
empty: 'All passwords pass the strength check. 👍',
formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100',
}));
grid.appendChild(renderHealthSection({
title: 'Reused passwords',
hint: 'Same password used on multiple entries — a single breach affects them all.',
items: h.reused,
empty: 'Every password is unique. 👍',
formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') +
' (' + group.length + ' entries)',
// Click on a reused group: open the first entry. Could be smarter.
idOfItem: group => group[0].id,
}));
grid.appendChild(renderHealthSection({
title: 'Old passwords',
hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) +
' months. Consider rotating periodically for high-value accounts.',
items: h.old,
empty: 'No stale passwords.',
formatItem: it => entryDisplayName(it.entry) + ' — ' +
Math.floor(it.ageDays / 30) + ' months old',
}));
grid.appendChild(renderHealthSection({
title: 'Breached passwords (HIBP)',
hint: state.hibpEnabled
? 'Found in the Have I Been Pwned database. Change them now.'
: 'Enable “Check passwords against breach database” in Settings to populate this list.',
items: h.pwned,
empty: state.hibpEnabled
? 'No password matches a known breach. 👍'
: '— breach check is OFF —',
formatItem: it => entryDisplayName(it.entry) +
' — seen ' + it.count.toLocaleString() + 'x',
}));
}
// Build one collapsible category card. opts:
// title, hint, items[], empty,
// formatItem(item) → text for the row,
// idOfItem(item) → entry id used by the Fix click. Default: item.entry.id
function renderHealthSection(opts) {
const card = el('section', { class: 'health-card' });
const head = el('header', { class: 'health-card-head' });
head.appendChild(el('h4', null, opts.title));
const badge = el('span', { class: 'health-badge' }, String(opts.items.length));
if (opts.items.length === 0) badge.classList.add('is-empty');
head.appendChild(badge);
card.appendChild(head);
card.appendChild(el('p', { class: 'health-hint' }, opts.hint));
if (opts.items.length === 0) {
card.appendChild(el('p', { class: 'health-empty' }, opts.empty));
return card;
}
const list = el('ul', { class: 'health-list' });
const getId = opts.idOfItem || (it => it.entry.id);
opts.items.slice(0, 20).forEach(it => {
const li = el('li', { class: 'health-item' });
li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it)));
const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' },
'Fix');
fix.addEventListener('click', ev => {
// Stop bubbling — the document-level "click outside slideover"
// handler would otherwise close the slideover we're about to
// open within the same click event.
ev.stopPropagation();
openEntryForFix(getId(it));
});
li.appendChild(fix);
list.appendChild(li);
});
card.appendChild(list);
if (opts.items.length > 20) {
card.appendChild(el('p', { class: 'health-more' },
'+ ' + (opts.items.length - 20) + ' more not shown'));
}
return card;
}
function showEmptyState() {
const illustration = $('#emptyIllustration use');
const title = $('#emptyTitle');
@@ -1714,9 +2077,19 @@ function renderCard(e) {
// checked. Card click anywhere not on the checkbox opens slideover.
const head = el('div', { class: 'entry-head' });
const displayName = entryDisplayName(e);
const avatar = el('div', {
class: 'entry-avatar',
}, initials(displayName));
const avatar = el('div', { class: 'entry-avatar' });
if (e.icon_b64) {
const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' });
// If the cached data URI fails to decode (corrupt blob), fall
// back to the initials so the card never shows a broken-image icon.
img.addEventListener('error', () => {
avatar.innerHTML = '';
avatar.textContent = initials(displayName);
});
avatar.appendChild(img);
} else {
avatar.textContent = initials(displayName);
}
const checkbox = el('button', {
class: 'entry-check' + (checked ? ' is-checked' : ''),
type: 'button',
@@ -2988,8 +3361,16 @@ async function saveEntry(e) {
}
closeEntryModal();
await loadEntries();
if (typeof healthCache !== 'undefined') healthCache = null;
render();
if (savedId) flashEntry(savedId);
// Fire-and-forget favicon fetch for the saved entry. Updates the
// card in place when it arrives. No-op when feature is off or
// the entry already has a cached icon.
if (savedId && state.faviconsEnabled) {
const saved = state.entries.find(e => e.id === savedId);
if (saved) ensureEntryFavicon(saved); // honours the toggle
}
} catch (err) {
toast(err.message, 'error');
}
@@ -4875,6 +5256,11 @@ function openSettings() {
$('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled;
$('#settingShowSite').checked = state.showSiteOnCards;
$('#settingFavicons').checked = state.faviconsEnabled;
// Action buttons + toggle row only meaningful when the Delphi bridge
// is available (the PHP frontend has no outbound proxy).
$('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none';
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
$('#settingAutofill').checked = state.autofillEnabled;
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
// Hotkey capture buttons — labels reflect current combos.
@@ -5034,6 +5420,7 @@ const SYNCED_SETTING_KEYS = [
// Sidebar section collapsed state. Object of { folders, tags, tools }
// booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed',
'faviconsEnabled',
];
function applySidebarCollapsed() {
@@ -5073,6 +5460,9 @@ async function loadServerSettings() {
// Object; persist as JSON so the next cold start picks it up.
localStorage.setItem(k, JSON.stringify(v));
break;
case 'faviconsEnabled':
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
break;
}
});
// Apply visual settings immediately.
@@ -5394,6 +5784,14 @@ async function init() {
render();
});
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool);
$('#sidebarHealthBtn').addEventListener('click', () => {
state.view = 'health';
state.currentPage = 1;
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
// Invalidate any stale cache so we recompute fresh each open.
healthCache = null;
render();
});
// Sidebar section collapse toggles
document.querySelectorAll('[data-section-toggle]').forEach(btn => {
@@ -5481,6 +5879,29 @@ async function init() {
? 'Will start with Windows (in tray)'
: 'Wont start with Windows');
});
$('#settingFavicons').addEventListener('change', e => {
state.faviconsEnabled = e.target.checked;
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
saveServerSettings();
if (state.faviconsEnabled) {
// Auto-backfill on first opt-in so the user sees the effect
// immediately instead of having to click the refresh button.
backfillFavicons(false);
} else {
toast('Website icons disabled (cached icons kept)');
}
});
$('#settingFaviconsRefresh').addEventListener('click', () => backfillFavicons(false));
$('#settingFaviconsRefreshAll').addEventListener('click', () => backfillFavicons(true));
$('#settingFaviconsClear').addEventListener('click', async () => {
const ok = await confirmDialog({
title: 'Clear cached icons?',
message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.',
okText: 'Clear',
danger: true,
});
if (ok) clearAllFavicons();
});
$('#settingAutofill').addEventListener('change', e => {
state.autofillEnabled = e.target.checked;
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');