refactor(js): extract sync module from app.js monofile (§3.1)
Second slice of the app.js split (after crypto). Moves the WebDAV sync section to js/app.sync.js: transport (_webdavCall), buildSyncSnapshot, applyRemoteSnapshot (merge + tombstone arbitration), runSyncNow, and the sync settings UI. - Byte-for-byte identical to the extracted block (verified before removal); no duplicate const; no top-level sync reference left in app.js. - Load order: AFTER app.js (unlike crypto, which loads before) because this module has a top-level side effect — `Bridge.onWebdavResult = …` — that needs Bridge/state/api already declared. Rule documented in CLAUDE.md. - index.html + BuildAssets whitelist + harness APP_PARTS updated; assets rebuilt to embed the new file. - Safety net: the existing merge tests exercise applyRemoteSnapshot / buildSyncSnapshot from the extracted file and stay green (42/42). app.js: 11936 → 11256 lines (crypto + sync now separate). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,11 +31,18 @@ global** dans le navigateur, donc les `const`/fonctions d'un fichier sont
|
|||||||
visibles des suivants exactement comme dans le monofichier. Ordre actuel :
|
visibles des suivants exactement comme dans le monofichier. Ordre actuel :
|
||||||
|
|
||||||
```
|
```
|
||||||
js/argon2.js (IIFE, globalThis.NobleArgon2)
|
js/argon2.js (IIFE, globalThis.NobleArgon2)
|
||||||
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
|
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
|
||||||
js/app.js (le reste)
|
js/app.js (le reste : state, Bridge, api, UI…)
|
||||||
|
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
||||||
|
effet de bord top-level `Bridge.onWebdavResult = …`)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note ordre : un module **sans** exécution top-level (que des déclarations,
|
||||||
|
comme `app.crypto.js`) peut se charger AVANT `app.js`. Un module **avec** un
|
||||||
|
effet de bord top-level qui touche un global d'`app.js` (`Bridge`, `state`…)
|
||||||
|
doit se charger APRÈS (`app.sync.js`).
|
||||||
|
|
||||||
Règles pour extraire un nouveau module :
|
Règles pour extraire un nouveau module :
|
||||||
- Il doit se charger **avant** ses consommateurs et **ne jamais redéclarer**
|
- Il doit se charger **avant** ses consommateurs et **ne jamais redéclarer**
|
||||||
un `const` d'un autre fichier (un `const` dupliqué entre deux classic
|
un `const` d'un autre fichier (un `const` dupliqué entre deux classic
|
||||||
@@ -87,6 +94,7 @@ parsing CSV d'import, et l'arbitrage merge/tombstone de sync
|
|||||||
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
|
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
|
||||||
| Frontend principal (en cours de découpage §3.1) | `js/app.js` |
|
| Frontend principal (en cours de découpage §3.1) | `js/app.js` |
|
||||||
| Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` |
|
| Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` |
|
||||||
|
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
|
||||||
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
||||||
| HTML racine | `index.html` |
|
| HTML racine | `index.html` |
|
||||||
| Styles | `css/style.css` |
|
| Styles | `css/style.css` |
|
||||||
|
|||||||
+8
-5
@@ -224,11 +224,14 @@ seulement au runtime).
|
|||||||
chargés dans l'ordre via `<script>` séparés — **pas de bundler ES/esbuild**
|
chargés dans l'ordre via `<script>` séparés — **pas de bundler ES/esbuild**
|
||||||
(les classic scripts partagent l'environnement lexical global, donc zéro
|
(les classic scripts partagent l'environnement lexical global, donc zéro
|
||||||
réécriture des call-sites, risque quasi nul vs conversion en modules ES).
|
réécriture des call-sites, risque quasi nul vs conversion en modules ES).
|
||||||
- ✅ `js/app.crypto.js` extrait (KDF, verifier, AES-GCM) — vérifié
|
- ✅ `js/app.crypto.js` extrait (KDF, verifier, AES-GCM) — byte-for-byte
|
||||||
byte-for-byte identique à l'original, 42 tests verts, pas de `const`
|
identique, 42 tests verts, pas de `const` dupliqué. Chargé AVANT app.js.
|
||||||
dupliqué. Pattern + règles documentés dans CLAUDE.md « Découpage frontend ».
|
- ✅ `js/app.sync.js` extrait (WebDAV, snapshot, merge `applyRemoteSnapshot`)
|
||||||
- Reste à extraire (grosses sections cohésives) : sync, slideover,
|
— byte-for-byte identique, couvert par les tests merge, chargé APRÈS app.js
|
||||||
settings, quicksearch, autofill…
|
(effet de bord top-level `Bridge.onWebdavResult`). Pattern + règles d'ordre
|
||||||
|
documentés dans CLAUDE.md « Découpage frontend ».
|
||||||
|
- Reste à extraire (grosses sections cohésives) : slideover, settings,
|
||||||
|
quicksearch, autofill, auto-backup…
|
||||||
- ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
|
- ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
|
||||||
|
|
||||||
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
|
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ $patterns = @(
|
|||||||
'js\argon2.js',
|
'js\argon2.js',
|
||||||
'js\app.crypto.js',
|
'js\app.crypto.js',
|
||||||
'js\app.js',
|
'js\app.js',
|
||||||
|
'js\app.sync.js',
|
||||||
'css\style.css'
|
'css\style.css'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Auto-generated by BuildAssets.ps1 - do not edit by hand.
|
// Auto-generated by BuildAssets.ps1 - do not edit by hand.
|
||||||
const
|
const
|
||||||
EMBEDDED_ASSET_COUNT = 5;
|
EMBEDDED_ASSET_COUNT = 6;
|
||||||
EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (
|
EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (
|
||||||
(UrlPath: '/index.html'; ResName: 'INDEX_HTML'),
|
(UrlPath: '/index.html'; ResName: 'INDEX_HTML'),
|
||||||
(UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'),
|
(UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'),
|
||||||
(UrlPath: '/js/app.crypto.js'; ResName: 'JS_APP_CRYPTO_JS'),
|
(UrlPath: '/js/app.crypto.js'; ResName: 'JS_APP_CRYPTO_JS'),
|
||||||
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
|
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
|
||||||
|
(UrlPath: '/js/app.sync.js'; ResName: 'JS_APP_SYNC_JS'),
|
||||||
(UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS')
|
(UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ INDEX_HTML RCDATA "Z:\\password-manager\\index.html"
|
|||||||
JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js"
|
JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js"
|
||||||
JS_APP_CRYPTO_JS RCDATA "Z:\\password-manager\\js\\app.crypto.js"
|
JS_APP_CRYPTO_JS RCDATA "Z:\\password-manager\\js\\app.crypto.js"
|
||||||
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
|
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
|
||||||
|
JS_APP_SYNC_JS RCDATA "Z:\\password-manager\\js\\app.sync.js"
|
||||||
CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css"
|
CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css"
|
||||||
|
|||||||
Binary file not shown.
@@ -1195,5 +1195,6 @@
|
|||||||
<script src="js/argon2.js"></script>
|
<script src="js/argon2.js"></script>
|
||||||
<script src="js/app.crypto.js"></script>
|
<script src="js/app.crypto.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
<script src="js/app.sync.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9768,598 +9768,10 @@ async function autoPurgeTrashIfNeeded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SYNC (WebDAV, auto-merge with timestamps)
|
// SYNC (WebDAV) — extracted to js/app.sync.js (§3.1), loaded as a
|
||||||
|
// separate <script> AFTER this file (it needs Bridge/state/api at
|
||||||
|
// load time for its Bridge.onWebdavResult handler).
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 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 || '' });
|
|
||||||
};
|
|
||||||
// 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.
|
|
||||||
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) pwd.value = cfg.pwd || '';
|
|
||||||
if (pre) pre.checked = !!cfg.preBackup;
|
|
||||||
const pwdStatus = document.getElementById('syncPwdStatus');
|
|
||||||
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? '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: 'At least 8 characters',
|
|
||||||
password: true,
|
|
||||||
okText: 'Save',
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
if (!v) return;
|
|
||||||
if (v.length >= 8) {
|
|
||||||
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 = 'Use at least 8 characters (attempt ' + n + ' / 5).';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSyncConfig() {
|
|
||||||
if (!Bridge.active) return null;
|
|
||||||
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([
|
|
||||||
Bridge.getPref(SYNC_PREFS.enabled),
|
|
||||||
Bridge.getPref(SYNC_PREFS.url),
|
|
||||||
Bridge.getPref(SYNC_PREFS.user),
|
|
||||||
Bridge.getPref(SYNC_PREFS.pwd),
|
|
||||||
Bridge.getPref(SYNC_PREFS.encPwd),
|
|
||||||
Bridge.getPref(SYNC_PREFS.preBackup),
|
|
||||||
Bridge.getPref(SYNC_PREFS.last),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
enabled: enabled === '1',
|
|
||||||
url: url || '',
|
|
||||||
user: user || '',
|
|
||||||
pwd: pwd || '',
|
|
||||||
encPwd: encPwd || '',
|
|
||||||
preBackup: pre === '1',
|
|
||||||
last: last || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncTestConnection() {
|
|
||||||
const cfg = await loadSyncConfig();
|
|
||||||
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);
|
|
||||||
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 || '' })),
|
|
||||||
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: !!e.favorite,
|
|
||||||
totp_secret: plainTotp,
|
|
||||||
kind: e.kind || 'login',
|
|
||||||
template: e.template || '',
|
|
||||||
custom_fields: plainCustom,
|
|
||||||
attachments,
|
|
||||||
icon_b64: e.icon_b64 || '',
|
|
||||||
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 };
|
|
||||||
let added = 0, updated = 0, deleted = 0, failed = 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);
|
|
||||||
|
|
||||||
// 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 (_) {}
|
|
||||||
}
|
|
||||||
await loadFolders();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
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 (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
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).
|
|
||||||
const 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) {
|
|
||||||
try {
|
|
||||||
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
} 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 bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
|
||||||
syncStatus('Pushing…');
|
|
||||||
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
|
||||||
bytesToBase64(bodyBytes),
|
|
||||||
{ ifMatch: remoteEtag });
|
|
||||||
// 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.
|
|
||||||
if (r.status === 412) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AUTO-BACKUP (silent encrypted exports on a schedule)
|
// AUTO-BACKUP (silent encrypted exports on a schedule)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+604
@@ -0,0 +1,604 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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 || '' });
|
||||||
|
};
|
||||||
|
// 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.
|
||||||
|
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) pwd.value = cfg.pwd || '';
|
||||||
|
if (pre) pre.checked = !!cfg.preBackup;
|
||||||
|
const pwdStatus = document.getElementById('syncPwdStatus');
|
||||||
|
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? '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: 'At least 8 characters',
|
||||||
|
password: true,
|
||||||
|
okText: 'Save',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
if (!v) return;
|
||||||
|
if (v.length >= 8) {
|
||||||
|
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 = 'Use at least 8 characters (attempt ' + n + ' / 5).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSyncConfig() {
|
||||||
|
if (!Bridge.active) return null;
|
||||||
|
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([
|
||||||
|
Bridge.getPref(SYNC_PREFS.enabled),
|
||||||
|
Bridge.getPref(SYNC_PREFS.url),
|
||||||
|
Bridge.getPref(SYNC_PREFS.user),
|
||||||
|
Bridge.getPref(SYNC_PREFS.pwd),
|
||||||
|
Bridge.getPref(SYNC_PREFS.encPwd),
|
||||||
|
Bridge.getPref(SYNC_PREFS.preBackup),
|
||||||
|
Bridge.getPref(SYNC_PREFS.last),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
enabled: enabled === '1',
|
||||||
|
url: url || '',
|
||||||
|
user: user || '',
|
||||||
|
pwd: pwd || '',
|
||||||
|
encPwd: encPwd || '',
|
||||||
|
preBackup: pre === '1',
|
||||||
|
last: last || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTestConnection() {
|
||||||
|
const cfg = await loadSyncConfig();
|
||||||
|
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);
|
||||||
|
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 || '' })),
|
||||||
|
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: !!e.favorite,
|
||||||
|
totp_secret: plainTotp,
|
||||||
|
kind: e.kind || 'login',
|
||||||
|
template: e.template || '',
|
||||||
|
custom_fields: plainCustom,
|
||||||
|
attachments,
|
||||||
|
icon_b64: e.icon_b64 || '',
|
||||||
|
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 };
|
||||||
|
let added = 0, updated = 0, deleted = 0, failed = 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);
|
||||||
|
|
||||||
|
// 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 (_) {}
|
||||||
|
}
|
||||||
|
await loadFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
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).
|
||||||
|
const 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) {
|
||||||
|
try {
|
||||||
|
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
} 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 bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||||
|
syncStatus('Pushing…');
|
||||||
|
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
||||||
|
bytesToBase64(bodyBytes),
|
||||||
|
{ ifMatch: remoteEtag });
|
||||||
|
// 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.
|
||||||
|
if (r.status === 412) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
+1
-1
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
|
|||||||
// top-level const/let across separate runInContext calls, so we CONCATENATE
|
// top-level const/let across separate runInContext calls, so we CONCATENATE
|
||||||
// the app.* parts (in <script> load order) into one script. argon2.js is a
|
// the app.* parts (in <script> load order) into one script. argon2.js is a
|
||||||
// self-contained IIFE and loads separately (see below).
|
// self-contained IIFE and loads separately (see below).
|
||||||
const APP_PARTS = ['app.crypto.js', 'app.js'].map(f => path.join(__dirname, '..', f));
|
const APP_PARTS = ['app.crypto.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
|
||||||
|
|
||||||
// In-memory Storage stub (Web Storage API surface used by app.js).
|
// In-memory Storage stub (Web Storage API surface used by app.js).
|
||||||
function makeStorage() {
|
function makeStorage() {
|
||||||
|
|||||||
Reference in New Issue
Block a user