c1d0eb3891
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>
745 lines
34 KiB
JavaScript
745 lines
34 KiB
JavaScript
// ============================================================
|
|
// app.sync.js — SYNC module (extracted from app.js, §3.1)
|
|
// ============================================================
|
|
//
|
|
// Loaded as a classic <script> AFTER js/app.js (it has top-level side
|
|
// effects — `Bridge.onWebdavResult = …` — that need `Bridge`, `state`, and
|
|
// the api/loadEntries helpers already declared by app.js). Classic scripts
|
|
// share one global lexical environment, so these functions see app.js's
|
|
// globals and vice-versa exactly as in the monofile. See CLAUDE.md
|
|
// "Découpage frontend" for the load-order rules.
|
|
//
|
|
// ============================================================
|
|
// SYNC (WebDAV, auto-merge with timestamps)
|
|
// ============================================================
|
|
// Multi-device sync via a remote WebDAV server (Nextcloud, ownCloud,
|
|
// Apache mod_dav, any compatible). The remote file is an encrypted
|
|
// JSON snapshot (same crypto container as the export, separate
|
|
// password — the sync password is device-local DPAPI and the user must
|
|
// configure the same one on each device they want to sync).
|
|
//
|
|
// Strategy: auto-merge with last-write-wins on per-entry updated_at,
|
|
// tombstones for delete propagation. No conflict UI in v1 — silent
|
|
// resolution because solo personal use rarely produces simultaneous
|
|
// edits across devices. Toast reports added / updated / deleted counts.
|
|
//
|
|
// Sensitive actions (export, change master pw…) keep their own reauth
|
|
// path. Sync only touches entries + folders + tombstones.
|
|
|
|
const SYNC_PREFS = {
|
|
enabled: 'syncEnabled',
|
|
url: 'syncUrl', // e.g. https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json
|
|
user: 'syncUser',
|
|
pwd: 'syncPwd', // WebDAV password / app token
|
|
encPwd: 'syncEncPwd', // secret for the encrypted JSON container
|
|
preBackup: 'syncPreBackup', // 'on' / '' — write a local copy before each sync
|
|
last: 'syncLast', // ISO timestamp of last successful run
|
|
};
|
|
|
|
let _webdavResolvers = {};
|
|
Bridge.onWebdavResult = function(reqId, status, payload, etag) {
|
|
const r = _webdavResolvers[reqId];
|
|
if (!r) return;
|
|
delete _webdavResolvers[reqId];
|
|
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
|
|
// 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) {
|
|
opts = opts || {};
|
|
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
|
const CHUNK = 1000000;
|
|
// Large PUT bodies (a big encrypted vault) can't ride in a single
|
|
// cmd:// URL — WebView2 caps it and the navigation blanks the page.
|
|
// Stream the base64 through the file/chunk transport, then commit via
|
|
// webdav/put-commit (which reads the accumulated buffer server-side).
|
|
if (method === 'put' && dataB64 && dataB64.length > CHUNK) {
|
|
return (async () => {
|
|
const ok = await _streamChunks(reqId, dataB64);
|
|
if (!ok) return { status: 0, payload: 'chunk transfer failed', etag: '' };
|
|
return await new Promise(resolve => {
|
|
_webdavResolvers[reqId] = resolve;
|
|
let q = 'cmd://webdav/put-commit'
|
|
+ '?reqId=' + encodeURIComponent(reqId)
|
|
+ '&url=' + encodeURIComponent(url)
|
|
+ '&user=' + encodeURIComponent(user || '')
|
|
+ '&pwd=' + encodeURIComponent(pwd || '');
|
|
if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch);
|
|
window.location.href = q;
|
|
setTimeout(() => {
|
|
if (_webdavResolvers[reqId]) {
|
|
delete _webdavResolvers[reqId];
|
|
resolve({ status: 0, payload: 'timeout', etag: '' });
|
|
}
|
|
}, 60000);
|
|
});
|
|
})();
|
|
}
|
|
return new Promise(resolve => {
|
|
_webdavResolvers[reqId] = resolve;
|
|
let q = 'cmd://webdav/' + method
|
|
+ '?reqId=' + encodeURIComponent(reqId)
|
|
+ '&url=' + encodeURIComponent(url)
|
|
+ '&user=' + encodeURIComponent(user || '')
|
|
+ '&pwd=' + encodeURIComponent(pwd || '');
|
|
if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
|
|
if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch);
|
|
window.location.href = q;
|
|
setTimeout(() => {
|
|
if (_webdavResolvers[reqId]) {
|
|
delete _webdavResolvers[reqId];
|
|
resolve({ status: 0, payload: 'timeout', etag: '' });
|
|
}
|
|
}, 60000);
|
|
});
|
|
}
|
|
|
|
function refreshSyncUI(cfg) {
|
|
if (!cfg) return;
|
|
const cb = document.getElementById('settingSyncEnabled');
|
|
const cfg2 = document.getElementById('syncConfig');
|
|
if (cb) cb.checked = !!cfg.enabled;
|
|
if (cfg2) cfg2.style.display = cfg.enabled ? '' : 'none';
|
|
const url = document.getElementById('syncUrl');
|
|
const user = document.getElementById('syncUser');
|
|
const pwd = document.getElementById('syncPwd');
|
|
const pre = document.getElementById('syncPreBackup');
|
|
if (url) url.value = cfg.url || '';
|
|
if (user) user.value = cfg.user || '';
|
|
if (pwd) {
|
|
// Left EMPTY on purpose — the settings panel no longer pulls the stored
|
|
// password across. The field only saves on `change`, so an untouched
|
|
// empty field never overwrites anything; the placeholder says so.
|
|
pwd.value = cfg.pwd || '';
|
|
pwd.placeholder = cfg.hasPwd ? 'Saved — type to replace' : '';
|
|
}
|
|
if (pre) pre.checked = !!cfg.preBackup;
|
|
const pwdStatus = document.getElementById('syncPwdStatus');
|
|
if (pwdStatus) pwdStatus.textContent = cfg.hasEncPwd ? 'Set.' : 'Not set.';
|
|
const last = document.getElementById('syncLast');
|
|
if (last) last.textContent = cfg.last
|
|
? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16)
|
|
: '';
|
|
}
|
|
|
|
async function syncSetEncPwdFlow() {
|
|
let err = '';
|
|
let n = 0;
|
|
for (;;) {
|
|
const v = await promptDialog({
|
|
title: 'Set sync password',
|
|
message: 'Use the SAME password on every device that syncs with this remote. ' +
|
|
'Stored DPAPI-protected on this device only — never transmitted.',
|
|
placeholder: '12+ chars, mix letters/digits/symbols',
|
|
password: true,
|
|
okText: 'Save',
|
|
error: err,
|
|
});
|
|
if (!v) return;
|
|
// This password is the ONLY thing protecting the remote snapshot
|
|
// (§1.4) — enforce a real minimum. computeStrength reused from app.js
|
|
// (no zxcvbn dep). >=12 + one complexity category clears 50.
|
|
if (v.length >= 12 && computeStrength(v) >= 50) {
|
|
Bridge.setPref(SYNC_PREFS.encPwd, v);
|
|
const pwdStatus = document.getElementById('syncPwdStatus');
|
|
if (pwdStatus) pwdStatus.textContent = 'Set.';
|
|
toast('Sync password saved');
|
|
return;
|
|
}
|
|
n++;
|
|
if (n >= 5) return toast('Too many invalid attempts', 'error');
|
|
err = 'Too weak — use 12+ chars mixing letters, digits and symbols (attempt ' + n + ' / 5).';
|
|
}
|
|
}
|
|
|
|
// withSecrets defaults to FALSE: the settings panel only needs to know whether
|
|
// the two passwords are set. Fetching them plants the values in the renderer
|
|
// for the life of the process — seen in a memory dump, still present after the
|
|
// vault had auto-locked. Only the paths that actually talk to the server or
|
|
// encrypt a snapshot ask for them.
|
|
async function loadSyncConfig(withSecrets) {
|
|
if (!Bridge.active) return null;
|
|
const [enabled, url, user, pwd, encPwd, pre, last, hasPwd, hasEnc] = await Promise.all([
|
|
Bridge.getPref(SYNC_PREFS.enabled),
|
|
Bridge.getPref(SYNC_PREFS.url),
|
|
Bridge.getPref(SYNC_PREFS.user),
|
|
withSecrets ? Bridge.getPref(SYNC_PREFS.pwd) : Promise.resolve(''),
|
|
withSecrets ? Bridge.getPref(SYNC_PREFS.encPwd) : Promise.resolve(''),
|
|
Bridge.getPref(SYNC_PREFS.preBackup),
|
|
Bridge.getPref(SYNC_PREFS.last),
|
|
Bridge.hasPref(SYNC_PREFS.pwd),
|
|
Bridge.hasPref(SYNC_PREFS.encPwd),
|
|
]);
|
|
return {
|
|
enabled: enabled === '1',
|
|
url: url || '',
|
|
user: user || '',
|
|
pwd: pwd || '',
|
|
encPwd: encPwd || '',
|
|
hasPwd: !!hasPwd,
|
|
hasEncPwd: !!hasEnc,
|
|
preBackup: pre === '1',
|
|
last: last || '',
|
|
};
|
|
}
|
|
|
|
async function syncTestConnection() {
|
|
const cfg = await loadSyncConfig(true); // needs the WebDAV credentials
|
|
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
|
|
toast('Testing connection…');
|
|
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) {
|
|
toast('Connection OK (' + r.status + ')');
|
|
} else if (r.status === 404) {
|
|
// Server reachable, snapshot file just doesn't exist yet — normal
|
|
// before the first sync. Treat as success.
|
|
toast('Connection OK · snapshot not created yet');
|
|
} else if (r.status === 401 || r.status === 403) {
|
|
toast('Auth failed (' + r.status + ') — check user/password', 'error');
|
|
} else if (r.status === 0) {
|
|
toast('Network error: ' + (r.payload || 'unreachable'), 'error');
|
|
} else {
|
|
toast('Server returned ' + r.status, 'error');
|
|
}
|
|
}
|
|
|
|
// Build the snapshot payload that gets encrypted + pushed to the remote.
|
|
// Includes entries (decrypted plaintext, then re-encrypted under the
|
|
// sync key), folders metadata, and tombstones. Mirrors doExport's shape
|
|
// so a sync snapshot is also importable via "Import vault".
|
|
async function buildSyncSnapshot() {
|
|
const payload = {
|
|
version: 1,
|
|
snapshot_at: new Date().toISOString(),
|
|
username: state.username,
|
|
folders: (state.folders || [])
|
|
.filter(f => f && f.name && f.name !== 'All')
|
|
.map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })),
|
|
// Cosmetic, unencrypted — travels so a new device picks up the avatar
|
|
// (adopted only if that device has none; see applyRemoteSnapshot).
|
|
avatar_b64: state.avatarDataUri || '',
|
|
entries: [],
|
|
tombstones: [],
|
|
};
|
|
// Progress feedback: buildSyncSnapshot dominates runtime (per-entry
|
|
// decrypt + attachment fetch). Report N/total as we go so the user
|
|
// doesn't think the app froze on large vaults.
|
|
const eligible = state.entries.filter(e => e && e.uuid);
|
|
const total = eligible.length;
|
|
let done = 0;
|
|
for (const e of eligible) {
|
|
done++;
|
|
if (total > 5 && (done === 1 || done % 5 === 0 || done === total)) {
|
|
syncStatus('Preparing… ' + done + '/' + total);
|
|
}
|
|
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
|
let plainTotp = '';
|
|
if (e.totp_secret && e.totp_iv) {
|
|
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
|
if (plainTotp === '[ERROR]') plainTotp = '';
|
|
}
|
|
let plainCustom = [];
|
|
if (e.custom_fields && e.custom_fields_iv) {
|
|
try { plainCustom = await decryptCustomFields(
|
|
e.custom_fields, e.custom_fields_iv); }
|
|
catch (_) {}
|
|
}
|
|
let attachments = [];
|
|
try {
|
|
const metas = await api('/entries/' + e.id + '/attachments',
|
|
{ headers: authHeaders() });
|
|
for (const m of (metas || [])) {
|
|
const full = await api('/attachments/' + m.id,
|
|
{ headers: authHeaders() });
|
|
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
|
|
attachments.push({
|
|
filename: m.filename, mime: m.mime,
|
|
size_bytes: m.size_bytes,
|
|
content_b64: bytesToBase64(bytes),
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
payload.entries.push({
|
|
uuid: e.uuid,
|
|
site: e.site || '',
|
|
title: e.title || '',
|
|
username: e.username || '',
|
|
password: plain === '[ERROR]' ? '' : plain,
|
|
folder: e.folder || 'All',
|
|
tags: parseTags(e.tags),
|
|
// favorite / pinned deliberately OMITTED — device-local by design
|
|
// (decided 2026-07-11, like the avatar). encryptImportEntry passes
|
|
// through whatever the container holds, so leaving them out here
|
|
// is what keeps them from propagating. Adding them back = they sync.
|
|
totp_secret: plainTotp,
|
|
kind: e.kind || 'login',
|
|
template: e.template || '',
|
|
custom_fields: plainCustom,
|
|
attachments,
|
|
icon_b64: e.icon_b64 || '',
|
|
icon_custom: e.icon_custom ? 1 : 0,
|
|
created_at: e.created_at,
|
|
updated_at: e.updated_at,
|
|
});
|
|
}
|
|
try {
|
|
const ts = await api('/entries/tombstones', { headers: authHeaders() });
|
|
payload.tombstones = (Array.isArray(ts) ? ts : []).map(t => ({
|
|
uuid: t.uuid, deleted_at: t.deleted_at,
|
|
}));
|
|
} catch (_) {}
|
|
return payload;
|
|
}
|
|
|
|
async function applyRemoteSnapshot(remote) {
|
|
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0, attFailed:0, folderFailed:0 };
|
|
let added = 0, updated = 0, deleted = 0, failed = 0, attFailed = 0, folderFailed = 0;
|
|
|
|
// Ensure state.entries reflects the live DB before we read updated_at
|
|
// for the resurrection arbitration below — a restore-then-sync must
|
|
// see the restored rows' fresh timestamps.
|
|
await loadEntries();
|
|
|
|
// Apply remote tombstones — but arbitrate against local resurrections.
|
|
// A remote tombstone says "this uuid was deleted at T". If the local
|
|
// entry with that uuid was updated AFTER T (e.g. restored from a
|
|
// backup since the deletion), the resurrection wins and the tombstone
|
|
// is skipped — otherwise a restore would be silently undone on the
|
|
// next sync. Entries the local side hasn't touched since T are
|
|
// deleted normally (standard delete propagation).
|
|
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
|
// Snapshot local uuid → updated_at BEFORE any deletion.
|
|
const localTs = new Map();
|
|
for (const e of state.entries)
|
|
if (e.uuid) localTs.set(e.uuid, e.updated_at || '');
|
|
|
|
// A local entry beats the tombstone only if it exists AND is
|
|
// provably newer than deleted_at. Unparseable/missing timestamps
|
|
// favour KEEP (data-loss is worse than a stale entry the user can
|
|
// re-delete).
|
|
const isResurrected = (uuid, deletedAt) => {
|
|
if (!localTs.has(uuid)) return false; // not local → apply
|
|
const up = Date.parse(String(localTs.get(uuid)).replace(' ', 'T'));
|
|
const del = Date.parse(String(deletedAt || '').replace(' ', 'T'));
|
|
if (isNaN(up)) return true; // can't tell → keep
|
|
if (isNaN(del)) return false; // no delete time → apply
|
|
return up > del;
|
|
};
|
|
|
|
const toApply = remote.tombstones
|
|
.filter(t => t.uuid && !isResurrected(t.uuid, t.deleted_at))
|
|
.map(t => t.uuid);
|
|
|
|
if (toApply.length > 0) {
|
|
const preLocal = new Set();
|
|
for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid);
|
|
try {
|
|
await api('/entries/tombstones', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ uuids: toApply }),
|
|
});
|
|
deleted = toApply.filter(u => preLocal.has(u)).length;
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// Refresh local view AFTER tombstones so the maps reflect the cull.
|
|
await loadEntries();
|
|
const byUuid = new Map();
|
|
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
|
|
|
|
// Entries sitting in the TRASH are soft-deleted: no tombstone is written
|
|
// (that only happens on a hard delete), and they are absent from
|
|
// state.entries. Without this set the merge sees their uuids as "missing
|
|
// locally" and POSTs them back as fresh live rows — moving 30 entries to
|
|
// the trash then syncing produced 30 duplicates, live copies alongside the
|
|
// trashed originals, two rows sharing one uuid. The local decision to trash
|
|
// wins; emptying the trash writes the tombstone that propagates the delete.
|
|
await loadTrash();
|
|
const trashedUuids = new Set();
|
|
for (const e of (state.trashed || [])) if (e.uuid) trashedUuids.add(e.uuid);
|
|
|
|
// Local tombstones must veto ANY remote entry with a matching uuid —
|
|
// otherwise a perm-delete on this device gets undone by the next
|
|
// pull ("48 added" after wiping the vault, because the remote
|
|
// snapshot still carries the pre-delete state). The tombstones are
|
|
// pushed back to the remote at the next push, propagating the
|
|
// delete cleanly.
|
|
const localTombstones = new Set();
|
|
try {
|
|
const ts = await api('/entries/tombstones', { headers: authHeaders() });
|
|
(Array.isArray(ts) ? ts : []).forEach(t => t.uuid && localTombstones.add(t.uuid));
|
|
} catch (_) {}
|
|
|
|
// Folders — add missing ones with the remote's color/icon. Existing
|
|
// folders are left untouched (user's local customisation wins).
|
|
if (Array.isArray(remote.folders)) {
|
|
const localNames = new Set((state.folders || []).map(f => f.name));
|
|
for (const f of remote.folders) {
|
|
if (!f.name || localNames.has(f.name)) continue;
|
|
try {
|
|
await api('/folders', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
name: f.name, color: f.color || '', icon: f.icon || '',
|
|
}),
|
|
});
|
|
} catch (_) { folderFailed++; }
|
|
}
|
|
await loadFolders();
|
|
}
|
|
|
|
// Avatar (cosmetic, unencrypted) — adopt the remote one only if this
|
|
// device has none, mirroring the import path. Additive so we never fight
|
|
// over / clobber a locally-set picture (no per-avatar timestamp to
|
|
// arbitrate). A device that already has an avatar keeps it.
|
|
if (typeof remote.avatar_b64 === 'string' && remote.avatar_b64 && !state.avatarDataUri) {
|
|
try {
|
|
await api('/avatar', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ avatar_b64: remote.avatar_b64 }),
|
|
});
|
|
state.avatarDataUri = remote.avatar_b64;
|
|
} catch (_) {}
|
|
}
|
|
|
|
// Per-entry merge.
|
|
for (const r of remote.entries) {
|
|
if (!r.uuid) continue;
|
|
// Skip entries this device has already tombstoned — never
|
|
// resurrect a deleted entry.
|
|
if (localTombstones.has(r.uuid)) continue;
|
|
// Same for the trash: the entry exists locally, just not in the live
|
|
// list. Re-creating it would duplicate it, not restore it.
|
|
if (trashedUuids.has(r.uuid)) continue;
|
|
const local = byUuid.get(r.uuid);
|
|
if (!local) {
|
|
// New entry on remote — encrypt locally + POST keeping the uuid.
|
|
try {
|
|
const enc = await encryptImportEntry(Object.assign({}, r, { uuid: r.uuid }));
|
|
const created = await api('/entries', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify(enc),
|
|
});
|
|
added++;
|
|
// Restore attachments for this new entry.
|
|
if (Array.isArray(r.attachments) && created && created.id) {
|
|
for (const a of r.attachments) {
|
|
try {
|
|
const bytes = base64ToBytes(a.content_b64 || '');
|
|
const blob = await encryptBlobBytes(bytes);
|
|
await api('/entries/' + created.id + '/attachments', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
filename: a.filename,
|
|
mime: a.mime || 'application/octet-stream',
|
|
encrypted_blob: blob.encrypted,
|
|
iv: blob.iv,
|
|
size_bytes: a.size_bytes || bytes.length,
|
|
}),
|
|
});
|
|
} catch (_) { attFailed++; }
|
|
}
|
|
}
|
|
} catch (_) { failed++; }
|
|
} else {
|
|
// Both sides have it — keep the newer one (lexical ISO sort).
|
|
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
|
|
if (!remoteWins) continue;
|
|
try {
|
|
const enc = await encryptImportEntry(r);
|
|
// PUT keeps the existing id but accepts the encrypted blobs.
|
|
// template + uuid not touched here (server preserves columns
|
|
// when fields aren't in body — uuid is immutable anyway).
|
|
await api('/entries/' + local.id, {
|
|
method: 'PUT',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify(enc),
|
|
});
|
|
updated++;
|
|
} catch (_) { failed++; }
|
|
}
|
|
}
|
|
return { added, updated, deleted, failed, attFailed, folderFailed };
|
|
}
|
|
|
|
// Update the inline sync-status label next to the button. Empty string
|
|
// hides it (end of sync). Called from every phase so the user sees
|
|
// where the runtime is spent (buildSyncSnapshot is by far the slowest,
|
|
// hence the per-entry counter).
|
|
function syncStatus(text) {
|
|
const el = document.getElementById('syncStatus');
|
|
if (el) {
|
|
if (text) { el.textContent = text; el.style.display = ''; }
|
|
else { el.textContent = ''; el.style.display = 'none'; }
|
|
}
|
|
// Also drive the global busy overlay so a running sync blocks stray
|
|
// clicks (e.g. the auto-backup "Choose…" picker) and reads the same
|
|
// as the manual backup. Empty text = clear.
|
|
if (text) showBusy(text); else hideBusy();
|
|
// Lock the Sync/Test buttons while a run is in flight so the user
|
|
// can't double-click a second concurrent sync.
|
|
const active = !!text;
|
|
['syncNowBtn', 'syncTestBtn'].forEach(id => {
|
|
const b = document.getElementById(id);
|
|
if (b) b.disabled = active;
|
|
});
|
|
}
|
|
|
|
async function runSyncNow(_attempt) {
|
|
_attempt = _attempt || 0;
|
|
const cfg = await loadSyncConfig(true); // talks to the server + encrypts
|
|
if (!cfg) return toast('Bridge not available', 'error');
|
|
if (!cfg.url) return toast('Sync not configured', 'warning');
|
|
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
|
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
|
|
|
if (_attempt === 0) toast('Syncing…');
|
|
let merged = { added: 0, updated: 0, deleted: 0 };
|
|
|
|
// Fail-fast connectivity: hit the remote FIRST so a dead server /
|
|
// wrong URL aborts before any heavy local work (the pre-sync backup
|
|
// and buildSyncSnapshot are expensive on large vaults — no point
|
|
// running them if we can't reach the server).
|
|
syncStatus('Connecting…');
|
|
let pullResp;
|
|
try {
|
|
pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
|
|
} catch (e) {
|
|
syncStatus('');
|
|
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
|
|
}
|
|
if (pullResp.status === 0) {
|
|
syncStatus('');
|
|
return toast('Network error: ' + (pullResp.payload || 'unreachable'), 'error');
|
|
}
|
|
if (!(pullResp.status === 404 ||
|
|
(pullResp.status >= 200 && pullResp.status < 300))) {
|
|
syncStatus('');
|
|
return toast('Pull failed: HTTP ' + pullResp.status, 'error');
|
|
}
|
|
// 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
|
|
// file in between (optimistic concurrency, avoids lost updates).
|
|
let remoteEtag = pullResp.etag || '';
|
|
|
|
// Decrypt + cross-account guard BEFORE touching local state, so a
|
|
// wrong sync password or a foreign account aborts cleanly.
|
|
let remoteSnap = null;
|
|
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 {
|
|
const container = JSON.parse(jsonText);
|
|
remoteSnap = await decryptExportContainer(container, cfg.encPwd);
|
|
} catch (e) {
|
|
syncStatus('');
|
|
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
|
}
|
|
if (remoteSnap && remoteSnap.username && state.username &&
|
|
remoteSnap.username !== state.username) {
|
|
// Drop the busy overlay so this confirm (z-index below it)
|
|
// is visible; a later syncStatus() re-shows it if we continue.
|
|
hideBusy();
|
|
const ok = await confirmDialog({
|
|
title: 'Different account on remote',
|
|
message: 'The remote snapshot belongs to <b>' +
|
|
(remoteSnap.username + '').replace(/[<>&]/g, '') +
|
|
'</b>, but you are signed in as <b>' +
|
|
(state.username + '').replace(/[<>&]/g, '') +
|
|
'</b>. Merging would mix the two vaults. ' +
|
|
'Use a distinct sync URL per account.',
|
|
okText: 'Merge anyway',
|
|
cancelText: 'Cancel',
|
|
danger: true,
|
|
});
|
|
if (!ok) { syncStatus(''); return toast('Sync cancelled — account mismatch', 'warning'); }
|
|
}
|
|
}
|
|
|
|
// Server reachable + snapshot decrypted → NOW do the optional
|
|
// pre-sync backup (captures current local state before the merge
|
|
// mutates it). Best-effort; a failure here doesn't block the sync.
|
|
if (cfg.preBackup) {
|
|
try {
|
|
const ab = await loadAutoBackupConfig();
|
|
const dir = (ab && ab.dir) ? ab.dir : null;
|
|
if (dir) {
|
|
syncStatus('Local backup…');
|
|
const snap = await buildSyncSnapshot();
|
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
|
const ts = new Date().toISOString()
|
|
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
|
const path = dir.replace(/[\\/]+$/, '') + '\\vault-presync-' + ts + '.json';
|
|
await Bridge.writeFile(path, JSON.stringify(container, null, 2));
|
|
} else {
|
|
toast('Pre-sync backup skipped — set an auto-backup folder first', 'warning');
|
|
}
|
|
} catch (_) { /* best-effort */ }
|
|
}
|
|
|
|
// Apply the remote snapshot (404 → nothing to merge, first sync).
|
|
if (remoteSnap) {
|
|
syncStatus('Merging…');
|
|
try {
|
|
merged = await applyRemoteSnapshot(remoteSnap);
|
|
} catch (e) {
|
|
syncStatus('');
|
|
return toast('Apply failed: ' + (e && e.message || e), 'error');
|
|
}
|
|
}
|
|
|
|
// Guard against data loss: if we failed to import ONE or more remote
|
|
// entries locally (server rejected them, encryption failed, etc.),
|
|
// pushing the current local snapshot would silently overwrite the
|
|
// remote file with a shrunken dataset. Abort the push and surface a
|
|
// clear error so the user can investigate + retry.
|
|
if (merged.failed && merged.failed > 0) {
|
|
syncStatus('');
|
|
return toast('Sync aborted — ' + merged.failed +
|
|
' remote entry(ies) failed to import locally. Push skipped to avoid overwriting remote data.',
|
|
'error');
|
|
}
|
|
|
|
// Push merged state back to the remote.
|
|
let pushedCount = 0;
|
|
try {
|
|
await loadEntries(); // pull latest after applying remote changes
|
|
const snap = await buildSyncSnapshot();
|
|
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0;
|
|
syncStatus('Encrypting…');
|
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
|
const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
|
syncStatus('Pushing…');
|
|
let bodyB64, opts;
|
|
if (syncIsGitea(cfg.url)) {
|
|
// 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
|
|
// (another device pushed). Re-run the whole pull→merge→push so we
|
|
// fold in their changes instead of clobbering them. Bounded to a
|
|
// few tries to avoid a livelock against a device syncing in a hot
|
|
// loop. Gitea says the same thing with 409: the sha we sent is no
|
|
// longer the file's.
|
|
if (r.status === 412 || r.status === 409) {
|
|
syncStatus('');
|
|
if (_attempt < 3) {
|
|
toast('Remote changed — re-syncing…');
|
|
return await runSyncNow(_attempt + 1);
|
|
}
|
|
return toast('Sync gave up after repeated remote changes — try again', 'error');
|
|
}
|
|
if (!(r.status >= 200 && r.status < 300)) {
|
|
syncStatus('');
|
|
return toast('Push failed: HTTP ' + r.status, 'error');
|
|
}
|
|
} catch (e) {
|
|
syncStatus('');
|
|
return toast('Sync push failed: ' + (e && e.message || e), 'error');
|
|
}
|
|
|
|
syncStatus('');
|
|
const now = new Date().toISOString();
|
|
Bridge.setPref(SYNC_PREFS.last, now);
|
|
render();
|
|
// Bidirectional summary: the added/updated/deleted counts are what
|
|
// was pulled FROM the remote into this device; pushedCount is the
|
|
// total entries written back to the remote (so "0·0·0 · pushed 12"
|
|
// makes clear the vault is safely uploaded even when nothing new
|
|
// came down).
|
|
const summary =
|
|
'pulled ' + merged.added + ' new · ' +
|
|
merged.updated + ' updated · ' +
|
|
merged.deleted + ' deleted · ' +
|
|
'pushed ' + pushedCount + ' ' +
|
|
(pushedCount === 1 ? 'entry' : 'entries');
|
|
toast('Sync complete — ' + summary);
|
|
|
|
// Surface additive-restore gaps that used to be swallowed silently
|
|
// (§2.3). These don't risk overwriting the remote — the entry synced,
|
|
// only its attachment/folder didn't come down — so they warn, not abort.
|
|
if (merged.attFailed || merged.folderFailed) {
|
|
const parts = [];
|
|
if (merged.attFailed) parts.push(merged.attFailed + ' attachment(s)');
|
|
if (merged.folderFailed) parts.push(merged.folderFailed + ' folder(s)');
|
|
toast(parts.join(' · ') + ' failed to sync — retry sync', 'warning');
|
|
}
|
|
}
|
|
|