c58424d58c
The profile picture (users.avatar_b64, cosmetic/unencrypted) travelled only
in the manual export. Now it's also in buildSyncSnapshot and the auto-backup
container, so a new device / a restore picks it up.
- Restore is ADDITIVE (mirrors the import path): applyRemoteSnapshot adopts
remote.avatar_b64 only when the local device has no avatar — never clobbers
a locally-set picture. No per-avatar timestamp to arbitrate, so changing an
existing avatar doesn't propagate (cosmetic, accepted).
- +2 merge tests (adopt-when-empty, don't-clobber-when-set). 65/65 green.
- Server /avatar endpoint unchanged (already accepts {avatar_b64}).
Closes the avatar item of CODE_AUDIT §4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
10 KiB
JavaScript
256 lines
10 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';
|
|
}
|
|
|
|
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 || '',
|
|
})),
|
|
// 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
|
|
}
|