Files
Password-Manager/delphi-backend/Source/PM.Favicon.pas
T
r-zakarya e23a78dda7 feat: entry templates + tag autocomplete + slideover push + robustness bundle
- Entry templates: new vault_entries.template column drives a typed
  sub-kind ('credit-card', 'ssh-key', 'server', 'recovery-codes'). Card
  + table label off the template, badge reads "credit card" instead of
  "note". Templates seed kind=note (no site/password required), use
  custom_fields with optional dropdown options (brand, month/year,
  protocol). Round-tripped across export/import/duplicate/master-pw
  rotation, preserved by partial PUTs via a HasTemplate flag.
- Custom fields: support per-field `options[]` rendering as <select>
  (card brand, expiry MM/YYYY, SSH/server protocol).
- Tags: existing-tag autocomplete dropdown under the chip input,
  filtered against what's already selected.
- Search history: per-query X for individual delete + 1s debounced
  commit (no Enter required).
- Slideover: clicking outside closes again (drag-selection respected
  via mousedown origin tracker), Esc closes, X closes. App shell is
  pushed left by 420px when the panel is open so the table / pagination
  / sort / search stay visible and interactive.
- Export/import: JSON now round-trips custom_fields, attachments
  (decrypted to base64, re-encrypted under current key on restore),
  icon_b64, and template. CSV warning lists what's not included.
- Auto-backup: same payload shape as user-driven export.
- Notes: import (JSON + CSV) accepts kind=note with empty site,
  preserves title/template/custom_fields. CSV parser detects kind/
  template columns.
- Bulk-import response returns `ids[]` parallel to input so the
  client can map back to new entry IDs (drives attachment restore).
- Move-to-folder bugs fixed: moveEntryToFolder, batchMoveToFolder,
  addTag, batchAddTag were all silently wiping TOTP / custom_fields
  / kind / template via partial PUT. Now re-ship full payload.
- Master-pw rotation: server mints a fresh session token + csrf so
  the very next request after rotation no longer ESessionRejects.
  Client adopts the new pair. Attachments are re-encrypted client-side
  during rotation (GET old → decrypt with old key → encrypt with new
  → PUT). New endpoints: GET /attachments/all, PUT /attachments/:id.
- Duplicate: carries icon_b64 + template + attachments to the copy.
- HandleCreateEntry: accepts icon_b64.
- FireDAC param fix: all blob/icon/custom_fields params use ftMemo +
  .Value assignment so SQLite TEXT no longer truncates to 4000 chars
  (deepseek's 200+ KB favicon was being wiped on lock/unlock).
- HandleSetEntryIcon cap: 262144 → 524288 chars (base64 of a 256 KB
  raw fetch overflows the old cap, fails silently in saveEntryIcon).
- Native save dialog: surfaces server errors instead of swallowing.
- Modals: reauth (export) + backup-password prompt support inline
  error display, retry up to 5 attempts, then hard-stop.
- Keyboard cursor (j/k): bootstraps to current page, auto-paginates
  when the cursor crosses a page boundary, Enter opens slideover.
- Slideover focuses Title on edit-open so j/k → Enter → type Just
  Works.
- TOTP tool: Esc closes the modal.
- App version + launch mode (auto/manual): exposed via bridge,
  surfaced in Settings → Account. Autostart launches suppress the
  first-time tray balloon.
- Passkey button hidden (Delphi backend stubs WebAuthn at 501).
- TEST_PLAN.md captured for regression coverage.

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

262 lines
8.6 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 = 262144; // 256 KB cap (DDG sometimes serves full-res
// assets; matches handler + JS upload limits)
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 = 300;
// 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 the SLD (brand domain) when the host has a subdomain,
// because DDG often returns a generic placeholder for chat.X.com / app.X.com
// / etc. (passes our byte threshold but looks wrong) while having the real
// brand icon under X.com. For bare 2-label hosts we go straight to step 2.
LSld := ExtractSLD(LHost);
LOk := False;
// 1) DDG SLD first when host has a subdomain (e.g. chat.deepseek.com →
// try deepseek.com.ico first). Skipped for bare hosts.
if LSld <> '' then
begin
LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
if FetchOneIcon(LUrl, LBytes) then
begin
if Length(LBytes) >= MIN_REAL_ICON_BYTES then
begin
LOk := True;
Trace(Format('OK step1 DDG sld: %s (%d bytes)', [LUrl, Length(LBytes)]));
end
else
Trace(Format('skip step1 DDG sld: %s only %d bytes', [LUrl, Length(LBytes)]));
end
else
Trace('fail step1 DDG sld: ' + LUrl);
end;
// 2) DDG full host as fallback (covers brands whose subdomain has its own
// distinct icon, OR plain hosts like github.com that have no SLD step).
if not LOk then
begin
var LTry: TBytes;
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
if FetchOneIcon(LUrl, LTry) then
begin
if Length(LTry) >= MIN_REAL_ICON_BYTES then
begin
LBytes := LTry; LOk := True;
Trace(Format('OK step2 DDG host: %s (%d bytes)', [LUrl, Length(LTry)]));
end
else
Trace(Format('skip step2 DDG host: %s only %d bytes', [LUrl, Length(LTry)]));
end
else
Trace('fail step2 DDG host: ' + 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.