feat(desktop): sync to a private Gitea repo, chosen by the URL

Same discriminant as the Android client - "/api/v1/repos/" in the URL - so one
address tells both ends which transport to speak, and nothing new appears in
Settings beyond a placeholder and a warning.

The bridge stays shared. Three things differ, and they are confined: the header
is "token <PAT>" rather than Basic (Gitea answers a silent 401 to the other),
a PUT carries a JSON envelope with the file base64-encoded inside it, and the
connection test uses GET because the contents API does not answer HEAD.

The sha does the ETag's whole job. It comes back from the pull, rides in the
PUT body instead of an If-Match, and a stale one earns a 409 - handled exactly
like WebDAV's 412, by re-running pull->merge->push so the other device's
changes are folded in rather than clobbered. On a first push `sha` is OMITTED
rather than sent empty: Gitea reads its presence as "replace that exact
version". `branch` is omitted too, so the repo's default is used instead of a
name we would be guessing.

A failing PUT now reads its response body: Gitea explains a 409 or a 422 there,
and without it the error was just a number.

The test button checks the SHAPE of what came back, not only the status. A URL
one path segment off answers 200 with valid JSON that is not a file - which
would otherwise surface much later as "wrong sync password" and send you
looking on the wrong side. Four tests cover that parsing, including the
line-wrapped base64 Gitea sends and the array an accidental directory URL
returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 14:37:39 +01:00
parent 6cab9b7c0a
commit c1d0eb3891
5 changed files with 206 additions and 20 deletions
+32 -6
View File
@@ -1419,6 +1419,11 @@ begin
// send it as If-Match on the push so the server rejects (412) the // send it as If-Match on the push so the server rejects (412) the
// write when another device changed the file in between. // write when another device changed the file in between.
var LIfMatch := GetParam('ifmatch'); var LIfMatch := GetParam('ifmatch');
// GITEA, reconnu a l'URL. Meme transport, trois differences : le jeton
// 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/');
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
var var
@@ -1441,13 +1446,17 @@ begin
try try
LHttp.ConnectionTimeout := 10000; LHttp.ConnectionTimeout := 10000;
LHttp.ResponseTimeout := 30000; LHttp.ResponseTimeout := 30000;
if (LUser <> '') then // "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
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd
else if (LUser <> '') then
begin begin
LHttp.CredentialsStorage.AddCredential( LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create( System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd)); System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
end; end;
if LMethod = 'webdav/get' then if (LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea) then
begin begin
LBodyStream := TBytesStream.Create; LBodyStream := TBytesStream.Create;
try try
@@ -1470,15 +1479,31 @@ begin
begin begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
LReqStream := TBytesStream.Create(LBytes); LReqStream := TBytesStream.Create(LBytes);
// La reponse est LUE meme en echec : Gitea explique un 409 ou un
// 422 dans son corps, et sans cela le message d'erreur se
// reduisait au code, ce qui ne dit pas quoi corriger.
LBodyStream := TBytesStream.Create;
try try
if LIfMatch <> '' then if LGitea then
LResp := LHttp.Put(LUrl, LReqStream, nil, LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
[System.Net.URLClient.TNetHeader.Create('Content-Type', 'application/json')])
else if LIfMatch <> '' then
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
[System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)]) [System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)])
else else
LResp := LHttp.Put(LUrl, LReqStream); LResp := LHttp.Put(LUrl, LReqStream, LBodyStream);
LStatus := LResp.StatusCode; LStatus := LResp.StatusCode;
LEtag := LResp.HeaderValue['ETag']; LEtag := LResp.HeaderValue['ETag'];
if ((LStatus < 200) or (LStatus >= 300)) and (LBodyStream.Size > 0) then
begin
SetLength(LBytes, LBodyStream.Size);
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
// Tronque : le corps d'une erreur tient en une phrase utile,
// et le reste n'a rien a faire dans un appel JavaScript.
LErr := Copy(TEncoding.UTF8.GetString(LBytes), 1, 300);
end;
finally finally
LBodyStream.Free;
LReqStream.Free; LReqStream.Free;
end; end;
end end
@@ -1506,7 +1531,8 @@ begin
// GET success path → ship body base64. Otherwise the field // GET success path → ship body base64. Otherwise the field
// carries either the empty string or the exception message // carries either the empty string or the exception message
// (for status=0 network errors). // (for status=0 network errors).
if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then if ((LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea))
and (LStatus >= 200) and (LStatus < 300) then
LEscPayload := LBodyB64 LEscPayload := LBodyB64
else else
LEscPayload := LErr; LEscPayload := LErr;
+15 -6
View File
@@ -756,10 +756,12 @@
</div> </div>
<div class="slideover-field" id="syncField" style="display:none"> <div class="slideover-field" id="syncField" style="display:none">
<div class="slideover-field-label">Sync (WebDAV)</div> <div class="slideover-field-label">Sync (WebDAV or Gitea)</div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5"> <p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
Bidirectional sync with a WebDAV server (Nextcloud, Bidirectional sync with a WebDAV server (Nextcloud,
ownCloud, Apache mod_dav). Auto-merge by entry ownCloud, Apache mod_dav) <b>or a private Gitea
repository</b> — paste a contents-API URL and it switches
transport on its own. Auto-merge by entry
timestamp. <b>Master password is never sent to the timestamp. <b>Master password is never sent to the
remote</b> — the snapshot is encrypted with a separate remote</b> — the snapshot is encrypted with a separate
sync password you set once per device (same on each). sync password you set once per device (same on each).
@@ -770,21 +772,28 @@
</label> </label>
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px"> <div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
<div style="margin-bottom:6px"> <div style="margin-bottom:6px">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">WebDAV URL (full path to the snapshot file)</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — full path to the snapshot file, or a Gitea contents path</div>
<input type="text" id="syncUrl" class="so-input" <input type="text" id="syncUrl" class="so-input"
placeholder="https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json" placeholder="https://git.example.com/api/v1/repos/OWNER/REPO/contents/vault-sync.json"
style="width:100%;font-size:12px"> style="width:100%;font-size:12px">
</div> </div>
<div style="display:flex;gap:8px;margin-bottom:6px"> <div style="display:flex;gap:8px;margin-bottom:6px">
<label style="flex:1"> <label style="flex:1">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username <span style="opacity:.7">(WebDAV only)</span></div>
<input type="text" id="syncUser" class="so-input" style="width:100%"> <input type="text" id="syncUser" class="so-input" style="width:100%">
</label> </label>
<label style="flex:1"> <label style="flex:1">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / app token</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / token</div>
<input type="password" id="syncPwd" class="so-input" style="width:100%"> <input type="password" id="syncPwd" class="so-input" style="width:100%">
</label> </label>
</div> </div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
On Gitea the username is ignored and the token needs
<b>write:repository</b> — a read-only one can pull but
never push. Each sync is a commit, and Git history is
kept: an old sync password stays attackable in earlier
versions, so prune the history if you rotate it.
</p>
<div style="margin-bottom:8px"> <div style="margin-bottom:8px">
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button> <button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
</div> </div>
+82 -8
View File
@@ -43,8 +43,34 @@ Bridge.onWebdavResult = function(reqId, status, payload, etag) {
delete _webdavResolvers[reqId]; delete _webdavResolvers[reqId];
r({ status: status | 0, payload: payload || '', etag: etag || '' }); r({ status: status | 0, payload: payload || '', etag: etag || '' });
}; };
// A Gitea repository, recognised by its contents-API path — the same
// 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.
function syncIsGitea(url) {
return (url || '').toLowerCase().includes('/api/v1/repos/');
}
// Unwraps a Gitea contents response into { container, sha }. The API answers
// JSON whose `content` is the file in base64 — so the payload is base64 twice
// over: once by our bridge, once by Gitea. Returns null if the JSON is not
// the shape we expect, which is what a slightly-wrong URL produces: valid
// JSON, but not this.
function giteaUnwrap(bodyText) {
let obj;
try { obj = JSON.parse(bodyText); } catch (e) { return null; }
if (!obj || !obj.content || !obj.sha) return null;
if (obj.encoding && obj.encoding !== 'base64') return null;
try {
const raw = obj.content.replace(/[\r\n]/g, ''); // Gitea wraps its base64
return { container: new TextDecoder().decode(base64ToBytes(raw)), sha: obj.sha };
} catch (e) { return null; }
}
// method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so // method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so
// the server rejects (412) a write when the remote changed since our pull. // the server rejects (412) a write when the remote changed since our pull.
// On Gitea the sha travels inside the PUT body instead, and the bridge sends
// no If-Match.
function _webdavCall(method, url, user, pwd, dataB64, opts) { function _webdavCall(method, url, user, pwd, dataB64, opts) {
opts = opts || {}; opts = opts || {};
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
@@ -188,6 +214,19 @@ async function syncTestConnection() {
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning'); if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
toast('Testing connection…'); toast('Testing connection…');
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd); const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
// A reachable Gitea with a valid token is not enough: a URL pointing one
// path segment off answers 200 with JSON that is not a file. Checking the
// shape here is what makes the test worth pressing.
if (syncIsGitea(cfg.url) && r.status >= 200 && r.status < 300) {
let un = null;
try {
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
} catch (e) { /* traite comme une forme inattendue */ }
return un
? toast('Connection OK · file found in the repository')
: toast('Reached Gitea, but this URL does not return a file — ' +
'expected .../api/v1/repos/OWNER/REPO/contents/FILE', 'error');
}
if (r.status >= 200 && r.status < 400) { if (r.status >= 200 && r.status < 400) {
toast('Connection OK (' + r.status + ')'); toast('Connection OK (' + r.status + ')');
} else if (r.status === 404) { } else if (r.status === 404) {
@@ -526,14 +565,32 @@ async function runSyncNow(_attempt) {
// ETag of the version we just pulled — sent back as If-Match on the // ETag of the version we just pulled — sent back as If-Match on the
// push so the server rejects our write if another device changed the // push so the server rejects our write if another device changed the
// file in between (optimistic concurrency, avoids lost updates). // file in between (optimistic concurrency, avoids lost updates).
const remoteEtag = pullResp.etag || ''; let remoteEtag = pullResp.etag || '';
// Decrypt + cross-account guard BEFORE touching local state, so a // Decrypt + cross-account guard BEFORE touching local state, so a
// wrong sync password or a foreign account aborts cleanly. // wrong sync password or a foreign account aborts cleanly.
let remoteSnap = null; let remoteSnap = null;
if (pullResp.status !== 404 && pullResp.payload) { if (pullResp.status !== 404 && pullResp.payload) {
let jsonText;
try {
jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
} catch (e) {
syncStatus('');
return toast('Unreadable reply from the remote', 'error');
}
if (syncIsGitea(cfg.url)) {
const un = giteaUnwrap(jsonText);
if (!un) {
syncStatus('');
return toast('Gitea replied without content/sha — is the URL a ' +
'contents-API path to the file?', 'error');
}
jsonText = un.container;
// The blob sha takes the ETag's place, and plays exactly its role:
// it goes back on the push, and a stale one is refused.
remoteEtag = un.sha;
}
try { try {
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
const container = JSON.parse(jsonText); const container = JSON.parse(jsonText);
remoteSnap = await decryptExportContainer(container, cfg.encPwd); remoteSnap = await decryptExportContainer(container, cfg.encPwd);
} catch (e) { } catch (e) {
@@ -613,17 +670,34 @@ async function runSyncNow(_attempt) {
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0; pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0;
syncStatus('Encrypting…'); syncStatus('Encrypting…');
const container = await encryptExportPayload(snap, cfg.encPwd); const container = await encryptExportPayload(snap, cfg.encPwd);
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
syncStatus('Pushing…'); syncStatus('Pushing…');
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, let bodyB64, opts;
bytesToBase64(bodyBytes), if (syncIsGitea(cfg.url)) {
{ ifMatch: remoteEtag }); // The file rides inside a JSON envelope, base64 again. `sha` is
// OMITTED rather than empty on a first push: Gitea reads its
// presence as "replace that exact version", and an empty one is
// not the same as none. `branch` is omitted too, so the repo's
// default branch is used instead of a name we would be guessing.
const env = {
message: 'sync from ' + (state.username || 'desktop'),
content: bytesToBase64(containerBytes)
};
if (remoteEtag) env.sha = remoteEtag;
bodyB64 = bytesToBase64(new TextEncoder().encode(JSON.stringify(env)));
opts = {};
} else {
bodyB64 = bytesToBase64(containerBytes);
opts = { ifMatch: remoteEtag };
}
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, bodyB64, opts);
// 412 Precondition Failed = the remote changed since our pull // 412 Precondition Failed = the remote changed since our pull
// (another device pushed). Re-run the whole pull→merge→push so we // (another device pushed). Re-run the whole pull→merge→push so we
// fold in their changes instead of clobbering them. Bounded to a // fold in their changes instead of clobbering them. Bounded to a
// few tries to avoid a livelock against a device syncing in a hot // few tries to avoid a livelock against a device syncing in a hot
// loop. // loop. Gitea says the same thing with 409: the sha we sent is no
if (r.status === 412) { // longer the file's.
if (r.status === 412 || r.status === 409) {
syncStatus(''); syncStatus('');
if (_attempt < 3) { if (_attempt < 3) {
toast('Remote changed — re-syncing…'); toast('Remote changed — re-syncing…');
+75
View File
@@ -0,0 +1,75 @@
// Le transport Gitea : reconnaissance de l'URL, et lecture de la reponse de
// l'API des contenus.
//
// POURQUOI CES DEUX-LA. `giteaUnwrap` est le seul endroit du chemin de sync qui
// interprete du JSON venu du reseau, et il decide de ce qui sera ensuite
// dechiffre. Une URL a un segment pres rend du JSON valide qui n'est PAS un
// fichier ; s'il passait, l'erreur ne se verrait qu'au message
// « mot de passe de sync incorrect », qui envoie chercher la cause ailleurs.
//
// Le `sha` compte autant que le contenu : il repart en `sha` au push, et c'est
// lui qui fait refuser une ecriture concurrente. Le rendre vide plutot
// qu'absent ferait ecraser la version d'un autre appareil.
const test = require('node:test');
const assert = require('node:assert');
const { loadApp } = require('./harness');
function b64(text) {
return Buffer.from(text, 'utf8').toString('base64');
}
test('syncIsGitea: recognises a contents-API URL, ignores WebDAV', async () => {
const { __test: t } = await loadApp();
assert.equal(t.syncIsGitea(
'https://git.example.com/api/v1/repos/Zaki/pm_sync/contents/vault-sync.json'), true);
// Casse indifferente : l'URL est saisie a la main.
assert.equal(t.syncIsGitea(
'https://git.example.com/API/V1/REPOS/Zaki/pm_sync/contents/v.json'), true);
// Une URL de depot n'est pas une URL d'API, et elle ne rendrait pas de sha.
assert.equal(t.syncIsGitea('https://git.example.com/Zaki/pm_sync'), false);
assert.equal(t.syncIsGitea('https://dav.example.com/remote.php/vault.json'), false);
assert.equal(t.syncIsGitea(''), false);
assert.equal(t.syncIsGitea(undefined), false);
});
test('giteaUnwrap: returns the file and its sha', async () => {
const { __test: t } = await loadApp();
const container = '{"version":1,"cipher":"AAAA"}';
const un = t.giteaUnwrap(JSON.stringify({
content: b64(container), encoding: 'base64', sha: 'abc123', name: 'vault-sync.json'
}));
assert.ok(un, 'devrait accepter une reponse bien formee');
assert.equal(un.container, container);
assert.equal(un.sha, 'abc123');
});
test('giteaUnwrap: accepts the line-wrapped base64 Gitea sends', async () => {
const { __test: t } = await loadApp();
const container = '{"version":1}';
// Gitea coupe son base64 en lignes ; un decodeur strict s'y casse.
const wrapped = b64(container).replace(/(.{4})/, '$1\r\n');
const un = t.giteaUnwrap(JSON.stringify({
content: wrapped, encoding: 'base64', sha: 'deadbeef'
}));
assert.ok(un, 'les retours a la ligne ne doivent pas faire echouer le decodage');
assert.equal(un.container, container);
});
test('giteaUnwrap: refuses anything that is not a file', async () => {
const { __test: t } = await loadApp();
// Ce que rend une URL a un segment pres : du JSON valide, sans fichier.
assert.equal(t.giteaUnwrap('{"message":"Not found"}'), null);
// Un repertoire : l'API rend un TABLEAU, pas un objet.
assert.equal(t.giteaUnwrap('[{"name":"vault-sync.json","sha":"x"}]'), null);
// Un sha sans contenu ne permet rien de dechiffrer.
assert.equal(t.giteaUnwrap('{"sha":"abc"}'), null);
// Un contenu sans sha interdirait l'ecriture concurrente : refuse aussi.
assert.equal(t.giteaUnwrap(JSON.stringify({ content: b64('{}') })), null);
// Un encodage que nous ne savons pas lire.
assert.equal(t.giteaUnwrap(JSON.stringify({
content: 'x', encoding: 'utf-8', sha: 'abc' })), null);
// Pas du JSON du tout - une page d'erreur HTML, par exemple.
assert.equal(t.giteaUnwrap('<html>502</html>'), null);
assert.equal(t.giteaUnwrap(''), null);
});
+2
View File
@@ -152,6 +152,8 @@ function loadApp(overrides = {}) {
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined), computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below) // merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot, applyRemoteSnapshot, buildSyncSnapshot,
// gitea transport
syncIsGitea, giteaUnwrap,
// metadata-at-rest // metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS, withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
}; };