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:
r-zakarya
2026-07-05 16:46:01 +01:00
parent 5fc07aed7a
commit 4fd768d4cf
10 changed files with 268 additions and 245 deletions
+252
View File
@@ -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
}