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
+15 -6
View File
@@ -756,10 +756,12 @@
</div>
<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">
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
remote</b> — the snapshot is encrypted with a separate
sync password you set once per device (same on each).
@@ -770,21 +772,28 @@
</label>
<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="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"
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">
</div>
<div style="display:flex;gap:8px;margin-bottom:6px">
<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%">
</label>
<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%">
</label>
</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">
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
</div>