aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/webdav.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-05-27 06:00:43 +0200
committerAgando <roduccomm@gmail.com>2026-05-27 06:00:43 +0200
commit212708b464c2bdbdd9670c00804623dca0729d2c (patch)
treeee190cbb0d1524350baea641df65aa19817744b5 /modules/ext/webdav.js
parentf4d934d341056142da1212c845eeeb170fd452c2 (diff)
auto: 2026-05-27_06:00 changes
Diffstat (limited to 'modules/ext/webdav.js')
-rw-r--r--modules/ext/webdav.js210
1 files changed, 201 insertions, 9 deletions
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 <backup-filename>', 'warning');
+ self.terminal.print('Usage: webdav-restore <backup-filename> [--passphrase <phrase>]', '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 <your-phrase>', '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';