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:
@@ -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<string>);
|
||||
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<string>('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<string>);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user