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
+53
View File
@@ -38,6 +38,7 @@ plus courant après modif frontend.
| Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` | | Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` |
| Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` | | Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` |
| Start with Windows (HKCU Run) | `delphi-backend/Source/PM.AutoStart.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` | | Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
| Frontend complet | `js/app.js` | | Frontend complet | `js/app.js` |
| HTML racine | `index.html` | | HTML racine | `index.html` |
@@ -53,6 +54,7 @@ Commandes connues :
- `quickunlock/{store,get,clear,status}` - `quickunlock/{store,get,clear,status}`
- `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas) - `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas)
- `autostart/{get,set}?enabled=1|0` (HKCU Run registry, "Start with Windows") - `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}` - `autofill/{configure,hotkeys,execute,cancel}`
- `app/focus` (ramène la fenêtre au premier plan, pour le picker) - `app/focus` (ramène la fenêtre au premier plan, pour le picker)
- `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input) - `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 Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par
`SetPreferredAppMode` ci-dessus). `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/<host>.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 `<img class="entry-avatar-img">` 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`) ## Start with Windows (`PM.AutoStart`)
- Toggle dans Settings → "Start with Windows" (visible seulement quand - 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, Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom,
**pas `remaining`** (utiliser `t.secondsLeft`). **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 ## Sidebar sections collapsibles
Sections `Folders`, `Tags`, `Tools` ont chacune un `.section-toggle` Sections `Folders`, `Tags`, `Tools` ont chacune un `.section-toggle`
+139
View File
@@ -837,6 +837,14 @@ input[type="range"]::-webkit-slider-thumb {
font-weight: 600; font-size: 14px; font-weight: 600; font-size: 14px;
color: var(--text); color: var(--text);
flex-shrink: 0; 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 { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.entry-title b { .entry-title b {
@@ -1102,6 +1110,137 @@ input[type="range"]::-webkit-slider-thumb {
} }
.auth-card-bar.is-warning { background: #f59e0b; } .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 -------------------------------------- */ /* ---- 10. SLIDE-OVER -------------------------------------- */
.slideover { .slideover {
@@ -116,6 +116,12 @@ begin
LObj.AddPair('totp_iv', TJSONNull.Create) LObj.AddPair('totp_iv', TJSONNull.Create)
else else
LObj.AddPair('totp_iv', LQ.FieldByName('totp_iv').AsString); 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('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
LArr.Add(LObj); LArr.Add(LObj);
@@ -463,6 +469,108 @@ begin
TJSONHelper.SendOK(AResponse, 'Toggled'); TJSONHelper.SendOK(AResponse, 'Toggled');
end; 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<string>);
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<string>('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<string>);
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 =========================================== // ===== DELETE /entries/trash/empty ===========================================
procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo; procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo;
@@ -663,9 +771,11 @@ initialization
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match. // /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}. // Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); 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/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon);
Router.Register('GET', '/entries/count', HandleEntriesCount); Router.Register('GET', '/entries/count', HandleEntriesCount);
Router.Register('GET', '/entries', HandleGetEntries); Router.Register('GET', '/entries', HandleGetEntries);
Router.Register('POST', '/entries', HandleCreateEntry); Router.Register('POST', '/entries', HandleCreateEntry);
+1
View File
@@ -19,6 +19,7 @@ uses
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas', PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.Favicon in 'Source\PM.Favicon.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas', PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas',
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas', PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
+1
View File
@@ -221,6 +221,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.UserPrefs.pas"/> <DCCReference Include="Source\PM.UserPrefs.pas"/>
<DCCReference Include="Source\PM.SingleInstance.pas"/> <DCCReference Include="Source\PM.SingleInstance.pas"/>
<DCCReference Include="Source\PM.AutoStart.pas"/> <DCCReference Include="Source\PM.AutoStart.pas"/>
<DCCReference Include="Source\PM.Favicon.pas"/>
<DCCReference Include="Handlers\PM.Handler.Ping.pas"/> <DCCReference Include="Handlers\PM.Handler.Ping.pas"/>
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/> <DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/> <DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
+4
View File
@@ -241,6 +241,10 @@ begin
// sees the plaintext secret. NULL = no TOTP configured for this entry. // sees the plaintext secret. NULL = no TOTP configured for this entry.
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT'); AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
AddColumnIfMissing('vault_entries', 'totp_iv', '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'''); AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2''');
// PBKDF2 iteration count per user. Legacy rows (predating this column) // PBKDF2 iteration count per user. Legacy rows (predating this column)
// default to 100000 — the value used by api.php / the early Delphi build. // default to 100000 — the value used by api.php / the early Delphi build.
+156
View File
@@ -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 <img src="...">.
Source: DuckDuckGo's icons service (icons.duckduckgo.com/ip3/<host>.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 : "<svg" or "<?xml" (text-prefixed)
if (ABytes[0] = Ord('<')) then Exit('image/svg+xml');
// ICO : 00 00 01 00
if (ABytes[0] = $00) and (ABytes[1] = $00) and
(ABytes[2] = $01) and (ABytes[3] = $00) then
Exit('image/x-icon');
end;
function FetchFaviconDataUri(const AHost: string): string;
var
LHost, LUrl, LMime, LBase64: string;
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TMemoryStream;
LBytes: TBytes;
begin
Result := '';
LHost := NormalizeHost(AHost);
if LHost = '' then Exit;
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
// zero extra DLLs to ship next to the exe (unlike Indy + OpenSSL which
// fails silently when libcrypto/libssl are missing).
LHttp := THTTPClient.Create;
LStream := TMemoryStream.Create;
try
LHttp.ConnectionTimeout := HTTP_TIMEOUT_MS;
LHttp.ResponseTimeout := HTTP_TIMEOUT_MS;
LHttp.HandleRedirects := True;
LHttp.MaxRedirects := 3;
LHttp.UserAgent := 'PMServer/1.0 (favicon-fetch)';
LHttp.CustHeaders.Add('Accept',
'image/png,image/x-icon,image/*;q=0.8,*/*;q=0.1');
try
LResp := LHttp.Get(LUrl, LStream);
except
// Any DNS / connect / TLS failure → silent ''.
Exit;
end;
if (LResp = nil) or (LResp.StatusCode <> 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 <img src="..."> 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.
+48 -1
View File
@@ -11,7 +11,8 @@ uses
FMX.Dialogs, FMX.DialogService, FMX.Dialogs, FMX.DialogService,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, 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 type
TMainForm = class(TForm) TMainForm = class(TForm)
@@ -575,6 +576,52 @@ begin
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')'); BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
end 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 else if ACmd = 'autostart/set' then
begin begin
var LOk := PM.AutoStart.SetAutoStart(GetParam('enabled') = '1'); var LOk := PM.AutoStart.SetAutoStart(GetParam('enabled') = '1');
Binary file not shown.
+30
View File
@@ -215,6 +215,10 @@
<svg><use href="#i-key"/></svg> <svg><use href="#i-key"/></svg>
<span>TOTP generator</span> <span>TOTP generator</span>
</button> </button>
<button class="nav-item" id="sidebarHealthBtn">
<svg><use href="#i-alert"/></svg>
<span>Vault health</span>
</button>
<button class="nav-item" id="sidebarImportBtn"> <button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg> <svg><use href="#i-log-in"/></svg>
<span>Import vault</span> <span>Import vault</span>
@@ -364,6 +368,32 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="setting-row" id="settingFaviconsRow">
<span>
Fetch website icons
<small class="setting-hint">
Loads a small icon for each entry via DuckDuckGo
(privacy-friendly proxy). Each entry's domain is
sent to <b>icons.duckduckgo.com</b> once and cached
locally. OFF by default.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingFavicons">
<span class="toggle-slider"></span>
</label>
</div>
<div id="settingFaviconActionsRow" style="display:flex;gap:6px;margin-top:6px">
<button class="btn btn-ghost btn-sm" id="settingFaviconsRefresh">
<svg><use href="#i-rotate-ccw"/></svg> Fetch missing
</button>
<button class="btn btn-ghost btn-sm" id="settingFaviconsRefreshAll">
Re-fetch all
</button>
<button class="btn btn-ghost btn-sm is-danger" id="settingFaviconsClear">
<svg><use href="#i-trash"/></svg> Clear cache
</button>
</div>
</div> </div>
<div class="slideover-field"> <div class="slideover-field">
+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. // Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {}; const prefResolvers = {};
let autoStartResolver = null; let autoStartResolver = null;
const faviconResolvers = {};
let _faviconReqSeq = 0;
const Bridge = (() => { const Bridge = (() => {
const active = (API === ''); const active = (API === '');
@@ -218,6 +220,33 @@ const Bridge = (() => {
const cb = document.getElementById('settingAutoStart'); const cb = document.getElementById('settingAutoStart');
if (cb) cb.checked = !!enabled; 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"}'), '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') || sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
'{"folders":false,"tags":false,"tools":false}'), '{"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); 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 = // Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size. // 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
function randomBase32Secret(numBytes) { function randomBase32Secret(numBytes) {
@@ -1046,6 +1184,7 @@ function lockVault() {
if (typeof totpToolTimer !== 'undefined' && totpToolTimer) { if (typeof totpToolTimer !== 'undefined' && totpToolTimer) {
clearInterval(totpToolTimer); totpToolTimer = null; clearInterval(totpToolTimer); totpToolTimer = null;
} }
if (typeof healthCache !== 'undefined') healthCache = null;
showAuth(); showAuth();
// Two UI variants for the auth screen: // Two UI variants for the auth screen:
@@ -1212,6 +1351,7 @@ function viewTitle() {
if (state.view === 'favorites') return 'Favorites'; if (state.view === 'favorites') return 'Favorites';
if (state.view === 'trash') return 'Trash'; if (state.view === 'trash') return 'Trash';
if (state.view === 'authenticator') return 'Authenticator'; 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('folder:')) return state.view.slice(7);
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
return 'Items'; return 'Items';
@@ -1386,6 +1526,21 @@ async function addTagToEntry(id, tag) {
function renderGrid() { function renderGrid() {
$('#contentTitle').textContent = viewTitle(); $('#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 // Authenticator view: bypass the standard pipeline — render a dedicated
// grid of TOTP cards (only entries that have a TOTP secret configured). // grid of TOTP cards (only entries that have a TOTP secret configured).
if (state.view === 'authenticator') { if (state.view === 'authenticator') {
@@ -1496,7 +1651,7 @@ function renderAuthenticatorGrid(grid, entries) {
wrap.appendChild(barWrap); wrap.appendChild(barWrap);
// Click anywhere on card (outside copy) opens the entry detail. // 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 }; 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() { function showEmptyState() {
const illustration = $('#emptyIllustration use'); const illustration = $('#emptyIllustration use');
const title = $('#emptyTitle'); const title = $('#emptyTitle');
@@ -1714,9 +2077,19 @@ function renderCard(e) {
// checked. Card click anywhere not on the checkbox opens slideover. // checked. Card click anywhere not on the checkbox opens slideover.
const head = el('div', { class: 'entry-head' }); const head = el('div', { class: 'entry-head' });
const displayName = entryDisplayName(e); const displayName = entryDisplayName(e);
const avatar = el('div', { const avatar = el('div', { class: 'entry-avatar' });
class: 'entry-avatar', if (e.icon_b64) {
}, initials(displayName)); 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', { const checkbox = el('button', {
class: 'entry-check' + (checked ? ' is-checked' : ''), class: 'entry-check' + (checked ? ' is-checked' : ''),
type: 'button', type: 'button',
@@ -2988,8 +3361,16 @@ async function saveEntry(e) {
} }
closeEntryModal(); closeEntryModal();
await loadEntries(); await loadEntries();
if (typeof healthCache !== 'undefined') healthCache = null;
render(); render();
if (savedId) flashEntry(savedId); 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) { } catch (err) {
toast(err.message, 'error'); toast(err.message, 'error');
} }
@@ -4875,6 +5256,11 @@ function openSettings() {
$('#settingMaskUser').checked = state.maskUsernames; $('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled; $('#settingHIBP').checked = state.hibpEnabled;
$('#settingShowSite').checked = state.showSiteOnCards; $('#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; $('#settingAutofill').checked = state.autofillEnabled;
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none'; $('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
// Hotkey capture buttons — labels reflect current combos. // Hotkey capture buttons — labels reflect current combos.
@@ -5034,6 +5420,7 @@ const SYNCED_SETTING_KEYS = [
// Sidebar section collapsed state. Object of { folders, tags, tools } // Sidebar section collapsed state. Object of { folders, tags, tools }
// booleans. Synced so the user gets the same fold state across devices. // booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed', 'sidebarCollapsed',
'faviconsEnabled',
]; ];
function applySidebarCollapsed() { function applySidebarCollapsed() {
@@ -5073,6 +5460,9 @@ async function loadServerSettings() {
// Object; persist as JSON so the next cold start picks it up. // Object; persist as JSON so the next cold start picks it up.
localStorage.setItem(k, JSON.stringify(v)); localStorage.setItem(k, JSON.stringify(v));
break; break;
case 'faviconsEnabled':
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
break;
} }
}); });
// Apply visual settings immediately. // Apply visual settings immediately.
@@ -5394,6 +5784,14 @@ async function init() {
render(); render();
}); });
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool); $('#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 // Sidebar section collapse toggles
document.querySelectorAll('[data-section-toggle]').forEach(btn => { document.querySelectorAll('[data-section-toggle]').forEach(btn => {
@@ -5481,6 +5879,29 @@ async function init() {
? 'Will start with Windows (in tray)' ? 'Will start with Windows (in tray)'
: 'Wont start with Windows'); : '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 => { $('#settingAutofill').addEventListener('change', e => {
state.autofillEnabled = e.target.checked; state.autofillEnabled = e.target.checked;
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0'); localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');