From 4fd768d4cfc47ae30daed980a5ed56f215afa13d Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:46:01 +0100 Subject: [PATCH] =?UTF-8?q?refactor(js):=20extract=20auto-backup=20module?= =?UTF-8?q?=20from=20app.js=20monofile=20(=C2=A73.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 2 + CODE_AUDIT.md | 7 +- delphi-backend/assets/BuildAssets.ps1 | 1 + delphi-backend/assets/assets.inc | 3 +- delphi-backend/assets/assets.rc | 1 + delphi-backend/assets/assets.res | Bin 708892 -> 709768 bytes index.html | 1 + js/app.backup.js | 252 ++++++++++++++++++++++++++ js/app.js | 244 +------------------------ js/tests/harness.js | 2 +- 10 files changed, 268 insertions(+), 245 deletions(-) create mode 100644 js/app.backup.js diff --git a/CLAUDE.md b/CLAUDE.md index 20ec08d..cf02427 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ js/argon2.js (IIFE, globalThis.NobleArgon2) js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1) js/app.totp.js (TOTP RFC 6238 + TOTP/custom-field crypto — extrait §3.1) js/app.import.js (export container + import CSV/JSON — extrait §3.1) +js/app.backup.js (auto-backup planifié — extrait §3.1) js/app.js (le reste : state, Bridge, api, UI…) js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car effet de bord top-level `Bridge.onWebdavResult = …`) @@ -103,6 +104,7 @@ seule `api()` est stubbée). | Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` | | TOTP (RFC 6238) + TOTP/custom-field crypto — extrait §3.1 | `js/app.totp.js` | | Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` | +| Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` | | Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` | | Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` | | HTML racine | `index.html` | diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index f003f40..fbff5eb 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -236,10 +236,13 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES). - ✅ `js/app.totp.js` extrait (TOTP RFC 6238 + crypto TOTP/custom-fields) — byte-for-byte identique, chargé AVANT app.js. **+13 tests TOTP (vecteurs RFC 6238)** ajoutés en même temps → extraction *et* nouvelle couverture. -- `app.js` : 11 936 → **10 138 lignes** (crypto + totp + sync + import sortis). +- ✅ `js/app.backup.js` extrait (auto-backup planifié) — byte-for-byte + identique, chargé AVANT app.js (pures déclarations). +- `app.js` : 11 936 → **9 900 lignes** (crypto + totp + import + backup + sync + sortis — 5 modules). - Suite de tests : 42 → **55 tests**. - Reste à extraire (grosses sections cohésives) : slideover, settings, - quicksearch, autofill, auto-backup, favicons… + quicksearch, autofill, favicons, vault-health… - ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2). ### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)** diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index a1745d3..e9a955b 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -55,6 +55,7 @@ $patterns = @( 'js\app.crypto.js', 'js\app.totp.js', 'js\app.import.js', + 'js\app.backup.js', 'js\app.js', 'js\app.sync.js', 'css\style.css' diff --git a/delphi-backend/assets/assets.inc b/delphi-backend/assets/assets.inc index f3742f6..13111a1 100644 --- a/delphi-backend/assets/assets.inc +++ b/delphi-backend/assets/assets.inc @@ -1,12 +1,13 @@ // Auto-generated by BuildAssets.ps1 - do not edit by hand. const - EMBEDDED_ASSET_COUNT = 8; + EMBEDDED_ASSET_COUNT = 9; EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = ( (UrlPath: '/index.html'; ResName: 'INDEX_HTML'), (UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'), (UrlPath: '/js/app.crypto.js'; ResName: 'JS_APP_CRYPTO_JS'), (UrlPath: '/js/app.totp.js'; ResName: 'JS_APP_TOTP_JS'), (UrlPath: '/js/app.import.js'; ResName: 'JS_APP_IMPORT_JS'), + (UrlPath: '/js/app.backup.js'; ResName: 'JS_APP_BACKUP_JS'), (UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'), (UrlPath: '/js/app.sync.js'; ResName: 'JS_APP_SYNC_JS'), (UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS') diff --git a/delphi-backend/assets/assets.rc b/delphi-backend/assets/assets.rc index a00d2ff..bd8fde6 100644 --- a/delphi-backend/assets/assets.rc +++ b/delphi-backend/assets/assets.rc @@ -6,6 +6,7 @@ JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js" JS_APP_CRYPTO_JS RCDATA "Z:\\password-manager\\js\\app.crypto.js" JS_APP_TOTP_JS RCDATA "Z:\\password-manager\\js\\app.totp.js" JS_APP_IMPORT_JS RCDATA "Z:\\password-manager\\js\\app.import.js" +JS_APP_BACKUP_JS RCDATA "Z:\\password-manager\\js\\app.backup.js" JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js" JS_APP_SYNC_JS RCDATA "Z:\\password-manager\\js\\app.sync.js" CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css" diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 81c32002de02a2ea356142b23468e16f442dd263..337a8057c83ac8d0a92d9f41078bfdf574969659 100644 GIT binary patch delta 462 zcmXv~&r2IY7@dj9=Eq3_#dwh~&=y1Drg+hVpo#WibLfK2twkoglVs{-cbS>2EeOU_ zdh9_MC?2W@rN` zn0Eue%X+|k*l-%oCWL}^WevLKRYJrFs6H*!>(C9jM@2o^Lq{;LN5LIRtCgAk delta 104 zcmeBJsXb?r_5=k+{zk>Eij057r@Koqx-v4f_ed~q?~!0y5X*ll9OqpI}$L2ge&xC_#`~C{1H)_+j1Twp9FEHh(dBDuTz|j6ilymzVQLZ`r E0NC&+LI3~& diff --git a/index.html b/index.html index 38bccba..54c4579 100644 --- a/index.html +++ b/index.html @@ -1196,6 +1196,7 @@ + diff --git a/js/app.backup.js b/js/app.backup.js new file mode 100644 index 0000000..bd2c8ee --- /dev/null +++ b/js/app.backup.js @@ -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 +} diff --git a/js/app.js b/js/app.js index be1d94a..ab8617d 100644 --- a/js/app.js +++ b/js/app.js @@ -8654,249 +8654,11 @@ async function autoPurgeTrashIfNeeded() { // separate