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:
@@ -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/<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`)
|
||||
|
||||
- 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`
|
||||
|
||||
+139
@@ -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 {
|
||||
|
||||
@@ -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<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 ===========================================
|
||||
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -221,6 +221,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DCCReference Include="Source\PM.UserPrefs.pas"/>
|
||||
<DCCReference Include="Source\PM.SingleInstance.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.Auth.pas"/>
|
||||
<DCCReference Include="Handlers\PM.Handler.Folders.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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
Binary file not shown.
+30
@@ -215,6 +215,10 @@
|
||||
<svg><use href="#i-key"/></svg>
|
||||
<span>TOTP generator</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarHealthBtn">
|
||||
<svg><use href="#i-alert"/></svg>
|
||||
<span>Vault health</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarImportBtn">
|
||||
<svg><use href="#i-log-in"/></svg>
|
||||
<span>Import vault</span>
|
||||
@@ -364,6 +368,32 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</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 class="slideover-field">
|
||||
|
||||
@@ -15,6 +15,8 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
|
||||
// Falls back to navigator.clipboard for the standalone PHP frontend.
|
||||
const prefResolvers = {};
|
||||
let autoStartResolver = null;
|
||||
const faviconResolvers = {};
|
||||
let _faviconReqSeq = 0;
|
||||
const Bridge = (() => {
|
||||
const active = (API === '');
|
||||
|
||||
@@ -218,6 +220,33 @@ const Bridge = (() => {
|
||||
const cb = document.getElementById('settingAutoStart');
|
||||
if (cb) cb.checked = !!enabled;
|
||||
},
|
||||
|
||||
// ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ----------
|
||||
// Returns a Promise<dataUri|''>. Multiple in-flight requests for
|
||||
// distinct hosts are tracked per reqId so they can't collide.
|
||||
fetchFavicon(host) {
|
||||
if (!active) return Promise.resolve('');
|
||||
if (!host) return Promise.resolve('');
|
||||
const reqId = 'fav_' + (++_faviconReqSeq);
|
||||
return new Promise(resolve => {
|
||||
faviconResolvers[reqId] = resolve;
|
||||
cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) +
|
||||
'&reqId=' + encodeURIComponent(reqId));
|
||||
setTimeout(() => {
|
||||
if (faviconResolvers[reqId]) {
|
||||
delete faviconResolvers[reqId];
|
||||
resolve('');
|
||||
}
|
||||
}, 8000);
|
||||
});
|
||||
},
|
||||
onFaviconResult(reqId, host, dataUri) {
|
||||
const r = faviconResolvers[reqId];
|
||||
if (r) {
|
||||
delete faviconResolvers[reqId];
|
||||
r(dataUri || '');
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -280,6 +309,10 @@ const state = {
|
||||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
|
||||
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
|
||||
'{"folders":false,"tags":false,"tools":false}'),
|
||||
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
|
||||
// default — opt-in because it sends each entry's domain to a third
|
||||
// party (DuckDuckGo). Synced because it's a portable preference.
|
||||
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -506,6 +539,111 @@ async function decryptTotpSecret(encB64, ivB64) {
|
||||
return await decryptPwd(encB64, ivB64);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FAVICONS (opt-in, cached server-side as base64 data URI)
|
||||
// ============================================================
|
||||
|
||||
// Extract a usable host from entry.site (we accept anything user-typed).
|
||||
// Returns '' for values that don't look like real hostnames — common case
|
||||
// is users storing a brand label ("Gitea", "Work GitHub") to help the
|
||||
// autofill matcher. Sending those to DDG would leak meaningless tokens
|
||||
// without ever producing an icon.
|
||||
function faviconHost(siteRaw) {
|
||||
if (!siteRaw) return '';
|
||||
let s = String(siteRaw).trim().toLowerCase();
|
||||
s = s.replace(/^https?:\/\//, '').replace(/^www\./, '');
|
||||
s = s.split('/')[0].split(':')[0];
|
||||
// Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2
|
||||
// letters. Rejects "Gitea", "my work pwd", IP-like "1.2.3.4" stays
|
||||
// valid (DDG handles IPs gracefully). 253-char overall cap mirrors
|
||||
// the DNS spec.
|
||||
if (!s || s.length > 253) return '';
|
||||
if (!/^[a-z0-9.-]+$/.test(s)) return '';
|
||||
if (s.indexOf('.') < 1) return '';
|
||||
if (!/\.[a-z]{2,}$/.test(s)) return '';
|
||||
if (s.startsWith('.') || s.endsWith('.')) return '';
|
||||
if (s.indexOf('..') >= 0) return '';
|
||||
return s;
|
||||
}
|
||||
|
||||
// Save the icon for one entry via the dedicated endpoint (no full PUT,
|
||||
// no re-encryption). Fire-and-forget: failures are silent so a flaky
|
||||
// network doesn't break the user's flow.
|
||||
async function saveEntryIcon(entryId, dataUri) {
|
||||
try {
|
||||
await fetch(API + '/entries/' + entryId + '/icon', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ icon_b64: dataUri || '' }),
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// Fetch + save the favicon for one entry. Updates state.entries in-place
|
||||
// so the next render() picks it up. No-op if the entry already has one.
|
||||
// opts: { force: bypass "already has icon" skip, manual: bypass the global
|
||||
// faviconsEnabled toggle (for explicit user actions like the Refresh button) }
|
||||
async function ensureEntryFavicon(entry, opts) {
|
||||
opts = opts || {};
|
||||
if (!Bridge.active) return;
|
||||
if (!opts.manual && !state.faviconsEnabled) return;
|
||||
if (!opts.force && entry.icon_b64) return;
|
||||
const host = faviconHost(entry.site);
|
||||
if (!host) return;
|
||||
const dataUri = await Bridge.fetchFavicon(host);
|
||||
if (!dataUri) return;
|
||||
entry.icon_b64 = dataUri;
|
||||
await saveEntryIcon(entry.id, dataUri);
|
||||
// Full render() — patching the avatar in place is fragile because
|
||||
// the avatar also contains the checkbox overlay.
|
||||
render();
|
||||
}
|
||||
|
||||
// Backfill: walk state.entries, fetch missing icons one at a time so we
|
||||
// don't hammer the upstream. Used by the "Refresh icons" button.
|
||||
async function backfillFavicons(force) {
|
||||
if (!Bridge.active) return;
|
||||
const all = state.entries;
|
||||
const eligible = all.filter(e => faviconHost(e.site));
|
||||
const skipped = all.length - eligible.length;
|
||||
const targets = eligible.filter(e => force || !e.icon_b64);
|
||||
if (targets.length === 0) {
|
||||
if (skipped > 0) {
|
||||
toast('No icons to fetch — ' + skipped +
|
||||
' entries have a non-domain site (e.g. "Gitea")', 'warning');
|
||||
} else {
|
||||
toast('No icons to fetch');
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…');
|
||||
let ok = 0;
|
||||
for (const e of targets) {
|
||||
// Explicit user action — bypass the global toggle so the buttons
|
||||
// work even when "Fetch website icons" is OFF (the toggle only
|
||||
// gates auto-fetch on save).
|
||||
await ensureEntryFavicon(e, { force: !!force, manual: true });
|
||||
if (e.icon_b64) ok++;
|
||||
}
|
||||
toast('Fetched ' + ok + ' / ' + targets.length + ' icons');
|
||||
render();
|
||||
}
|
||||
|
||||
async function clearAllFavicons() {
|
||||
try {
|
||||
await fetch(API + '/entries/icons/all', {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast('Failed to clear icons', 'error');
|
||||
return;
|
||||
}
|
||||
state.entries.forEach(e => { e.icon_b64 = null; });
|
||||
render();
|
||||
toast('Cached icons cleared');
|
||||
}
|
||||
|
||||
// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
|
||||
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
|
||||
function randomBase32Secret(numBytes) {
|
||||
@@ -1046,6 +1184,7 @@ function lockVault() {
|
||||
if (typeof totpToolTimer !== 'undefined' && totpToolTimer) {
|
||||
clearInterval(totpToolTimer); totpToolTimer = null;
|
||||
}
|
||||
if (typeof healthCache !== 'undefined') healthCache = null;
|
||||
showAuth();
|
||||
|
||||
// Two UI variants for the auth screen:
|
||||
@@ -1212,6 +1351,7 @@ function viewTitle() {
|
||||
if (state.view === 'favorites') return 'Favorites';
|
||||
if (state.view === 'trash') return 'Trash';
|
||||
if (state.view === 'authenticator') return 'Authenticator';
|
||||
if (state.view === 'health') return 'Vault health';
|
||||
if (state.view.startsWith('folder:')) return state.view.slice(7);
|
||||
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
|
||||
return 'Items';
|
||||
@@ -1386,6 +1526,21 @@ async function addTagToEntry(id, tag) {
|
||||
function renderGrid() {
|
||||
$('#contentTitle').textContent = viewTitle();
|
||||
|
||||
// Vault health dashboard: bypass the standard list rendering entirely.
|
||||
if (state.view === 'health') {
|
||||
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
|
||||
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
|
||||
renderBatchBar();
|
||||
$('#contentMeta').textContent = state.entries.length +
|
||||
(state.entries.length === 1 ? ' entry analysed' : ' entries analysed');
|
||||
const grid = $('#entryGrid');
|
||||
grid.className = 'entry-grid is-health';
|
||||
grid.innerHTML = '';
|
||||
$('#emptyState').classList.add('is-hidden');
|
||||
renderHealthDashboard(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticator view: bypass the standard pipeline — render a dedicated
|
||||
// grid of TOTP cards (only entries that have a TOTP secret configured).
|
||||
if (state.view === 'authenticator') {
|
||||
@@ -1496,7 +1651,7 @@ function renderAuthenticatorGrid(grid, entries) {
|
||||
wrap.appendChild(barWrap);
|
||||
|
||||
// Click anywhere on card (outside copy) opens the entry detail.
|
||||
wrap.addEventListener('click', () => openSlideover(e.id));
|
||||
wrap.addEventListener('click', () => openSlideOver(e.id));
|
||||
|
||||
return { entry: e, wrap, codeEl, bar, secret: null };
|
||||
});
|
||||
@@ -1546,6 +1701,214 @@ function renderAuthenticatorGrid(grid, entries) {
|
||||
})();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VAULT HEALTH dashboard
|
||||
// ============================================================
|
||||
//
|
||||
// One-shot computation per session — decrypting every entry is the
|
||||
// expensive part, so we cache the result and clear it on lock / entry
|
||||
// edit / view re-entry (Tools → Vault health).
|
||||
|
||||
let healthCache = null;
|
||||
const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak
|
||||
const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year
|
||||
|
||||
function entryAgeDays(e) {
|
||||
const ts = e.updated_at || e.created_at;
|
||||
if (!ts) return 0;
|
||||
// ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking.
|
||||
const d = new Date(ts.replace(' ', 'T'));
|
||||
if (isNaN(d)) return 0;
|
||||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
async function computeHealthCache() {
|
||||
const weak = [], old = [], pwned = [];
|
||||
const byPwd = new Map(); // plaintext → [entries]
|
||||
for (const e of state.entries) {
|
||||
const ageD = entryAgeDays(e);
|
||||
if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD });
|
||||
|
||||
const pwn = state.hibpResults.get(e.id);
|
||||
if (typeof pwn === 'number' && pwn > 0)
|
||||
pwned.push({ entry: e, count: pwn });
|
||||
|
||||
// Decrypt for strength + reuse detection. '[ERROR]' bubbles up
|
||||
// from decryptPwd for corrupted ciphertext — skip those silently.
|
||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||
if (plain === '[ERROR]') continue;
|
||||
const score = computeStrength(plain);
|
||||
if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score });
|
||||
if (!byPwd.has(plain)) byPwd.set(plain, []);
|
||||
byPwd.get(plain).push(e);
|
||||
}
|
||||
// Reuse: groups of ≥2 entries sharing the same plaintext password.
|
||||
const reused = [];
|
||||
for (const [, entries] of byPwd) {
|
||||
if (entries.length >= 2) reused.push(entries);
|
||||
}
|
||||
|
||||
// Score: start at 100, subtract per issue (capped at 0). Weights
|
||||
// chosen so a single pwned password dominates over a single old one.
|
||||
let score = 100;
|
||||
score -= Math.min(40, weak.length * 5);
|
||||
score -= Math.min(30, reused.length * 10);
|
||||
score -= Math.min(20, old.length * 2);
|
||||
score -= Math.min(50, pwned.length * 15);
|
||||
if (score < 0) score = 0;
|
||||
|
||||
return { weak, reused, old, pwned, score };
|
||||
}
|
||||
|
||||
function healthScoreBand(score) {
|
||||
if (score >= 80) return { label: 'Good', cls: 'is-ok' };
|
||||
if (score >= 50) return { label: 'Fair', cls: 'is-fair' };
|
||||
if (score >= 25) return { label: 'At risk', cls: 'is-warn' };
|
||||
return { label: 'Critical', cls: 'is-danger' };
|
||||
}
|
||||
|
||||
// Open the entry slideover, unmask the password, focus it, and pulse the
|
||||
// generator button. The user keeps full context (which entry they're
|
||||
// fixing) and decides whether to type a new password, click the dice, or
|
||||
// dismiss. Auto-opening the generator modal hid the entry context and
|
||||
// forced an extra Save click — worse UX than this lighter nudge.
|
||||
async function openEntryForFix(entryId) {
|
||||
await openSlideOver(entryId);
|
||||
const pwd = document.getElementById('soPassword');
|
||||
if (pwd) {
|
||||
pwd.type = 'text'; // unmask so the user sees what they're replacing
|
||||
pwd.focus();
|
||||
pwd.select();
|
||||
}
|
||||
const genBtn = document.querySelector('.so-pw-row button[title="Generate"]');
|
||||
if (genBtn) {
|
||||
genBtn.classList.add('is-pulse');
|
||||
setTimeout(() => genBtn.classList.remove('is-pulse'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHealthDashboard(grid) {
|
||||
// Recompute on demand. The "Recompute" button below also triggers it.
|
||||
if (!healthCache) {
|
||||
grid.appendChild(el('div', { class: 'health-loading' },
|
||||
'Analysing ' + state.entries.length + ' entries…'));
|
||||
healthCache = await computeHealthCache();
|
||||
grid.innerHTML = '';
|
||||
}
|
||||
const h = healthCache;
|
||||
const band = healthScoreBand(h.score);
|
||||
|
||||
// Header: big score + recompute action
|
||||
const header = el('div', { class: 'health-header' });
|
||||
const scoreEl = el('div', { class: 'health-score ' + band.cls });
|
||||
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
|
||||
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
|
||||
header.appendChild(scoreEl);
|
||||
const intro = el('div', { class: 'health-intro' });
|
||||
intro.appendChild(el('h3', null, 'How healthy is your vault?'));
|
||||
intro.appendChild(el('p', null,
|
||||
'A summary of weak, reused, old and breached passwords. ' +
|
||||
'Click any item to open it and rotate the password.'));
|
||||
const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
|
||||
recompute.appendChild(icon('i-rotate-ccw'));
|
||||
recompute.appendChild(document.createTextNode(' Recompute'));
|
||||
recompute.addEventListener('click', () => {
|
||||
healthCache = null;
|
||||
render();
|
||||
});
|
||||
intro.appendChild(recompute);
|
||||
header.appendChild(intro);
|
||||
grid.appendChild(header);
|
||||
|
||||
// Four category cards
|
||||
grid.appendChild(renderHealthSection({
|
||||
title: 'Weak passwords',
|
||||
hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD +
|
||||
'/100 (short / few character classes).',
|
||||
items: h.weak,
|
||||
empty: 'All passwords pass the strength check. 👍',
|
||||
formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100',
|
||||
}));
|
||||
|
||||
grid.appendChild(renderHealthSection({
|
||||
title: 'Reused passwords',
|
||||
hint: 'Same password used on multiple entries — a single breach affects them all.',
|
||||
items: h.reused,
|
||||
empty: 'Every password is unique. 👍',
|
||||
formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') +
|
||||
' (' + group.length + ' entries)',
|
||||
// Click on a reused group: open the first entry. Could be smarter.
|
||||
idOfItem: group => group[0].id,
|
||||
}));
|
||||
|
||||
grid.appendChild(renderHealthSection({
|
||||
title: 'Old passwords',
|
||||
hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) +
|
||||
' months. Consider rotating periodically for high-value accounts.',
|
||||
items: h.old,
|
||||
empty: 'No stale passwords.',
|
||||
formatItem: it => entryDisplayName(it.entry) + ' — ' +
|
||||
Math.floor(it.ageDays / 30) + ' months old',
|
||||
}));
|
||||
|
||||
grid.appendChild(renderHealthSection({
|
||||
title: 'Breached passwords (HIBP)',
|
||||
hint: state.hibpEnabled
|
||||
? 'Found in the Have I Been Pwned database. Change them now.'
|
||||
: 'Enable “Check passwords against breach database” in Settings to populate this list.',
|
||||
items: h.pwned,
|
||||
empty: state.hibpEnabled
|
||||
? 'No password matches a known breach. 👍'
|
||||
: '— breach check is OFF —',
|
||||
formatItem: it => entryDisplayName(it.entry) +
|
||||
' — seen ' + it.count.toLocaleString() + 'x',
|
||||
}));
|
||||
}
|
||||
|
||||
// Build one collapsible category card. opts:
|
||||
// title, hint, items[], empty,
|
||||
// formatItem(item) → text for the row,
|
||||
// idOfItem(item) → entry id used by the Fix click. Default: item.entry.id
|
||||
function renderHealthSection(opts) {
|
||||
const card = el('section', { class: 'health-card' });
|
||||
const head = el('header', { class: 'health-card-head' });
|
||||
head.appendChild(el('h4', null, opts.title));
|
||||
const badge = el('span', { class: 'health-badge' }, String(opts.items.length));
|
||||
if (opts.items.length === 0) badge.classList.add('is-empty');
|
||||
head.appendChild(badge);
|
||||
card.appendChild(head);
|
||||
card.appendChild(el('p', { class: 'health-hint' }, opts.hint));
|
||||
|
||||
if (opts.items.length === 0) {
|
||||
card.appendChild(el('p', { class: 'health-empty' }, opts.empty));
|
||||
return card;
|
||||
}
|
||||
|
||||
const list = el('ul', { class: 'health-list' });
|
||||
const getId = opts.idOfItem || (it => it.entry.id);
|
||||
opts.items.slice(0, 20).forEach(it => {
|
||||
const li = el('li', { class: 'health-item' });
|
||||
li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it)));
|
||||
const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' },
|
||||
'Fix');
|
||||
fix.addEventListener('click', ev => {
|
||||
// Stop bubbling — the document-level "click outside slideover"
|
||||
// handler would otherwise close the slideover we're about to
|
||||
// open within the same click event.
|
||||
ev.stopPropagation();
|
||||
openEntryForFix(getId(it));
|
||||
});
|
||||
li.appendChild(fix);
|
||||
list.appendChild(li);
|
||||
});
|
||||
card.appendChild(list);
|
||||
if (opts.items.length > 20) {
|
||||
card.appendChild(el('p', { class: 'health-more' },
|
||||
'+ ' + (opts.items.length - 20) + ' more not shown'));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function showEmptyState() {
|
||||
const illustration = $('#emptyIllustration use');
|
||||
const title = $('#emptyTitle');
|
||||
@@ -1714,9 +2077,19 @@ function renderCard(e) {
|
||||
// checked. Card click anywhere not on the checkbox opens slideover.
|
||||
const head = el('div', { class: 'entry-head' });
|
||||
const displayName = entryDisplayName(e);
|
||||
const avatar = el('div', {
|
||||
class: 'entry-avatar',
|
||||
}, initials(displayName));
|
||||
const avatar = el('div', { class: 'entry-avatar' });
|
||||
if (e.icon_b64) {
|
||||
const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' });
|
||||
// If the cached data URI fails to decode (corrupt blob), fall
|
||||
// back to the initials so the card never shows a broken-image icon.
|
||||
img.addEventListener('error', () => {
|
||||
avatar.innerHTML = '';
|
||||
avatar.textContent = initials(displayName);
|
||||
});
|
||||
avatar.appendChild(img);
|
||||
} else {
|
||||
avatar.textContent = initials(displayName);
|
||||
}
|
||||
const checkbox = el('button', {
|
||||
class: 'entry-check' + (checked ? ' is-checked' : ''),
|
||||
type: 'button',
|
||||
@@ -2988,8 +3361,16 @@ async function saveEntry(e) {
|
||||
}
|
||||
closeEntryModal();
|
||||
await loadEntries();
|
||||
if (typeof healthCache !== 'undefined') healthCache = null;
|
||||
render();
|
||||
if (savedId) flashEntry(savedId);
|
||||
// Fire-and-forget favicon fetch for the saved entry. Updates the
|
||||
// card in place when it arrives. No-op when feature is off or
|
||||
// the entry already has a cached icon.
|
||||
if (savedId && state.faviconsEnabled) {
|
||||
const saved = state.entries.find(e => e.id === savedId);
|
||||
if (saved) ensureEntryFavicon(saved); // honours the toggle
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
@@ -4875,6 +5256,11 @@ function openSettings() {
|
||||
$('#settingMaskUser').checked = state.maskUsernames;
|
||||
$('#settingHIBP').checked = state.hibpEnabled;
|
||||
$('#settingShowSite').checked = state.showSiteOnCards;
|
||||
$('#settingFavicons').checked = state.faviconsEnabled;
|
||||
// Action buttons + toggle row only meaningful when the Delphi bridge
|
||||
// is available (the PHP frontend has no outbound proxy).
|
||||
$('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none';
|
||||
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
|
||||
$('#settingAutofill').checked = state.autofillEnabled;
|
||||
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
|
||||
// Hotkey capture buttons — labels reflect current combos.
|
||||
@@ -5034,6 +5420,7 @@ const SYNCED_SETTING_KEYS = [
|
||||
// Sidebar section collapsed state. Object of { folders, tags, tools }
|
||||
// booleans. Synced so the user gets the same fold state across devices.
|
||||
'sidebarCollapsed',
|
||||
'faviconsEnabled',
|
||||
];
|
||||
|
||||
function applySidebarCollapsed() {
|
||||
@@ -5073,6 +5460,9 @@ async function loadServerSettings() {
|
||||
// Object; persist as JSON so the next cold start picks it up.
|
||||
localStorage.setItem(k, JSON.stringify(v));
|
||||
break;
|
||||
case 'faviconsEnabled':
|
||||
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Apply visual settings immediately.
|
||||
@@ -5394,6 +5784,14 @@ async function init() {
|
||||
render();
|
||||
});
|
||||
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool);
|
||||
$('#sidebarHealthBtn').addEventListener('click', () => {
|
||||
state.view = 'health';
|
||||
state.currentPage = 1;
|
||||
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
|
||||
// Invalidate any stale cache so we recompute fresh each open.
|
||||
healthCache = null;
|
||||
render();
|
||||
});
|
||||
|
||||
// Sidebar section collapse toggles
|
||||
document.querySelectorAll('[data-section-toggle]').forEach(btn => {
|
||||
@@ -5481,6 +5879,29 @@ async function init() {
|
||||
? 'Will start with Windows (in tray)'
|
||||
: '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');
|
||||
|
||||
Reference in New Issue
Block a user