From ad5fb21a1804d3eef6c9203e54e714714ff44efd Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Tue, 9 Jun 2026 00:05:54 +0100 Subject: [PATCH] feat: website favicons + vault health dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- CLAUDE.md | 53 +++ css/style.css | 139 ++++++ .../Handlers/PM.Handler.Entries.pas | 110 +++++ delphi-backend/PMServer.dpr | 1 + delphi-backend/PMServer.dproj | 1 + delphi-backend/Source/PM.Database.pas | 4 + delphi-backend/Source/PM.Favicon.pas | 156 +++++++ delphi-backend/UMainForm.pas | 49 +- delphi-backend/assets/assets.res | Bin 328316 -> 351612 bytes index.html | 30 ++ js/app.js | 429 +++++++++++++++++- 11 files changed, 967 insertions(+), 5 deletions(-) create mode 100644 delphi-backend/Source/PM.Favicon.pas diff --git a/CLAUDE.md b/CLAUDE.md index 9938d26..00a3209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ plus courant après modif frontend. | Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` | | Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` | | Start with Windows (HKCU Run) | `delphi-backend/Source/PM.AutoStart.pas` | +| Favicon proxy (DuckDuckGo, async THTTPClient/WinHTTP) | `delphi-backend/Source/PM.Favicon.pas` | | Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` | | Frontend complet | `js/app.js` | | HTML racine | `index.html` | @@ -53,6 +54,7 @@ Commandes connues : - `quickunlock/{store,get,clear,status}` - `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas) - `autostart/{get,set}?enabled=1|0` (HKCU Run registry, "Start with Windows") +- `favicon/fetch?host=X&reqId=Y` (async via anonymous thread, callback `Bridge.onFaviconResult(reqId, host, dataUri)`) - `autofill/{configure,hotkeys,execute,cancel}` - `app/focus` (ramène la fenêtre au premier plan, pour le picker) - `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input) @@ -158,6 +160,34 @@ la fenêtre + clipboard clear + balloon first-time. Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par `SetPreferredAppMode` ci-dessus). +## Favicons (`PM.Favicon`) + +Opt-in (`state.faviconsEnabled`, default OFF, synced via `settings_json`). +La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS). + +- Source : `https://icons.duckduckgo.com/ip3/.ico` — proxy DDG, pas + de tracking, retourne PNG 16-32 px. Centralisé → seul DDG voit la + liste des domaines stockés, vs hit chaque /favicon.ico (qui leakerait + TOUT le vault à chaque site) +- `THTTPClient` (`System.Net.HttpClient`) qui wrappe **WinHTTP** sur + Windows → TLS natif via le store de certificats Windows. Pas de + DLLs OpenSSL à shipper (Indy aurait silencieusement fail sans + `libcrypto-3.dll`/`libssl-3.dll`). 5 s timeout, max 3 redirects, + cap 64 KB. Toute erreur → return `''` +- Magic-byte sniffing pour le MIME (PNG/JPEG/GIF/SVG/ICO) +- Cmd async : `TThread.CreateAnonymousThread` car un GET HTTP bloquerait + le main thread jusqu'à 5 s. Callback via `TThread.Queue` → + `Bridge.onFaviconResult(reqId, host, dataUri)` +- Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint + dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet + (qui re-PUT le password chiffré) +- Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache") +- Render : `entry-avatar` contient `` si + `icon_b64`, sinon fallback aux initials. `onerror` repasse aux + initials si la data URI est corrompue +- Auto-fetch après save d'entry, backfill via "Fetch missing" / + "Re-fetch all" boutons dans Settings + ## Start with Windows (`PM.AutoStart`) - Toggle dans Settings → "Start with Windows" (visible seulement quand @@ -223,6 +253,29 @@ Sidebar Tools expose 2 items MFA : Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom, **pas `remaining`** (utiliser `t.secondsLeft`). +## Vault health dashboard + +Sidebar Tools → "Vault health" (`state.view = 'health'`). Vue dédiée avec +un score 0-100 et 4 cards de catégorie : + +- **Weak** : `computeStrength(plain) < 50` +- **Reused** : groupes d'entries partageant le même plaintext (≥ 2) +- **Old** : `updated_at > 365 days` +- **Pwned** : depuis `state.hibpResults` (n'apparaît que si HIBP actif) + +`computeHealthCache()` décrypte chaque entry une fois et stocke dans +`healthCache` (module-level let, pas dans state pour ne pas polluer). +Invalidé sur lock, save d'entry, et bouton "Recompute". + +Score : start 100 → -5/weak (cap -40), -10/reused (cap -30), -2/old (cap +-20), -15/pwned (cap -50). Bandes : ≥80 Good (vert), ≥50 Fair (cyan), +≥25 At risk (orange), <25 Critical (rouge). + +Bouton "Fix" par item → `openEntryForFix(id)` = `openSlideover` puis +click sur le bouton edit-password (fallback : juste ouvre le slideover). + +Liste tronquée à 20 items par catégorie + count "+N more". + ## Sidebar sections collapsibles Sections `Folders`, `Tags`, `Tools` ont chacune un `.section-toggle` diff --git a/css/style.css b/css/style.css index 2d02b9c..d2ab425 100644 --- a/css/style.css +++ b/css/style.css @@ -837,6 +837,14 @@ input[type="range"]::-webkit-slider-thumb { font-weight: 600; font-size: 14px; color: var(--text); flex-shrink: 0; + overflow: hidden; +} +.entry-avatar-img { + width: 100%; height: 100%; + object-fit: contain; + padding: 4px; + box-sizing: border-box; + image-rendering: -webkit-optimize-contrast; /* sharper 16x16 icons */ } .entry-title { flex: 1; min-width: 0; display: flex; flex-direction: column; } .entry-title b { @@ -1102,6 +1110,137 @@ input[type="range"]::-webkit-slider-thumb { } .auth-card-bar.is-warning { background: #f59e0b; } +/* ---- Vault health dashboard ----------------------------- */ +.entry-grid.is-health { + display: flex; + flex-direction: column; + gap: 16px; +} +.health-loading { + color: var(--text-dim); + text-align: center; + padding: 40px; + font-size: 14px; +} +.health-header { + display: flex; align-items: center; gap: 20px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} +.health-score { + width: 110px; height: 110px; + border-radius: 50%; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + flex-shrink: 0; + border: 4px solid var(--accent); + background: var(--bg); +} +.health-score.is-ok { border-color: #10b981; color: #10b981; } +.health-score.is-fair { border-color: var(--accent); color: var(--accent); } +.health-score.is-warn { border-color: #f59e0b; color: #f59e0b; } +.health-score.is-danger { border-color: #ef4444; color: #ef4444; } +.health-score-num { + font-size: 36px; font-weight: 700; + font-family: 'JetBrains Mono', ui-monospace, monospace; +} +.health-score-lbl { + font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; + margin-top: 2px; +} +.health-intro { flex: 1; } +.health-intro h3 { margin: 0 0 6px; font-size: 16px; } +.health-intro p { + margin: 0 0 10px; + color: var(--text-dim); + font-size: 13px; + line-height: 1.5; +} +.health-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; +} +.health-card-head { + display: flex; align-items: center; gap: 10px; + margin-bottom: 6px; +} +.health-card-head h4 { + margin: 0; + font-size: 14px; + font-weight: 600; +} +.health-badge { + min-width: 22px; + padding: 2px 8px; + border-radius: 11px; + background: var(--accent); + color: white; + font-size: 11px; + font-weight: 700; + text-align: center; +} +.health-badge.is-empty { + background: var(--bg); + color: var(--text-dim); +} +.health-hint { + margin: 0 0 10px; + font-size: 12px; + color: var(--text-dim); + line-height: 1.4; +} +.health-empty { + margin: 0; + font-size: 12px; + color: var(--text-faint); + font-style: italic; +} +.health-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; flex-direction: column; + gap: 4px; +} +.health-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; + background: var(--bg); + border-radius: var(--radius-sm); + font-size: 13px; +} +.health-item-label { + flex: 1; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text); +} +.health-more { + margin: 6px 0 0; + color: var(--text-faint); + font-size: 11px; + font-style: italic; + text-align: center; +} +.btn-xs { + font-size: 11px; + padding: 3px 10px; +} + +/* Brief attention pulse on the slideover generator button after Fix */ +@keyframes attentionPulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--accent); } + 50% { box-shadow: 0 0 0 6px transparent; } +} +.icon-btn.is-pulse { + animation: attentionPulse 0.7s ease-out 3; + border-radius: var(--radius-sm); +} + /* ---- 10. SLIDE-OVER -------------------------------------- */ .slideover { diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 1ba6218..09c5647 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -116,6 +116,12 @@ begin LObj.AddPair('totp_iv', TJSONNull.Create) else LObj.AddPair('totp_iv', LQ.FieldByName('totp_iv').AsString); + // Cached favicon (base64 data URI). NULL = no icon cached yet — + // the JS layer falls back to first-letter avatar. + if LQ.FieldByName('icon_b64').IsNull then + LObj.AddPair('icon_b64', TJSONNull.Create) + else + LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString); LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at'))); LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); LArr.Add(LObj); @@ -463,6 +469,108 @@ begin TJSONHelper.SendOK(AResponse, 'Toggled'); end; +// ===== POST /entries/{id}/icon =============================================== +// Stores (or clears) a cached favicon for one entry. Separate endpoint so the +// client can save the icon without re-PUT-ing the full entry (which would +// require re-encrypting the password). Body: {"icon_b64":"data:image/...;base64,..."} +// — empty string clears the cached icon. +procedure HandleSetEntryIcon(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LBody: TJSONObject; + LIcon: string; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LIcon := LBody.GetValue('icon_b64', ''); + finally + LBody.Free; + end; + + // Soft cap to prevent a misbehaving fetcher from ballooning the DB. + // 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64. + if Length(LIcon) > 65536 then + begin + TJSONHelper.SendError(AResponse, 413, 'Icon too large'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries SET icon_b64 = :ic ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite + if LIcon = '' then LQ.ParamByName('ic').Clear + else LQ.ParamByName('ic').AsString := LIcon; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + TJSONHelper.SendOK(AResponse, 'Icon saved'); +end; + +// ===== DELETE /entries/icons/all ============================================= +// Bulk-clear cached favicons for all entries of the current user. Used by the +// Settings "Clear cached icons" button. +procedure HandleClearAllIcons(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries SET icon_b64 = NULL WHERE user_id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'clear_icons', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Icons cleared'); +end; + // ===== DELETE /entries/trash/empty =========================================== procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo; @@ -663,9 +771,11 @@ initialization // /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match. // Same logic for /entries/bulk-import — register before the catch-all /entries/{id}. Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); + Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons); Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); + Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon); Router.Register('GET', '/entries/count', HandleEntriesCount); Router.Register('GET', '/entries', HandleGetEntries); Router.Register('POST', '/entries', HandleCreateEntry); diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr index f15e66e..cd49234 100644 --- a/delphi-backend/PMServer.dpr +++ b/delphi-backend/PMServer.dpr @@ -19,6 +19,7 @@ uses PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.AutoStart in 'Source\PM.AutoStart.pas', + PM.Favicon in 'Source\PM.Favicon.pas', PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas', PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas', diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj index 8cbf281..4799856 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -221,6 +221,7 @@ $(PreBuildEvent)]]> + diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 94f8d5d..e7787f8 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -241,6 +241,10 @@ begin // sees the plaintext secret. NULL = no TOTP configured for this entry. AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT'); AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT'); + // Cached favicon as a base64 data URI (e.g. "data:image/png;base64,..."). + // Fetched on demand by the Delphi favicon proxy when the user opts in. + // NULL = no icon cached → JS falls back to the first-letter avatar. + AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT'); AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2'''); // PBKDF2 iteration count per user. Legacy rows (predating this column) // default to 100000 — the value used by api.php / the early Delphi build. diff --git a/delphi-backend/Source/PM.Favicon.pas b/delphi-backend/Source/PM.Favicon.pas new file mode 100644 index 0000000..3fb7728 --- /dev/null +++ b/delphi-backend/Source/PM.Favicon.pas @@ -0,0 +1,156 @@ +unit PM.Favicon; + +{ + Favicon proxy — fetches a website's icon and returns a base64 data URI + ready to drop into an . + + Source: DuckDuckGo's icons service (icons.duckduckgo.com/ip3/.ico) + - No tracking pixels / analytics on the icon endpoints + - Returns a 32×32 PNG (or ICO) with the proper MIME type + - Centralised: only DDG sees the list of domains the user looks up, + vs hitting each site's /favicon.ico directly (which would leak the + full vault contents to every site listed) + - Falls back to a generic globe glyph for unknown sites + HTTPS only; 5s timeout; cap response at 64 KB; no redirects beyond 3. + + Threat model: this is the ONLY outbound network call from Delphi (HIBP is + done client-side). The user explicitly opts in via Settings. Failure + modes (DNS, TLS, 4xx, oversize) all return '' — caller falls back to + the first-letter avatar. +} + +interface + +// Fetches an icon for AHost (bare hostname, no scheme). Returns a +// "data:image/...;base64,..." string on success, or '' on any failure. +function FetchFaviconDataUri(const AHost: string): string; + +implementation + +uses + System.SysUtils, System.Classes, System.NetEncoding, + System.Net.HttpClient, System.Net.URLClient; + +const + ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico'; + MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit) + HTTP_TIMEOUT_MS = 5000; + +function NormalizeHost(const ARaw: string): string; +var + S: string; + SlashPos, ColonPos, I: Integer; + Ch: Char; +begin + // Accept anything user-typed: "https://www.github.com/login", "github.com", + // "GitHub.com:8443". Return lowercase bare hostname, or '' if the input + // doesn't look like a real domain — defense in depth alongside the JS + // faviconHost() validation (so a future bridge caller can't leak a + // brand label like "Gitea" upstream). + Result := ''; + S := Trim(ARaw).ToLower; + if S.StartsWith('https://') then S := Copy(S, 9, MaxInt) + else if S.StartsWith('http://') then S := Copy(S, 8, MaxInt); + if S.StartsWith('www.') then S := Copy(S, 5, MaxInt); + SlashPos := Pos('/', S); + if SlashPos > 0 then S := Copy(S, 1, SlashPos - 1); + ColonPos := Pos(':', S); + if ColonPos > 0 then S := Copy(S, 1, ColonPos - 1); + + if (S = '') or (Length(S) > 253) then Exit; + // Must contain a dot, no leading/trailing dot, no consecutive dots, + // only [a-z0-9.-] characters. + if Pos('.', S) < 2 then Exit; + if S.StartsWith('.') or S.EndsWith('.') or S.Contains('..') then Exit; + for I := 1 to Length(S) do + begin + Ch := S[I]; + if not (((Ch >= 'a') and (Ch <= 'z')) or + ((Ch >= '0') and (Ch <= '9')) or + (Ch = '.') or (Ch = '-')) then + Exit; + end; + Result := S; +end; + +function GuessMimeFromBytes(const ABytes: TBytes): string; +begin + // Lightweight magic-byte sniffing. Saves a Content-Type round-trip parse. + Result := 'image/x-icon'; // safe default for an .ico fetch + if Length(ABytes) < 8 then Exit; + // PNG : 89 50 4E 47 0D 0A 1A 0A + if (ABytes[0] = $89) and (ABytes[1] = $50) and (ABytes[2] = $4E) and (ABytes[3] = $47) then + Exit('image/png'); + // GIF : "GIF8" + if (ABytes[0] = Ord('G')) and (ABytes[1] = Ord('I')) and + (ABytes[2] = Ord('F')) and (ABytes[3] = Ord('8')) then + Exit('image/gif'); + // JPEG : FF D8 FF + if (ABytes[0] = $FF) and (ABytes[1] = $D8) and (ABytes[2] = $FF) then + Exit('image/jpeg'); + // SVG : " 200) then Exit; + if LStream.Size <= 0 then Exit; + if LStream.Size > MAX_ICON_BYTES then Exit; + + LStream.Position := 0; + SetLength(LBytes, LStream.Size); + LStream.ReadBuffer(LBytes[0], LStream.Size); + + LMime := GuessMimeFromBytes(LBytes); + LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); + // Strip CR/LF that the encoder inserts every 76 chars — invalid inside + // an attribute and bloats the cached blob. + LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]); + LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]); + + Result := 'data:' + LMime + ';base64,' + LBase64; + finally + LStream.Free; + LHttp.Free; + end; +end; + +end. diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 0ecf167..8a62c18 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -11,7 +11,8 @@ uses FMX.Dialogs, FMX.DialogService, FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, - PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart; + PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart, + PM.Favicon; type TMainForm = class(TForm) @@ -575,6 +576,52 @@ begin BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')'); end + // ---- Favicon proxy (Delphi-side fetch to keep CSP tight + privacy + // centralised on one upstream domain). Async: the HTTP GET would + // block the main thread for up to 5 s on slow networks. + else if ACmd = 'favicon/fetch' then + begin + var LHost := GetParam('host'); + var LReqId := GetParam('reqId'); // opaque, echoed back to JS resolver + if LHost = '' then Exit; + LogLine('favicon/fetch host="' + LHost + '" reqId=' + LReqId); + TThread.CreateAnonymousThread( + procedure + var + LDataUri: string; + begin + LDataUri := PM.Favicon.FetchFaviconDataUri(LHost); + TThread.Queue(nil, + procedure + begin + if LDataUri = '' then + LogLine('favicon: NO RESULT for "' + LHost + + '" (TLS error? OpenSSL DLLs missing? DDG 404?)') + else + LogLine(Format('favicon: got %d bytes for "%s"', + [Length(LDataUri), LHost])); + end); + TThread.Queue(nil, + procedure + var + LEscHost, LEscData, LEscReq: string; + begin + LEscHost := StringReplace(LHost, '\', '\\', [rfReplaceAll]); + LEscHost := StringReplace(LEscHost, '"', '\"', [rfReplaceAll]); + LEscReq := StringReplace(LReqId, '\', '\\', [rfReplaceAll]); + LEscReq := StringReplace(LEscReq, '"', '\"', [rfReplaceAll]); + // The data URI is base64 (ASCII-safe) plus a small prefix — + // no embedded quotes by construction, but escape anyway. + LEscData := StringReplace(LDataUri, '\', '\\', [rfReplaceAll]); + LEscData := StringReplace(LEscData, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFaviconResult)' + + 'Bridge.onFaviconResult("' + LEscReq + '","' + LEscHost + '","' + + LEscData + '")'); + end); + end).Start; + end + else if ACmd = 'autostart/set' then begin var LOk := PM.AutoStart.SetAutoStart(GetParam('enabled') = '1'); diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 20e8cee1f8ee153ac36d26afce74de450f5903d2..84e249828df873e00c2e552b93a52f8014d98b90 100644 GIT binary patch delta 16803 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 delta 265 zcmV+k0rvjH` zx1RU`+8dX_;{g?iJ=FrYJ=FsYD3{-O1PHgk z{sRLUx7im2TnLj7-3zzRAOvYOmk}2PAh(xS1R)NW-*^NFw=8M|Y$gK$Nx!$6kOXC4 Px7+XpoEx|M7zKeuE@xvp diff --git a/index.html b/index.html index a55dda2..b390e85 100644 --- a/index.html +++ b/index.html @@ -215,6 +215,10 @@ TOTP generator + + + +
diff --git a/js/app.js b/js/app.js index fb0e095..ebfe93f 100644 --- a/js/app.js +++ b/js/app.js @@ -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. 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)' : 'Won’t 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');