feat(android): the write transport, and an error that says why
TSyncClient.Put publishes a container to the Gitea contents API: token header, JSON envelope with the file base64 inside, sha omitted rather than empty when creating, branch omitted so the repo default is used. 409 becomes its own status, ssConflict, because it means re-read and re-apply rather than retry. The first PUT is where a mistake costs data, so the probe writes to write-test.json rather than the vault. Same repository, same token, same envelope, same concurrency check - and the vault is never at stake. Reachable from a debug-only button; the page asks Delphi whether to show it. Two mistakes of mine on the way, both about replacing information with a guess: - the probe did not push the config first, unlike Sync, so a token that had just been pasted had not reached Delphi and the 403 blamed the wrong thing. - 401 and 403 were rewritten as "token lacks write access", discarding Gitea's body. That is an interpretation, not an observation: the same code covers a read-only repository, a protected branch, a token from another account. The body says which - here, plainly, "token scope=read:repository". Also recorded: the sha comparison now short-circuits (304 where it used to decrypt), so the difference was case or whitespace, absorbed by SameText on trimmed values. The offending character itself was never seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -170,7 +170,7 @@ tentée d'abord : MSBuild l'ignore, comme il ignore toutes les autres ici.
|
||||
régénération de `web.res` — l'oublier embarque la page d'avant l'édition, et le
|
||||
symptôme se lit alors comme un bug applicatif.
|
||||
|
||||
## 1 quater. OUVERT — la comparaison de sha ne se declenche pas
|
||||
## 1 quater. ~~La comparaison de sha ne se declenche pas~~ — RESOLU le 2026-08-17
|
||||
|
||||
Mesure sur appareil, deux ouvertures successives du cache :
|
||||
|
||||
|
||||
@@ -39,7 +39,11 @@ type
|
||||
ssNoSnapshot, // 404 : pas d'instantane, OU jeton invalide
|
||||
ssRefused, // 403 : HTTPS exige par le serveur
|
||||
ssHttpError, // autre code
|
||||
ssNetworkError // rien n'a repondu
|
||||
ssNetworkError, // rien n'a repondu
|
||||
/// 409 : le sha envoye n'est plus celui du fichier - un autre appareil a
|
||||
/// publie entre notre lecture et notre ecriture. L'appelant doit relire,
|
||||
/// refusionner et renvoyer, pas reessayer tel quel.
|
||||
ssConflict
|
||||
);
|
||||
|
||||
TSyncResult = record
|
||||
@@ -69,6 +73,15 @@ type
|
||||
/// n'y ressemble pas est de toute facon inutilisable.
|
||||
function IsGitea: Boolean;
|
||||
|
||||
/// Publie un conteneur. ASha est celui du fichier qu'on a lu : Gitea s'en
|
||||
/// sert pour refuser (409) une ecriture si quelqu'un d'autre a publie
|
||||
/// entre-temps. Vide = creation d'un fichier qui n'existait pas.
|
||||
///
|
||||
/// GITEA SEULEMENT pour l'instant. L'endpoint PHP repond 405 au PUT, et
|
||||
/// lui ajouter l'ecriture n'a plus d'interet maintenant que le depot fait
|
||||
/// le meme travail avec un controle de concurrence eprouve.
|
||||
function Put(const AContainerJson, ASha, AMessage: string): TSyncResult;
|
||||
|
||||
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
|
||||
@@ -86,6 +99,24 @@ begin
|
||||
Result := FBaseUrl.ToLower.Contains('/api/v1/repos/');
|
||||
end;
|
||||
|
||||
/// Le sha du fichier ecrit, tire de la reponse au PUT : Gitea rend
|
||||
/// { "content": { "sha": ... }, "commit": { ... } }. Vide si la forme change -
|
||||
/// l'appelant relira, ce qui coute un appel mais ne perd rien.
|
||||
function ShaFromPutResponse(const ABody: string): string;
|
||||
var
|
||||
LRoot, LContent: TJSONObject;
|
||||
begin
|
||||
Result := '';
|
||||
LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject;
|
||||
if LRoot = nil then Exit;
|
||||
try
|
||||
if LRoot.TryGetValue<TJSONObject>('content', LContent) then
|
||||
Result := LContent.GetValue<string>('sha', '');
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
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.
|
||||
@@ -137,6 +168,95 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
function TSyncClient.Put(const AContainerJson, ASha, AMessage: string): TSyncResult;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LEnv: TJSONObject;
|
||||
LReq, LResync: TStringStream;
|
||||
LB64: TBase64Encoding;
|
||||
begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
|
||||
if not IsGitea then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'Ecriture disponible seulement vers un depot Gitea';
|
||||
Exit;
|
||||
end;
|
||||
if (FBaseUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LEnv := TJSONObject.Create;
|
||||
LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto
|
||||
try
|
||||
LEnv.AddPair('message', AMessage);
|
||||
LEnv.AddPair('content',
|
||||
LB64.EncodeBytesToString(TEncoding.UTF8.GetBytes(AContainerJson)));
|
||||
// OMIS, PAS VIDE, quand on cree le fichier : Gitea lit la presence de la
|
||||
// cle comme « remplace exactement cette version-la », et une chaine vide
|
||||
// n'est pas la meme chose que son absence.
|
||||
if ASha <> '' then LEnv.AddPair('sha', ASha);
|
||||
// `branch` reste absent : le depot utilise alors sa branche par defaut,
|
||||
// plutot qu'un nom qu'on devinerait.
|
||||
LReq := TStringStream.Create(LEnv.ToJSON, TEncoding.UTF8);
|
||||
LResync := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
|
||||
LHttp.CustomHeaders['Content-Type'] := 'application/json';
|
||||
try
|
||||
LResp := LHttp.Put(FBaseUrl, LReq, LResync);
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Status := ssNetworkError;
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
case LResp.StatusCode of
|
||||
200, 201:
|
||||
begin
|
||||
Result.Status := ssOK;
|
||||
// Le sha du fichier VENANT D'ETRE ECRIT, a garder pour la
|
||||
// prochaine ecriture - sans lui il faudrait relire pour publier.
|
||||
Result.ETag := ShaFromPutResponse(LResync.DataString);
|
||||
end;
|
||||
409: Result.Status := ssConflict;
|
||||
else
|
||||
Result.Status := ssHttpError;
|
||||
// LE CORPS, TOUJOURS. Il etait remplace par un message fige sur 401
|
||||
// et 403 - « jeton sans droit d'ecriture » - ce qui est une
|
||||
// interpretation, pas une observation : le meme code couvre un depot
|
||||
// en lecture seule, une branche protegee, un jeton d'un autre compte.
|
||||
// Gitea dit lequel dans son corps, et le taire envoie chercher a
|
||||
// cote.
|
||||
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
|
||||
LResp.StatusText, Copy(LResync.DataString, 1, 300)]);
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
finally
|
||||
LResync.Free;
|
||||
LReq.Free;
|
||||
end;
|
||||
finally
|
||||
LB64.Free;
|
||||
LEnv.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
constructor TSyncClient.Create(const ABaseUrl, AToken: string);
|
||||
begin
|
||||
inherited Create;
|
||||
|
||||
@@ -214,6 +214,10 @@
|
||||
<button class="btn" id="btnCfg">⚙ Server settings</button>
|
||||
<button class="btn" id="btnLog">≡ Log</button>
|
||||
</div>
|
||||
<!-- Essai du chemin d'ecriture, visible uniquement en build de debogage :
|
||||
Delphi appelle PM.setDebug(true). Il ecrit dans un AUTRE fichier du
|
||||
depot, jamais dans le coffre. -->
|
||||
<button class="btn wide" id="btnWriteTest" hidden>Write test (debug)</button>
|
||||
</div>
|
||||
<div id="cfg" hidden>
|
||||
<input class="field-in" id="url" placeholder="endpoint URL"
|
||||
@@ -341,6 +345,14 @@ document.getElementById('btnLog').addEventListener('click', function () {
|
||||
var l = document.getElementById('log');
|
||||
l.hidden = !l.hidden;
|
||||
});
|
||||
document.getElementById('btnWriteTest').addEventListener('click', function () {
|
||||
// pushConfig D'ABORD, comme Sync. Sans cela, un jeton qui vient d'etre colle
|
||||
// et dont le champ n'a pas encore perdu le focus n'est pas parti vers Delphi,
|
||||
// qui agit alors avec l'ancien - et rend un 403 parfaitement trompeur.
|
||||
pushConfig();
|
||||
document.getElementById('log').hidden = false;
|
||||
send('writetest');
|
||||
});
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
||||
@@ -500,6 +512,9 @@ var PM = {
|
||||
renderRows();
|
||||
},
|
||||
|
||||
setDebug: function (on) {
|
||||
document.getElementById('btnWriteTest').hidden = !on;
|
||||
},
|
||||
showUnlock: function () {
|
||||
document.body.classList.remove('on-list', 'on-detail', 'searching');
|
||||
view = 'unlock';
|
||||
|
||||
+54
@@ -187,6 +187,7 @@ type
|
||||
procedure PushConfig;
|
||||
{$IFDEF DEBUG}
|
||||
procedure CryptoSelfCheck;
|
||||
procedure WriteTest;
|
||||
{$ENDIF}
|
||||
procedure OpenEntry(AVaultIndex: Integer);
|
||||
procedure OnClipTimer(Sender: TObject);
|
||||
@@ -290,6 +291,7 @@ begin
|
||||
ShowUnlock;
|
||||
|
||||
{$IFDEF DEBUG}
|
||||
WebCall('PM.setDebug(true)');
|
||||
// VERIFICATION DU CHIFFREMENT AVANT TOUTE ECRITURE REELLE (§4). Elle depose
|
||||
// aussi un conteneur sur le disque prive, pour qu'il soit relu par l'AUTRE
|
||||
// implementation : un aller-retour avec soi-meme ne prouve que la coherence
|
||||
@@ -464,6 +466,54 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
/// Essai du chemin d'ecriture, VERS UN AUTRE FICHIER que le coffre.
|
||||
///
|
||||
/// Le premier PUT est le moment ou une erreur de transport se paie en donnees
|
||||
/// perdues : un corps mal forme, un sha mal place, et vault-sync.json est
|
||||
/// remplace par quelque chose que personne ne sait relire. L'ecrire d'abord
|
||||
/// sous un autre nom prouve exactement les memes choses - enveloppe, jeton,
|
||||
/// droits, sha rendu - sans mettre le coffre en jeu une seule fois.
|
||||
procedure TForm8.WriteTest;
|
||||
var
|
||||
LUrl: string;
|
||||
begin
|
||||
if FBusy then Exit;
|
||||
SetBusy(True);
|
||||
// Meme depot, autre fichier : on remplace le dernier segment du chemin.
|
||||
LUrl := FUrl.Substring(0, FUrl.LastIndexOf('/') + 1) + 'write-test.json';
|
||||
TThread.CreateAnonymousThread(
|
||||
procedure
|
||||
var
|
||||
LClient: TSyncClient;
|
||||
LRes: TSyncResult;
|
||||
LBody: string;
|
||||
begin
|
||||
LClient := TSyncClient.Create(LUrl, FToken);
|
||||
try
|
||||
LBody := EncryptContainer(
|
||||
Format('{"probe":"write path","at":"%s"}',
|
||||
[FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"',
|
||||
TTimeZone.Local.ToUniversalTime(Now))]),
|
||||
FSyncPwd, 1000);
|
||||
Log('PUT ' + LUrl);
|
||||
Log(' token ' + IntToStr(Length(FToken)) + ' chars, body ' +
|
||||
IntToStr(Length(LBody)) + ' chars');
|
||||
// Sha vide : on cree le fichier, ou on echoue en 409 s'il existe deja -
|
||||
// ce qui prouverait le controle de concurrence aussi surement.
|
||||
LRes := LClient.Put(LBody, '', 'write path probe from phone');
|
||||
case LRes.Status of
|
||||
ssOK: Log(' OK ' + LRes.HttpCode.ToString + ' - new sha ' + LRes.ETag);
|
||||
ssConflict: Log(' 409 - file already there, concurrency control works');
|
||||
else
|
||||
Log(' failed: ' + LRes.HttpCode.ToString + ' ' + LRes.Error);
|
||||
end;
|
||||
finally
|
||||
LClient.Free;
|
||||
TThread.Queue(nil, procedure begin SetBusy(False); end);
|
||||
end;
|
||||
end).Start;
|
||||
end;
|
||||
|
||||
/// Descend les coordonnees du serveur dans les champs de la page.
|
||||
procedure TForm8.PushConfig;
|
||||
var
|
||||
@@ -570,6 +620,10 @@ begin
|
||||
|
||||
else if LAction = 'sync' then
|
||||
DoSync(nil)
|
||||
{$IFDEF DEBUG}
|
||||
else if LAction = 'writetest' then
|
||||
WriteTest
|
||||
{$ENDIF}
|
||||
|
||||
else if LAction = 'copy' then
|
||||
begin
|
||||
|
||||
Reference in New Issue
Block a user