From ad5fb21a1804d3eef6c9203e54e714714ff44efd Mon Sep 17 00:00:00 2001
From: Zaki <18zaki18@gmail.com>
Date: Tue, 9 Jun 2026 00:05:54 +0100
Subject: [PATCH] feat: website favicons + vault health dashboard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
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
---
CLAUDE.md | 53 +++
css/style.css | 139 ++++++
.../Handlers/PM.Handler.Entries.pas | 110 +++++
delphi-backend/PMServer.dpr | 1 +
delphi-backend/PMServer.dproj | 1 +
delphi-backend/Source/PM.Database.pas | 4 +
delphi-backend/Source/PM.Favicon.pas | 156 +++++++
delphi-backend/UMainForm.pas | 49 +-
delphi-backend/assets/assets.res | Bin 328316 -> 351612 bytes
index.html | 30 ++
js/app.js | 429 +++++++++++++++++-
11 files changed, 967 insertions(+), 5 deletions(-)
create mode 100644 delphi-backend/Source/PM.Favicon.pas
diff --git a/CLAUDE.md b/CLAUDE.md
index 9938d26..00a3209 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,6 +38,7 @@ plus courant après modif frontend.
| Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` |
| Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` |
| Start with Windows (HKCU Run) | `delphi-backend/Source/PM.AutoStart.pas` |
+| Favicon proxy (DuckDuckGo, async THTTPClient/WinHTTP) | `delphi-backend/Source/PM.Favicon.pas` |
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
| Frontend complet | `js/app.js` |
| HTML racine | `index.html` |
@@ -53,6 +54,7 @@ Commandes connues :
- `quickunlock/{store,get,clear,status}`
- `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas)
- `autostart/{get,set}?enabled=1|0` (HKCU Run registry, "Start with Windows")
+- `favicon/fetch?host=X&reqId=Y` (async via anonymous thread, callback `Bridge.onFaviconResult(reqId, host, dataUri)`)
- `autofill/{configure,hotkeys,execute,cancel}`
- `app/focus` (ramène la fenêtre au premier plan, pour le picker)
- `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input)
@@ -158,6 +160,34 @@ la fenêtre + clipboard clear + balloon first-time.
Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par
`SetPreferredAppMode` ci-dessus).
+## Favicons (`PM.Favicon`)
+
+Opt-in (`state.faviconsEnabled`, default OFF, synced via `settings_json`).
+La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS).
+
+- Source : `https://icons.duckduckgo.com/ip3/.ico` — proxy DDG, pas
+ de tracking, retourne PNG 16-32 px. Centralisé → seul DDG voit la
+ liste des domaines stockés, vs hit chaque /favicon.ico (qui leakerait
+ TOUT le vault à chaque site)
+- `THTTPClient` (`System.Net.HttpClient`) qui wrappe **WinHTTP** sur
+ Windows → TLS natif via le store de certificats Windows. Pas de
+ DLLs OpenSSL à shipper (Indy aurait silencieusement fail sans
+ `libcrypto-3.dll`/`libssl-3.dll`). 5 s timeout, max 3 redirects,
+ cap 64 KB. Toute erreur → return `''`
+- Magic-byte sniffing pour le MIME (PNG/JPEG/GIF/SVG/ICO)
+- Cmd async : `TThread.CreateAnonymousThread` car un GET HTTP bloquerait
+ le main thread jusqu'à 5 s. Callback via `TThread.Queue` →
+ `Bridge.onFaviconResult(reqId, host, dataUri)`
+- Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint
+ dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet
+ (qui re-PUT le password chiffré)
+- Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache")
+- Render : `entry-avatar` contient `
` si
+ `icon_b64`, sinon fallback aux initials. `onerror` repasse aux
+ initials si la data URI est corrompue
+- Auto-fetch après save d'entry, backfill via "Fetch missing" /
+ "Re-fetch all" boutons dans Settings
+
## Start with Windows (`PM.AutoStart`)
- Toggle dans Settings → "Start with Windows" (visible seulement quand
@@ -223,6 +253,29 @@ Sidebar Tools expose 2 items MFA :
Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom,
**pas `remaining`** (utiliser `t.secondsLeft`).
+## Vault health dashboard
+
+Sidebar Tools → "Vault health" (`state.view = 'health'`). Vue dédiée avec
+un score 0-100 et 4 cards de catégorie :
+
+- **Weak** : `computeStrength(plain) < 50`
+- **Reused** : groupes d'entries partageant le même plaintext (≥ 2)
+- **Old** : `updated_at > 365 days`
+- **Pwned** : depuis `state.hibpResults` (n'apparaît que si HIBP actif)
+
+`computeHealthCache()` décrypte chaque entry une fois et stocke dans
+`healthCache` (module-level let, pas dans state pour ne pas polluer).
+Invalidé sur lock, save d'entry, et bouton "Recompute".
+
+Score : start 100 → -5/weak (cap -40), -10/reused (cap -30), -2/old (cap
+-20), -15/pwned (cap -50). Bandes : ≥80 Good (vert), ≥50 Fair (cyan),
+≥25 At risk (orange), <25 Critical (rouge).
+
+Bouton "Fix" par item → `openEntryForFix(id)` = `openSlideover` puis
+click sur le bouton edit-password (fallback : juste ouvre le slideover).
+
+Liste tronquée à 20 items par catégorie + count "+N more".
+
## Sidebar sections collapsibles
Sections `Folders`, `Tags`, `Tools` ont chacune un `.section-toggle`
diff --git a/css/style.css b/css/style.css
index 2d02b9c..d2ab425 100644
--- a/css/style.css
+++ b/css/style.css
@@ -837,6 +837,14 @@ input[type="range"]::-webkit-slider-thumb {
font-weight: 600; font-size: 14px;
color: var(--text);
flex-shrink: 0;
+ overflow: hidden;
+}
+.entry-avatar-img {
+ width: 100%; height: 100%;
+ object-fit: contain;
+ padding: 4px;
+ box-sizing: border-box;
+ image-rendering: -webkit-optimize-contrast; /* sharper 16x16 icons */
}
.entry-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.entry-title b {
@@ -1102,6 +1110,137 @@ input[type="range"]::-webkit-slider-thumb {
}
.auth-card-bar.is-warning { background: #f59e0b; }
+/* ---- Vault health dashboard ----------------------------- */
+.entry-grid.is-health {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.health-loading {
+ color: var(--text-dim);
+ text-align: center;
+ padding: 40px;
+ font-size: 14px;
+}
+.health-header {
+ display: flex; align-items: center; gap: 20px;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px;
+}
+.health-score {
+ width: 110px; height: 110px;
+ border-radius: 50%;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ flex-shrink: 0;
+ border: 4px solid var(--accent);
+ background: var(--bg);
+}
+.health-score.is-ok { border-color: #10b981; color: #10b981; }
+.health-score.is-fair { border-color: var(--accent); color: var(--accent); }
+.health-score.is-warn { border-color: #f59e0b; color: #f59e0b; }
+.health-score.is-danger { border-color: #ef4444; color: #ef4444; }
+.health-score-num {
+ font-size: 36px; font-weight: 700;
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+}
+.health-score-lbl {
+ font-size: 11px; font-weight: 600;
+ text-transform: uppercase; letter-spacing: 0.5px;
+ margin-top: 2px;
+}
+.health-intro { flex: 1; }
+.health-intro h3 { margin: 0 0 6px; font-size: 16px; }
+.health-intro p {
+ margin: 0 0 10px;
+ color: var(--text-dim);
+ font-size: 13px;
+ line-height: 1.5;
+}
+.health-card {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px 18px;
+}
+.health-card-head {
+ display: flex; align-items: center; gap: 10px;
+ margin-bottom: 6px;
+}
+.health-card-head h4 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+.health-badge {
+ min-width: 22px;
+ padding: 2px 8px;
+ border-radius: 11px;
+ background: var(--accent);
+ color: white;
+ font-size: 11px;
+ font-weight: 700;
+ text-align: center;
+}
+.health-badge.is-empty {
+ background: var(--bg);
+ color: var(--text-dim);
+}
+.health-hint {
+ margin: 0 0 10px;
+ font-size: 12px;
+ color: var(--text-dim);
+ line-height: 1.4;
+}
+.health-empty {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-faint);
+ font-style: italic;
+}
+.health-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex; flex-direction: column;
+ gap: 4px;
+}
+.health-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 10px;
+ background: var(--bg);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+}
+.health-item-label {
+ flex: 1;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ color: var(--text);
+}
+.health-more {
+ margin: 6px 0 0;
+ color: var(--text-faint);
+ font-size: 11px;
+ font-style: italic;
+ text-align: center;
+}
+.btn-xs {
+ font-size: 11px;
+ padding: 3px 10px;
+}
+
+/* Brief attention pulse on the slideover generator button after Fix */
+@keyframes attentionPulse {
+ 0%, 100% { box-shadow: 0 0 0 0 var(--accent); }
+ 50% { box-shadow: 0 0 0 6px transparent; }
+}
+.icon-btn.is-pulse {
+ animation: attentionPulse 0.7s ease-out 3;
+ border-radius: var(--radius-sm);
+}
+
/* ---- 10. SLIDE-OVER -------------------------------------- */
.slideover {
diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas
index 1ba6218..09c5647 100644
--- a/delphi-backend/Handlers/PM.Handler.Entries.pas
+++ b/delphi-backend/Handlers/PM.Handler.Entries.pas
@@ -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);
+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('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);
+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);
diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr
index f15e66e..cd49234 100644
--- a/delphi-backend/PMServer.dpr
+++ b/delphi-backend/PMServer.dpr
@@ -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',
diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj
index 8cbf281..4799856 100644
--- a/delphi-backend/PMServer.dproj
+++ b/delphi-backend/PMServer.dproj
@@ -221,6 +221,7 @@ $(PreBuildEvent)]]>
+
diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas
index 94f8d5d..e7787f8 100644
--- a/delphi-backend/Source/PM.Database.pas
+++ b/delphi-backend/Source/PM.Database.pas
@@ -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.
diff --git a/delphi-backend/Source/PM.Favicon.pas b/delphi-backend/Source/PM.Favicon.pas
new file mode 100644
index 0000000..3fb7728
--- /dev/null
+++ b/delphi-backend/Source/PM.Favicon.pas
@@ -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
.
+
+ Source: DuckDuckGo's icons service (icons.duckduckgo.com/ip3/.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 : "