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:
@@ -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.
|
||||
Reference in New Issue
Block a user