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