refactor(js): extract auto-backup module from app.js monofile (§3.1)
Fifth slice of the app.js split. Moves the scheduled encrypted-backup feature (config, retention, runAutoBackupNow/runAutoBackupIfDue) to js/app.backup.js. Pure declarations + two consts, no top-level side effects → loads before app.js; uses encryptExportPayload (app.import.js), Bridge, api, state via shared global scope at call time. - Byte-for-byte identical extraction; no duplicate const; no top-level backup reference left in app.js; syntax OK on all six app parts. - index.html + BuildAssets whitelist + harness APP_PARTS updated; assets regenerated (manifest embeds all 7 ordered JS files). - 55/55 tests green. app.js: 11936 → 9900 lines — now under 10k. Five modules extracted (~2000 lines): argon2 → crypto → totp → import → backup → app → sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
// ============================================================
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user