63fac5b3b7
Big feature trio
- Secure notes (kind='login'|'note') reusing the encrypted_password+iv
pipeline for the body. New sidebar entry, slideover variant (title +
multiline body), distinct card / table-view rendering, badge in name
column, copy-content button replacing the password copy on note rows.
- Password history: entries_password_history table keeps up to 20 prior
ciphertexts per entry. HandleUpdateEntry pushes the pre-update
encrypted_password into history ONLY when it actually differs from
the incoming one (JS reuses originalEncrypted bit-for-bit when the
plaintext is unchanged — avoids spamming history on title/folder edits).
GET /entries/{id}/history endpoint. Slideover modal lists versions
with mask/reveal/copy/revert. Master-pw rotation wipes history (old
ciphertext can't be decrypted with the new key).
- Custom fields: per-entry encrypted JSON array of {label, value,
is_secret}. Same crypto pipeline as the password. Slideover row UI
with label/value inputs, secret toggle (eye), copy, delete. Re-
encryption flows through bulk-import, change-master-password, and
duplicate.
Quick wins
- Cheatsheet overlay (press '?' or topbar button or Ctrl+K). Lists all
hotkeys + global / tray / card actions. SVG icons inline so the
cheatsheet matches the actual app glyphs (no emoji mismatch).
- Open URL button on entry cards: ShellExecute via cmd://app/open-url,
http(s) only, validates entry.site looks like a real hostname.
- Trash auto-purge: setting "Empty trash after N days" (never/7/30/90).
DELETE /entries/trash/old?days=N called at every unlock.
Favicon strategy
- Subdomains (chat.deepseek.com, app.X.com…) now try the SLD first
(deepseek.com.ico) before the full host. DDG often returns a generic
placeholder for subdomains that passes the byte threshold; the SLD-first
switch surfaces the real brand icon.
- Cap bumped 64 KB → 256 KB on all three sides (Delphi fetch, server
endpoint, JS upload). DDG sometimes serves the full-res asset.
UX polish
- Click-outside-slideover: stopPropagation everywhere it bites. Custom
fields buttons (add / delete / secret toggle / copy / eye) all stop
the click bubble so the document-level "close on outside click" handler
doesn't fire when rerender() detaches the target from the DOM.
- Native search-cancel button restyled: cyan accent X via mask-image,
cursor: pointer, breathing room before the Ctrl+K kbd chip.
- Password history modal: scrollable body, multiline wrapped passwords,
hover border highlight.
- Cheatsheet panel widened (560 → 720 px) so the descriptions no longer
ellipsis-clip.
- "+ New" topbar splits into a small dropdown: New login / New note.
- Notes show a "note" badge in table-view name column, italic
"Encrypted note" placeholder in the username column.
Internals
- duplicateEntry copies kind + custom_fields too (one-line forgotten
earlier).
- entries_password_history dropped on master-pw rotation — the old
ciphertexts are unrecoverable with the new key.
- bulk-import re-encryption path includes custom_fields.
CLAUDE.md
- "Entry payload — call sites à toucher ensemble" lists the 6 spots
to update when adding a new (en)crypted field. Notes the historical
miss of kind in duplicateEntry and custom_fields in the rotation +
duplicate.
Repo hygiene
- .gitattributes forces CRLF on Delphi sources (RAD Studio refuses LF).
text=auto for web frontend / docs, binary for .res / .exe / images.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
262 lines
8.6 KiB
ObjectPascal
262 lines
8.6 KiB
ObjectPascal
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 = 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 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.
|