45ba47f772
Auto-backup no longer wipes the stored password when disabled, so re-enabling reuses it silently. A dedicated "Set/Change backup password" button (mirrors sync) owns the password, with a warning status when unset. Corrected the stale hint that claimed the backup pwd was derived from the master password. Added icons to each settings tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
// ============================================================
|
|
// app.backup.js — AUTO-BACKUP module (extracted from app.js, §3.1)
|
|
// ============================================================
|
|
//
|
|
// Silent scheduled encrypted exports (config in DPAPI prefs, files via the
|
|
// folder/file bridge cmds). Pure declarations + two consts, no top-level
|
|
// side effects → loads BEFORE app.js. Uses encryptExportPayload (app.import.js),
|
|
// Bridge, api, state — all resolved via shared global scope at call time.
|
|
//
|
|
// ============================================================
|
|
// AUTO-BACKUP (silent encrypted exports on a schedule)
|
|
// ============================================================
|
|
// All config is device-local (folder paths and passwords don't sync
|
|
// meaningfully across machines) and persisted via Delphi DPAPI prefs
|
|
// so it survives the port-rotation localStorage wipe.
|
|
|
|
const ABK = {
|
|
enabled: 'autoBackupEnabled', // '1' | ''
|
|
dir: 'autoBackupDir', // absolute Windows path
|
|
interval: 'autoBackupInterval', // days, int as string
|
|
keep: 'autoBackupKeep', // count, int as string
|
|
last: 'autoBackupLast', // ISO timestamp of last successful run
|
|
pwd: 'autoBackupPwd', // user-chosen pwd, used silently
|
|
};
|
|
const AUTO_BACKUP_PREFIX = 'vault-autobackup-';
|
|
|
|
async function loadAutoBackupConfig() {
|
|
if (!Bridge.active) return null;
|
|
const [enabled, dir, interval, keep, last, pwd] = await Promise.all([
|
|
Bridge.getPref(ABK.enabled),
|
|
Bridge.getPref(ABK.dir),
|
|
Bridge.getPref(ABK.interval),
|
|
Bridge.getPref(ABK.keep),
|
|
Bridge.getPref(ABK.last),
|
|
Bridge.getPref(ABK.pwd),
|
|
]);
|
|
return {
|
|
enabled: enabled === '1',
|
|
dir: dir || '',
|
|
interval: Math.max(1, parseInt(interval, 10) || 7),
|
|
keep: Math.max(1, parseInt(keep, 10) || 10),
|
|
last: last || '',
|
|
hasPwd: !!pwd,
|
|
pwd,
|
|
};
|
|
}
|
|
|
|
function refreshAutoBackupUI(cfg) {
|
|
if (!cfg) {
|
|
$('#autoBackupField').style.display = 'none';
|
|
return;
|
|
}
|
|
$('#autoBackupField').style.display = '';
|
|
$('#settingAutoBackupEnabled').checked = cfg.enabled;
|
|
$('#autoBackupConfig').style.display = cfg.enabled ? '' : 'none';
|
|
$('#autoBackupDir').textContent = cfg.dir || '(not set)';
|
|
$('#settingAutoBackupInterval').value = cfg.interval;
|
|
$('#settingAutoBackupKeep').value = cfg.keep;
|
|
$('#autoBackupLast').textContent = cfg.last
|
|
? 'Last run: ' + cfg.last.replace('T', ' ').slice(0, 16)
|
|
: 'Never run yet';
|
|
$('#autoBackupSetPwdBtn').textContent = cfg.hasPwd
|
|
? 'Change backup password' : 'Set backup password';
|
|
const pwdStatus = $('#autoBackupPwdStatus');
|
|
if (cfg.hasPwd) {
|
|
pwdStatus.innerHTML = '';
|
|
pwdStatus.style.color = 'var(--text-faint)';
|
|
pwdStatus.textContent = 'Set.';
|
|
} else {
|
|
pwdStatus.style.color = 'var(--warning, #e0a800)';
|
|
pwdStatus.innerHTML = '<svg width="13" height="13"><use href="#i-alert"/></svg>' +
|
|
'<span>Not set — backups won\'t run</span>';
|
|
}
|
|
}
|
|
|
|
async function pickAutoBackupFolder() {
|
|
const path = await Bridge.pickFolder();
|
|
if (!path) return;
|
|
Bridge.setPref(ABK.dir, path);
|
|
$('#autoBackupDir').textContent = path;
|
|
toast('Backup folder set');
|
|
}
|
|
|
|
async function promptAndStoreBackupPwd() {
|
|
let lastError = '';
|
|
let attempts = 0;
|
|
const MAX_ATTEMPTS = 5;
|
|
for (;;) {
|
|
const pwd = await promptDialog({
|
|
title: 'Choose a backup password',
|
|
message: 'You will need this to restore the auto-backups. Save it somewhere safe — it is independent of your master password.',
|
|
placeholder: 'At least 6 characters',
|
|
password: true,
|
|
okText: 'Save',
|
|
error: lastError,
|
|
});
|
|
if (!pwd) return false; // cancelled (false / null / '' / undefined)
|
|
if (pwd.length >= 6) {
|
|
Bridge.setPref(ABK.pwd, pwd);
|
|
return true;
|
|
}
|
|
attempts++;
|
|
if (attempts >= MAX_ATTEMPTS) {
|
|
toast('Too many invalid attempts', 'error');
|
|
return false;
|
|
}
|
|
lastError = 'Password must be at least 6 characters (attempt ' +
|
|
attempts + ' / ' + MAX_ATTEMPTS + ').';
|
|
}
|
|
}
|
|
|
|
async function onToggleAutoBackup(ev) {
|
|
const enabled = ev.target.checked;
|
|
if (enabled) {
|
|
Bridge.setPref(ABK.enabled, '1');
|
|
$('#autoBackupConfig').style.display = '';
|
|
// No pwd prompt here — the dedicated "Set backup password" button
|
|
// owns it (like sync). Backups no-op until it's set.
|
|
const cfg = await loadAutoBackupConfig();
|
|
refreshAutoBackupUI(cfg);
|
|
toast(cfg.hasPwd ? 'Auto-backup enabled'
|
|
: 'Auto-backup enabled — set a backup password to start');
|
|
} else {
|
|
Bridge.setPref(ABK.enabled, '');
|
|
// Keep the stored pwd — re-enabling reuses it silently. Change it
|
|
// anytime via the dedicated "Change backup password" button.
|
|
$('#autoBackupConfig').style.display = 'none';
|
|
toast('Auto-backup disabled');
|
|
}
|
|
}
|
|
|
|
// Dedicated pwd button (mirrors sync's syncSetEncPwdFlow) so the user can
|
|
// change the backup password without the disable/re-enable dance.
|
|
async function changeBackupPwd() {
|
|
if (await promptAndStoreBackupPwd()) {
|
|
refreshAutoBackupUI(await loadAutoBackupConfig());
|
|
toast('Backup password saved');
|
|
}
|
|
}
|
|
|
|
async function runAutoBackupNow(silent) {
|
|
const cfg = await loadAutoBackupConfig();
|
|
if (!cfg) return silent || toast('Bridge not available', 'error');
|
|
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
|
|
if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning');
|
|
if (!state.cryptoKey) return silent || toast('Vault is locked', 'warning');
|
|
|
|
// Manual "Backup now" shows a spinner (big vaults take ~30s). The
|
|
// scheduled on-unlock run stays silent (no overlay stealing focus).
|
|
if (!silent) {
|
|
showBusy('Reading vault…');
|
|
await new Promise(r => setTimeout(r, 0));
|
|
}
|
|
try {
|
|
const payload = {
|
|
version: 1,
|
|
exported_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 || '',
|
|
})),
|
|
// Same as the manual export container — keep the avatar so a
|
|
// restore from an auto-backup brings the profile picture back.
|
|
avatar_b64: state.avatarDataUri || '',
|
|
entries: [],
|
|
};
|
|
let _bkDone = 0;
|
|
const _bkTotal = state.entries.length;
|
|
for (const e of state.entries) {
|
|
_bkDone++;
|
|
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
|
|
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
|
|
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 (_) { plainCustom = []; }
|
|
}
|
|
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, folder: e.folder, tags: parseTags(e.tags),
|
|
favorite: !!e.favorite, totp_secret: plainTotp,
|
|
kind: e.kind || 'login',
|
|
template: e.template || '',
|
|
custom_fields: plainCustom,
|
|
attachments: attachments,
|
|
icon_b64: e.icon_b64 || '',
|
|
created_at: e.created_at, updated_at: e.updated_at,
|
|
});
|
|
}
|
|
if (!silent) updateBusy('Encrypting backup…');
|
|
const container = await encryptExportPayload(payload, cfg.pwd);
|
|
const json = JSON.stringify(container, null, 2);
|
|
// Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness.
|
|
const ts = new Date().toISOString()
|
|
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
|
const fname = AUTO_BACKUP_PREFIX + ts + '.json';
|
|
const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname;
|
|
if (!silent) updateBusy('Writing file…');
|
|
const res = await Bridge.writeFile(path, json, pct => {
|
|
if (!silent) updateBusy('Writing file… ' + pct + '%');
|
|
});
|
|
if (!res.ok) {
|
|
if (!silent) toast('Backup failed: ' + (res.error || 'unknown'), 'error');
|
|
return;
|
|
}
|
|
const now = new Date().toISOString();
|
|
Bridge.setPref(ABK.last, now);
|
|
$('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16);
|
|
if (!silent) toast(payload.entries.length + ' entries backed up');
|
|
applyAutoBackupRetention(cfg.dir, cfg.keep);
|
|
} catch (err) {
|
|
if (!silent) toast('Backup failed: ' + (err && err.message ? err.message : err), 'error');
|
|
} finally {
|
|
if (!silent) hideBusy();
|
|
}
|
|
}
|
|
|
|
async function applyAutoBackupRetention(dir, keep) {
|
|
try {
|
|
const files = await Bridge.listFiles(dir, AUTO_BACKUP_PREFIX);
|
|
if (files.length <= keep) return;
|
|
// Sort by name desc (timestamps in filename → lexical = chronological)
|
|
files.sort((a, b) => (a.name < b.name ? 1 : -1));
|
|
const toDelete = files.slice(keep);
|
|
for (const f of toDelete) {
|
|
const path = dir.replace(/[\\/]+$/, '') + '\\' + f.name;
|
|
await Bridge.deleteFile(path);
|
|
}
|
|
} catch (e) {
|
|
// Retention is best-effort; user can clean up manually.
|
|
}
|
|
}
|
|
|
|
async function runAutoBackupIfDue() {
|
|
if (!Bridge.active) return;
|
|
const cfg = await loadAutoBackupConfig();
|
|
if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return;
|
|
if (!state.cryptoKey) return;
|
|
const intervalMs = cfg.interval * 24 * 3600 * 1000;
|
|
const last = cfg.last ? Date.parse(cfg.last) : 0;
|
|
if (last && (Date.now() - last) < intervalMs) return;
|
|
await runAutoBackupNow(true); // silent — no spinner on the scheduled run
|
|
}
|