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', }; } _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) { const encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + 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; } 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); } 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) => { const name = (args[0] || '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'); const filename = name + '-' + dateStr + '.json'; const remotePath = 'backups/' + filename; try { await self._ensureDir('backups'); const serialized = JSON.stringify(self.terminal.fs.root, null, 2); const resp = await self._webdavRequest('PUT', remotePath, { body: serialized, headers: { 'Content-Type': 'application/json' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success'); } 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 ', 'warning'); self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info'); return; } let remotePath = args[0]; 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(); 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-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'); } }; } _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'; } }