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;
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<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;
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;
+10 -2
View File
@@ -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. -->
<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"
spellcheck="false" inputmode="url"></textarea>
<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.
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');
+13 -2
View File
@@ -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 <PAT>" 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
+28 -3
View File
@@ -65,8 +65,8 @@ Bridge.onSyncQr = function (size, bits) {
'background:#fff;display:block;border-radius:6px">' +
'<path d="' + d + '" fill="#000"/></svg>' +
'<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 ' +
'token fill in, then type the sync password (never in the QR) and Save. ' +
'In the phone app: Settings &rarr; 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.</p>';
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);
+7 -1
View File
@@ -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);