diff --git a/android-client/PMA.Favicon.pas b/android-client/PMA.Favicon.pas new file mode 100644 index 0000000..d378783 --- /dev/null +++ b/android-client/PMA.Favicon.pas @@ -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:///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; +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. diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 4016550..1353898 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -87,6 +87,18 @@ function DuplicateEntry(const APlainJson, AUuid: string): string; function AttachmentContent(const APlainJson, AUuid: string; AIndex: Integer): string; +/// Pose l'icone d'une entree, et le drapeau de provenance avec elle. +/// +/// PAS PatchEntryField : `icon_custom` est un NOMBRE dans l'instantane, et +/// l'ecrire comme une chaine donnerait `"icon_custom": "0"` - que le poste lit +/// avec GetValue, qui echouerait sur une chaine. Deux champs de types +/// differents, donc une fonction qui les connait tous les deux. +/// +/// AUri vide efface l'icone. Le drapeau retombe alors a zero comme sur le +/// poste : une icone absente n'est pas une icone choisie. +function PatchEntryIcon(const APlainJson, AUuid, AUri: string; + ACustom: Boolean): string; + /// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat. /// /// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne @@ -297,6 +309,48 @@ begin end; end; +function PatchEntryIcon(const APlainJson, AUuid, AUri: string; + ACustom: Boolean): string; +var + LRoot, LEntry: TJSONObject; + LEntries: TJSONArray; + I: Integer; + LFound: Boolean; +begin + if AUuid = '' then + raise EPMAPatch.Create('Entree sans uuid : rien a retrouver'); + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then + raise EPMAPatch.Create('Instantane illisible'); + try + if not LRoot.TryGetValue('entries', LEntries) then + raise EPMAPatch.Create('Instantane sans tableau "entries"'); + LFound := False; + for I := 0 to LEntries.Count - 1 do + begin + if not (LEntries.Items[I] is TJSONObject) then Continue; + LEntry := LEntries.Items[I] as TJSONObject; + if LEntry.GetValue('uuid', '') <> AUuid then Continue; + + LEntry.RemovePair('icon_b64').Free; + LEntry.AddPair('icon_b64', AUri); + LEntry.RemovePair('icon_custom').Free; + LEntry.AddPair('icon_custom', + TJSONNumber.Create(Ord(ACustom and (AUri <> '')))); + LEntry.RemovePair('updated_at').Free; + LEntry.AddPair('updated_at', NowUtcIso); + + LFound := True; + Break; + end; + if not LFound then + raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]); + Result := LRoot.ToJSON; + finally + LRoot.Free; + end; +end; + function AttachmentContent(const APlainJson, AUuid: string; AIndex: Integer): string; var diff --git a/android-client/TESTS.md b/android-client/TESTS.md index 9a54ff7..fbe1120 100644 --- a/android-client/TESTS.md +++ b/android-client/TESTS.md @@ -140,12 +140,26 @@ prouve rien. empêche quelqu'un tenant le téléphone déverrouillé de lever la protection sans la connaître. -### G. Pièces jointes +### G. Icônes + +1. Ouvrir une entrée dont le *Site* est une adresse — `git.ai-agents4you.com`. +2. Trois points → **Fetch icon** → l'icône doit apparaître dans la liste. +3. Une entrée dont le site n'est **pas** une adresse (*Gitea*, ou vide) → + refus *« no web address to look up »*. Sans point dans le nom, il n'y a + rien à demander au relais. +4. Un domaine à trois niveaux (`chat.qwen.ai`) doit retomber sur le second + niveau : c'est le deuxième essai. +5. **Remove icon** → l'entrée reprend son initiale. +6. Synchroniser depuis le poste : l'icône doit y arriver, et le poste ne doit + **pas** la traiter comme une icône choisie — c'est le rôle du drapeau de + provenance, mis à zéro par une recherche automatique. + +### H. Pièces jointes 1. Ligne *Attachment* → flèche de téléchargement → le fichier apparaît dans *Téléchargements* sous son nom, accents et arabe compris. -### H. Code de déverrouillage +### I. Code de déverrouillage 1. Poser un code, ouvrir le coffre, fermer, rouvrir → il est demandé. 2. Code faux → *« Wrong unlock code »*, et **Open vault reste offert** (le @@ -155,7 +169,7 @@ prouve rien. 4. Contre-épreuve : taper n'importe quoi dans le champ du code puis *Sync* → le code **ne doit pas changer**. -### I. Empaquetage +### J. Empaquetage 1. `build-apk.ps1 -Release -Keystore ... -Alias ...` → la sortie doit afficher `Signer #1 certificate DN: CN=R Zakarya`. Si elle affiche `CN=Android diff --git a/android-client/web/app.html b/android-client/web/app.html index 8033779..775400a 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -1260,7 +1260,9 @@ function closeMenu() { /// un menu qui apparait loin du doigt ne se rattache a rien. function openMenu() { var m = document.getElementById('menu'); - m.innerHTML = '' + m.innerHTML = '' + + '' + + '' + ''; m.hidden = false; } @@ -1274,6 +1276,12 @@ document.getElementById('kebab').addEventListener('click', function (ev) { function onMenuClick(ev) { var b = ev.target.closest('button'); if (!b) return; + var ic = b.getAttribute('data-icon'); + if (ic !== null) { + closeMenu(); + send('icon?a=' + ic); + return; + } if (b.getAttribute('data-dup') !== null) { closeMenu(); send('dupentry'); diff --git a/mainApk.pas b/mainApk.pas index 76b947e..6903852 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -306,6 +306,8 @@ type /// Retire le code de deverrouillage : le cache se rescelle sous la seule /// cle de l'appareil. Exige un coffre ouvert. procedure ClearUnlockCode; + /// Va chercher l'icone du site de l'entree ouverte, ou l'efface. + procedure SetEntryIcon(AFetch: Boolean); procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -330,7 +332,7 @@ implementation uses PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp, PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, PMA.VaultPatch, - PMA.SaveFile, System.JSON; + PMA.SaveFile, PMA.Favicon, System.JSON; {$R *.fmx} @@ -1619,6 +1621,9 @@ begin TNetEncoding.URL.Decode(Param('k')), TNetEncoding.URL.Decode(Param('d'))) + else if LAction = 'icon' then + SetEntryIcon(Param('a') = 'fetch') + else if LAction = 'pinclear' then ClearUnlockCode @@ -2545,6 +2550,82 @@ end; /// s'est laisse lire que sous le bon code. Sans cette condition, n'importe qui /// tenant l'appareil deverrouille pourrait retirer la protection sans la /// connaitre. +/// L'ICONE DE L'ENTREE OUVERTE : la chercher, ou l'effacer. +/// +/// LE SEUL APPEL DE CETTE APPLICATION QUI SORT AILLEURS QUE VERS LE DEPOT, et +/// il ne part que sur demande, entree par entree. Il dit a DuckDuckGo qu'on +/// s'interesse a ce domaine - c'est peu, mais ce n'est pas rien, et cela ne +/// doit pas se produire dans le dos de quelqu'un. +/// +/// La recherche est hors du thread principal : cinq secondes d'attente +/// figeraient l'ecran. L'ecriture, elle, repasse par CommitChange - donc par +/// le meme chemin que tout le reste, avec sa publication et son hors-ligne. +procedure TForm8.SetEntryIcon(AFetch: Boolean); +var + LUuid, LSite, LTitle: string; +begin + if FBusy then Exit; + if FCurrent.Uuid = '' then + begin + RefuseChange('This entry has no uuid'); + Exit; + end; + LUuid := FCurrent.Uuid; + LTitle := FCurrent.Display; + LSite := FCurrent.Site; + + if not AFetch then + begin + CommitChange('Icon removed: ' + LTitle, 'icon removed from phone', LUuid, + function (APlain: string): string + begin + Result := PatchEntryIcon(APlain, LUuid, '', False); + end); + Exit; + end; + + if HostOf(LSite) = '' then + begin + RefuseChange('This entry has no web address to look up'); + Exit; + end; + + SetBusy(True); + TThread.CreateAnonymousThread( + procedure + var + LUri, LErr: string; + begin + LUri := ''; + LErr := ''; + try + LUri := FetchFaviconDataUri(LSite, LErr); + except + on E: Exception do LErr := E.ClassName + ': ' + E.Message; + end; + TThread.Queue(nil, + procedure + begin + SetBusy(False); + if LUri = '' then + begin + Log('icon: ' + LErr); + WebCall('PM.toast(' + JsArg('No icon - ' + LErr) + ')'); + Exit; + end; + Log(Format('icon: %d chars for %s', [Length(LUri), HostOf(LSite)])); + // Provenance a FAUX : elle vient d'une recherche automatique, pas + // d'un choix. Le poste ne remplace jamais une icone choisie lors + // d'un rafraichissement, et ce drapeau est ce qui le lui dit. + CommitChange('Icon set: ' + LTitle, 'icon fetched from phone', LUuid, + function (APlain: string): string + begin + Result := PatchEntryIcon(APlain, LUuid, LUri, False); + end); + end); + end).Start; +end; + procedure TForm8.ClearUnlockCode; begin if FBusy then Exit; diff --git a/pmserverApk.dpr b/pmserverApk.dpr index 418f2f1..064c9aa 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -16,7 +16,8 @@ uses PMA.Theme in 'android-client\PMA.Theme.pas', PMA.WebUI in 'android-client\PMA.WebUI.pas', PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas', - PMA.SaveFile in 'android-client\PMA.SaveFile.pas'; + PMA.SaveFile in 'android-client\PMA.SaveFile.pas', + PMA.Favicon in 'android-client\PMA.Favicon.pas'; {$R *.res}