From 3c16b417149146eccb138815c0e2cc77d078eddd Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:36:18 +0100 Subject: [PATCH] feat(sync): GitHub en plus de Gitea, et refus d'un depot public GitHub parle la meme API des contenus (base64, sha, corps du PUT) : meme chemin, reconnu a api.github.com/repos/. User-Agent pose partout (GitHub refuse sans). Un depot public est refuse avant toute lecture ou ecriture, Gitea comme GitHub : l'historique garde tous les instantanes chiffres. Telephone : github.com/O/R se convertit en URL d'API et inversement. Co-Authored-By: Claude Opus 5 --- android-client/PMA.SyncClient.pas | 84 ++++++++++++++++++++++++++++--- android-client/web/app.html | 12 ++++- delphi-backend/UMainForm.pas | 15 +++++- js/app.sync.js | 31 ++++++++++-- mainApk.pas | 8 ++- 5 files changed, 136 insertions(+), 14 deletions(-) diff --git a/android-client/PMA.SyncClient.pas b/android-client/PMA.SyncClient.pas index d529491..44d0d90 100644 --- a/android-client/PMA.SyncClient.pas +++ b/android-client/PMA.SyncClient.pas @@ -61,6 +61,7 @@ type FBaseUrl: string; FToken: string; FTimeoutMs: Integer; + FPrivacyChecked: Boolean; public constructor Create(const ABaseUrl, AToken: string); @@ -73,6 +74,12 @@ type /// n'y ressemble pas est de toute facon inutilisable. function IsGitea: 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 + /// est prive ou si on n'a pas pu le savoir. Verifie une fois par instance. + function PublicRepoWarning: string; + /// POURQUOI UN 404 OU UN 401, en clair pour l'utilisateur. Gitea repond 404 /// aussi bien pour un fichier absent que pour un depot ou un proprietaire /// inexistant : deux requetes de plus tranchent - le depot, puis @@ -102,7 +109,55 @@ uses function TSyncClient.IsGitea: Boolean; begin - Result := FBaseUrl.ToLower.Contains('/api/v1/repos/'); + // GitHub parle la meme API des contenus : meme chemin pour les deux. + Result := FBaseUrl.ToLower.Contains('/api/v1/repos/') or + FBaseUrl.ToLower.Contains('api.github.com/repos/'); +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; +var + LP: Integer; +begin + LP := Pos('/contents/', AContentsUrl); + if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := ''; +end; + +function TSyncClient.PublicRepoWarning: string; +var + LHttp: THTTPClient; + LResp: IHTTPResponse; + LDoc: TJSONObject; + LPriv: Boolean; +begin + Result := ''; + if FPrivacyChecked or not IsGitea or (RepoApiUrl(FBaseUrl) = '') then Exit; + FPrivacyChecked := True; + LHttp := THTTPClient.Create; + try + LHttp.ConnectionTimeout := FTimeoutMs; + LHttp.ResponseTimeout := FTimeoutMs; + LHttp.HandleRedirects := False; + LHttp.UserAgent := 'PMServer'; + LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; + try + LResp := LHttp.Get(RepoApiUrl(FBaseUrl)); + except + Exit; // doute reseau : pas de verdict, la vraie requete le dira + end; + if LResp.StatusCode <> 200 then Exit; + LDoc := TJSONObject.ParseJSONValue(LResp.ContentAsString(TEncoding.UTF8)) as TJSONObject; + if LDoc = nil then Exit; + try + if LDoc.TryGetValue('private', LPriv) and not LPriv then + Result := 'This repository is PUBLIC - make it private before syncing: anyone could download your encrypted vault and all its history.'; + finally + LDoc.Free; + end; + finally + LHttp.Free; + end; end; function TSyncClient.Diagnose(AHttpCode: Integer): string; @@ -126,10 +181,12 @@ begin if AHttpCode = 401 then Exit('Token refused - expired, revoked or mistyped'); if (AHttpCode <> 404) or not IsGitea then Exit; - // https://hote/api/v1/repos/OWNER/REPO/contents/FICHIER - LP := Pos('/api/v1/repos/', LowerCase(FBaseUrl)); - LApi := Copy(FBaseUrl, 1, LP + Length('/api/v1/') - 1); - LParts := Copy(FBaseUrl, LP + Length('/api/v1/repos/'), MaxInt).Split(['/']); + // https://hote/api/v1/repos/O/R/contents/F ou https://api.github.com/repos/O/R/... + // Tout ce qui precede "repos/" est la racine de l'API, pour les deux. + LP := Pos('/repos/', LowerCase(FBaseUrl)); + if LP = 0 then Exit; + LApi := Copy(FBaseUrl, 1, LP); + LParts := Copy(FBaseUrl, LP + Length('/repos/'), MaxInt).Split(['/']); if Length(LParts) < 2 then Exit; LOwner := LParts[0]; LRepo := LParts[1]; @@ -138,6 +195,7 @@ begin LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs; LHttp.HandleRedirects := False; + LHttp.UserAgent := 'PMServer'; LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; case CodeOf(LApi + 'repos/' + LOwner + '/' + LRepo) of 200: Result := 'No vault in this repository yet - sync from the PC first'; @@ -237,7 +295,7 @@ begin if not IsGitea then begin Result.Status := ssHttpError; - Result.Error := 'Ecriture disponible seulement vers un depot Gitea'; + Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub'; Exit; end; if (FBaseUrl = '') or (FToken = '') then @@ -245,6 +303,12 @@ begin Result.Error := 'URL ou jeton non configure'; Exit; end; + Result.Error := PublicRepoWarning; + if Result.Error <> '' then + begin + Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur + Exit; + end; LEnv := TJSONObject.Create; LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto @@ -266,6 +330,7 @@ begin LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs; LHttp.HandleRedirects := False; + LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; LHttp.CustomHeaders['Content-Type'] := 'application/json'; try @@ -346,11 +411,18 @@ begin Result.Error := 'URL ou jeton non configure'; Exit; end; + Result.Error := PublicRepoWarning; + if Result.Error <> '' then + begin + Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur + Exit; + end; LHttp := THTTPClient.Create; try LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs; + LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans // Pas de redirection suivie : une redirection vers un autre hote nous // ferait envoyer le jeton ailleurs. LHttp.HandleRedirects := False; diff --git a/android-client/web/app.html b/android-client/web/app.html index da8dfed..9bb3d5a 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -574,7 +574,7 @@ 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. -->
×
@@ -1696,6 +1696,9 @@ document.addEventListener('input', function (ev) { // L'inverse, pour l'affichage : l'URL d'API se relit comme l'adresse du depot. function shortRepoUrl(u) { + var g = /^https:\/\/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || ''); + if (g) return 'https://github.com/' + g[1] + '/' + g[2] + + (g[3] === 'vault-sync.json' ? '' : '/blob/main/' + g[3]); var m = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || ''); if (!m) return u; return m[1] + '/' + m[2] + '/' + m[3] + @@ -1703,7 +1706,12 @@ function shortRepoUrl(u) { } function normalizeRepoUrl(u) { u = u.replace(/\s+/g, ''); - if (!u || /\/api\/v1\/repos\//i.test(u) || /\.php(\?|$)/i.test(u)) return u; + if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) || + /\.php(\?|$)/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] + + '/contents/' + (g[4] || 'vault-sync.json'); var m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i); if (!m) return u; return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || 'vault-sync.json'); diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 4b2758a..81d12f1 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -1396,7 +1396,14 @@ begin // changeait chaque ":" et "/" en trois caracteres. var LShort := LQrUrl; var LApi := Pos('/api/v1/repos/', LShort); - if (LApi > 0) and EndsText('/contents/vault-sync.json', LShort) then + // GitHub : l'adresse du depot est sur github.com, l'API sur api.github.com. + if StartsText('https://api.github.com/repos/', LShort) and + EndsText('/contents/vault-sync.json', LShort) then + LShort := 'https://github.com/' + + Copy(LShort, Length('https://api.github.com/repos/') + 1, + Length(LShort) - Length('https://api.github.com/repos/') - + Length('/contents/vault-sync.json')) + else if (LApi > 0) and EndsText('/contents/vault-sync.json', LShort) then LShort := Copy(LShort, 1, LApi) + Copy(LShort, LApi + Length('/api/v1/repos/'), Length(LShort) - LApi - Length('/api/v1/repos/') - Length('/contents/vault-sync.json') + 1); @@ -1608,7 +1615,9 @@ begin // remplace l'authentification Basic, le corps d'un PUT est du JSON que JS // a deja mis en forme (le sha y voyage, pas dans un If-Match), et le test // de connexion se fait par GET - l'API des contenus ne repond pas a HEAD. - var LGitea := LUrl.ToLower.Contains('/api/v1/repos/'); + // GitHub parle la meme API des contenus : meme traitement. + var LGitea := LUrl.ToLower.Contains('/api/v1/repos/') or + LUrl.ToLower.Contains('api.github.com/repos/'); TThread.CreateAnonymousThread( procedure var @@ -1631,6 +1640,8 @@ begin try LHttp.ConnectionTimeout := 10000; LHttp.ResponseTimeout := 30000; + // GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore. + LHttp.UserAgent := 'PMServer'; // "token " et non "Bearer" : c'est la forme que Gitea attend // d'un jeton d'acces personnel, et l'autre donne un 401 muet. if LGitea then diff --git a/js/app.sync.js b/js/app.sync.js index 5fc591c..daedf77 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -65,8 +65,8 @@ Bridge.onSyncQr = function (size, bits) { 'background:#fff;display:block;border-radius:6px">' + '' + '

' + - 'Scan it with the phone camera and open it with Coffre: the URL and the ' + - 'token fill in, then type the sync password (never in the QR) and Save. ' + + 'In the phone app: Settings → Scan the QR shown on the PC. The URL and ' + + 'the token fill in; type the sync password (never in the QR), then Save. ' + 'Hidden again in 60 s.

'; box.hidden = false; clearTimeout(syncQrTimer); @@ -83,8 +83,27 @@ Bridge.onWebdavResult = function(reqId, status, payload, etag) { // discriminant the Android client uses, so one URL tells both ends which // transport to speak. Everything below that differs is confined to the two // call sites in runSyncNow(); the bridge itself is shared. +// GitHub parle la MEME API des contenus (base64, sha, meme corps de PUT) : un +// seul chemin pour les deux, reconnu a l'URL. function syncIsGitea(url) { - return (url || '').toLowerCase().includes('/api/v1/repos/'); + const u = (url || '').toLowerCase(); + return u.includes('/api/v1/repos/') || u.includes('api.github.com/repos/'); +} + +// UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde +// tous les instantanes, chacun attaquable hors ligne par qui peut les lire. +// '' = prive ou impossible a savoir (on ne bloque pas sur un doute reseau). +async function syncRepoPublicWarning(cfg) { + if (!syncIsGitea(cfg.url)) return ''; + const repoUrl = cfg.url.split('/contents/')[0]; + const r = await _webdavCall('get', repoUrl, cfg.user, cfg.pwd); + if (r.status < 200 || r.status >= 300 || !r.payload) return ''; + try { + const o = JSON.parse(new TextDecoder().decode(base64ToBytes(r.payload))); + if (o && o.private === false) + return 'This repository is PUBLIC - make it private before syncing: anyone could download your encrypted vault and all its history.'; + } catch (e) { /* forme inattendue : pas de verdict */ } + return ''; } // Unwraps a Gitea contents response into { container, sha }. The API answers @@ -278,6 +297,8 @@ async function syncTestConnection() { try { un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || ''))); } catch (e) { /* traite comme une forme inattendue */ } + const pub = await syncRepoPublicWarning(cfg); + if (pub) return toast(pub, 'error'); return un ? toast('Connection OK · file found in the repository') : toast('Reached Gitea, but this URL does not return a file — ' + @@ -634,6 +655,10 @@ async function runSyncNow(_attempt) { // and buildSyncSnapshot are expensive on large vaults — no point // running them if we can't reach the server). syncStatus('Connecting…'); + if (_attempt === 0) { + const pub = await syncRepoPublicWarning(cfg); + if (pub) { syncStatus(''); return toast(pub, 'error'); } + } let pullResp; try { pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd); diff --git a/mainApk.pas b/mainApk.pas index 82ebb7e..9b242e0 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -2769,7 +2769,13 @@ begin // est affiche, hors ligne est un etat normal, et une alerte ici // apprendrait a l'utilisateur a ignorer les alertes. Log(' failed: ' + LRes.Error); - if (not ASilent) and (LRes.HttpCode = 401) then + // Depot public : refus LOCAL (HttpCode 0), le message dit quoi faire. + if (not ASilent) and (LRes.Status = ssRefused) and (LRes.HttpCode = 0) then + begin + var LPub := LRes.Error; + TThread.Queue(nil, procedure begin ShowSettingsError(LPub); end); + end + else if (not ASilent) and (LRes.HttpCode = 401) then begin var LWhy401 := LClient.Diagnose(401); TThread.Queue(nil, procedure begin ShowSettingsError(LWhy401); end);