feat: website favicons + vault health dashboard

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:05:54 +01:00
parent 33e4b4b614
commit ad5fb21a18
11 changed files with 967 additions and 5 deletions
@@ -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);
+1
View File
@@ -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',
+1
View File
@@ -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"/>
+4
View File
@@ -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.
+156
View File
@@ -0,0 +1,156 @@
unit PM.Favicon;
{
Favicon proxy — fetches a website's icon and returns a base64 data URI
ready to drop into an <img src="...">.
Source: DuckDuckGo's icons service (icons.duckduckgo.com/ip3/<host>.ico)
- No tracking pixels / analytics on the icon endpoints
- Returns a 32×32 PNG (or ICO) with the proper MIME type
- Centralised: only DDG sees the list of domains the user looks up,
vs hitting each site's /favicon.ico directly (which would leak the
full vault contents to every site listed)
- Falls back to a generic globe glyph for unknown sites
HTTPS only; 5s timeout; cap response at 64 KB; no redirects beyond 3.
Threat model: this is the ONLY outbound network call from Delphi (HIBP is
done client-side). The user explicitly opts in via Settings. Failure
modes (DNS, TLS, 4xx, oversize) all return '' — caller falls back to
the first-letter avatar.
}
interface
// Fetches an icon for AHost (bare hostname, no scheme). Returns a
// "data:image/...;base64,..." string on success, or '' on any failure.
function FetchFaviconDataUri(const AHost: string): string;
implementation
uses
System.SysUtils, System.Classes, System.NetEncoding,
System.Net.HttpClient, System.Net.URLClient;
const
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
HTTP_TIMEOUT_MS = 5000;
function NormalizeHost(const ARaw: string): string;
var
S: string;
SlashPos, ColonPos, I: Integer;
Ch: Char;
begin
// Accept anything user-typed: "https://www.github.com/login", "github.com",
// "GitHub.com:8443". Return lowercase bare hostname, or '' if the input
// doesn't look like a real domain — defense in depth alongside the JS
// faviconHost() validation (so a future bridge caller can't leak a
// brand label like "Gitea" upstream).
Result := '';
S := Trim(ARaw).ToLower;
if S.StartsWith('https://') then S := Copy(S, 9, MaxInt)
else if S.StartsWith('http://') then S := Copy(S, 8, MaxInt);
if S.StartsWith('www.') then S := Copy(S, 5, MaxInt);
SlashPos := Pos('/', S);
if SlashPos > 0 then S := Copy(S, 1, SlashPos - 1);
ColonPos := Pos(':', S);
if ColonPos > 0 then S := Copy(S, 1, ColonPos - 1);
if (S = '') or (Length(S) > 253) then Exit;
// Must contain a dot, no leading/trailing dot, no consecutive dots,
// only [a-z0-9.-] characters.
if Pos('.', S) < 2 then Exit;
if S.StartsWith('.') or S.EndsWith('.') or S.Contains('..') then Exit;
for I := 1 to Length(S) do
begin
Ch := S[I];
if not (((Ch >= 'a') and (Ch <= 'z')) or
((Ch >= '0') and (Ch <= '9')) or
(Ch = '.') or (Ch = '-')) then
Exit;
end;
Result := S;
end;
function GuessMimeFromBytes(const ABytes: TBytes): string;
begin
// Lightweight magic-byte sniffing. Saves a Content-Type round-trip parse.
Result := 'image/x-icon'; // safe default for an .ico fetch
if Length(ABytes) < 8 then Exit;
// PNG : 89 50 4E 47 0D 0A 1A 0A
if (ABytes[0] = $89) and (ABytes[1] = $50) and (ABytes[2] = $4E) and (ABytes[3] = $47) then
Exit('image/png');
// GIF : "GIF8"
if (ABytes[0] = Ord('G')) and (ABytes[1] = Ord('I')) and
(ABytes[2] = Ord('F')) and (ABytes[3] = Ord('8')) then
Exit('image/gif');
// JPEG : FF D8 FF
if (ABytes[0] = $FF) and (ABytes[1] = $D8) and (ABytes[2] = $FF) then
Exit('image/jpeg');
// SVG : "<svg" or "<?xml" (text-prefixed)
if (ABytes[0] = Ord('<')) then Exit('image/svg+xml');
// ICO : 00 00 01 00
if (ABytes[0] = $00) and (ABytes[1] = $00) and
(ABytes[2] = $01) and (ABytes[3] = $00) then
Exit('image/x-icon');
end;
function FetchFaviconDataUri(const AHost: string): string;
var
LHost, LUrl, LMime, LBase64: string;
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TMemoryStream;
LBytes: TBytes;
begin
Result := '';
LHost := NormalizeHost(AHost);
if LHost = '' then Exit;
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
// zero extra DLLs to ship next to the exe (unlike Indy + OpenSSL which
// fails silently when libcrypto/libssl are missing).
LHttp := THTTPClient.Create;
LStream := TMemoryStream.Create;
try
LHttp.ConnectionTimeout := HTTP_TIMEOUT_MS;
LHttp.ResponseTimeout := HTTP_TIMEOUT_MS;
LHttp.HandleRedirects := True;
LHttp.MaxRedirects := 3;
LHttp.UserAgent := 'PMServer/1.0 (favicon-fetch)';
LHttp.CustHeaders.Add('Accept',
'image/png,image/x-icon,image/*;q=0.8,*/*;q=0.1');
try
LResp := LHttp.Get(LUrl, LStream);
except
// Any DNS / connect / TLS failure → silent ''.
Exit;
end;
if (LResp = nil) or (LResp.StatusCode <> 200) then Exit;
if LStream.Size <= 0 then Exit;
if LStream.Size > MAX_ICON_BYTES then Exit;
LStream.Position := 0;
SetLength(LBytes, LStream.Size);
LStream.ReadBuffer(LBytes[0], LStream.Size);
LMime := GuessMimeFromBytes(LBytes);
LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
// Strip CR/LF that the encoder inserts every 76 chars — invalid inside
// an <img src="..."> attribute and bloats the cached blob.
LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]);
LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]);
Result := 'data:' + LMime + ';base64,' + LBase64;
finally
LStream.Free;
LHttp.Free;
end;
end;
end.
+48 -1
View File
@@ -11,7 +11,8 @@ uses
FMX.Dialogs, FMX.DialogService,
FMX.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.