Files
Password-Manager/js/app.sync.js
T
r-zakarya 074e6f92e7 feat(sync): messages de commit neutres des deux cotes
L'historique du depot affichait "sync from <utilisateur du coffre>" a chaque
envoi du poste, et le telephone y ecrivait ce qu'il venait de faire ("new entry
from phone", "icon fetched from phone", "entries deleted from phone"). Le
contenu est chiffre, ces lignes ne l'etaient pas. Il ne reste que l'appareil
d'origine - le detail vit dans le journal local, ou il sert au diagnostic sans
quitter l'appareil.

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

915 lines
44 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 = {
// NI LE NOM DU COMPTE NI CE QU'IL GARDE. L'historique du
// depot affichait "sync from <utilisateur du coffre>" a chaque
// envoi : le contenu est chiffre, cette ligne ne l'etait pas.
// L'appareil d'origine suffit a savoir qui a publie.
message: 'update from 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');
}
}