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` |
|
| 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
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.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
@@ -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">
|
||||||
|
|||||||
@@ -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)'
|
||||||
: 'Won’t start with Windows');
|
: '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 => {
|
$('#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');
|
||||||
|
|||||||
Reference in New Issue
Block a user