feat(sync): WebDAV sur l'APK, et le QR du poste qui le porte

Jeton "utilisateur:mot-de-passe" sur une URL ni Gitea ni GitHub = WebDAV,
authentification Basic. Fichier lu et ecrit tel quel ; l'ETag fait le sha
(If-None-Match, If-Match, 412 -> conflit). Clair autorise vers l'IP fixe du
poste seulement. Eprouve contre le WsgiDAV local : 304, PUT 204, puis
synchro du poste. Docs : CLAUDE.md, android-client/README.md (page eveillee
comprise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-10-02 23:13:05 +01:00
parent 2cc8d1b62b
commit 3051c3c392
6 changed files with 151 additions and 10 deletions
+90 -7
View File
@@ -76,6 +76,12 @@ type
/// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean;
/// WEBDAV : toute autre URL dont le "jeton" s'ecrit utilisateur:mot-de-passe.
/// Le fichier se lit et s'ecrit tel quel (le meme que publie le poste), et
/// l'ETag HTTP joue le role du sha : If-None-Match a la lecture, If-Match a
/// l'ecriture (412 = un autre appareil a publie entre-temps).
function IsWebDav: Boolean;
/// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
/// commit, et l'historique garde tous les instantanes, attaquables hors
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot
@@ -116,6 +122,81 @@ begin
FBaseUrl.ToLower.Contains('api.github.com/repos/');
end;
function TSyncClient.IsWebDav: Boolean;
begin
Result := (not IsGitea) and FToken.Contains(':');
end;
/// Basic sur WebDAV, Bearer pour l'endpoint PHP historique.
function PlainAuthHeader(const AToken: string; AWebDav: Boolean): string;
begin
if AWebDav then
Result := 'Basic ' + TNetEncoding.Base64.Encode(AToken).Replace(#13, '').Replace(#10, '')
else
Result := 'Bearer ' + AToken;
end;
function PutWebDav(const AUrl, AToken, ABody, AETag: string;
AConnectMs, ATimeoutMs: Integer): TSyncResult;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LReq, LOut: TStringStream;
begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
LHttp := THTTPClient.Create;
LReq := TStringStream.Create(ABody, TEncoding.UTF8);
LOut := TStringStream.Create('', TEncoding.UTF8);
try
LHttp.ConnectionTimeout := AConnectMs;
LHttp.ResponseTimeout := ATimeoutMs;
LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer';
LHttp.CustomHeaders['Connection'] := 'close';
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(AToken, True);
LHttp.CustomHeaders['Content-Type'] := 'application/json';
// Sans ETag connu (premiere publication, serveur qui n'en rend pas) : pas
// de condition, le dernier ecrit gagne - comme le poste dans ce cas.
if AETag <> '' then
LHttp.CustomHeaders['If-Match'] := AETag;
LResp := nil;
for var LTry := 1 to 3 do
begin
try
LReq.Position := 0;
LOut.Clear;
LResp := LHttp.Put(AUrl, LReq, LOut);
Break;
except
on E: Exception do
begin
Result.Error := E.ClassName + ': ' + E.Message;
if LTry = 3 then Exit;
end;
end;
end;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode;
case LResp.StatusCode of
200, 201, 204:
begin
Result.Status := ssOK;
Result.ETag := LResp.HeaderValue['ETag'];
end;
412: Result.Status := ssConflict;
else
Result.Status := ssHttpError;
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
LResp.StatusText, Copy(LOut.DataString, 1, 300)]);
end;
finally
LOut.Free;
LReq.Free;
LHttp.Free;
end;
end;
/// URL de l'API du depot (sans /contents/...), identique pour Gitea
/// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R).
function RepoApiUrl(const AContentsUrl: string): string;
@@ -295,17 +376,19 @@ begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
if not IsGitea then
begin
Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub';
Exit;
end;
if (FBaseUrl = '') or (FToken = '') then
begin
Result.Error := 'URL ou jeton non configure';
Exit;
end;
if IsWebDav then
Exit(PutWebDav(FBaseUrl, FToken, AContainerJson, ASha, FConnectMs, FTimeoutMs));
if not IsGitea then
begin
Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers Gitea, GitHub ou WebDAV (jeton utilisateur:mot-de-passe)';
Exit;
end;
Result.Error := PublicRepoWarning;
if Result.Error <> '' then
begin
@@ -466,7 +549,7 @@ begin
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
else
begin
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(FToken, IsWebDav);
// 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
+26
View File
@@ -416,6 +416,32 @@ gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
détail reste dans le journal local.
- **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md.
## WebDAV — 2026-10-02
Troisième transport, à côté de Gitea et GitHub, éprouvé contre le **WsgiDAV
local du poste** (`E:\webdav.json`, `0.0.0.0:8080`) : lecture, 304 à
l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.
- **Reconnu au jeton, pas à l'URL** : une URL ni Gitea ni GitHub dont le jeton
s'écrit `utilisateur:mot-de-passe` → `IsWebDav`, authentification Basic.
Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu
(sans `:`, c'est toujours l'ancien endpoint PHP, en Bearer).
- **Le fichier tel quel**, le même que publie le poste : GET / PUT sans
enveloppe base64. **L'ETag HTTP fait le sha** : `If-None-Match` à la lecture
(304 = rien à faire), `If-Match` à l'écriture, **412 → `ssConflict`**, donc la
même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag
connu, pas de condition : le dernier écrit gagne, comme le poste.
- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl`
(elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt,
`/src/branch/…` et `/blob/…`.
- **Clair autorisé vers `192.168.1.28` seulement** (IP fixe du poste), dans
`network_security_config.xml` : le mot de passe WebDAV passe en clair sur le
Wi-Fi, le coffre reste chiffré. À retirer quand le serveur parlera HTTPS.
Android ne sait pas autoriser « le réseau local » : chaque adresse se nomme.
- **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et**
Portmaster (entrant autorisé pour Python) — sans le second, le port restait
fermé malgré la règle.
## Saisie : ce qui a résisté
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne
@@ -22,5 +22,9 @@
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
<!-- ponytail: WebDAV local du poste (IP fixe), en clair sur le Wi-Fi.
Le coffre reste chiffre ; le mot de passe WebDAV, lui, passe en
clair. A retirer quand le serveur parlera HTTPS. -->
<domain includeSubdomains="false">192.168.1.28</domain>
</domain-config>
</network-security-config>
+6 -3
View File
@@ -528,7 +528,7 @@
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
nommer d'apres un seul des deux le rend faux pour l'autre. -->
<input class="field-in" id="tok" placeholder="access token"
<input class="field-in" id="tok" placeholder="access token (WebDAV: user:password)"
type="password" autocomplete="off">
<div class="in-x" data-clear="tok">&#215;</div>
</div>
@@ -615,13 +615,13 @@
entier. Un input d'une ligne ne defilait pas sous le doigt dans la
WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
<textarea class="field-in multi" id="sUrl" rows="1"
placeholder="repository URL - Gitea or github.com/owner/repo (private)"
placeholder="repository URL - Gitea, github.com/owner/repo (private), or WebDAV file URL"
autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false" inputmode="url"></textarea>
<div class="in-x" data-clear="sUrl">&#215;</div>
</div>
<div class="in-wrap">
<input class="field-in" id="sTok" placeholder="access token"
<input class="field-in" id="sTok" placeholder="access token (WebDAV: user:password)"
type="password" autocomplete="off">
<div class="in-x" data-clear="sTok">&#215;</div>
</div>
@@ -1903,6 +1903,9 @@ function normalizeRepoUrl(u) {
u = u.replace(/\s+/g, '');
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
/\.php(\?|$)/i.test(u)) return u;
// WEBDAV : l'URL du fichier lui-meme, a garder telle quelle. Seules les
// formes longues d'un depot (/src/branch/, /blob/) finissent aussi en .json.
if (/\.json$/i.test(u) && !/\/(src\/branch|blob)\//i.test(u)) return u;
// GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +