From 68df2dd7e7af5fb7ac4c6e97bdba38dd0c7a11d2 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:55:07 +0100 Subject: [PATCH] feat(android): read the snapshot from a Gitea repo, and close cleartext PMA.SyncClient now speaks two transports, told apart by the URL: /api/v1/repos/ means the Gitea contents API, anything else the old PHP endpoint. The PHP path stays until the Windows side publishes to the repo too - without it the migration would need both ends flipped at once, with no way back. Three things differ on the Gitea side and nothing else: the header is "token " not "Bearer" (swapping them gives a silent 401), the body is JSON carrying base64 content, and the fingerprint comparison happens in the client since the API has no If-None-Match. The body is re-downloaded, which is cheap; the four-second key derivation is still avoided. The result's ETag field carries the sha, so the caller cannot tell which transport answered. usesCleartextTraffic is replaced by a network config that permits cleartext to 127.0.0.1 and localhost BY NAME and forbids it everywhere else. The attribute drew no such line: a mistyped URL sent the bearer token over the network in the clear with nothing to signal it. Now it fails immediately, which is the behaviour worth having. Verified in the built APK: res/xml/network_security_config.xml is present and the manifest attribute resolves to a real resource id. NOT verified against a real repo - that needs a private repo, a published vault-sync.json and a token, none of which exist yet. Co-Authored-By: Claude Opus 5 --- AndroidManifest.template.xml | 15 +- android-client/PLAN.md | 40 ++++- android-client/PMA.SyncClient.pas | 142 ++++++++++++++++-- .../xml/network_security_config.xml | 26 ++++ android-client/build-apk.ps1 | 15 +- 5 files changed, 207 insertions(+), 31 deletions(-) create mode 100644 android-client/android-res/xml/network_security_config.xml diff --git a/AndroidManifest.template.xml b/AndroidManifest.template.xml index 6be2ed1..2e31e2c 100644 --- a/AndroidManifest.template.xml +++ b/AndroidManifest.template.xml @@ -19,12 +19,13 @@ <%queries-child-elements%> - + ` et non +`Bearer` (l'un à la place de l'autre donne un 401 muet), le corps est du JSON +dont il faut extraire `content` en base64, et **la comparaison d'empreinte se +fait dans le client** — l'API ne connaît pas `If-None-Match`, donc le corps est +retéléchargé, mais la dérivation de clé, elle, est bien épargnée. Le champ +`ETag` du résultat porte le `sha` : l'appelant n'a pas à savoir lequel des deux +transports lui répond. + +**Rien de tout cela n'a encore atteint un vrai dépôt.** Il faut, dans cet ordre : +un dépôt privé, un fichier `vault-sync.json` publié par le poste Windows, un +jeton limité à ce dépôt, puis l'URL de contenu complète dans *Server settings*. +Tant que le poste publie sur `sync.php`, le téléphone continue de lire là. + +**Reste à faire** : le PUT (§4 seulement — la lecture n'écrit rien), la même +bascule côté poste Windows en remplacement de WebDAV, et une purge périodique de +l'historique — sans quoi le dépôt grossit d'un instantané complet à chaque synchronisation, pièces jointes comprises. ## Ordre proposé diff --git a/android-client/PMA.SyncClient.pas b/android-client/PMA.SyncClient.pas index f9d9c30..088db25 100644 --- a/android-client/PMA.SyncClient.pas +++ b/android-client/PMA.SyncClient.pas @@ -1,15 +1,29 @@ unit PMA.SyncClient; { - Lecture de l'instantane chiffre depuis l'endpoint PHP (server-php/sync.php). + Lecture de l'instantane chiffre. DEUX TRANSPORTS, reconnus a l'URL : - Ne dechiffre rien : il ramene des octets opaques et l'ETag qui les identifie. - Le dechiffrement est le travail de PMA.Crypto, et il coute 4 secondes - d'ou - l'importance de l'ETag, qui evite de le refaire quand rien n'a change. + - un depot Gitea prive, via l'API des contenus. C'est la destination : plus + rien a heberger, HTTPS et certificat deja en place, acces depuis + l'exterieur gratuit. + - l'endpoint PHP historique (server-php/sync.php), garde le temps que le + poste Windows publie aussi dans le depot. Sans lui, la migration exigerait + de tout basculer d'un coup, des deux cotes, sans retour possible. - Le serveur repond 404 aussi bien pour un jeton invalide que pour un - instantane absent : c'est voulu (un appelant non authentifie n'apprend pas - ce qui existe), et le client ne peut donc pas les distinguer. Le message - utilisateur doit couvrir les deux cas. + Ne dechiffre rien : il ramene des octets opaques et l'empreinte qui les + identifie. Le dechiffrement est le travail de PMA.Crypto, et il coute + 4 secondes - d'ou l'importance de cette empreinte, qui evite de le refaire + quand rien n'a change. + + CE QUE L'EMPREINTE DESIGNE DIFFERE selon le transport, mais son role est le + meme et l'appelant n'a pas a le savoir : l'ETag HTTP pour PHP, le sha du blob + pour Gitea. Sur Gitea la comparaison se fait ICI, apres reception : l'API ne + connait pas If-None-Match, donc le corps est retelecharge - ce qui est + bon marche - mais la derivation, elle, est bien evitee. + + Le PHP repond 404 aussi bien pour un jeton invalide que pour un instantane + absent : c'est voulu (un appelant non authentifie n'apprend pas ce qui + existe), et le client ne peut donc pas les distinguer. Gitea repond de meme + sur un depot prive. Le message utilisateur doit couvrir les deux cas. } interface @@ -31,7 +45,9 @@ type TSyncResult = record Status: TSyncStatus; Container: string; // le JSON chiffre, vide sauf sur ssOK - ETag: string; // a repasser au prochain appel + /// Ce qui identifie ce contenu : ETag HTTP sur PHP, sha du blob sur Gitea. + /// A ranger avec le clair et a repasser au prochain appel. + ETag: string; HttpCode: Integer; Error: string; // message brut, pour le journal end; @@ -44,10 +60,15 @@ type public constructor Create(const ABaseUrl, AToken: string); - /// GET de l'instantane. AKnownETag non vide -> If-None-Match, et un 304 - /// evite de retelecharger ET de re-deriver la cle. + /// GET de l'instantane. AKnownETag non vide -> ssNotModified si le serveur + /// rend le meme contenu, ce qui epargne la derivation de la cle. function Fetch(const AKnownETag: string = ''): TSyncResult; + /// Vrai si l'URL designe l'API des contenus de Gitea. C'est le seul + /// discriminant : le chemin de cette API est fixe, et une URL de dépôt qui + /// n'y ressemble pas est de toute facon inutilisable. + function IsGitea: Boolean; + property BaseUrl: string read FBaseUrl write FBaseUrl; property Token: string read FToken write FToken; /// Volontairement court : sur mobile, un reseau qui ne repond pas doit @@ -57,6 +78,65 @@ type implementation +uses + System.JSON, System.NetEncoding; + +function TSyncClient.IsGitea: Boolean; +begin + Result := FBaseUrl.ToLower.Contains('/api/v1/repos/'); +end; + +/// Extrait le conteneur et le sha de la reponse de l'API des contenus. Rend +/// faux, avec la raison, si la reponse n'a pas la forme attendue - une URL de +/// dépôt juste a cote rend du JSON, mais pas celui-la. +function ParseGiteaContents(const ABody: string; + out AContainer, ASha, AError: string): Boolean; +var + LRoot: TJSONObject; + LEncoded, LEncoding: string; +begin + Result := False; + AContainer := ''; + ASha := ''; + LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject; + if LRoot = nil then + begin + AError := 'reponse Gitea illisible (pas du JSON)'; + Exit; + end; + try + ASha := LRoot.GetValue('sha', ''); + LEncoding := LRoot.GetValue('encoding', ''); + LEncoded := LRoot.GetValue('content', ''); + if (LEncoded = '') or (ASha = '') then + begin + AError := 'reponse Gitea sans content/sha - URL de contenu correcte ?'; + Exit; + end; + if not SameText(LEncoding, 'base64') then + begin + AError := 'encodage Gitea inattendu : ' + LEncoding; + Exit; + end; + // Le base64 de Gitea est decoupe en lignes ; le decodeur s'en accommode, + // mais on ne compte pas dessus. + LEncoded := LEncoded.Replace(#13, '').Replace(#10, ''); + try + AContainer := TEncoding.UTF8.GetString(TNetEncoding.Base64.DecodeStringToBytes(LEncoded)); + except + on E: Exception do + begin + AError := 'base64 Gitea invalide : ' + E.Message; + Exit; + end; + end; + Result := AContainer.Trim <> ''; + if not Result then AError := 'fichier vide dans le depot'; + finally + LRoot.Free; + end; +end; + constructor TSyncClient.Create(const ABaseUrl, AToken: string); begin inherited Create; @@ -87,9 +167,19 @@ begin // Pas de redirection suivie : une redirection vers un autre hote nous // ferait envoyer le jeton ailleurs. LHttp.HandleRedirects := False; - LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken; - if AKnownETag <> '' then - LHttp.CustomHeaders['If-None-Match'] := AKnownETag; + // Gitea attend "token " pour un jeton d'acces personnel ; le PHP + // attend "Bearer". Envoyer l'un a la place de l'autre donne un 401 qui ne + // dit pas pourquoi. + if IsGitea then + LHttp.CustomHeaders['Authorization'] := 'token ' + FToken + else + begin + LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken; + // Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait + // repondre 200 avec un corps complet de toute facon. + if AKnownETag <> '' then + LHttp.CustomHeaders['If-None-Match'] := AKnownETag; + end; LStream := TStringStream.Create('', TEncoding.UTF8); try @@ -109,6 +199,23 @@ begin case LResp.StatusCode of 200: + if IsGitea then + begin + if not ParseGiteaContents(LStream.DataString, Result.Container, + Result.ETag, Result.Error) then + Result.Status := ssHttpError + // Meme sha : le contenu est celui qu'on detient deja. On rend la + // meme reponse qu'un 304 pour que l'appelant epargne la derivation, + // et on jette le conteneur - il ne servirait a rien. + else if (AKnownETag <> '') and (Result.ETag = AKnownETag) then + begin + Result.Status := ssNotModified; + Result.Container := ''; + end + else + Result.Status := ssOK; + end + else begin Result.Status := ssOK; Result.Container := LStream.DataString; @@ -127,7 +234,14 @@ begin Result.Status := ssRefused; Result.Error := 'Le serveur exige HTTPS'; end; + // Gitea repond 404 sur un depot prive qu'on n'a pas le droit de voir, + // comme sur un fichier absent - exactement l'ambiguite du PHP. 404: Result.Status := ssNoSnapshot; + 401: + begin + Result.Status := ssHttpError; + Result.Error := '401 : jeton refuse'; + end; else Result.Status := ssHttpError; Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText; diff --git a/android-client/android-res/xml/network_security_config.xml b/android-client/android-res/xml/network_security_config.xml new file mode 100644 index 0000000..95af0d5 --- /dev/null +++ b/android-client/android-res/xml/network_security_config.xml @@ -0,0 +1,26 @@ + + + + + + 127.0.0.1 + localhost + + diff --git a/android-client/build-apk.ps1 b/android-client/build-apk.ps1 index 10167d6..d54dd3e 100644 --- a/android-client/build-apk.ps1 +++ b/android-client/build-apk.ps1 @@ -39,8 +39,19 @@ Copy-Item "$out\AndroidManifest.xml" "$stage\AndroidManifest.xml" -Force Copy-Item "$out\pmserverApk.classes\*.dex" "$stage\classes\" -Force & $strip --strip-debug -o "$stage\library\lib\arm64-v8a\libpmserverApk.so" "$out\libpmserverApk.so" -cmd /c "$msbuild /p:Platform=Android64 /p:Config=Debug /t:Deploy /v:minimal /nologo" 2>&1 | - Select-String -Pattern 'error' | ForEach-Object { $_.Line } +# L'AAPT DE DEPLOY ECHOUE, ET C'EST ATTENDU. Le manifeste reference +# @xml/network_security_config, que la generation de ressources de Delphi ne +# contient pas ; poser le fichier avant Deploy ne sert a rien, Deploy reconstruit +# res\ juste avant d'appeler aapt. L'etape suivante repose nos ressources et +# refait l'archive entiere, donc l'APK livre est complet - verifie a +# "aapt list" : res/xml/network_security_config.xml y est, et l'attribut du +# manifeste resout vers un identifiant reel. +# +# On ne masque donc que CETTE erreur, nommement, et tout autre message remonte. +$deploy = cmd /c "$msbuild /p:Platform=Android64 /p:Config=Debug /t:Deploy /v:minimal /nologo" 2>&1 +$deploy | Select-String -Pattern 'error' | + Where-Object { $_.Line -notmatch 'network_security_config|Unable to execute.*Aapt\.exe' } | + ForEach-Object { $_.Line } # 4. aapt + zipalign + apksigner, et remise en place de ce que Deploy efface. & "$PSScriptRoot\repack-apk.ps1" | Select-Object -Last 1