class WebDAVModule { constructor() { this.commands = {}; this.terminal = null; this.config = null; // { url, username, password } this._browsingPath = '/'; this._STORAGE_KEY = 'webx11_webdav_config'; // Auto-discover space name from page URL this._spaceName = this._detectSpaceName(); this._autoConfigured = false; this._loadConfig(); } _detectSpaceName() { try { const path = window.location.pathname; // /spaces/chatstudio/newos4/ → chatstudio const parts = path.split('/').filter(p => p); const spacesIdx = parts.indexOf('spaces'); if (spacesIdx >= 0 && spacesIdx + 1 < parts.length) { return parts[spacesIdx + 1]; } } catch (e) {} return ''; } requirements() { const cfg = localStorage.getItem('webx11_webdav_config'); if (!cfg) return { met: false, missing: ['WebDAV endpoint not configured. Use webdav-config to set it up.'] }; try { const parsed = JSON.parse(cfg); if (!parsed.url) return { met: false, missing: ['WebDAV URL not set'] }; return { met: true, missing: [] }; } catch (e) { return { met: false, missing: ['WebDAV config corrupted'] }; } } getHelpInfo() { return { 'webdav-config': 'webdav-config [url user pass] — Configure WebDAV. No args = auto-detect from current page URL', 'webdav-status': 'Show current WebDAV configuration status', 'webdav-ls': 'List files/directories on the WebDAV remote', 'webdav-push': 'Upload a VFS file to WebDAV', 'webdav-pull': 'Download a WebDAV file to VFS', 'webdav-backup': 'Backup entire VFS to WebDAV as a dated JSON snapshot', 'webdav-restore': 'Restore VFS from a WebDAV backup file', 'webdav-backup-list': 'List available backup files on WebDAV', 'webdav-explorer': 'webdav-explorer [path] — Interactive remote file browser', 'webdav-browser': 'webdav-browser [path] — Visual WebDAV file explorer modal', }; } _loadConfig() { try { const saved = localStorage.getItem(this._STORAGE_KEY); if (saved) { this.config = JSON.parse(saved); // Try to get password from SecretVault if (window.webx11_secretVault && this.config && this.config.username) { let encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + this.config.username); if (encryptedPass !== null && encryptedPass !== undefined) { this.config.password = encryptedPass; } else { // Fallback to old token key encryptedPass = window.webx11_secretVault.decrypt('webdav_token_' + this.config.username); if (encryptedPass !== null && encryptedPass !== undefined) { this.config.password = encryptedPass; } } } } } catch (e) { /* ignore */ } } _saveConfig() { try { if (this.config) { const toStore = { url: this.config.url, username: this.config.username }; // Try SecretVault for encrypted password storage if (window.webx11_secretVault && this.config.password) { window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password); // Also store space token if (this._spaceName) { window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password); } } // Also store in localStorage for backward compat (SecretVault decrypts later) // Store only url and username hint in plaintext lS, encrypted password handled above localStorage.setItem(this._STORAGE_KEY, JSON.stringify(toStore)); } else { localStorage.removeItem(this._STORAGE_KEY); } } catch (e) { console.error('WebDAV config save error:', e); } } _getAuthHeader() { if (!this.config || !this.config.password) return {}; const token = btoa(this.config.username + ':' + this.config.password); return { 'Authorization': 'Basic ' + token }; } async _webdavRequest(method, path, options = {}) { if (!this.config || !this.config.url) { throw new Error('WebDAV not configured. Use webdav-config first.'); } const baseUrl = this.config.url.replace(/\/+$/, ''); const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; const url = baseUrl + urlPath; const headers = { ...this._getAuthHeader(), ...(options.headers || {}) }; const fetchOpts = { method: method, headers: headers, body: options.body || undefined }; const response = await fetch(url, fetchOpts); return response; } async _listDir(path) { // Try PROPFIND first (works with WebDAV servers), fallback to nginx autoindex try { const response = await this._webdavRequest('PROPFIND', path, { headers: { 'Depth': '1' } }); if (response.ok) { const text = await response.text(); return this._parsePropfind(text); } } catch (e) { /* PROPFIND failed, try autoindex */ } // Fallback: use nginx __list__ endpoint return this._listDirViaAutoIndex(path); } async _listDirViaAutoIndex(path) { const baseUrl = this.config.url.replace(/\/+$/, '') + '/'; const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; // Use __list__ for directory listing const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, ''); try { const resp = await fetch(listUrl, { method: 'GET' }); if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status); const html = await resp.text(); return this._parseAutoindex(html); } catch (e) { throw new Error('Cannot list directory: ' + e.message); } } _parseAutoindex(html) { const entries = []; // Parse nginx autoindex HTML format const linkRegex = /([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; let match; while ((match = linkRegex.exec(html)) !== null) { const href = match[1]; const name = match[2].replace(/\/$/, ''); const sizeStr = match[4]; if (name === '..' || name === '../') continue; if (name === '__list__') continue; const isDir = href.endsWith('/'); let size = 0; if (!isDir && sizeStr !== '-') { size = parseInt(sizeStr) || 0; } entries.push({ name: name, type: isDir ? 'dir' : 'file', size: size, href: href }); } // Fallback: if regex failed, try simpler parse if (entries.length === 0) { const simpleRegex = //g; const names = []; while ((match = simpleRegex.exec(html)) !== null) { const href = match[1]; if (href === '../' || href === '/' || href === './') continue; if (href.startsWith('?')) continue; names.push(href); } for (const href of names) { const isDir = href.endsWith('/'); entries.push({ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, href: href }); } } return entries; } _parsePropfind(xml) { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); const responses = doc.querySelectorAll('D\\:response, response'); const entries = []; responses.forEach((resp) => { const href = (resp.querySelector('D\\:href, href') || {}).textContent || ''; const isDir = !!((resp.querySelector('D\\:collection, collection') || {}).textContent); const getContentLength = (resp.querySelector('D\\:getcontentlength, getcontentlength, lp1\\:getcontentlength') || {}).textContent; const size = getContentLength ? parseInt(getContentLength) : 0; const displayName = href.split('/').filter(Boolean).pop() || '/'; if (displayName) { entries.push({ name: decodeURIComponent(displayName), type: isDir ? 'dir' : 'file', size: size, href: href }); } }); // Remove the root entry (same as first child's dir) return entries.filter((e, i) => i > 0 || e.type !== 'dir' || entries.length <= 1); } async _ensureDir(remotePath) { // Try MKCOL — ignore 405 (already exists) and 409 (parent missing) const response = await this._webdavRequest('MKCOL', remotePath); if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true; if (response.status === 409) { // Parent missing — try creating parent recursively const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); if (parts.length > 1) { const parent = parts.slice(0, -1).join('/'); await this._ensureDir(parent); // Retry MKCOL const retry = await this._webdavRequest('MKCOL', remotePath); if (retry.ok || retry.status === 405) return true; } // Try creating path component by component as a fallback let current = ''; for (const part of parts) { current += '/' + part; try { const attempt = await this._webdavRequest('MKCOL', current); if (!attempt.ok && attempt.status !== 405) { // Ignore — it likely exists already or we can't create it } } catch (e) { /* ignore */ } } return true; } 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; // --- webdav-config: set WebDAV endpoint and credentials --- this.commands['webdav-config'] = async (args) => { // No args: auto-detect space name and try to configure from token if (!args || args.length === 0) { const spaceName = self._spaceName; if (!spaceName) { self.terminal.print('Could not detect space name from URL.', 'warning'); self.terminal.print('Usage: webdav-config [username] [password]', 'info'); return; } // Auto-configure URL from space name const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/'; let token = null; // Check SecretVault first if (window.webx11_secretVault) { token = window.webx11_secretVault.decrypt('webdav_token_' + spaceName); } // Fallback to localStorage if (!token) { token = localStorage.getItem('webx11_spaces_' + spaceName); } // Fallback: fetch .token directly from server if (!token) { try { const tokenResp = await fetch('/spaces/' + spaceName + '/.token'); if (tokenResp.ok) { token = (await tokenResp.text()).trim(); // Cache in SecretVault for future use if (window.webx11_secretVault) { window.webx11_secretVault.encrypt('webdav_token_' + spaceName, token); } } } catch (e) { /* .token not accessible */ } } if (token) { self.config = { url: autoUrl, username: spaceName, password: token }; self._saveConfig(); self._autoConfigured = true; self.terminal.print('Auto-configured WebDAV for space: ' + spaceName, 'success'); try { const resp = await self._webdavRequest('OPTIONS', '/'); self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); } catch (e) { self.terminal.print('Connection test failed: ' + e.message, 'warning'); self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); } } else { self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning'); self.terminal.print('Get your token from /spaces/' + spaceName + '/.token or the dashboard.', 'info'); self.terminal.print('Auto-setting URL: ' + autoUrl, 'info'); self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' ', 'info'); // At minimum set the URL self.config = { url: autoUrl, username: spaceName, password: '' }; self._saveConfig(); self._autoConfigured = true; } return; } // Legacy 1-3 arg behavior self.config = { url: args[0], username: args[1] || (self.config ? self.config.username : ''), password: args[2] || (self.config ? self.config.password : '') }; self._saveConfig(); self.terminal.print('Testing WebDAV connection...', 'info'); try { const resp = await self._webdavRequest('OPTIONS', '/'); self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); } catch (e) { self.terminal.print('Connection test failed: ' + e.message, 'warning'); self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); } }; // --- webdav-status: show current WebDAV config --- this.commands['webdav-status'] = () => { if (!self.config || !self.config.url) { self.terminal.print('WebDAV not configured. Use webdav-config [username] [password]', 'warning'); return; } self.terminal.print('WebDAV Status:', 'text-muted'); self.terminal.print(' URL: ' + self.config.url); self.terminal.print(' User: ' + (self.config.username || '(none)')); self.terminal.print(' Password: ' + (self.config.password ? '**** (' + self.config.password.length + ' chars)' : '(none)')); self.terminal.print(' Space detected: ' + (self._spaceName || '(none)')); if (self._autoConfigured) { self.terminal.print(' Auto-configured: yes', 'success'); } }; // --- webdav-ls: list remote directory --- this.commands['webdav-ls'] = async (args) => { const remotePath = args[0] || '/'; try { const entries = await self._listDir(remotePath); if (entries.length === 0) { self.terminal.print('(empty)', 'info'); return; } entries.forEach(e => { const icon = e.type === 'dir' ? '📁' : '📄'; const sizeStr = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/'; self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + (e.type === 'file' ? sizeStr : '')); }); self.terminal.print(entries.length + ' item(s)', 'text-muted'); } catch (e) { self.terminal.print('Failed to list: ' + e.message, 'error'); } }; // --- webdav-push: upload a VFS file to WebDAV --- this.commands['webdav-push'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-push [remote-path]', 'warning'); return; } const vfsPath = args[0]; const remotePath = args[1] || vfsPath; const content = self.terminal.fs.readFile(vfsPath); if (content === null) { self.terminal.print('File not found in VFS: ' + vfsPath, 'error'); return; } try { // Ensure remote dir exists const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); if (parts.length > 1) { const parent = parts.slice(0, -1).join('/'); await self._ensureDir(parent); } const resp = await self._webdavRequest('PUT', remotePath, { body: content, headers: { 'Content-Type': 'text/plain' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Pushed: ' + vfsPath + ' → ' + remotePath, 'success'); } else { self.terminal.print('Push failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Push error: ' + e.message, 'error'); } }; // --- webdav-pull: download a WebDAV file to VFS --- this.commands['webdav-pull'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-pull [vfs-path]', 'warning'); return; } const remotePath = args[0]; const vfsPath = args[1] || remotePath.split('/').pop(); try { const resp = await self._webdavRequest('GET', remotePath); if (!resp.ok) throw new Error('HTTP ' + resp.status); const content = await resp.text(); self.terminal.fs.writeFile(vfsPath, content); self.terminal.print('Pulled: ' + remotePath + ' → ' + vfsPath + ' (' + content.length + ' bytes)', 'success'); } catch (e) { self.terminal.print('Pull error: ' + e.message, 'error'); } }; // --- webdav-backup: backup entire VFS to WebDAV --- this.commands['webdav-backup'] = async (args) => { // 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') + '-' + String(now.getDate()).padStart(2, '0') + '_' + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); let filename = name + '-' + dateStr + '.json'; let remotePath = 'backups/' + filename; try { await self._ensureDir('backups'); // 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: payload, headers: { 'Content-Type': encryptPass ? 'application/json' : (isGzipped ? 'application/gzip' : 'application/json') } }); if (resp.ok || resp.status === 201 || resp.status === 204) { 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'); } } catch (e) { self.terminal.print('Backup error: ' + e.message, 'error'); } }; // --- webdav-restore: restore VFS from a WebDAV backup --- this.commands['webdav-restore'] = async (args) => { if (!args[0]) { 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); // 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'); return; } if (self.terminal.fs.restoreFromExport(data)) { self.terminal.updatePrompt(); self.terminal.print('VFS restored from: ' + remotePath, 'success'); self.terminal.executeCommand('ls'); } else { self.terminal.print('Failed to restore VFS from backup', 'error'); } } catch (e) { self.terminal.print('Restore error: ' + e.message, 'error'); } }; // --- webdav-backup-list: list available backups --- this.commands['webdav-backup-list'] = async () => { try { const entries = await self._listDir('backups'); if (entries.length === 0) { self.terminal.print('No backups found', 'info'); return; } self.terminal.print('Available backups:', 'text-muted'); entries.forEach(e => { if (e.type === 'file') { self.terminal.print(' ' + e.name + ' (' + self._formatSize(e.size) + ')'); } }); } catch (e) { self.terminal.print('Failed to list backups: ' + e.message, 'info'); self.terminal.print(' (backups directory may not exist yet — run webdav-backup first)', 'info'); } }; // --- webdav-browser: visual modal file browser --- this.commands['webdav-browser'] = async (args) => { const startPath = args[0] || self._browsingPath || '/'; await self.openWebdavBrowser(startPath); }; // --- webdav-explorer: interactive file browser via WebDAV --- this.commands['webdav-explorer'] = async (args) => { const startPath = args[0] || '/'; try { const entries = await self._listDir(startPath); self.terminal.print('📁 WebDAV: ' + startPath, 'info'); if (entries.length === 0) { self.terminal.print('(empty)', 'info'); return; } entries.forEach(e => { const icon = e.type === 'dir' ? '📁' : '📄'; const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/'; self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size); }); self.terminal.print('---'); self.terminal.print('↳ Navigate: webdav-explorer | Pull: webdav-pull ', 'text-muted'); self.terminal.print(entries.length + ' item(s)', 'text-muted'); } catch (e) { self.terminal.print('WebDAV error: ' + e.message, 'error'); } }; } /* ── Visual WebDAV Browser Modal ── */ async openWebdavBrowser(path) { if (!this.config || !this.config.url) { if (this.terminal) this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); return; } this._browsingPath = path; const self = this; // Remove existing modal if any const existing = document.getElementById('webdav-browser-modal'); if (existing) existing.remove(); // Fetch listing let entries = []; let errorMsg = ''; try { entries = await this._listDir(path); } catch (e) { errorMsg = e.message; } // Build modal const overlay = document.createElement('div'); overlay.id = 'webdav-browser-modal'; overlay.className = 'modal-overlay'; overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:999;'; overlay.onclick = function(e) { if (e.target === this) self.closeWebdavBrowser(); }; const content = document.createElement('div'); content.className = 'modal-content'; content.style.cssText = 'max-width:650px; max-height:80vh; width:92%;'; // Header const header = document.createElement('div'); header.className = 'modal-header'; header.innerHTML = '📁 WebDAV Browser✕'; content.appendChild(header); // Body const body = document.createElement('div'); body.className = 'modal-body'; body.style.cssText = 'padding:8px; overflow-y:auto; flex-grow:1;'; if (errorMsg) { const errDiv = document.createElement('div'); errDiv.style.cssText = 'color:var(--error-color); padding:16px; text-align:center;'; errDiv.textContent = '❌ ' + errorMsg; body.appendChild(errDiv); } else if (entries.length === 0) { const emptyDiv = document.createElement('div'); emptyDiv.className = 'text-center'; emptyDiv.style.cssText = 'padding:20px; color:var(--text-muted);'; emptyDiv.textContent = '(empty) — upload files using the button below'; body.appendChild(emptyDiv); } else { // Sort: dirs first, then files, alphabetically entries.sort(function(a, b) { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); var frag = document.createDocumentFragment(); entries.forEach(function(entry) { var item = document.createElement('div'); item.className = entry.type === 'dir' ? 'file-item dir' : 'file-item'; item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:8px; border-bottom:1px solid #111; cursor:pointer; min-height:36px;'; item.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; item.onmouseout = function() { this.style.background = ''; }; var nameSpan = document.createElement('span'); var icon = entry.type === 'dir' ? '📁' : '📄'; nameSpan.textContent = icon + ' ' + entry.name + (entry.type === 'dir' ? '/' : ''); var sizeSpan = document.createElement('span'); sizeSpan.style.cssText = 'font-size:0.8em; color:var(--text-muted);'; if (entry.type === 'file') { sizeSpan.textContent = self._formatSize(entry.size); } item.appendChild(nameSpan); item.appendChild(sizeSpan); if (entry.type === 'dir') { item.onclick = function() { self.openWebdavBrowser(entry.href); }; } else { item.onclick = function() { self.previewWebdavFile(entry); }; // Right-click or context menu could download — but for simplicity, long tap or regular click previews // Double-click to download item.ondblclick = function() { self.downloadFromWebdav(entry); }; } frag.appendChild(item); }); body.appendChild(frag); } content.appendChild(body); // Footer with breadcrumb + buttons const footer = document.createElement('div'); footer.className = 'modal-footer'; footer.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap; padding:8px 10px;'; // Breadcrumb var breadDiv = document.createElement('div'); breadDiv.style.cssText = 'font-size:0.8em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0;'; var cleanPath = path.replace(/\/+$/, '') || '/'; var parts = cleanPath.split('/').filter(Boolean); var breadHtml = '📁 /'; var cumulative = ''; for (var i = 0; i < parts.length; i++) { cumulative += '/' + parts[i]; var seg = parts[i]; breadHtml += '' + seg + ''; if (i < parts.length - 1) breadHtml += ' ▸ '; } breadDiv.innerHTML = breadHtml; // Attach nav function to modal element for breadcrumb clicks overlay.__nav = function(p) { self.openWebdavBrowser(p); }; // Button group var btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex; gap:4px; flex-shrink:0;'; var uploadBtn = document.createElement('button'); uploadBtn.className = 'btn'; uploadBtn.textContent = '⬆ Upload'; uploadBtn.onclick = function() { self.uploadToWebdav(path); }; var refreshBtn = document.createElement('button'); refreshBtn.className = 'btn'; refreshBtn.textContent = '🔄'; refreshBtn.title = 'Refresh'; refreshBtn.onclick = function() { self.openWebdavBrowser(path); }; var closeBtn = document.createElement('button'); closeBtn.className = 'btn'; closeBtn.textContent = 'Close'; closeBtn.onclick = function() { self.closeWebdavBrowser(); }; btnGroup.appendChild(uploadBtn); btnGroup.appendChild(refreshBtn); btnGroup.appendChild(closeBtn); footer.appendChild(breadDiv); footer.appendChild(btnGroup); content.appendChild(footer); overlay.appendChild(content); document.body.appendChild(overlay); this.terminal.print('📂 Opened WebDAV browser: ' + path, 'info'); } closeWebdavBrowser() { var modal = document.getElementById('webdav-browser-modal'); if (modal) modal.remove(); if (this.terminal && this.terminal.inputField) this.terminal.inputField.focus(); this.terminal.print('WebDAV browser closed', 'text-muted'); } async previewWebdavFile(entry) { try { var resp = await this._webdavRequest('GET', entry.href); if (!resp.ok) { this.terminal.print('Failed to fetch: HTTP ' + resp.status, 'error'); return; } var content = await resp.text(); var isHtml = entry.name.match(/\.html?$/i); var isImage = entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i); var size = content.length; if (size > 512000 && !isHtml && !isImage) { // Large non-html file — offer download instead this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — double-click to download instead of preview.', 'warning'); this.terminal.print('--- ' + entry.name + ' (first 2000 chars) ---'); this.terminal.print(content.substring(0, 2000)); if (content.length > 2000) this.terminal.print('...(truncated ' + this._formatSize(size) + ' total)'); return; } if (isImage) { var dataUrl = 'data:image/' + (entry.name.split('.').pop()) + ';base64,' + btoa(unescape(encodeURIComponent(content))); // Use preview modal var previewTitle = document.getElementById('preview-title'); var previewIframe = document.getElementById('preview-iframe'); if (previewTitle && previewIframe) { previewTitle.textContent = '🖼 ' + entry.name; previewIframe.srcdoc = ''; document.getElementById('preview-modal').style.display = 'flex'; return; } } if (isHtml) { var previewTitle = document.getElementById('preview-title'); var previewIframe = document.getElementById('preview-iframe'); if (previewTitle && previewIframe) { previewTitle.textContent = '📄 ' + entry.name; previewIframe.srcdoc = content; document.getElementById('preview-modal').style.display = 'flex'; return; } } // Text file — print to terminal this.terminal.print('--- ' + entry.name + ' (' + this._formatSize(size) + ') ---', 'info'); this.terminal.print(content); this.terminal.print('--- End: ' + entry.name + ' ---', 'text-muted'); } catch (e) { this.terminal.print('Preview error: ' + e.message, 'error'); } } uploadToWebdav(currentPath) { var self = this; var input = document.createElement('input'); input.type = 'file'; input.onchange = async function(e) { var file = e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = async function(evt) { var basePath = currentPath.replace(/\/+$/, ''); var remotePath = basePath + '/' + file.name; var parentDir = remotePath.split('/').slice(0, -1).join('/'); try { await self._ensureDir(parentDir); var resp = await self._webdavRequest('PUT', remotePath, { body: evt.target.result, headers: { 'Content-Type': 'application/octet-stream' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); self.openWebdavBrowser(currentPath); } else { self.terminal.print('Upload failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Upload error: ' + e.message, 'error'); } }; reader.readAsText(file); }; input.click(); } async downloadFromWebdav(entry) { try { var resp = await this._webdavRequest('GET', entry.href); if (!resp.ok) { this.terminal.print('Download failed: HTTP ' + resp.status, 'error'); return; } var content = await resp.text(); var blob = new Blob([content], { type: 'application/octet-stream' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = entry.name; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(function() { document.body.removeChild(a); URL.revokeObjectURL(url); }, 200); this.terminal.print('⬇ Downloaded: ' + entry.name + ' (' + this._formatSize(content.length) + ')', 'success'); } catch (e) { this.terminal.print('Download error: ' + e.message, 'error'); } } /* ── 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'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } }