Files
Password-Manager/js/app.sync.js
T
r-zakarya 75a1169dc9 feat(sync): nom par defaut neutre (data.json), et plus de clavier pendant une synchro
Un fichier nomme "vault-sync.json" annonce un coffre a qui parcourt la liste
des depots ; le contenu est chiffre, le nom ne l'etait pas. Les configurations
existantes gardent leur nom - c'est l'URL complete qui est enregistree - et le
QR du poste ne raccourcit plus que le nom par defaut courant, sinon le
telephone rallongerait vers un autre fichier.

Synchroniser coffre ferme ouvre d'abord le coffre : ce chemin depliait le
clavier de recherche au milieu d'une synchro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:24:11 +01:00

911 lines
43 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 = {};
// QR POUR LE TELEPHONE. L'exe le fabrique depuis ses prefs (URL + jeton, jamais
// le mot de passe de synchro) et n'envoie que la matrice : '1' = module noir,
// ligne par ligne. Masque au bout d'une minute - il donne acces au depot.
let syncQrTimer = null;
function syncQrHide() {
const box = document.getElementById('syncQrBox');
if (box) { box.hidden = true; box.innerHTML = ''; }
clearTimeout(syncQrTimer);
}
function syncQrToggle() {
const box = document.getElementById('syncQrBox');
if (box && !box.hidden) return syncQrHide();
window.location.href = 'cmd://sync/qr';
}
Bridge.onSyncQr = function (size, bits) {
const box = document.getElementById('syncQrBox');
if (!box) return;
if (!size) return toast('Set the URL and the token first', 'warning');
let d = '';
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (bits.charCodeAt(r * size + c) === 49) d += 'M' + c + ' ' + r + 'h1v1h-1z';
box.innerHTML =
'<svg viewBox="0 0 ' + size + ' ' + size + '" width="240" height="240" ' +
'shape-rendering="crispEdges" style="width:240px;height:240px;max-width:none;flex:none;' +
'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">' +
'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);
syncQrTimer = setTimeout(syncQrHide, 60000);
};
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.
// 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) {
const u = (url || '').toLowerCase();
return u.includes('/api/v1/repos/') || u.includes('api.github.com/repos/');
}
// ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR (OU "git clone") LA DONNE.
// https://github.com/O/R(.git) -> API de GitHub ; https://hote/O/R(.git) -> API
// de Gitea. Fichier par defaut SYNC_DEFAULT_FILE, ou celui d'un lien .../blob/B/F
// (GitHub) ou .../src/branch/B/F (Gitea). Tout le reste passe tel quel - une
// URL WebDAV, une URL d'API deja complete. GARDE : un dernier segment qui a
// l'air d'un fichier (.json...) n'est pas un depot - sinon
// https://serveur/webdav/vault.json deviendrait une API Gitea imaginaire.
// LE NOM NE DIT PAS CE QUE C'EST. Un fichier appele "vault-sync.json" dans un
// depot annonce un coffre a qui parcourt la liste des depots ; le contenu est
// chiffre, mais le nom, lui, designe la cible. Les anciennes configurations
// gardent leur nom : c'est l'URL complete qui est enregistree, et elle ne
// passe pas par ici.
const SYNC_DEFAULT_FILE = 'data.json';
function syncNormalizeRepoUrl(u) {
u = (u || '').replace(/\s+/g, '');
if (!u || syncIsGitea(u)) return u;
const 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] || SYNC_DEFAULT_FILE);
const m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
if (!m) return u;
if (!m[4] && !m[5] && /\.[a-z0-9]{2,5}$/i.test(m[3])) return u; // un fichier, pas un depot
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || SYNC_DEFAULT_FILE);
}
// 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];
// SANS JETON, expres : "public" veut dire "visible par n'importe qui", et
// c'est exactement la question posee. Avec un jeton faux, GitHub repondait
// 401 avant de dire quoi que ce soit du depot ; un depot prive repond 404
// sans jeton, ce qui veut dire "pas public".
const r = await _webdavCall('get', repoUrl, '', '');
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
// 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; }
}
// Do these two snapshots carry the same vault? Compared as plaintext, because
// the ciphertext never repeats — a fresh salt and IV per encryption make two
// containers of an identical vault differ completely.
//
// `snapshot_at` is stripped: it is the time of the build, not part of what the
// vault contains, and keeping it would make every comparison fail. Everything
// else is compared verbatim, which is deliberately strict — a difference in key
// order or in a field we have not thought about falls on the side of pushing,
// which costs a commit rather than losing a change.
function syncSnapshotsEqual(a, b) {
if (!a || !b) return false;
const strip = s => {
const c = Object.assign({}, s);
delete c.snapshot_at;
delete c.exported_at;
return JSON.stringify(c);
};
try { return strip(a) === strip(b); } catch (e) { return false; }
}
// 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',
// Convertie aussi a la LECTURE : une adresse de depot enregistree par un
// exe anterieur (ou tapee avant la conversion) ne l'avait jamais ete.
url: syncNormalizeRepoUrl(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…');
// LE DEPOT PUBLIC D'ABORD : il est refuse quel que soit le jeton.
const pubFirst = await syncRepoPublicWarning(cfg);
if (pubFirst) return toast(pubFirst, 'error');
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 (_) {}
}
// TROIS ANCIENNES VERSIONS, PAS VINGT.
//
// La table en garde vingt, et c'est bien sur le poste : elles y restent
// chiffrees colonne par colonne et ne bougent jamais. Dans l'instantane
// tout voyage EN CLAIR a l'interieur du conteneur, et redescend dans le
// cache d'un telephone que seule la cle de l'appareil scelle - pas le
// mot de passe maitre. Un ancien mot de passe reste sensible : il est
// souvent encore valide ailleurs.
//
// L'usage reel de cette liste est court - "je l'ai change recemment,
// quel etait l'ancien ?" - et trois le couvrent. Les dix-sept autres
// seraient des copies supplementaires sans besoin en face.
let history = [];
try {
const hist = await api('/entries/' + e.id + '/history',
{ headers: authHeaders() });
for (const h of (hist || []).slice(0, 3)) {
const old = await decryptPwd(h.encrypted_password, h.iv);
if (old === '[ERROR]') continue;
history.push({ password: old, changed_at: h.changed_at });
}
} 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,
// Lecture seule pour les autres appareils : la table du poste est
// la source, et applyRemoteSnapshot ignore ce champ. Il survit aux
// ecritures du telephone parce que celui-ci modifie le JSON recu
// sans jamais le reserialiser.
password_history: history,
});
}
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 {
// Le chiffré d'origine si le mot de passe n'a pas bougé :
// sans cela le serveur archive une version d'historique à
// chaque synchronisation. Voir keepCiphertextIfSamePassword.
const enc = await keepCiphertextIfSamePassword(
await encryptImportEntry(r), local, r.password);
// 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…');
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);
} 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;
// RIEN A DIRE, RIEN A ECRIRE. Un push a lieu meme quand la fusion n'a
// rien change, et il ne passe pas inapercu : le conteneur est rechiffre
// avec un sel et un IV neufs, donc son contenu differe a chaque fois
// meme a coffre identique. Sur Gitea cela vaut un commit de plus et un
// nouveau sha ; les autres appareils voient alors un instantane
// "nouveau" et paient une derivation de cle complete - mesuree a 5,7 s
// sur le telephone - pour retrouver exactement ce qu'ils avaient deja.
//
// La comparaison porte sur le CLAIR, seul endroit ou l'egalite est
// observable, et ignore `snapshot_at`, qui change par construction.
if (remoteSnap && syncSnapshotsEqual(remoteSnap, snap)) {
syncStatus('');
// La synchronisation a bien eu lieu, et l'horodatage le dit : ne
// rien avoir a ecrire est une reussite, pas une absence.
Bridge.setPref(SYNC_PREFS.last, new Date().toISOString());
render();
return toast('Already up to date · ' + pushedCount + ' entries');
}
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');
}
}