From 212708b464c2bdbdd9670c00804623dca0729d2c Mon Sep 17 00:00:00 2001 From: Agando Date: Wed, 27 May 2026 06:00:43 +0200 Subject: auto: 2026-05-27_06:00 changes --- modules/ext/webdav.js | 210 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 201 insertions(+), 9 deletions(-) (limited to 'modules/ext/webdav.js') diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index 89e2115..b4dfa6f 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -256,6 +256,69 @@ class WebDAVModule { return false; } + async _deleteFile(remotePath) { + const resp = await this._webdavRequest('DELETE', remotePath); + return resp.ok || resp.status === 204 || resp.status === 404; + } + + async _cleanupRetention() { + // Smart retention: keep newest 10 + 1 per day for last 7 days + try { + const entries = await this._listDir('backups'); + const files = entries.filter(e => e.type === 'file' && e.name.endsWith('.json')); + if (files.length <= 17) return; // 10 newest + 7 daily = 17 max needed + + // Sort by date descending (newest first) + files.sort((a, b) => { + const da = this._extractDateFromBackup(a.name); + const db = this._extractDateFromBackup(b.name); + return db - da; + }); + + const keep = new Set(); + + // Always keep newest 10 + for (let i = 0; i < Math.min(10, files.length); i++) keep.add(files[i].name); + + // Keep 1 per day for last 7 days (outside the newest 10) + const dayMap = new Map(); // YYYY-MM-DD -> first backup of that day + for (let i = 10; i < files.length; i++) { + const f = files[i]; + const day = this._extractDayString(f.name); + if (!dayMap.has(day)) dayMap.set(day, f); + } + // Get last 7 unique days + const sortedDays = Array.from(dayMap.keys()).sort().slice(-7); + for (const day of sortedDays) keep.add(dayMap.get(day).name); + + // Delete the rest + let deleted = 0; + for (const f of files) { + if (!keep.has(f.name)) { + const ok = await this._deleteFile('backups/' + f.name); + if (ok) deleted++; + } + } + if (deleted > 0) { + this.terminal.print(`Retention cleanup: removed ${deleted} old backup(s)`, 'info'); + } + } catch (e) { + this.terminal.print('Retention cleanup skipped: ' + e.message, 'info'); + } + } + + _extractDateFromBackup(name) { + // Match YYYY-MM-DD_HHMMSS in filename + const m = name.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})/); + if (!m) return 0; + return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`).getTime(); + } + + _extractDayString(name) { + const m = name.match(/(\d{4}-\d{2}-\d{2})_/); + return m ? m[1] : ''; + } + registerCommands() { const self = this; @@ -441,7 +504,18 @@ class WebDAVModule { // --- webdav-backup: backup entire VFS to WebDAV --- this.commands['webdav-backup'] = async (args) => { - const name = (args[0] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_'); + // Parse flags + let encryptPass = null; + let nameIdx = 0; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--encrypt' || args[i] === '-e') { + encryptPass = args[i + 1]; + i++; + } else if (!args[i].startsWith('-')) { + nameIdx = i; + } + } + const name = (args[nameIdx] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_'); const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + @@ -449,18 +523,56 @@ class WebDAVModule { String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); - const filename = name + '-' + dateStr + '.json'; - const remotePath = 'backups/' + filename; + let filename = name + '-' + dateStr + '.json'; + let remotePath = 'backups/' + filename; try { await self._ensureDir('backups'); - const serialized = JSON.stringify(self.terminal.fs.root, null, 2); + // Build structured export: root + currentPath + aliases + moduleRegistry + const exportData = { + type: 'webx11-backup', + version: 2, + savedAt: new Date().toISOString(), + root: self.terminal.fs.root, + currentPath: self.terminal.fs.currentPath, + aliases: self.terminal.moduleManager.aliases || {}, + moduleRegistry: self.terminal.moduleManager.moduleRegistry || [] + }; + const serialized = JSON.stringify(exportData, null, 2); + // Gzip payload + let payload = serialized; + let isGzipped = false; + if (typeof CompressionStream !== 'undefined') { + try { + const blob = new Blob([serialized]); + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + const resp = new Response(stream); + const buf = await resp.arrayBuffer(); + payload = new Uint8Array(buf); + isGzipped = true; + } catch (e) { /* fallback to plain */ } + } + // Encrypt if requested + if (encryptPass) { + if (!isGzipped) { + self.terminal.print('Encryption requires gzip compression. CompressionStream unavailable — aborting.', 'error'); + return; + } + const wrapper = await self._encryptPayload(payload, encryptPass); + payload = JSON.stringify(wrapper); + filename = name + '-' + dateStr + '.json.enc'; + remotePath = 'backups/' + filename; + } const resp = await self._webdavRequest('PUT', remotePath, { - body: serialized, - headers: { 'Content-Type': 'application/json' } + body: payload, + headers: { 'Content-Type': encryptPass ? 'application/json' : (isGzipped ? 'application/gzip' : 'application/json') } }); if (resp.ok || resp.status === 201 || resp.status === 204) { - self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success'); + const sizeStr = isGzipped ? self._formatSize(serialized.length) + ' → ' + self._formatSize(typeof payload === 'string' ? payload.length : payload.length) : self._formatSize(serialized.length); + const encMark = encryptPass ? ' [🔒 encrypted]' : ''; + self.terminal.print('Backup saved: ' + remotePath + ' (' + sizeStr + ')' + encMark, 'success'); + // Run retention cleanup in background + self._cleanupRetention().catch(() => {}); } else { self.terminal.print('Backup failed: HTTP ' + resp.status, 'error'); } @@ -472,18 +584,56 @@ class WebDAVModule { // --- webdav-restore: restore VFS from a WebDAV backup --- this.commands['webdav-restore'] = async (args) => { if (!args[0]) { - self.terminal.print('Usage: webdav-restore ', 'warning'); + self.terminal.print('Usage: webdav-restore [--passphrase ]', 'warning'); self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info'); return; } let remotePath = args[0]; + let passphrase = null; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--passphrase' || args[i] === '-p') passphrase = args[i + 1]; + } if (!remotePath.startsWith('backups/') && !remotePath.startsWith('/backups/')) { remotePath = 'backups/' + remotePath; } try { const resp = await self._webdavRequest('GET', remotePath); if (!resp.ok) throw new Error('HTTP ' + resp.status); - const content = await resp.text(); + // Read raw bytes first (resp.text() corrupts gzip binary) + const arrayBuf = await resp.arrayBuffer(); + const bytes = new Uint8Array(arrayBuf); + let text; + // Detect gzip by magic bytes 0x1f 0x8b + if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { + self.terminal.print('Detected gzip-compressed backup, decompressing...', 'info'); + try { + const blob = new Blob([bytes]); + const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); + const decompressed = await new Response(stream).text(); + text = decompressed; + } catch (e) { + throw new Error('Gzip decompression failed: ' + e.message); + } + } else { + text = new TextDecoder().decode(bytes); + } + // Detect encrypted wrapper + let isEncrypted = false; + let wrapper = null; + try { + wrapper = JSON.parse(text); + if (wrapper && wrapper._encrypted) isEncrypted = true; + } catch (e) { /* not JSON wrapper */ } + let content = text; + if (isEncrypted) { + if (!passphrase) { + self.terminal.print('🔒 This backup is encrypted. Run: webdav-restore ' + args[0] + ' --passphrase ', 'warning'); + return; + } + self.terminal.print('Decrypting backup...', 'info'); + const decryptedU8 = await self._decryptPayload(wrapper, passphrase); + content = new TextDecoder().decode(decryptedU8); + } const data = JSON.parse(content); if (!data || !data.type) { self.terminal.print('Invalid backup file (not a valid VFS export)', 'error'); @@ -820,6 +970,48 @@ class WebDAVModule { } } + /* ── Encryption helpers (AES-GCM for backups) ── */ + _u8ToBase64(u8) { + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s); + } + _base64ToU8(b64) { + const s = atob(b64); + const u8 = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); + return u8; + } + async _deriveKey(passphrase, salt) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, + keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] + ); + } + async _encryptPayload(gzipU8, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await this._deriveKey(passphrase, salt); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, gzipU8); + return { + _encrypted: true, + salt: this._u8ToBase64(salt), + iv: this._u8ToBase64(iv), + iterations: 100000, + cipher: this._u8ToBase64(new Uint8Array(ciphertext)) + }; + } + async _decryptPayload(wrapper, passphrase) { + const salt = this._base64ToU8(wrapper.salt); + const iv = this._base64ToU8(wrapper.iv); + const key = await this._deriveKey(passphrase, salt); + const ciphertext = this._base64ToU8(wrapper.cipher); + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return new Uint8Array(decrypted); + } + _formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; -- cgit v1.2.3