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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-09-18 10:36:18 +01:00
parent d7a8b4ddff
commit 3c16b41714
5 changed files with 136 additions and 14 deletions
+78 -6
View File
@@ -61,6 +61,7 @@ type
FBaseUrl: string; FBaseUrl: string;
FToken: string; FToken: string;
FTimeoutMs: Integer; FTimeoutMs: Integer;
FPrivacyChecked: Boolean;
public public
constructor Create(const ABaseUrl, AToken: string); constructor Create(const ABaseUrl, AToken: string);
@@ -73,6 +74,12 @@ type
/// n'y ressemble pas est de toute facon inutilisable. /// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean; 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 /// 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 /// aussi bien pour un fichier absent que pour un depot ou un proprietaire
/// inexistant : deux requetes de plus tranchent - le depot, puis /// inexistant : deux requetes de plus tranchent - le depot, puis
@@ -102,7 +109,55 @@ uses
function TSyncClient.IsGitea: Boolean; function TSyncClient.IsGitea: Boolean;
begin 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<Boolean>('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; end;
function TSyncClient.Diagnose(AHttpCode: Integer): string; function TSyncClient.Diagnose(AHttpCode: Integer): string;
@@ -126,10 +181,12 @@ begin
if AHttpCode = 401 then if AHttpCode = 401 then
Exit('Token refused - expired, revoked or mistyped'); Exit('Token refused - expired, revoked or mistyped');
if (AHttpCode <> 404) or not IsGitea then Exit; if (AHttpCode <> 404) or not IsGitea then Exit;
// https://hote/api/v1/repos/OWNER/REPO/contents/FICHIER // https://hote/api/v1/repos/O/R/contents/F ou https://api.github.com/repos/O/R/...
LP := Pos('/api/v1/repos/', LowerCase(FBaseUrl)); // Tout ce qui precede "repos/" est la racine de l'API, pour les deux.
LApi := Copy(FBaseUrl, 1, LP + Length('/api/v1/') - 1); LP := Pos('/repos/', LowerCase(FBaseUrl));
LParts := Copy(FBaseUrl, LP + Length('/api/v1/repos/'), MaxInt).Split(['/']); if LP = 0 then Exit;
LApi := Copy(FBaseUrl, 1, LP);
LParts := Copy(FBaseUrl, LP + Length('/repos/'), MaxInt).Split(['/']);
if Length(LParts) < 2 then Exit; if Length(LParts) < 2 then Exit;
LOwner := LParts[0]; LOwner := LParts[0];
LRepo := LParts[1]; LRepo := LParts[1];
@@ -138,6 +195,7 @@ begin
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer';
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
case CodeOf(LApi + 'repos/' + LOwner + '/' + LRepo) of case CodeOf(LApi + 'repos/' + LOwner + '/' + LRepo) of
200: Result := 'No vault in this repository yet - sync from the PC first'; 200: Result := 'No vault in this repository yet - sync from the PC first';
@@ -237,7 +295,7 @@ begin
if not IsGitea then if not IsGitea then
begin begin
Result.Status := ssHttpError; Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers un depot Gitea'; Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub';
Exit; Exit;
end; end;
if (FBaseUrl = '') or (FToken = '') then if (FBaseUrl = '') or (FToken = '') then
@@ -245,6 +303,12 @@ begin
Result.Error := 'URL ou jeton non configure'; Result.Error := 'URL ou jeton non configure';
Exit; Exit;
end; end;
Result.Error := PublicRepoWarning;
if Result.Error <> '' then
begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
Exit;
end;
LEnv := TJSONObject.Create; LEnv := TJSONObject.Create;
LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto
@@ -266,6 +330,7 @@ begin
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
LHttp.CustomHeaders['Content-Type'] := 'application/json'; LHttp.CustomHeaders['Content-Type'] := 'application/json';
try try
@@ -346,11 +411,18 @@ begin
Result.Error := 'URL ou jeton non configure'; Result.Error := 'URL ou jeton non configure';
Exit; Exit;
end; end;
Result.Error := PublicRepoWarning;
if Result.Error <> '' then
begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
Exit;
end;
LHttp := THTTPClient.Create; LHttp := THTTPClient.Create;
try try
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
// Pas de redirection suivie : une redirection vers un autre hote nous // Pas de redirection suivie : une redirection vers un autre hote nous
// ferait envoyer le jeton ailleurs. // ferait envoyer le jeton ailleurs.
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
+10 -2
View File
@@ -574,7 +574,7 @@
entier. Un input d'une ligne ne defilait pas sous le doigt dans la 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. --> WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
<textarea class="field-in multi" id="sUrl" rows="1" <textarea class="field-in multi" id="sUrl" rows="1"
placeholder="repository URL, e.g. https://git.example.com/owner/repo" placeholder="repository URL - Gitea or github.com/owner/repo (private)"
autocomplete="off" autocorrect="off" autocapitalize="none" autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false" inputmode="url"></textarea> spellcheck="false" inputmode="url"></textarea>
<div class="in-x" data-clear="sUrl">&#215;</div> <div class="in-x" data-clear="sUrl">&#215;</div>
@@ -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. // L'inverse, pour l'affichage : l'URL d'API se relit comme l'adresse du depot.
function shortRepoUrl(u) { 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 || ''); var m = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
if (!m) return u; if (!m) return u;
return m[1] + '/' + m[2] + '/' + m[3] + return m[1] + '/' + m[2] + '/' + m[3] +
@@ -1703,7 +1706,12 @@ function shortRepoUrl(u) {
} }
function normalizeRepoUrl(u) { function normalizeRepoUrl(u) {
u = u.replace(/\s+/g, ''); 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); var m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
if (!m) return u; if (!m) return u;
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || 'vault-sync.json'); return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || 'vault-sync.json');
+13 -2
View File
@@ -1396,7 +1396,14 @@ begin
// changeait chaque ":" et "/" en trois caracteres. // changeait chaque ":" et "/" en trois caracteres.
var LShort := LQrUrl; var LShort := LQrUrl;
var LApi := Pos('/api/v1/repos/', LShort); 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) + LShort := Copy(LShort, 1, LApi) +
Copy(LShort, LApi + Length('/api/v1/repos/'), Copy(LShort, LApi + Length('/api/v1/repos/'),
Length(LShort) - LApi - Length('/api/v1/repos/') - Length('/contents/vault-sync.json') + 1); 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 // 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 // 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. // 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( TThread.CreateAnonymousThread(
procedure procedure
var var
@@ -1631,6 +1640,8 @@ begin
try try
LHttp.ConnectionTimeout := 10000; LHttp.ConnectionTimeout := 10000;
LHttp.ResponseTimeout := 30000; LHttp.ResponseTimeout := 30000;
// GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore.
LHttp.UserAgent := 'PMServer';
// "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend // "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
// d'un jeton d'acces personnel, et l'autre donne un 401 muet. // d'un jeton d'acces personnel, et l'autre donne un 401 muet.
if LGitea then if LGitea then
+28 -3
View File
@@ -65,8 +65,8 @@ Bridge.onSyncQr = function (size, bits) {
'background:#fff;display:block;border-radius:6px">' + 'background:#fff;display:block;border-radius:6px">' +
'<path d="' + d + '" fill="#000"/></svg>' + '<path d="' + d + '" fill="#000"/></svg>' +
'<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0">' + '<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0">' +
'Scan it with the phone camera and open it with Coffre: the URL and the ' + 'In the phone app: Settings &rarr; Scan the QR shown on the PC. The URL and ' +
'token fill in, then type the sync password (never in the QR) and Save. ' + 'the token fill in; type the sync password (never in the QR), then Save. ' +
'Hidden again in 60 s.</p>'; 'Hidden again in 60 s.</p>';
box.hidden = false; box.hidden = false;
clearTimeout(syncQrTimer); 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 // discriminant the Android client uses, so one URL tells both ends which
// transport to speak. Everything below that differs is confined to the two // transport to speak. Everything below that differs is confined to the two
// call sites in runSyncNow(); the bridge itself is shared. // 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) { 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 // Unwraps a Gitea contents response into { container, sha }. The API answers
@@ -278,6 +297,8 @@ async function syncTestConnection() {
try { try {
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || ''))); un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
} catch (e) { /* traite comme une forme inattendue */ } } catch (e) { /* traite comme une forme inattendue */ }
const pub = await syncRepoPublicWarning(cfg);
if (pub) return toast(pub, 'error');
return un return un
? toast('Connection OK · file found in the repository') ? toast('Connection OK · file found in the repository')
: toast('Reached Gitea, but this URL does not return a file — ' + : 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 // and buildSyncSnapshot are expensive on large vaults — no point
// running them if we can't reach the server). // running them if we can't reach the server).
syncStatus('Connecting…'); syncStatus('Connecting…');
if (_attempt === 0) {
const pub = await syncRepoPublicWarning(cfg);
if (pub) { syncStatus(''); return toast(pub, 'error'); }
}
let pullResp; let pullResp;
try { try {
pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd); pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
+7 -1
View File
@@ -2769,7 +2769,13 @@ begin
// est affiche, hors ligne est un etat normal, et une alerte ici // est affiche, hors ligne est un etat normal, et une alerte ici
// apprendrait a l'utilisateur a ignorer les alertes. // apprendrait a l'utilisateur a ignorer les alertes.
Log(' failed: ' + LRes.Error); 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 begin
var LWhy401 := LClient.Diagnose(401); var LWhy401 := LClient.Diagnose(401);
TThread.Queue(nil, procedure begin ShowSettingsError(LWhy401); end); TThread.Queue(nil, procedure begin ShowSettingsError(LWhy401); end);