// ============================================================ // 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'; } 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) { const cfg = await loadAutoBackupConfig(); if (!cfg.hasPwd && !(await promptAndStoreBackupPwd())) { ev.target.checked = false; return; } Bridge.setPref(ABK.enabled, '1'); $('#autoBackupConfig').style.display = ''; toast('Auto-backup enabled'); // Refresh the dir/last display in case we came from cold state. const fresh = await loadAutoBackupConfig(); refreshAutoBackupUI(fresh); } else { Bridge.setPref(ABK.enabled, ''); // Forget the stored backup pwd so re-enabling prompts fresh — // gives the user a way to change it without extra UI. Bridge.setPref(ABK.pwd, ''); $('#autoBackupConfig').style.display = 'none'; toast('Auto-backup disabled'); } } 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 || '', })), 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 }