feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle
- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server + JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with pull/merge/push flow, Settings UI, pre-sync backup option. Test connection now treats 404 as OK (snapshot not yet created) and 401/ 403 as auth failure with dedicated toast. - Batch drag-drop: cards + table rows carry checked-set ids (CSV) when dragged from an active selection; folder + trash drop handlers parse and apply in batch via new moveEntriesToFolder helper that preserves TOTP / custom_fields / kind in the full PUT payload. - Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the bridge message-only window; FormCloseQuery bypasses the tray-minimize intercept on system shutdown / restart / logoff so FireDAC closes the SQLite WAL cleanly instead of leaving -shm / -wal residue after a force-kill. - Center-mode modal: blur+dim backdrop via body::before pseudo-element in editor-position=center, swallows clicks below the panel so the existing outside-click handlers reliably dismiss the slideover / settings panel. - Batch bar state fixes: state.checked cleared before render in moveEntriesToFolder, emptyTrash, and per-card restoreEntry / permanentDelete / deleteEntry so the action bar disappears once the selection is fully processed. - Save-then-discard duplicate fix: soState reset to null before openSlideOver re-opens the freshly saved entry, otherwise the dirty check fired on the soState.id=null → newId switch and a Cancel left the form in new-entry mode (second Save → POST duplicate). - TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync with 2 real instances. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2451,8 +2451,12 @@ function renderSidebar() {
|
||||
await reorderFolderTo(draggedFolder, name, before);
|
||||
return;
|
||||
}
|
||||
const id = e.dataTransfer.getData('text/plain');
|
||||
if (id) await moveEntryToFolder(parseInt(id), name);
|
||||
const raw = e.dataTransfer.getData('text/plain') || '';
|
||||
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||||
if (ids.length) {
|
||||
await moveEntriesToFolder(ids, name);
|
||||
state.checked.clear();
|
||||
}
|
||||
});
|
||||
|
||||
const delBtn = el('button', {
|
||||
@@ -2496,8 +2500,12 @@ function renderSidebar() {
|
||||
item.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
item.classList.remove('drag-over');
|
||||
const id = e.dataTransfer.getData('text/plain');
|
||||
if (id) await moveEntryToFolder(parseInt(id), 'All');
|
||||
const raw = e.dataTransfer.getData('text/plain') || '';
|
||||
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||||
if (ids.length) {
|
||||
await moveEntriesToFolder(ids, 'All');
|
||||
state.checked.clear();
|
||||
}
|
||||
});
|
||||
|
||||
fList.appendChild(item);
|
||||
@@ -3385,7 +3393,16 @@ function renderCard(e) {
|
||||
|
||||
if (!inTrash) {
|
||||
card.addEventListener('dragstart', ev => {
|
||||
ev.dataTransfer.setData('text/plain', String(e.id));
|
||||
// If the dragged card is part of an active selection, carry
|
||||
// ALL checked ids so a drop on a folder / trash moves the
|
||||
// whole batch in one gesture. Otherwise carry just this one.
|
||||
let ids;
|
||||
if (state.checked.size > 1 && state.checked.has(e.id)) {
|
||||
ids = Array.from(state.checked).join(',');
|
||||
} else {
|
||||
ids = String(e.id);
|
||||
}
|
||||
ev.dataTransfer.setData('text/plain', ids);
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
}
|
||||
@@ -3849,8 +3866,21 @@ function renderTableRow(e) {
|
||||
+ (state.selectedId === e.id ? ' is-selected' : '')
|
||||
+ (checked ? ' is-checked' : ''),
|
||||
'data-id': String(e.id),
|
||||
draggable: !inTrash ? 'true' : 'false',
|
||||
on: { click: ev => handleCardClick(ev, e, inTrash) },
|
||||
});
|
||||
if (!inTrash) {
|
||||
tr.addEventListener('dragstart', ev => {
|
||||
let ids;
|
||||
if (state.checked.size > 1 && state.checked.has(e.id)) {
|
||||
ids = Array.from(state.checked).join(',');
|
||||
} else {
|
||||
ids = String(e.id);
|
||||
}
|
||||
ev.dataTransfer.setData('text/plain', ids);
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
}
|
||||
|
||||
// Cells are emitted in EXACTLY the same order as getTableColumns()
|
||||
// returns headers, otherwise THs and TDs drift apart and clicks land
|
||||
@@ -5293,6 +5323,13 @@ async function soSave() {
|
||||
if (typeof healthCache !== 'undefined') healthCache = null;
|
||||
|
||||
const updated = state.entries.find(x => x.id === targetId);
|
||||
// Clear soState BEFORE re-opening so openSlideOver doesn't mistake
|
||||
// the re-open for a "switch entry while dirty" — the form still
|
||||
// holds the just-saved values but soState.original is stale, which
|
||||
// would falsely trigger the discard-confirm modal and (on Cancel)
|
||||
// leave soState at id=null, causing a second Save to POST again
|
||||
// and create a duplicate.
|
||||
soState = null;
|
||||
if (updated) openSlideOver(updated.id);
|
||||
else closeSlideOver();
|
||||
render();
|
||||
@@ -5586,6 +5623,7 @@ async function restoreEntry(id) {
|
||||
try {
|
||||
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
|
||||
toast('Restored');
|
||||
state.checked.delete(id);
|
||||
await loadEntries();
|
||||
await loadTrash();
|
||||
render();
|
||||
@@ -5603,6 +5641,7 @@ async function permanentDelete(id) {
|
||||
try {
|
||||
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
|
||||
toast('Deleted permanently');
|
||||
state.checked.delete(id);
|
||||
await loadTrash();
|
||||
render();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
@@ -5620,6 +5659,7 @@ async function emptyTrash() {
|
||||
try {
|
||||
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
|
||||
toast('Trash emptied');
|
||||
state.checked.clear();
|
||||
await loadTrash();
|
||||
render();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
@@ -5727,6 +5767,7 @@ async function deleteEntry(id) {
|
||||
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
||||
toast('Moved to trash');
|
||||
closeSlideOver();
|
||||
state.checked.delete(id);
|
||||
await loadEntries();
|
||||
state.trashedCount = (state.trashedCount || 0) + 1;
|
||||
render();
|
||||
@@ -5752,6 +5793,42 @@ async function togglePin(id) {
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function moveEntriesToFolder(ids, folder) {
|
||||
let ok = 0, skipped = 0;
|
||||
for (const id of ids) {
|
||||
const e = state.entries.find(x => x.id === id);
|
||||
if (!e) continue;
|
||||
if (e.folder === folder) { skipped++; continue; }
|
||||
try {
|
||||
await api('/entries/' + id, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
site: e.site,
|
||||
title: e.title || '',
|
||||
username: e.username,
|
||||
encrypted_password: e.encrypted_password,
|
||||
iv: e.iv,
|
||||
folder,
|
||||
tags: e.tags || '',
|
||||
kind: e.kind || 'login',
|
||||
totp_secret: e.totp_secret || '',
|
||||
totp_iv: e.totp_iv || '',
|
||||
custom_fields: e.custom_fields || '',
|
||||
custom_fields_iv: e.custom_fields_iv || '',
|
||||
}),
|
||||
});
|
||||
e.folder = folder;
|
||||
ok++;
|
||||
} catch (err) { /* try the rest */ }
|
||||
}
|
||||
state.checked.clear();
|
||||
render();
|
||||
if (ok === 0 && skipped > 0) return;
|
||||
if (ok === 1) toast('Moved to ' + folder);
|
||||
else if (ok > 1) toast('Moved ' + ok + ' entries to ' + folder);
|
||||
}
|
||||
|
||||
async function moveEntryToFolder(id, folder) {
|
||||
const e = state.entries.find(x => x.id === id);
|
||||
if (!e || e.folder === folder) return;
|
||||
@@ -7998,6 +8075,7 @@ function parseEntriesFromJSON(text) {
|
||||
a && typeof a === 'object' && a.filename && a.content_b64);
|
||||
}
|
||||
entries.push({
|
||||
uuid: String(e.uuid || '').trim(),
|
||||
site: site,
|
||||
title: String(e.title || '').trim(),
|
||||
username: String(e.username || e.user || e.login || '').trim(),
|
||||
@@ -8043,6 +8121,7 @@ async function encryptImportEntry(plain) {
|
||||
} catch (e) { /* drop silently */ }
|
||||
}
|
||||
return {
|
||||
uuid: plain.uuid || '',
|
||||
site: plain.site,
|
||||
title: plain.title || '',
|
||||
username: plain.username || '',
|
||||
@@ -8366,6 +8445,7 @@ async function doExport() {
|
||||
} catch (_) { /* partial export beats a failed one */ }
|
||||
|
||||
payload.entries.push({
|
||||
uuid: e.uuid || '',
|
||||
site: e.site,
|
||||
title: e.title || '',
|
||||
username: e.username,
|
||||
@@ -8845,6 +8925,12 @@ function openSettings() {
|
||||
} else {
|
||||
refreshAutoBackupUI(null);
|
||||
}
|
||||
// Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs).
|
||||
const syncField = document.getElementById('syncField');
|
||||
if (syncField) {
|
||||
syncField.style.display = Bridge.active ? '' : 'none';
|
||||
if (Bridge.active) loadSyncConfig().then(refreshSyncUI);
|
||||
}
|
||||
|
||||
$('#settingsPanel').classList.add('is-open');
|
||||
}
|
||||
@@ -9027,6 +9113,399 @@ async function autoPurgeTrashIfNeeded() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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) {
|
||||
const r = _webdavResolvers[reqId];
|
||||
if (!r) return;
|
||||
delete _webdavResolvers[reqId];
|
||||
r({ status: status | 0, payload: payload || '' });
|
||||
};
|
||||
function _webdavCall(method, url, user, pwd, dataB64) {
|
||||
return new Promise(resolve => {
|
||||
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||
_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);
|
||||
window.location.href = q;
|
||||
setTimeout(() => {
|
||||
if (_webdavResolvers[reqId]) {
|
||||
delete _webdavResolvers[reqId];
|
||||
resolve({ status: 0, payload: 'timeout' });
|
||||
}
|
||||
}, 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: [],
|
||||
};
|
||||
for (const e of state.entries) {
|
||||
if (!e.uuid) continue; // legacy row that missed the backfill — skip
|
||||
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 };
|
||||
let added = 0, updated = 0, deleted = 0;
|
||||
|
||||
// Push remote tombstones first — server will hard-delete any matching
|
||||
// local entries AND remember them so they don't reappear from a future
|
||||
// local push.
|
||||
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
||||
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
|
||||
if (uuids.length > 0) {
|
||||
try {
|
||||
await api('/entries/tombstones', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ uuids }),
|
||||
});
|
||||
deleted = uuids.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);
|
||||
|
||||
// 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;
|
||||
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 (_) {}
|
||||
} 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 (_) {}
|
||||
}
|
||||
}
|
||||
return { added, updated, deleted };
|
||||
}
|
||||
|
||||
async function runSyncNow() {
|
||||
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');
|
||||
|
||||
// Pre-sync backup — best effort, doesn't block sync on failure.
|
||||
if (cfg.preBackup) {
|
||||
try {
|
||||
const ab = await loadAutoBackupConfig();
|
||||
const dir = (ab && ab.dir) ? ab.dir : null;
|
||||
if (dir) {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
toast('Syncing…');
|
||||
let merged = { added: 0, updated: 0, deleted: 0 };
|
||||
let pulled = false;
|
||||
try {
|
||||
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
|
||||
if (r.status >= 200 && r.status < 300 && r.payload) {
|
||||
try {
|
||||
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
|
||||
const container = JSON.parse(jsonText);
|
||||
const snap = await decryptExportContainer(container, cfg.encPwd);
|
||||
merged = await applyRemoteSnapshot(snap);
|
||||
pulled = true;
|
||||
} catch (e) {
|
||||
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
||||
}
|
||||
} else if (r.status === 404) {
|
||||
// First sync — no remote yet, we'll just upload our state.
|
||||
pulled = true;
|
||||
} else if (r.status === 0) {
|
||||
return toast('Network error: ' + (r.payload || 'unreachable'), 'error');
|
||||
} else {
|
||||
return toast('Pull failed: HTTP ' + r.status, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
|
||||
}
|
||||
|
||||
// Push merged state back to the remote.
|
||||
try {
|
||||
await loadEntries(); // pull latest after applying remote changes
|
||||
const snap = await buildSyncSnapshot();
|
||||
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
||||
bytesToBase64(bodyBytes));
|
||||
if (!(r.status >= 200 && r.status < 300)) {
|
||||
return toast('Push failed: HTTP ' + r.status, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
return toast('Sync push failed: ' + (e && e.message || e), 'error');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
Bridge.setPref(SYNC_PREFS.last, now);
|
||||
render();
|
||||
const summary =
|
||||
merged.added + ' added · ' +
|
||||
merged.updated + ' updated · ' +
|
||||
merged.deleted + ' deleted';
|
||||
toast('Sync complete — ' + summary);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AUTO-BACKUP (silent encrypted exports on a schedule)
|
||||
// ============================================================
|
||||
@@ -9194,6 +9673,7 @@ async function runAutoBackupNow() {
|
||||
}
|
||||
} catch (_) {}
|
||||
payload.entries.push({
|
||||
uuid: e.uuid || '',
|
||||
site: e.site, title: e.title || '', username: e.username,
|
||||
password: plain, folder: e.folder, tags: parseTags(e.tags),
|
||||
favorite: !!e.favorite, totp_secret: plainTotp,
|
||||
@@ -9620,15 +10100,21 @@ async function init() {
|
||||
trashItem.addEventListener('drop', async ev => {
|
||||
ev.preventDefault();
|
||||
trashItem.classList.remove('drag-over');
|
||||
const id = parseInt(ev.dataTransfer.getData('text/plain'));
|
||||
if (!id) return;
|
||||
try {
|
||||
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
||||
toast('Moved to trash');
|
||||
await loadEntries();
|
||||
await loadTrash();
|
||||
render();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
const raw = ev.dataTransfer.getData('text/plain') || '';
|
||||
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||||
if (!ids.length) return;
|
||||
let ok = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
||||
ok++;
|
||||
} catch (err) { /* keep going for the rest */ }
|
||||
}
|
||||
toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash');
|
||||
state.checked.clear();
|
||||
await loadEntries();
|
||||
await loadTrash();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10002,6 +10488,34 @@ async function init() {
|
||||
saveServerSettings();
|
||||
toast('Unlock method updated');
|
||||
});
|
||||
// Sync (WebDAV) listeners
|
||||
const syncCb = document.getElementById('settingSyncEnabled');
|
||||
const syncCfgBox = document.getElementById('syncConfig');
|
||||
const syncUrlEl = document.getElementById('syncUrl');
|
||||
const syncUserEl = document.getElementById('syncUser');
|
||||
const syncPwdEl = document.getElementById('syncPwd');
|
||||
const syncPreEl = document.getElementById('syncPreBackup');
|
||||
const syncSetPwd = document.getElementById('syncSetPwdBtn');
|
||||
const syncTest = document.getElementById('syncTestBtn');
|
||||
const syncNow = document.getElementById('syncNowBtn');
|
||||
if (syncCb) syncCb.addEventListener('change', e => {
|
||||
const on = !!e.target.checked;
|
||||
Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : '');
|
||||
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
|
||||
toast(on ? 'Sync enabled' : 'Sync disabled');
|
||||
});
|
||||
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
|
||||
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
|
||||
if (syncUserEl) syncUserEl.addEventListener('change', e =>
|
||||
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
|
||||
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
|
||||
Bridge.setPref(SYNC_PREFS.pwd, e.target.value));
|
||||
if (syncPreEl) syncPreEl.addEventListener('change', e =>
|
||||
Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : ''));
|
||||
if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow);
|
||||
if (syncTest) syncTest.addEventListener('click', syncTestConnection);
|
||||
if (syncNow) syncNow.addEventListener('click', runSyncNow);
|
||||
|
||||
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
|
||||
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
|
||||
$('#settingAutoBackupInterval').addEventListener('change', e => {
|
||||
|
||||
Reference in New Issue
Block a user