feat(android): an entry can get its icon, through the same relay as the desktop
Fetching https://<site>/favicon.ico directly would resolve a DNS name - and open a connection - to EVERY domain kept in the vault. The list of sites where someone holds an account is precisely what this vault protects; handing it to the network for a sixteen-pixel drawing would be a poor trade. The relay sees only that someone is interested in one domain, which is why the call is asked for entry by entry and never happens on its own. Two tries: the full host, then the second-level domain - chat.qwen.ai has no icon at the relay, qwen.ai does. The type comes from the first bytes, never from the extension: the relay answers PNG under a .ico address. A dedicated patch rather than PatchEntryField, because icon_custom is a NUMBER in the snapshot and writing it as a string would give "0" - which the desktop reads with GetValue<Boolean> and would choke on. It is set to zero: the icon comes from an automatic lookup, not from a choice, and that flag is what stops the desktop from ever replacing a chosen icon during a refresh. Base64 without line breaks. Delphi's encoder wraps at 76 characters by default, a wrapped data: URI does not display, and that trap has already been paid once on the containers.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
unit PMA.Favicon;
|
||||
{
|
||||
L'icone d'un site, par le relais de DuckDuckGo.
|
||||
|
||||
MEME SOURCE QUE LE POSTE, et pour la meme raison. Aller chercher
|
||||
`https://<site>/favicon.ico` en direct ferait resoudre un DNS - et ouvrir une
|
||||
connexion - vers CHAQUE domaine range dans le coffre. La liste des sites ou
|
||||
quelqu'un a un compte est precisement ce que ce coffre protege : la donner au
|
||||
reseau pour un dessin de seize pixels serait un mauvais marche.
|
||||
|
||||
Le relais, lui, ne voit qu'une chose : que quelqu'un s'interesse a ce domaine.
|
||||
C'est deja quelque chose, d'ou le caractere facultatif de l'appel - il ne part
|
||||
que lorsqu'on le demande, entree par entree.
|
||||
|
||||
DEUX ESSAIS : l'hote complet, puis le domaine de second niveau. `chat.qwen.ai`
|
||||
n'a pas d'icone chez le relais, `qwen.ai` en a une.
|
||||
}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
System.SysUtils;
|
||||
|
||||
/// Rend une URI `data:` prete a etre rangee dans `icon_b64`, ou '' si rien
|
||||
/// n'a ete trouve - AError dit alors pourquoi.
|
||||
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
|
||||
|
||||
/// L'hote d'une adresse, sans schema ni chemin. Public parce que c'est aussi
|
||||
/// ce qu'il faut pour decider s'il y a quelque chose a chercher.
|
||||
function HostOf(const ASite: string): string;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.Classes, System.NetEncoding, System.Net.HttpClient,
|
||||
System.Net.URLClient;
|
||||
|
||||
const
|
||||
// 64 Ko : une icone de site en fait quelques-uns. Au-dela, ce n'est pas une
|
||||
// icone, et l'accepter ferait gonfler l'instantane que chaque enregistrement
|
||||
// republie en entier.
|
||||
MAX_BYTES = 64 * 1024;
|
||||
TIMEOUT_MS = 5000;
|
||||
|
||||
function HostOf(const ASite: string): string;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := Trim(ASite);
|
||||
if Result = '' then Exit;
|
||||
I := Pos('://', Result);
|
||||
if I > 0 then Result := Copy(Result, I + 3, MaxInt);
|
||||
// Chemin, requete, ancre, et les identifiants d'une URL a la user@host.
|
||||
I := Pos('/', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
I := Pos('?', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
I := Pos('#', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
I := LastDelimiter('@', Result);
|
||||
if I > 0 then Result := Copy(Result, I + 1, MaxInt);
|
||||
I := Pos(':', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
|
||||
Result := LowerCase(Trim(Result));
|
||||
// Un site note "Gitea" plutot qu'en adresse n'a pas d'hote : sans point, il
|
||||
// n'y a rien a demander au relais.
|
||||
if Pos('.', Result) = 0 then Result := '';
|
||||
end;
|
||||
|
||||
/// Le domaine de second niveau, ou '' s'il n'y a rien de plus court a essayer.
|
||||
function SecondLevel(const AHost: string): string;
|
||||
var
|
||||
LParts: TArray<string>;
|
||||
begin
|
||||
Result := '';
|
||||
LParts := AHost.Split(['.']);
|
||||
if Length(LParts) < 3 then Exit; // deja un SLD
|
||||
Result := LParts[High(LParts) - 1] + '.' + LParts[High(LParts)];
|
||||
end;
|
||||
|
||||
/// Le type d'apres les premiers octets, jamais d'apres l'extension : le relais
|
||||
/// rend du PNG sous une adresse en .ico, et un type faux ferait afficher un
|
||||
/// cadre vide sans rien dire.
|
||||
function SniffMime(const AData: TBytes): string;
|
||||
begin
|
||||
Result := '';
|
||||
if Length(AData) < 4 then Exit;
|
||||
if (AData[0] = $89) and (AData[1] = Ord('P')) then Result := 'image/png'
|
||||
else if (AData[0] = $FF) and (AData[1] = $D8) then Result := 'image/jpeg'
|
||||
else if (AData[0] = Ord('G')) and (AData[1] = Ord('I')) then Result := 'image/gif'
|
||||
else if (AData[0] = 0) and (AData[1] = 0) and (AData[2] = 1) then
|
||||
Result := 'image/x-icon'
|
||||
else if (AData[0] = Ord('<')) then Result := 'image/svg+xml';
|
||||
end;
|
||||
|
||||
function TryHost(const AHost: string; out AData: TBytes): Boolean;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LStream: TBytesStream;
|
||||
LResp: IHTTPResponse;
|
||||
begin
|
||||
Result := False;
|
||||
AData := nil;
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := TIMEOUT_MS;
|
||||
LHttp.ResponseTimeout := TIMEOUT_MS;
|
||||
LHttp.MaxRedirects := 3;
|
||||
LStream := TBytesStream.Create;
|
||||
try
|
||||
try
|
||||
LResp := LHttp.Get('https://icons.duckduckgo.com/ip3/' + AHost + '.ico',
|
||||
LStream);
|
||||
except
|
||||
Exit;
|
||||
end;
|
||||
if LResp.StatusCode <> 200 then Exit;
|
||||
if (LStream.Size = 0) or (LStream.Size > MAX_BYTES) then Exit;
|
||||
AData := Copy(LStream.Bytes, 0, LStream.Size);
|
||||
Result := True;
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
|
||||
var
|
||||
LHost, LSld, LMime: string;
|
||||
LData: TBytes;
|
||||
LB64: TBase64Encoding;
|
||||
begin
|
||||
Result := '';
|
||||
AError := '';
|
||||
LHost := HostOf(ASite);
|
||||
if LHost = '' then
|
||||
begin
|
||||
AError := 'this entry has no web address';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if not TryHost(LHost, LData) then
|
||||
begin
|
||||
LSld := SecondLevel(LHost);
|
||||
if (LSld = '') or not TryHost(LSld, LData) then
|
||||
begin
|
||||
AError := 'no icon found for ' + LHost;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
LMime := SniffMime(LData);
|
||||
if LMime = '' then
|
||||
begin
|
||||
AError := 'what came back is not an image';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// SANS COUPURE DE LIGNE. L'encodeur de Delphi coupe a 76 caracteres par
|
||||
// defaut ; une URI data: coupee ne s'affiche pas, et le piege s'est deja
|
||||
// paye une fois sur les conteneurs.
|
||||
LB64 := TBase64Encoding.Create(0);
|
||||
try
|
||||
Result := 'data:' + LMime + ';base64,' + LB64.EncodeBytesToString(LData);
|
||||
finally
|
||||
LB64.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
Reference in New Issue
Block a user