diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
| commit | 4baa484b84c5184286cac2743575251454a5ee2b (patch) | |
| tree | cb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/ext/webdav.js | |
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/ext/webdav.js')
| -rw-r--r-- | modules/ext/webdav.js | 532 |
1 files changed, 532 insertions, 0 deletions
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js new file mode 100644 index 0000000..63dfb05 --- /dev/null +++ b/modules/ext/webdav.js @@ -0,0 +1,532 @@ +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 href="([^"]+)">([^<]+)<\/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 = /<a href="([^"]+)">/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 <url> [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 + ' <your-token>', '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 <url> [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 <vfs-path> [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 <remote-path> [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 <backup-filename>', '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 <dir> | Pull: webdav-pull <file>', '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'; + } +} |