Files
Password-Manager/delphi-backend/Source/PM.Favicon.pas
T
Zaki 2ef636ce30 feat: unified entry slideover + custom icons + UX fixes
Unified create/edit slideover
- openSlideOver(id) now accepts null for new entries. Same UI
  (icon, name, site, user, password, TOTP, folder, tags) for both
  create and edit. Drops the separate entry modal — no more "save
  first, then add TOTP" two-step.
- "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route
  through the slideover. Ctrl+Shift+A pre-fills the title with the
  foreground window's name.
- Save button visible from the start in new mode (no dirty wait).
- Title shows mode unambiguously: cyan "+ New entry" vs
  "Edit · <name>".

Custom icon upload (soIconField)
- 56×56 preview at the top of every slideover + Upload icon /
  Remove buttons. Same POST /entries/{id}/icon endpoint as the
  auto-fetch path. Validates type / size (64 KB cap matching server).
- Solves the case where DDG doesn't index a domain (self-hosted
  apps, private sites): the user pastes any image and it sticks.

Favicon: privacy-first, DDG only
- Removed the direct-fetch fallback steps (3-5). Privacy stance:
  zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't
  cover stay icon-less until the user uploads a custom one.
- PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog
  callback so UMainForm can stream per-step trace into LogLine for
  diagnostics.

Fixes
- Slideover z-index 30 → 50. The topbar's backdrop-filter creates a
  stacking context at z-index 40 which was clipping the slideover
  header (title + close button hidden behind topbar).
- RestoreFromTray no longer un-maximises a maximised window when
  called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L
  picker, app/focus cmd). SW_RESTORE on a maximised window reverts
  to normal — now we only SW_RESTORE if IsIconic.
- "Show all"/"Show less" per-category state survives renderGrid
  re-renders (healthExpanded map).
- "+ New" and dashboard "Fix" buttons stopPropagation so the
  document-level click-outside handler doesn't close the slideover
  they just opened.
- soDirtyCheck keeps Save visible while in new mode regardless of
  diff.
- openSlideover → openSlideOver typo fix across all call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 00:15:20 +01:00

258 lines
8.3 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
type
TFaviconLog = reference to procedure(const ALine: string);
// Fetches an icon for AHost (bare hostname, no scheme). Returns a
// "data:image/...;base64,..." string on success, or '' on any failure.
// ALog (optional): called for each fallback step so the host can trace
// exactly which URL hit / missed.
function FetchFaviconDataUri(const AHost: string;
ALog: TFaviconLog = nil): 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;
// DDG returns a generic placeholder for unknown domains. Bigger threshold
// than 100 to avoid treating its blank globe glyph as a real icon.
MIN_REAL_ICON_BYTES = 500;
// Privacy stance: DDG-only fetches. We don't fall back to the site's
// own /favicon.ico because that would leak DNS to every domain stored
// in the vault. For sites DDG doesn't index, the user can upload a
// custom icon via the slideover (soIconField).
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;
// "chat.deepseek.com" → "deepseek.com". Returns '' if S has no dot or
// is already a 2-label hostname (we'd fall back to the same input).
function ExtractSLD(const S: string): string;
var
DotCount, FirstDot: Integer;
I: Integer;
begin
Result := '';
DotCount := 0;
FirstDot := 0;
for I := 1 to Length(S) do
if S[I] = '.' then
begin
Inc(DotCount);
if FirstDot = 0 then FirstDot := I;
end;
if DotCount < 2 then Exit; // already SLD or no dots
Result := Copy(S, FirstDot + 1, MaxInt);
end;
function FetchOneIcon(const AUrl: string;
out ABytes: TBytes): Boolean; forward;
function FetchFaviconDataUri(const AHost: string;
ALog: TFaviconLog = nil): string;
procedure Trace(const ALine: string);
begin
if Assigned(ALog) then ALog(ALine);
end;
var
LHost, LSld, LMime, LBase64, LUrl: string;
LBytes: TBytes;
LOk: Boolean;
begin
Result := '';
LHost := NormalizeHost(AHost);
if LHost = '' then
begin
Trace('reject: "' + AHost + '" not a valid hostname');
Exit;
end;
// Strategy: prefer DDG (privacy-centralising) but fall back to the
// site's own /favicon.ico for domains DDG doesn't index (self-hosted
// tools, niche services, fresh subdomains, etc.). The user already
// opted into "fetch icons" so the DNS leak to one extra host they
// already visit is an acceptable trade-off for actually getting an icon.
LSld := ExtractSLD(LHost);
LOk := False;
// 1) DDG full host.
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
if FetchOneIcon(LUrl, LBytes) then
begin
if Length(LBytes) >= MIN_REAL_ICON_BYTES then
begin
LOk := True;
Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)]));
end
else
Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)',
[LUrl, Length(LBytes), MIN_REAL_ICON_BYTES]));
end
else
Trace('fail step1 DDG host: ' + LUrl);
// 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s).
if (not LOk) and (LSld <> '') then
begin
var LTry: TBytes;
LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
if FetchOneIcon(LUrl, LTry) then
begin
if Length(LTry) >= MIN_REAL_ICON_BYTES then
begin
LBytes := LTry; LOk := True;
Trace(Format('OK step2 DDG sld: %s (%d bytes)', [LUrl, Length(LTry)]));
end
else
Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)]));
end
else
Trace('fail step2 DDG sld: ' + LUrl);
end;
if (not LOk) or (Length(LBytes) = 0) then
begin
Trace('DDG has no icon for ' + LHost + ' — user can upload a custom one');
Exit;
end;
LMime := GuessMimeFromBytes(LBytes);
LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]);
LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]);
Result := 'data:' + LMime + ';base64,' + LBase64;
end;
// Low-level HTTP GET. Returns False on any failure (DNS, TLS, non-200,
// oversize). On success ABytes contains the raw image bytes.
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
// zero extra DLLs to ship next to the exe.
function FetchOneIcon(const AUrl: string; out ABytes: TBytes): Boolean;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TMemoryStream;
begin
Result := False;
SetLength(ABytes, 0);
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(AUrl, LStream);
except
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(ABytes, LStream.Size);
LStream.ReadBuffer(ABytes[0], LStream.Size);
Result := True;
finally
LStream.Free;
LHttp.Free;
end;
end;
end.