diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-27 06:00:43 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-27 06:00:43 +0200 |
| commit | 212708b464c2bdbdd9670c00804623dca0729d2c (patch) | |
| tree | ee190cbb0d1524350baea641df65aa19817744b5 /modules/ext | |
| parent | f4d934d341056142da1212c845eeeb170fd452c2 (diff) | |
auto: 2026-05-27_06:00 changes
Diffstat (limited to 'modules/ext')
| -rw-r--r-- | modules/ext/busybox.js | 407 | ||||
| -rw-r--r-- | modules/ext/cryptogzip.js | 281 | ||||
| -rw-r--r-- | modules/ext/mindmap.js | 21 | ||||
| -rw-r--r-- | modules/ext/shell.js | 517 | ||||
| -rw-r--r-- | modules/ext/skill-pack-core.js | 2 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 210 |
6 files changed, 1427 insertions, 11 deletions
diff --git a/modules/ext/busybox.js b/modules/ext/busybox.js new file mode 100644 index 0000000..f143087 --- /dev/null +++ b/modules/ext/busybox.js @@ -0,0 +1,407 @@ +class BusyBoxModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'which': 'which <name> — Locate a command in the module registry', + 'env': 'env — List environment variables (webx11_ keys + user profile)', + 'date': 'date — Show current date/time (ISO, local, UTC, timestamp)', + 'cal': 'cal [month] [year] — Display an ASCII calendar', + 'yes': 'yes [string] — Output repeated string (default "y", capped at 50)', + 'seq': 'seq <last> or seq <first> <last> — Generate sequence of numbers', + 'base64': 'base64 [-d] <string> — Base64 encode/decode', + 'printf': 'printf <format> [args...] — Formatted print (%s, %d, %f, \\n, \\t)', + 'cut': 'cut -f<N> [-d<delim>] <file> — Extract column from file', + 'uniq': 'uniq <file> — Filter adjacent duplicate lines from a file', + 'hostname': 'hostname — Show host/user-agent info', + 'whoami': 'whoami — Show current user name', + 'basename': 'basename <path> — Strip directory from path', + 'dirname': 'dirname <path> — Strip filename from path', + 'sleep': 'sleep <seconds> — Delay execution with countdown', + 'expand': 'expand <file> — Convert tabs to spaces (default 4) in file', + }; + } + + registerCommands() { + this.commands['which'] = (args) => this._cmdWhich(args); + this.commands['env'] = () => this._cmdEnv(); + this.commands['date'] = (args) => this._cmdDate(args); + this.commands['cal'] = (args) => this._cmdCal(args); + this.commands['yes'] = (args) => this._cmdYes(args); + this.commands['seq'] = (args) => this._cmdSeq(args); + this.commands['base64'] = (args) => this._cmdBase64(args); + this.commands['printf'] = (args) => this._cmdPrintf(args); + this.commands['cut'] = (args) => this._cmdCut(args); + this.commands['uniq'] = (args) => this._cmdUniq(args); + this.commands['hostname'] = () => this._cmdHostname(); + this.commands['whoami'] = () => this._cmdWhoami(); + this.commands['basename'] = (args) => this._cmdBasename(args); + this.commands['dirname'] = (args) => this._cmdDirname(args); + this.commands['sleep'] = (args) => this._cmdSleep(args); + this.commands['expand'] = (args) => this._cmdExpand(args); + } + + // ── which ── + + _cmdWhich(args) { + if (!args[0]) return this.terminal.print('Usage: which <name>', 'warning'); + const query = args[0].toLowerCase(); + const all = this.terminal.moduleManager.getAllCommands(); + const keys = Object.keys(all); + + if (all[query]) { + const mod = this.terminal.moduleManager._findModuleForCommand(query); + const src = mod ? ` (from ${mod.constructor.name})` : ''; + return this.terminal.print(`${query}${src}`, 'info'); + } + + // Fuzzy match: check Levenshtein distance up to 2 + let best = null, bestDist = 3; + for (const k of keys) { + const d = this._levenshtein(query, k); + if (d < bestDist) { best = k; bestDist = d; } + } + if (best) { + this.terminal.print(`which: no '${query}' in registry`, 'error'); + this.terminal.print(`Did you mean '${best}'?`, 'info'); + } else { + this.terminal.print(`which: no '${query}' in registry`, 'error'); + } + } + + _levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]); + } + } + return dp[m][n]; + } + + // ── env ── + + _cmdEnv() { + const lines = []; + // webx11_ localStorage keys + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith('webx11_')) { + let v = localStorage.getItem(k) || ''; + if (v.length > 80) v = v.substring(0, 77) + '...'; + lines.push(`${k}=${v}`); + } + } + // Runtime info + if (this.terminal.ollamaManager) { + lines.push(`OLLAMA_MODEL=${this.terminal.ollamaManager.currentModel || 'none'}`); + lines.push(`OLLAMA_CONNECTED=${this.terminal.ollamaManager.connected}`); + } + lines.push(`PWD=/${this.terminal.fs.currentPath.join('/')}`); + lines.push(`SHELL=webx11-terminal`); + lines.push(`USER=darkbot`); + lines.push(`HOME=/`); + lines.push(`TERM=xterm-256color`); + const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (prof && prof._userProfile) { + const facts = prof._userProfile.knownFacts; + if (facts) { + for (const [k, v] of Object.entries(facts)) { + lines.push(`USER_${k.toUpperCase()}=${v}`); + } + } + } + this.terminal.print(lines.join('\n'), 'info'); + } + + // ── date ── + + _cmdDate(args) { + const n = new Date(); + if (args.length > 0) { + const flag = args[0].toLowerCase(); + if (flag === '-u' || flag === '--utc' || flag === 'utc') + return this.terminal.print(n.toUTCString(), 'info'); + if (flag === '-i' || flag === '--iso' || flag === 'iso') + return this.terminal.print(n.toISOString(), 'info'); + if (flag === '-t' || flag === '--ts' || flag === 'timestamp') + return this.terminal.print(String(n.getTime()), 'info'); + if (flag === '-r' || flag === '--rfc' || flag === 'rfc') + return this.terminal.print(n.toUTCString(), 'info'); + } + const local = n.toLocaleString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + timeZoneName: 'short' + }); + this.terminal.print(local, 'info'); + this.terminal.print(`ISO: ${n.toISOString()}`, 'info'); + this.terminal.print(`UTC: ${n.toUTCString()}`, 'info'); + this.terminal.print(`Unix: ${Math.floor(n.getTime() / 1000)}`, 'info'); + } + + // ── cal ── + + _cmdCal(args) { + const now = new Date(); + let year = now.getFullYear(); + let month = now.getMonth() + 1; + + if (args.length >= 2) { + month = parseInt(args[0]); + year = parseInt(args[1]); + } else if (args.length === 1) { + // If only one arg, could be year (4 digits) or month + const v = parseInt(args[0]); + if (args[0].length === 4 || v > 12) { + year = v; + } else { + month = v; + } + } + + if (isNaN(month) || isNaN(year) || month < 1 || month > 12) { + return this.terminal.print('cal: invalid month or year', 'error'); + } + + const monthNames = ['January','February','March','April','May','June', + 'July','August','September','October','November','December']; + + const first = new Date(year, month - 1, 1); + const last = new Date(year, month, 0); + const daysInMonth = last.getDate(); + const startDay = first.getDay(); // 0=Sun + + const header = ` ${monthNames[month-1]} ${year}`; + const weekdays = 'Su Mo Tu We Th Fr Sa'; + + let cal = header + '\n' + weekdays + '\n'; + + let line = ' '.repeat(startDay); + for (let d = 1; d <= daysInMonth; d++) { + line += String(d).padStart(2) + ' '; + if ((startDay + d) % 7 === 0 || d === daysInMonth) { + cal += line.trimEnd() + '\n'; + line = ''; + } + } + + this.terminal.print(cal, 'info'); + } + + // ── yes ── + + _cmdYes(args) { + const text = args.length > 0 ? args.join(' ') : 'y'; + const count = 50; + for (let i = 0; i < count; i++) { + this.terminal.print(text); + } + this.terminal.print(`(repeated ${count} times, capped)`, 'text-muted'); + } + + // ── seq ── + + _cmdSeq(args) { + if (args.length === 0) return this.terminal.print('Usage: seq <last> or seq <first> <last>', 'warning'); + let first = 1, last; + if (args.length === 1) { + last = parseInt(args[0]); + } else { + first = parseInt(args[0]); + last = parseInt(args[1]); + } + if (isNaN(first) || isNaN(last)) return this.terminal.print('seq: non-integer argument', 'error'); + const max = 1000; + if (last - first > max) return this.terminal.print(`seq: range too large (max ${max})`, 'error'); + const output = []; + if (first <= last) { + for (let i = first; i <= last; i++) output.push(String(i)); + } else { + for (let i = first; i >= last; i--) output.push(String(i)); + } + this.terminal.print(output.join('\n'), 'info'); + } + + // ── base64 ── + + _cmdBase64(args) { + if (args.length === 0) return this.terminal.print('Usage: base64 [-d] <string>', 'warning'); + if (args[0] === '-d' || args[0] === '--decode') { + if (args.length < 2) return this.terminal.print('Usage: base64 -d <encoded>', 'warning'); + const encoded = args.slice(1).join(' '); + try { + const decoded = atob(encoded); + this.terminal.print(decoded, 'info'); + } catch (e) { + this.terminal.print('base64: invalid input (not valid base64)', 'error'); + } + } else { + const input = args.join(' '); + this.terminal.print(btoa(input), 'info'); + } + } + + // ── printf ── + + _cmdPrintf(args) { + if (args.length === 0) return this.terminal.print('Usage: printf <format> [args...]', 'warning'); + let fmt = args[0]; + const vals = args.slice(1); + let vi = 0; + // Replace format specifiers step by step + const result = fmt + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\\\/g, '\\') + .replace(/%s/g, () => vals[vi++] || '') + .replace(/%d/g, () => { + const v = parseInt(vals[vi++]); + return isNaN(v) ? '0' : String(v); + }) + .replace(/%f/g, () => { + const v = parseFloat(vals[vi++]); + return isNaN(v) ? '0.000000' : v.toFixed(6); + }) + .replace(/%[^sdf%]/g, (m) => `(unsupported: ${m})`); + this.terminal.print(result, 'info'); + } + + // ── cut ── + + _cmdCut(args) { + if (args.length < 2) return this.terminal.print('Usage: cut -f<N> [-d<delim>] <file>', 'warning'); + let field = null, delim = '\t', file = ''; + for (const a of args) { + if (a.startsWith('-f')) field = parseInt(a.substring(2)); + else if (a.startsWith('-d')) delim = a.substring(2); + else file = a; + } + if (!field || isNaN(field)) return this.terminal.print('cut: -f flag required', 'error'); + if (!file) return this.terminal.print('cut: file argument required', 'error'); + + const content = this.terminal.fs.readFile(file); + if (content === null) return this.terminal.print(`cut: ${file}: No such file`, 'error'); + + const lines = content.split('\n').filter(l => l); + const output = []; + for (const line of lines) { + const parts = line.split(delim); + const idx = field - 1; + if (idx >= 0 && idx < parts.length) { + output.push(parts[idx]); + } + } + this.terminal.print(output.join('\n'), 'info'); + } + + // ── uniq ── + + _cmdUniq(args) { + if (!args[0]) return this.terminal.print('Usage: uniq <file>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if (content === null) return this.terminal.print(`uniq: ${args[0]}: No such file`, 'error'); + const lines = content.split('\n'); + let last = null; + const output = []; + for (const line of lines) { + if (line !== last) { + output.push(line); + last = line; + } + } + this.terminal.print(output.join('\n'), 'info'); + } + + // ── hostname ── + + _cmdHostname() { + const ua = navigator.userAgent; + const space = 'newos4'; + const platform = navigator.platform || 'unknown'; + const version = 'WebX11 v4.2'; + this.terminal.print(`Hostname: ${space}`, 'info'); + this.terminal.print(`Platform: ${version} on ${platform}`, 'info'); + this.terminal.print(`User Agent: ${ua}`, 'info'); + } + + // ── whoami ── + + _cmdWhoami() { + // Try to get from user profile + let name = 'darkbot'; + try { + const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (prof && prof._userProfile && prof._userProfile.knownFacts && prof._userProfile.knownFacts.name) { + name = prof._userProfile.knownFacts.name; + } + } catch (e) {} + this.terminal.print(name, 'info'); + } + + // ── basename ── + + _cmdBasename(args) { + if (!args[0]) return this.terminal.print('Usage: basename <path>', 'warning'); + const parts = args[0].split('/').filter(p => p); + const result = parts.pop() || '/'; + this.terminal.print(result, 'info'); + } + + // ── dirname ── + + _cmdDirname(args) { + if (!args[0]) return this.terminal.print('Usage: dirname <path>', 'warning'); + const parts = args[0].split('/').filter(p => p); + if (parts.length <= 1) { + this.terminal.print('.', 'info'); + return; + } + parts.pop(); + const result = args[0].startsWith('/') ? '/' + parts.join('/') : parts.join('/'); + this.terminal.print(result || '.', 'info'); + } + + // ── sleep ── + + _cmdSleep(args) { + if (!args[0]) return this.terminal.print('Usage: sleep <seconds>', 'warning'); + const secs = parseInt(args[0]); + if (isNaN(secs) || secs < 1) return this.terminal.print('sleep: invalid duration', 'error'); + const max = 30; + const actual = Math.min(secs, max); + if (actual !== secs) this.terminal.print(`sleep: capped to ${max}s (from ${secs}s)`, 'warning'); + this.terminal.print(`Sleeping ${actual}s...`, 'info'); + let remaining = actual; + const interval = setInterval(() => { + remaining--; + if (remaining > 0) { + this.terminal.print(`${remaining}s...`, 'info'); + } else { + clearInterval(interval); + this.terminal.print('Done', 'success'); + } + }, 1000); + } + + // ── expand ── + + _cmdExpand(args) { + if (!args[0]) return this.terminal.print('Usage: expand <file>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if (content === null) return this.terminal.print(`expand: ${args[0]}: No such file`, 'error'); + const expanded = content.replace(/\t/g, ' '); + this.terminal.print(expanded, 'info'); + } +} diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js new file mode 100644 index 0000000..6f873ff --- /dev/null +++ b/modules/ext/cryptogzip.js @@ -0,0 +1,281 @@ +class CryptoGzipModule { + // CryptoGzipModule — VFS-level gzip/encrypt commands for WebX11 newOS4 + // Commands: gzip <path>, gunzip <path.gz>, encrypt <path>, decrypt <path.enc> + constructor(terminal) { + this.terminal = terminal; + this.commands = {}; + } + + _vfsExists(path) { + // readFile returns null if not found + const normalized = path.replace(/^\/+/, ''); + return this.terminal.fs.readFile(normalized) !== null; + } + + _vfsRead(path) { + const normalized = path.replace(/^\/+/, ''); + const content = this.terminal.fs.readFile(normalized); + if (content === null) throw new Error(`File not found: ${path}`); + return content; + } + + _vfsWrite(path, content) { + const normalized = path.replace(/^\/+/, ''); + return this.terminal.fs.writeFile(normalized, content); + } + + _vfsDelete(path) { + const normalized = path.replace(/^\/+/, ''); + this.terminal.fs.deleteFile(normalized); + } + + // ============================================================ + // Prompt Utilities (WebX11 terminal compatible) + // ============================================================ + + _promptPassphrase(message = 'Enter passphrase: ') { + return new Promise((resolve) => { + this.terminal.print(message, 'info'); + const savedHandler = this.terminal.inputCallback || this.terminal._onCommand; + const handler = (line) => { + if (this.terminal.inputCallback === handler) this.terminal.inputCallback = savedHandler; + if (this.terminal._onCommand === handler) this.terminal._onCommand = savedHandler; + resolve(line); + }; + this.terminal.inputCallback = handler; + this.terminal._onCommand = handler; + }); + } + + _confirmPrompt(message) { + return new Promise((resolve) => { + this.terminal.print(message + ' (yes/no): ', 'warning'); + const savedHandler = this.terminal.inputCallback || this.terminal._onCommand; + const handler = (line) => { + if (this.terminal.inputCallback === handler) this.terminal.inputCallback = savedHandler; + if (this.terminal._onCommand === handler) this.terminal._onCommand = savedHandler; + resolve(line.trim().toLowerCase() === 'yes'); + }; + this.terminal.inputCallback = handler; + this.terminal._onCommand = handler; + }); + } + + // ============================================================ + // GZIP + // ============================================================ + + async _cmdGzip(args) { + const path = args[0]; + if (!path) { + this.terminal.print('Usage: gzip <path> — Compress a VFS file to .gz', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + + const outPath = path + '.gz'; + if (this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) { this.terminal.print('Cancelled.', 'info'); return; } + } + + try { + const content = this._vfsRead(path); + const compressed = await this._gzipString(content); + this._vfsWrite(outPath, compressed); + this._vfsDelete(path); + const ratio = (compressed.length / Math.max(content.length, 1)).toFixed(2); + this.terminal.print(`gzipped: ${path} → ${outPath} (${content.length} → ${compressed.length} chars, ratio ${ratio})`, 'success'); + } catch (e) { + this.terminal.print('gzip error: ' + e.message, 'error'); + } + } + + async _cmdGunzip(args) { + const path = args[0]; + if (!path || !path.endsWith('.gz')) { + this.terminal.print('Usage: gunzip <path.gz> — Decompress a .gz file in VFS', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + + const outPath = path.replace(/\.gz$/, ''); + if (this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) { this.terminal.print('Cancelled.', 'info'); return; } + } + + try { + const compressed = this._vfsRead(path); + const content = await this._gunzipString(compressed); + this._vfsWrite(outPath, content); + this._vfsDelete(path); + this.terminal.print(`gunzipped: ${path} → ${outPath} (${compressed.length} → ${content.length} chars)`, 'success'); + } catch (e) { + this.terminal.print('gunzip error: ' + e.message, 'error'); + } + } + + async _gzipString(str) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const cs = new CompressionStream('gzip'); + const writer = cs.writable.getWriter(); + writer.write(bytes); + writer.close(); + const compressed = await new Response(cs.readable).arrayBuffer(); + return this._bytesToBase64(new Uint8Array(compressed)); + } + + async _gunzipString(b64) { + const compressed = this._base64ToBytes(b64); + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + writer.write(compressed); + writer.close(); + const decompressed = await new Response(ds.readable).arrayBuffer(); + return new TextDecoder().decode(decompressed); + } + + // ============================================================ + // ENCRYPT / DECRYPT (AES-256-GCM) + // ============================================================ + + async _cmdEncrypt(args) { + const path = args[0]; + const passphrase = args[1]; + if (!path || !passphrase) { + this.terminal.print('Usage: encrypt <path> <passphrase> — AES-256-GCM encrypt a VFS file', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + if (passphrase.length < 4) { + this.terminal.print('Passphrase too short (min 4 chars)', 'error'); + return; + } + const outPath = path + '.enc'; + if (this._vfsExists(outPath)) { + this.terminal.print(`Overwriting existing ${outPath}`, 'warning'); + } + try { + const plaintext = this._vfsRead(path); + const envelope = await this._encryptString(plaintext, passphrase); + this._vfsWrite(outPath, JSON.stringify(envelope, null, 0)); + this._vfsDelete(path); + this.terminal.print(`encrypted: ${path} → ${outPath}`, 'success'); + } catch (e) { + this.terminal.print('encrypt error: ' + e.message, 'error'); + } + } + + async _cmdDecrypt(args) { + const path = args[0]; + const passphrase = args[1]; + if (!path || !path.endsWith('.enc') || !passphrase) { + this.terminal.print('Usage: decrypt <path.enc> <passphrase> — Decrypt a .enc file', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + const outPath = path.replace(/\.enc$/, ''); + if (this._vfsExists(outPath)) { + this.terminal.print(`Overwriting existing ${outPath}`, 'warning'); + } + try { + const envelopeJson = this._vfsRead(path); + let envelope; + try { envelope = JSON.parse(envelopeJson); } + catch (e) { throw new Error('Invalid .enc file format (expected JSON envelope)'); } + + const plaintext = await this._decryptEnvelope(envelope, passphrase); + this._vfsWrite(outPath, plaintext); + this._vfsDelete(path); + this.terminal.print(`decrypted: ${path} → ${outPath}`, 'success'); + } catch (e) { + this.terminal.print('decrypt error: ' + e.message, 'error'); + } + } + + async _encryptString(plaintext, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await this._deriveKey(passphrase, salt); + const encoder = new TextEncoder(); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, key, encoder.encode(plaintext) + ); + return { + v: 1, + alg: 'AES-256-GCM+PBKDF2', + salt: this._bytesToBase64(salt), + iv: this._bytesToBase64(iv), + ct: this._bytesToBase64(new Uint8Array(ciphertext)), + }; + } + + async _decryptEnvelope(envelope, passphrase) { + if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope version'); + const salt = this._base64ToBytes(envelope.salt); + const iv = this._base64ToBytes(envelope.iv); + const ct = this._base64ToBytes(envelope.ct); + const key = await this._deriveKey(passphrase, salt); + try { + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + return new TextDecoder().decode(decrypted); + } catch (e) { + throw new Error('Decryption failed — wrong passphrase or corrupted file'); + } + } + + async _deriveKey(passphrase, salt) { + const encoder = new TextEncoder(); + const baseKey = await crypto.subtle.importKey( + 'raw', encoder.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, + baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] + ); + } + + // ============================================================ + // Base64 Helpers + // ============================================================ + + _bytesToBase64(bytes) { + const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join(''); + return btoa(bin); + } + + _base64ToBytes(b64) { + const bin = atob(b64); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); + } + + // ============================================================ + + registerCommands() { + const self = this; + this.commands['gzip'] = async (args) => self._cmdGzip(args); + this.commands['gunzip'] = async (args) => self._cmdGunzip(args); + this.commands['encrypt'] = async (args) => self._cmdEncrypt(args); + this.commands['decrypt'] = async (args) => self._cmdDecrypt(args); + this.commandsMeta = { + gzip: { argType: 'file', description: 'Compress a VFS file to .gz' }, + gunzip: { argType: 'file', description: 'Decompress a .gz file' }, + encrypt: { argType: 'file', description: 'Encrypt a file with passphrase' }, + decrypt: { argType: 'file', description: 'Decrypt a .enc file' } + }; + } +} diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js index 012bae7..30d6889 100644 --- a/modules/ext/mindmap.js +++ b/modules/ext/mindmap.js @@ -43,10 +43,29 @@ class MindmapModule { registerCommands() { const self = this; this.commands['mindmap'] = (args) => { - if (!args[0]) return this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'warning'); + if (!args[0] || args[0] === '--help' || args[0] === '-h') { + this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info'); + this.terminal.print(' new <topic> [-t tags] — Create a new mindmap', 'info'); + this.terminal.print(' add <parent-id> <text> [-t tags] — Add a child node', 'info'); + this.terminal.print(' show — Display the mindmap tree', 'info'); + this.terminal.print(' export — Print as indented text', 'info'); + this.terminal.print(' save [name] — Save to VFS', 'info'); + this.terminal.print(' load <name> — Load from VFS', 'info'); + this.terminal.print(' list — List saved mindmaps', 'info'); + this.terminal.print(' delete <id> — Remove a node', 'info'); + this.terminal.print(' rename <id> <new-text>', 'info'); + this.terminal.print(' color <id> <color>', 'info'); + this.terminal.print(' tag <id> [tag1,tag2...]', 'info'); + return; + } const sub = args[0]; const rest = args.slice(1); switch (sub) { + case '--help': + case '-h': + case 'help': + this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info'); + return; case 'new': return this._new(rest); case 'add': return this._add(rest); case 'show': return this._show(rest); diff --git a/modules/ext/shell.js b/modules/ext/shell.js new file mode 100644 index 0000000..d5e6a43 --- /dev/null +++ b/modules/ext/shell.js @@ -0,0 +1,517 @@ +class ShellModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._vars = { + HOME: '/', + USER: 'darkbot', + SHELL: 'webx11-shell', + TERM: 'xterm-256color', + PWD: '/' + }; + this._scriptDepth = 0; + this._maxScriptDepth = 3; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'export': 'export <var>=<value> — Set a shell variable', + 'unset': 'unset <var> — Remove a shell variable', + 'script': 'script <path.sh> — Run a shell script from VFS', + '$': '$ <command> — Execute with shell parsing (default: auto-detected)', + 'set': 'set — List all shell variables', + }; + } + + registerCommands() { + this.commands['export'] = (args) => this._cmdExport(args); + this.commands['unset'] = (args) => this._cmdUnset(args); + this.commands['script'] = (args) => this._cmdScript(args); + this.commands['set'] = () => this._cmdSet(); + } + + // ── Hook into terminal.executeCommand ── + + init() { + // Wrap executeCommand to add shell parsing + const origExecute = this.terminal.executeCommand.bind(this.terminal); + const self = this; + this.terminal.executeCommand = async function(rawInput) { + if (!rawInput.trim()) return; + this.markDirty(); + this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt'); + // Substitute variables in the raw input + const substituted = self._substituteVars(rawInput); + // Split on ; first (sequence) + const seqParts = self._splitSequence(substituted); + for (const seqPart of seqParts) { + if (!seqPart.trim()) continue; + // Split on && and || + const chainParts = self._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.trim()) continue; + const { cmd: chainCmd, operator } = chainPart; + if (operator === '&&' && !lastOk) continue; + if (operator === '||' && lastOk) continue; + // Check for redirects + const redirectInfo = self._extractRedirects(chainCmd); + if (redirectInfo.hasRedirect) { + // Run, capture, write to file + await self._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + // Normal or piped command + const pipeParts = self.terminal.splitPipe(chainCmd); + if (pipeParts.length > 1) { + await self.terminal.executePipeChain(pipeParts); + } else { + // Single command + const parts = chainCmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + await this.security.checkAndRun(chainCmd); + } + lastOk = true; + } + } + } + }; + this.terminal._shellModule = this; + + // Modify pipe chain to read _pipeInput for commands that support it + const origPipeChain = this.terminal.executePipeChain.bind(this.terminal); + this.terminal.executePipeChain = async function(pipeParts) { + // Write pipe input from previous segment as temp file for next segment + const shellMod = this._shellModule; + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const cmdText = pipeParts[i]; + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + let args = parts.slice(1); + this._pipeInput = pipeInput; + + if (pipeInput !== null) { + // Write pipe input as temp file so file-reading commands can use it + this.fs.writeFile('__pipebuf__', pipeInput); + // Inject __pipebuf__ as file arg if command is pipe-aware (reads files for input) + if (shellMod && shellMod._isPipeAware(cmd)) { + // Don't inject if command already has enough file args + if (!args.some(a => !a.startsWith('-') && a !== '|' && a !== '>')) { + args = args.concat('__pipebuf__'); + } + } + } + + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + + // Clean up temp file + if (this.fs.readFile('__pipebuf__') !== null) { + this.fs.root.content['__pipebuf__'] = undefined; + delete this.fs.root.content['__pipebuf__']; + } + } + this._pipeInput = null; + }; + } + + // ── Tokenizer helpers ── + + _splitSequence(input) { + // Split on ; (not inside quotes) + const parts = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + for (const ch of input) { + if ((ch === '"' || ch === "'") && !inQuote) { + inQuote = true; quoteChar = ch; current += ch; + } else if (ch === quoteChar && inQuote) { + inQuote = false; quoteChar = ''; current += ch; + } else if (ch === ';' && !inQuote) { + parts.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + parts.push(current.trim()); + return parts; + } + + _splitChain(input) { + // Split on && and ||, preserving operator info + const parts = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + let i = 0; + while (i < input.length) { + const ch = input[i]; + if ((ch === '"' || ch === "'") && !inQuote) { + inQuote = true; quoteChar = ch; current += ch; i++; + } else if (ch === quoteChar && inQuote) { + inQuote = false; quoteChar = ''; current += ch; i++; + } else if (!inQuote && input.substring(i, i+2) === '&&') { + parts.push({ cmd: current.trim(), operator: '&&' }); + current = ''; i += 2; + } else if (!inQuote && input.substring(i, i+2) === '||') { + parts.push({ cmd: current.trim(), operator: '||' }); + current = ''; i += 2; + } else { + current += ch; i++; + } + } + parts.push({ cmd: current.trim(), operator: null }); + return parts; + } + + _extractRedirects(input) { + // Find >, >>, < outside quotes + let cmd = ''; + let hasRedirect = false; + let redirectOp = ''; + let redirectFile = ''; + let append = false; + let inQuote = false, quoteChar = ''; + let i = 0; + const parts = []; + let current = ''; + while (i < input.length) { + const ch = input[i]; + if ((ch === '"' || ch === "'") && !inQuote) { + inQuote = true; quoteChar = ch; current += ch; i++; + } else if (ch === quoteChar && inQuote) { + inQuote = false; quoteChar = ''; current += ch; i++; + } else if (!inQuote) { + if (input.substring(i, i+2) === '>>') { + if (current.trim()) parts.push(current.trim()); + hasRedirect = true; + redirectOp = '>>'; + append = true; + i += 2; + // Read until next redirect or end + let file = ''; + while (i < input.length && input[i] === ' ') i++; + while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { + file += input[i]; i++; + } + redirectFile = file.trim(); + if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; + break; + } else if (input.substring(i, i+1) === '>') { + if (current.trim()) parts.push(current.trim()); + hasRedirect = true; + redirectOp = '>'; + append = false; + i += 1; + let file = ''; + while (i < input.length && input[i] === ' ') i++; + while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { + file += input[i]; i++; + } + redirectFile = file.trim(); + if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; + break; + } else if (input.substring(i, i+1) === '<') { + if (current.trim()) parts.push(current.trim()); + hasRedirect = true; + redirectOp = '<'; + i += 1; + let file = ''; + while (i < input.length && input[i] === ' ') i++; + while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { + file += input[i]; i++; + } + redirectFile = file.trim(); + if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; + break; + } else { + current += ch; i++; + } + } else { + current += ch; i++; + } + } + if (current.trim()) parts.push(current.trim()); + + const cmdText = parts.join(' '); + return { + hasRedirect, + redirectOp, + redirectFile, + append, + cmdText, + exitOk: true + }; + } + + async _executeWithRedirect(redirectInfo) { + const { redirectOp, redirectFile, append, cmdText } = redirectInfo; + const fs = this.terminal.fs; + + if (redirectOp === '<') { + // Read file content as stdin + const inputContent = fs.readFile(redirectFile); + if (inputContent === null) { + this.terminal.print(`shell: ${redirectFile}: No such file`, 'error'); + return; + } + this.terminal._pipeInput = inputContent; + // Execute the command with pipe input available + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // If command reads files, inject pipe content as temp file + if (this._isPipeAware(cmd)) { + fs.writeFile('__pipebuf__', inputContent); + const newArgs = [...args]; + if (!newArgs.some(a => !a.startsWith('-'))) { + newArgs.push('__pipebuf__'); + } + await this.terminal.dispatchCommand(cmd, newArgs); + if (fs.readFile('__pipebuf__') !== null) { + delete fs.root.content['__pipebuf__']; + } + } else { + await this.terminal.dispatchCommand(cmd, args); + } + this.terminal._pipeInput = null; + return; + } + + // > or >> — capture output, write to file + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + + // Check for pipes within the command before redirect + const pipeParts = this.terminal.splitPipe(cmdText); + if (pipeParts.length > 1) { + // Execute pipe chain manually to capture all output + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const pText = pipeParts[i]; + const pParts = pText.split(/\s+/); + const pCmd = pParts[0].toLowerCase(); + const pArgs = pParts.slice(1); + if (pipeInput !== null) { + fs.writeFile('__pipebuf__', pipeInput); + } + if (i < pipeParts.length - 1) { + const captPrint = this.terminal.print.bind(this.terminal); + let capt = ''; + this.terminal.print = (t, ty) => { capt += t + '\n'; }; + await this.terminal.dispatchCommand(pCmd, pArgs); + this.terminal.print = captPrint; + pipeInput = capt.trim(); + } else { + await this.terminal.dispatchCommand(pCmd, pArgs); + } + if (fs.readFile('__pipebuf__') !== null) { + delete fs.root.content['__pipebuf__']; + } + } + } else { + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + await this.terminal.dispatchCommand(cmd, args); + } + + this.terminal.print = originalPrint; + const captured = output.trimEnd(); + + if (captured) { + if (append) { + const existing = fs.readFile(redirectFile) || ''; + fs.writeFile(redirectFile, existing + (existing ? '\n' : '') + captured); + } else { + fs.writeFile(redirectFile, captured); + } + this.terminal.print(`Redirected ${captured.split('\n').length} line(s) to ${redirectFile}`, 'success'); + } else { + // Write empty file + if (!append) { + fs.writeFile(redirectFile, ''); + this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success'); + } + } + } + + _isPipeAware(cmd) { + // Commands that read from files and should use pipe buffer + return ['grep', 'head', 'tail', 'sort', 'wc', 'cut', 'uniq', 'expand'].includes(cmd); + } + + // ── Variable substitution ── + + _substituteVars(input) { + // Replace $VAR and ${VAR} (not inside single quotes) + let result = ''; + let inSingle = false; + let i = 0; + while (i < input.length) { + const ch = input[i]; + if (ch === "'") { + inSingle = !inSingle; + result += ch; + i++; + } else if (ch === '$' && !inSingle && i + 1 < input.length) { + // Check for $( + if (input[i+1] === '(') { + result += ch; + i++; + continue; + } + // Check for ${VAR} + if (input[i+1] === '{') { + let end = input.indexOf('}', i); + if (end !== -1) { + const varName = input.substring(i+2, end); + const val = this._getVar(varName); + result += val !== undefined ? val : '$' + varName; + i = end + 1; + continue; + } + } + // Check for $VAR (alphanumeric/underscore) + let j = i + 1; + if (j < input.length && /[A-Za-z_]/.test(input[j])) { + while (j < input.length && /\w/.test(input[j])) j++; + const varName = input.substring(i+1, j); + const val = this._getVar(varName); + result += val !== undefined ? val : ''; + i = j; + continue; + } + // $ followed by digit — not a var + result += ch; + i++; + } else { + result += ch; + i++; + } + } + return result; + } + + _getVar(name) { + if (name === 'PWD') return '/' + this.terminal.fs.currentPath.join('/'); + return this._vars[name] !== undefined ? this._vars[name] : undefined; + } + + _setVar(name, value) { + this._vars[name] = value; + } + + // ── Commands ── + + _cmdExport(args) { + if (!args[0] || !args[0].includes('=')) { + return this.terminal.print('Usage: export <var>=<value>', 'warning'); + } + const eqIdx = args[0].indexOf('='); + const name = args[0].substring(0, eqIdx); + let value = args[0].substring(eqIdx + 1); + // Support quoted values: export FOO="hello world" + if (args.length > 1) { + const rest = args.slice(1).join(' '); + if (value.endsWith('"') || value.endsWith("'")) { + // First part ended quote — fine + } else if (value.startsWith('"') || value.startsWith("'")) { + value = value + (rest ? ' ' + rest : ''); + } + } + // Strip surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length - 1); + } + this._setVar(name, value); + this.terminal.print(`Shell ${name}=${value}`, 'success'); + } + + _cmdUnset(args) { + if (!args[0]) return this.terminal.print('Usage: unset <var>', 'warning'); + delete this._vars[args[0]]; + this.terminal.print(`Shell var removed: ${args[0]}`, 'info'); + } + + _cmdSet() { + const fs = this.terminal.fs; + this.terminal.print('Shell variables:', 'info'); + for (const [k, v] of Object.entries(this._vars)) { + this.terminal.print(`${k}=${v}`, 'info'); + } + this.terminal.print(`PWD=/${fs.currentPath.join('/')}`, 'info'); + } + + async _cmdScript(args) { + if (!args[0]) return this.terminal.print('Usage: script <path.sh>', 'warning'); + if (this._scriptDepth >= this._maxScriptDepth) { + return this.terminal.print(`script: max recursion depth (${this._maxScriptDepth}) reached`, 'error'); + } + const path = args[0]; + const content = this.terminal.fs.readFile(path); + if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error'); + this._scriptDepth++; + this.terminal.print(`Running script: ${path}`, 'info'); + const lines = content.split('\n'); + let lineNum = 0; + for (const rawLine of lines) { + lineNum++; + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + // Run each line through the shell (by calling executeCommand directly) + // We need to manually call the wrapped executeCommand + this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt'); + // Parse inline: handle pipe, redirect, chain, sequence + const substituted = this._substituteVars(line); + const seqParts = this._splitSequence(substituted); + for (const seqPart of seqParts) { + if (!seqPart.trim()) continue; + const chainParts = this._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.cmd.trim()) continue; + if (chainPart.operator === '&&' && !lastOk) continue; + if (chainPart.operator === '||' && lastOk) continue; + const redirectInfo = this._extractRedirects(chainPart.cmd); + if (redirectInfo.hasRedirect) { + await this._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + const pipeParts = this.terminal.splitPipe(chainPart.cmd); + if (pipeParts.length > 1) { + await this.terminal.executePipeChain(pipeParts); + } else { + const parts = chainPart.cmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Check if it's our built-in command + if (this.commands[cmd]) { + this.terminal.markDirty(); + await this.commands[cmd](args); + } else { + await this.terminal.security.checkAndRun(chainPart.cmd); + } + } + lastOk = true; + } + } + } + } + this._scriptDepth--; + this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success'); + } +} diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js index 95da385..51a2e55 100644 --- a/modules/ext/skill-pack-core.js +++ b/modules/ext/skill-pack-core.js @@ -265,7 +265,7 @@ class SkillPackCoreModule { let seeded = 0; for (const skill of this._skills) { if (!index[skill.name]) { - fs.fsWrite(`/skills/${skill.name}.md`, skill.content); + fs.writeFile(`/skills/${skill.name}.md`, skill.content); index[skill.name] = { name: skill.name, description: skill.description, 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'; |