class WebDAVModule { constructor() { this.commands = {}; this.commandsMeta = { 'webdav-ls': { argType: 'file' }, 'webdav-push': { argType: 'file' }, 'webdav-pull': { argType: 'file' }, 'webdav-rm': { argType: 'file' }, 'webdav-mkdir': { argType: 'dir' }, 'webdav-rename': { argType: 'file' }, 'webdav-move': { argType: 'file' }, 'webdav-backup': { argType: 'file' }, 'webdav-restore': { argType: 'file' }, 'webdav-browser': { argType: 'file' }, 'webdav-config': { argType: 'file' }, }; 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 ''; } _extractSpaceNameFromUrl(url) { try { const u = new URL(url); const parts = u.pathname.split('/').filter(p => p); const idx = parts.indexOf('spaces'); if (idx >= 0 && idx + 1 < parts.length) return parts[idx + 1]; } catch (e) {} return ''; } _resolveSpaceName() { // Prefer the configured URL's space, fall back to the page URL if (this.config && this.config.url) { const fromUrl = this._extractSpaceNameFromUrl(this.config.url); if (fromUrl) return fromUrl; } return this._spaceName; } _detectSessionUser() { try { const lsUser = localStorage.getItem('webx11_username'); if (lsUser && lsUser.trim()) return lsUser.trim(); const cookies = document.cookie.split(';').map(c => c.trim()); for (const c of cookies) { if (c.startsWith('webx11_user=')) { return decodeURIComponent(c.substring('webx11_user='.length)); } } } catch (e) {} return null; } _detectSessionToken() { try { return localStorage.getItem('webx11_webdav_token') || ''; } catch (e) { return ''; } } _promptForToken(spaceName) { const input = window.prompt('Enter your WebDAV token for space "' + spaceName + '":', ''); if (input && input.trim()) return input.trim(); return null; } 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-browser': 'webdav-browser [path] — Modern WebDAV file-tree browser with bulk operations', 'webdav-rm': 'webdav-rm — Delete a remote file or directory', 'webdav-mkdir': 'webdav-mkdir — Create a remote directory', 'webdav-rename': 'webdav-rename — Rename or move a remote file/dir', 'webdav-move': 'webdav-move — Alias for webdav-rename', }; } _loadConfig() { try { const saved = localStorage.getItem(this._STORAGE_KEY); if (saved) { this.config = JSON.parse(saved); // Try to get password from SecretVault (async — will be resolved on first use) if (window.webx11_secretVault && this.config && this.config.username) { // Store a placeholder; actual decrypt happens on first auth call this._cachedPassUser = this.config.username; } } } catch (e) { /* ignore */ } } async _resolvePassword() { if (!window.webx11_secretVault || !this._cachedPassUser) return; try { const pass = await window.webx11_secretVault.decrypt('webdav_pass_' + this._cachedPassUser); if (pass !== null && typeof pass === 'string') { this.config.password = pass; } else { // Fallback to old token key const tokenPass = await window.webx11_secretVault.decrypt('webdav_token_' + this._cachedPassUser); if (tokenPass !== null && typeof tokenPass === 'string') { this.config.password = tokenPass; } } } catch (e) { /* ignore */ } this._cachedPassUser = null; } async _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) { await window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password); // Also store space token if (this._spaceName) { await window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password); } } // Also store in localStorage for backward compat (url and username only) 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.'); } // Resolve password from SecretVault if needed (lazy load) if (this._cachedPassUser) { await this._resolvePassword(); } 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) { // For WebX11 spaces, skip PROPFIND (nginx returns 302) and use __list__ directly if (this._spaceName) { return this._listDirViaAutoIndex(path); } // Non-nginx: try PROPFIND first, fallback to 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 */ } return this._listDirViaAutoIndex(path); } async _listDirViaAutoIndex(path) { const spaceName = this._resolveSpaceName(); if (!spaceName) { throw new Error('WebDAV: cannot use __list__ without space name'); } // Derive host from configured URL to support different environments let host = 'https://webx11.duckdns.org'; if (this.config && this.config.url) { try { const u = new URL(this.config.url); host = u.origin; } catch (e) {} } const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; // Avoid double slash when path starts with / const listUrl = host + '/spaces/' + spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); 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 — flexible regex that doesn't depend on date locale const linkRegex = /([^\u003c]+)<\/a>\s+([\w]{2}-[\w]{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 dateStr = match[3]; const sizeStr = match[4]; if (name === '..' || name === '../' || 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, mtime: this._parseNginxDate(dateStr), href: href }); } // Fallback: if regex failed, try simpler parse if (entries.length === 0) { // Try broadest: extract all hrefs const broadRegex = //g; const names = []; while ((match = broadRegex.exec(html)) !== null) { const href = match[1]; if (href === '../' || href === '/' || href === './') continue; if (href.startsWith('?')) continue; names.push(href); } if (names.length > 0) { for (const href of names) { const isDir = href.endsWith('/'); entries.push({ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, mtime: null, href: href }); } } else { // Last resort: match any const lastRegex = / { 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) 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 — create each missing component once, bottom-up. // (Previously this recursed + retried + looped, producing an MKCOL // storm of 6-8 requests per call that delayed uploads.) const parts = remotePath.replace(/\/+$/, '').split('/').filter(Boolean); let current = ''; let created = false; for (const part of parts) { current += '/' + part; try { const attempt = await this._webdavRequest('MKCOL', current); if (attempt.ok || attempt.status === 405) created = true; } catch (e) { /* ignore — component may exist */ } } return created; } 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) => { const autoUrlFor = (space) => 'https://webx11.duckdns.org/spaces/' + space + '/'; // No args: prefer logged-in user's space, fall back to URL space if (!args || args.length === 0) { const sessionUser = self._detectSessionUser(); const sessionToken = self._detectSessionToken(); if (sessionUser && sessionToken) { // Auto-mount the logged-in user's own space const autoUrl = autoUrlFor(sessionUser); self.config = { url: autoUrl, username: sessionUser, password: sessionToken }; await self._saveConfig(); self._autoConfigured = true; self.terminal.print('Auto-configured WebDAV for logged-in user: ' + sessionUser, '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'); } return; } const spaceName = self._spaceName; if (!spaceName) { self.terminal.print('Could not detect user or space from URL.', 'warning'); self.terminal.print('Usage: webdav-config [username] [password]', 'info'); return; } // Not logged in: pre-fill URL/username for this space and ask for token const sessionUserOther = self._detectSessionUser(); const autoUrl = autoUrlFor(spaceName); let token = null; // Check SecretVault first if (window.webx11_secretVault) { const vaultToken = window.webx11_secretVault.decrypt('webdav_token_' + spaceName); if (vaultToken && typeof vaultToken.then === 'function') { token = await vaultToken; } else if (vaultToken !== null && vaultToken !== undefined) { token = vaultToken; } } // Fallback to localStorage if (!token) { token = localStorage.getItem('webx11_spaces_' + spaceName); } if (token) { self.config = { url: autoUrl, username: spaceName, password: token }; await 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 { if (sessionUserOther) { self.terminal.print('👋 You are logged in as "' + sessionUserOther + '" — this space is "' + spaceName + '".', 'info'); } else { self.terminal.print('Not logged in. Please enter the WebDAV token for space "' + spaceName + '":', 'info'); } self.terminal.print('', 'text-muted'); self.terminal.print(' webdav-config ' + autoUrl + ' ' + spaceName + ' ', 'success'); self.terminal.print('', 'text-muted'); // Pre-configure URL + username so user only needs token self.config = { url: autoUrl, username: spaceName, password: '' }; await self._saveConfig(); } 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 : '') }; await 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'] = async () => { if (!self.config || !self.config.url) { self.terminal.print('WebDAV not configured. Use webdav-config [username] [password]', 'warning'); return; } // Resolve password from SecretVault if needed if (self._cachedPassUser) { await self._resolvePassword(); } 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 let exportData; try { // Deep-clone and strip system dirs to prevent fractal bloat let rootForExport; try { rootForExport = JSON.parse(JSON.stringify(self.terminal.fs.root)); if (rootForExport && rootForExport.content) { delete rootForExport.content['_sessions']; delete rootForExport.content['_snapshots']; delete rootForExport.content['.trash']; } } catch (e) { rootForExport = self.terminal.fs.root; } exportData = { type: 'webx11-backup', version: 2, savedAt: new Date().toISOString(), root: rootForExport, currentPath: self.terminal.fs.currentPath, aliases: self.terminal.moduleManager.aliases || {}, moduleRegistry: self.terminal.moduleManager.moduleRegistry || [] }; } catch (e) { self.terminal.print('Failed to read VFS: ' + e.message, 'error'); return; } const serialized = JSON.stringify(exportData, null, 2); // Size check: abort if >5MB if (serialized.length > 52428800) { self.terminal.print('Backup too large: ' + self._formatSize(serialized.length) + ' (>50MB limit). Try reducing VFS size first.', 'error'); return; } // 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) { self.terminal.print('Gzip unavailable, falling back to plain JSON', 'info'); } } else { self.terminal.print('CompressionStream not supported, saving as plain JSON', 'info'); } // 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: modern visual file browser --- this.commands['webdav-browser'] = async (args) => { const startPath = (args && args[0]) || self._browsingPath || '/'; await self.openWebdavBrowser(startPath); }; // --- webdav-rm: delete remote file/dir --- this.commands['webdav-rm'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-rm ', 'warning'); return; } try { const ok = await self._deleteFile(args[0]); if (ok) { self.terminal.print('Deleted: ' + args[0], 'success'); } else { self.terminal.print('Delete failed: resource may not exist', 'error'); } } catch (e) { self.terminal.print('Delete error: ' + e.message, 'error'); } }; // --- webdav-mkdir: create remote directory --- this.commands['webdav-mkdir'] = async (args) => { if (!args[0]) { self.terminal.print('Usage: webdav-mkdir ', 'warning'); return; } try { const ok = await self._ensureDir(args[0]); if (ok) { self.terminal.print('Created directory: ' + args[0], 'success'); } else { self.terminal.print('Failed to create directory', 'error'); } } catch (e) { self.terminal.print('Mkdir error: ' + e.message, 'error'); } }; // --- webdav-rename: rename/move remote file or dir (MOVE method) --- this.commands['webdav-rename'] = async (args) => { if (!args[0] || !args[1]) { self.terminal.print('Usage: webdav-rename ', 'warning'); self.terminal.print('Renames or moves a file/directory using WebDAV MOVE', 'info'); return; } try { const resp = await self._webdavRequest('MOVE', args[0], { headers: { 'Destination': args[1] } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Renamed: ' + args[0] + ' → ' + args[1], 'success'); } else { self.terminal.print('Rename failed: HTTP ' + resp.status, 'error'); } } catch (e) { self.terminal.print('Rename error: ' + e.message, 'error'); } }; // --- webdav-move: alias for webdav-rename --- this.commands['webdav-move'] = this.commands['webdav-rename']; } /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ _getFileIcon(name) { if (!name) return '📄'; const ext = name.split('.').pop().toLowerCase(); const icons = { html: '🌐', htm: '🌐', css: '🎨', js: '📜', json: '📋', yaml: '📋', yml: '📋', ts: '📘', py: '🐍', sh: '🔧', md: '📝', txt: '📄', png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️', mp4: '🎬', webm: '🎬', mp3: '🎵', wav: '🎵', ogg: '🎵', pdf: '📕', doc: '📘', docx: '📘', xls: '📗', xlsx: '📗', zip: '📦', tar: '📦', gz: '📦', rar: '📦', '7z': '📦', php: '🌐', sql: '🗃️', db: '🗃️', log: '📋' }; return icons[ext] || '📄'; } _formatDate(iso) { if (!iso) return ''; try { const d = new Date(iso); return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch (e) { return iso; } } _escapeHtml(text) { return (text || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } async _createRemoteFile(remotePath) { try { const parentDir = remotePath.split('/').slice(0, -1).join('/') || '/'; await this._ensureDir(parentDir); const resp = await this._webdavRequest('PUT', remotePath, { body: '', headers: { 'Content-Type': 'text/plain' } }); return resp.ok || resp.status === 201 || resp.status === 204; } catch (e) { this.terminal.print('Create file error: ' + e.message, 'error'); return false; } } /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ 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; this._selected = {}; this._browserSort = this._browserSort || { key: 'name', dir: 'asc' }; this._browserFilter = this._browserFilter || ''; // Remove existing modal if any const existing = document.getElementById('webdav-browser-modal'); if (existing) existing.remove(); // Fetch listing let allEntries = []; let errorMsg = ''; try { allEntries = await this._listDir(path); } catch (e) { errorMsg = e.message; } // Hide dotfiles allEntries = allEntries.filter(function(e) { return !e.name.startsWith('.'); }); if (errorMsg) { const errOverlay = document.createElement('div'); errOverlay.id = 'webdav-browser-modal'; errOverlay.className = 'modal-overlay'; errOverlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:0;'; errOverlay.onclick = function(e) { if (e.target === this) this.remove(); }; const errContent = document.createElement('div'); errContent.className = 'modal-content'; errContent.style.cssText = 'width:100vw; height:100vh; max-width:none; max-height:none; display:flex; flex-direction:column; overflow:hidden; border-radius:0;'; const errDiv = document.createElement('div'); errDiv.style.cssText = 'color:var(--error-color); padding:40px; text-align:center; font-size:1.05em;'; errDiv.textContent = '❌ ' + errorMsg; const errFooter = document.createElement('div'); errFooter.className = 'modal-footer'; errFooter.style.cssText = 'display:flex; justify-content:center; align-items:center; gap:8px; padding:12px; border-top:1px solid var(--border);'; const backBtn = document.createElement('button'); backBtn.className = 'btn'; backBtn.textContent = '⬅ Back'; backBtn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; backBtn.onclick = function() { const parentPath = path.replace(/\/[^\/]*\/?$/, '') || '/'; self.openWebdavBrowser(parentPath); }; const closeBtn = document.createElement('button'); closeBtn.className = 'btn'; closeBtn.textContent = 'Close'; closeBtn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; closeBtn.onclick = function() { self.closeWebdavBrowser(); }; errFooter.appendChild(backBtn); errFooter.appendChild(closeBtn); errContent.appendChild(errDiv); errContent.appendChild(errFooter); errOverlay.appendChild(errContent); document.body.appendChild(errOverlay); this.terminal.print('❌ WebDAV browser error: ' + errorMsg, 'error'); return; } // Sort: dirs first, then by current sort key function sortEntries(entries) { const sortKey = self._browserSort.key; const dir = self._browserSort.dir; return entries.slice().sort(function(a, b) { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; let cmp = 0; if (sortKey === 'name') cmp = a.name.localeCompare(b.name); else if (sortKey === 'size') cmp = (a.size || 0) - (b.size || 0); else if (sortKey === 'date') cmp = new Date(a.mtime || 0) - new Date(b.mtime || 0); if (cmp === 0) cmp = a.name.localeCompare(b.name); return dir === 'asc' ? cmp : -cmp; }); } function filterEntries(entries) { const f = (self._browserFilter || '').toLowerCase(); if (!f) return entries; return entries.filter(function(e) { return e.name.toLowerCase().indexOf(f) !== -1; }); } function getEntries() { return sortEntries(filterEntries(allEntries)); } // 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:9999; padding:12px;'; overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:0;'; const content = document.createElement('div'); content.className = 'modal-content'; content.style.cssText = 'width:100vw; height:100vh; max-width:none; max-height:none; display:flex; flex-direction:column; overflow:hidden; border-radius:0;'; // Header const header = document.createElement('div'); header.className = 'modal-header'; header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; padding:12px 14px;'; header.innerHTML = '📁 WebDAV File Browser' + '' + '' + '' + '' + '✕'; content.appendChild(header); // Toolbar const toolbar = document.createElement('div'); toolbar.style.cssText = 'display:flex; gap:10px; align-items:center; padding:10px 14px; border-bottom:1px solid var(--border); flex-wrap:wrap; background:var(--bg-tertiary);'; // Breadcrumb const breadDiv = document.createElement('div'); breadDiv.id = 'wdav-breadcrumb'; breadDiv.style.cssText = 'font-size:0.95em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:160px;'; function renderBreadcrumb() { const cleanPath = path.replace(/\/+$/, '') || '/'; const parts = cleanPath.split('/').filter(Boolean); let html = '📁 ~\u003c/span>'; let cumulative = ''; for (let i = 0; i < parts.length; i++) { cumulative += '/' + parts[i]; html += ' ▸ '; html += '' + parts[i] + ''; } breadDiv.innerHTML = html; } renderBreadcrumb(); toolbar.appendChild(breadDiv); // Search const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = '🔍 Search...'; searchInput.value = self._browserFilter; searchInput.style.cssText = 'background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:0.95em;min-width:160px;flex:1;'; searchInput.oninput = function() { self._browserFilter = this.value; renderList(); }; toolbar.appendChild(searchInput); // Sort buttons function makeSortBtn(key, label) { const btn = document.createElement('button'); btn.className = 'btn btn-sm'; btn.style.cssText = 'font-size:0.85em; padding:5px 10px;'; function update() { const active = self._browserSort.key === key; const arrow = active ? (self._browserSort.dir === 'asc' ? ' ▾' : ' ▴') : ''; btn.textContent = label + arrow; btn.style.background = active ? 'var(--accent)' : 'var(--bg-secondary)'; btn.style.color = active ? '#000' : 'var(--text-primary)'; } btn.onclick = function() { if (self._browserSort.key === key) { self._browserSort.dir = self._browserSort.dir === 'asc' ? 'desc' : 'asc'; } else { self._browserSort.key = key; self._browserSort.dir = 'asc'; } updateSortBtns(); renderList(); }; btn.update = update; return btn; } const sortName = makeSortBtn('name', 'Name'); const sortDate = makeSortBtn('date', 'Date'); const sortSize = makeSortBtn('size', 'Size'); toolbar.appendChild(sortName); toolbar.appendChild(sortDate); toolbar.appendChild(sortSize); function updateSortBtns() { sortName.update(); sortDate.update(); sortSize.update(); } updateSortBtns(); // View toggle: list vs tree (future) — placeholder for layout parity with dashboard const listViewBtn = document.createElement('button'); listViewBtn.className = 'btn btn-sm'; listViewBtn.textContent = '📋'; listViewBtn.title = 'List view'; listViewBtn.style.cssText = 'font-size:0.85em; padding:5px 10px;'; toolbar.appendChild(listViewBtn); content.appendChild(toolbar); // Main body: file list + preview side-by-side on wide screens const mainBody = document.createElement('div'); mainBody.style.cssText = 'display:flex; flex:1; min-height:0; overflow:hidden;'; const filePane = document.createElement('div'); filePane.style.cssText = 'flex:1; min-width:0; min-height:0; overflow-y:auto; overflow-x:hidden;'; const listContainer = document.createElement('div'); listContainer.id = 'wdav-list-container'; // Select-all row const selectAllRow = document.createElement('div'); selectAllRow.style.cssText = 'display:flex;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border);font-size:0.9em;color:var(--text-muted);gap:10px;position:sticky;top:0;background:var(--bg-tertiary);z-index:2;'; const selAllCheck = document.createElement('input'); selAllCheck.type = 'checkbox'; selAllCheck.style.cssText = 'accent-color:var(--success-color);width:18px;height:18px;cursor:pointer;'; selAllCheck.onchange = function() { const checked = this.checked; getEntries().forEach(function(e) { self._selected[e.name] = checked; }); renderList(); self._updateSelectionCount(); }; selectAllRow.appendChild(selAllCheck); const selAllLabel = document.createElement('span'); selAllLabel.id = 'wdav-select-all-label'; selAllLabel.textContent = 'Select All'; selectAllRow.appendChild(selAllLabel); listContainer.appendChild(selectAllRow); const fileListDiv = document.createElement('div'); fileListDiv.id = 'wdav-file-list'; listContainer.appendChild(fileListDiv); filePane.appendChild(listContainer); mainBody.appendChild(filePane); content.appendChild(mainBody); // Bulk action bar const bulkBar = document.createElement('div'); bulkBar.id = 'wdav-bulk-bar'; bulkBar.style.cssText = 'display:none;padding:10px 14px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:8px;align-items:center;flex-wrap:wrap;'; bulkBar.innerHTML = '0 selected' + '' + '' + ''; content.appendChild(bulkBar); // Footer buttons const footer = document.createElement('div'); footer.className = 'modal-footer'; footer.style.cssText = 'display:flex; justify-content:flex-start; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--border);'; function makeFootBtn(label, title, onClick) { const btn = document.createElement('button'); btn.className = 'btn'; btn.textContent = label; btn.title = title; btn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; btn.onclick = onClick; return btn; } footer.appendChild(makeFootBtn('📁+ New Folder', 'Create directory', function() { const name = prompt('Directory name:'); if (name) { self._ensureDir((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } else self.terminal.print('Failed to create directory', 'error'); }); } })); footer.appendChild(makeFootBtn('📄 New File', 'Create empty file', function() { const name = prompt('File name:'); if (name) { self._createRemoteFile((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } else self.terminal.print('Failed to create file', 'error'); }); } })); footer.appendChild(makeFootBtn('⬆ Upload', 'Upload file', function() { self.uploadToWebdav(path); })); footer.appendChild(makeFootBtn('🔄 Refresh', 'Refresh listing', function() { self.openWebdavBrowser(path); })); footer.appendChild(makeFootBtn('Close', 'Close browser', function() { self.closeWebdavBrowser(); })); content.appendChild(footer); overlay.appendChild(content); document.body.appendChild(overlay); overlay.__nav = function(p) { self.openWebdavBrowser(p); }; // Minimize: collapse modal into a floating restore button document.getElementById('wdav-minimize').onclick = function() { overlay.style.display = 'none'; var fl = document.getElementById('wdav-floating-restore'); if (!fl) { fl = document.createElement('div'); fl.id = 'wdav-floating-restore'; fl.textContent = '📁'; fl.title = 'Restore WebDAV browser (' + path + ')'; fl.style.cssText = 'position:fixed; right:16px; bottom:16px; z-index:10002; width:52px; height:52px; border-radius:50%; background:var(--accent,#4a9eff); color:#000; display:flex; align-items:center; justify-content:center; font-size:22px; cursor:pointer; box-shadow:0 4px 14px rgba(0,0,0,0.5);'; fl.onclick = function() { overlay.style.display = 'flex'; fl.remove(); self.openWebdavBrowser(path); }; document.body.appendChild(fl); } if (window.webx11 && window.webx11.inputField) window.webx11.inputField.focus(); }; // Share: copy direct folder URL to clipboard document.getElementById('wdav-share').onclick = function() { const spaceName = self._resolveSpaceName(); const cleanPath = (path || '/').replace(/\/+$/, '') + '/'; const shareUrl = 'https://webx11.duckdns.org/spaces/' + (spaceName || '') + cleanPath; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(shareUrl).then(function() { self.terminal.print('🔗 Shared URL copied: ' + shareUrl, 'success'); }, function() { self.terminal.print('🔗 Share URL: ' + shareUrl, 'info'); }); } else { self.terminal.print('🔗 Share URL: ' + shareUrl, 'info'); } }; function renderList() { const entries = getEntries(); fileListDiv.innerHTML = ''; selAllLabel.textContent = 'Select All (' + entries.length + ' items)'; // Parent ".." row if (path !== '/' && path !== '') { const parentPath = path.replace(/\/[^\/]*\/?$/, '') || '/'; const parentRow = document.createElement('div'); parentRow.style.cssText = 'display:flex;align-items:center;padding:9px 14px;border-bottom:1px solid var(--border);cursor:pointer;min-height:44px;gap:10px;'; parentRow.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; parentRow.onmouseout = function() { this.style.background = ''; }; parentRow.innerHTML = '📁..'; parentRow.onclick = function() { self.openWebdavBrowser(parentPath); }; fileListDiv.appendChild(parentRow); } if (entries.length === 0) { const empty = document.createElement('div'); empty.style.cssText = 'text-align:center;padding:50px;color:var(--text-secondary);font-size:1.05em;'; empty.textContent = 'Empty directory'; fileListDiv.appendChild(empty); return; } entries.forEach(function(entry) { const row = document.createElement('div'); row.className = 'wdav-row'; row.style.cssText = 'display:flex;align-items:center;padding:9px 14px;border-bottom:1px solid var(--border);cursor:default;min-height:44px;gap:10px;'; row.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; row.onmouseout = function() { this.style.background = ''; }; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'wdav-checkbox'; cb.style.cssText = 'accent-color:var(--success-color);width:18px;height:18px;cursor:pointer;flex-shrink:0;'; cb.checked = !!self._selected[entry.name]; cb.onchange = function(e) { self._selected[entry.name] = this.checked; self._updateSelectionCount(); e.stopPropagation(); }; row.appendChild(cb); const iconSpan = document.createElement('span'); iconSpan.textContent = entry.type === 'dir' ? '📁' : self._getFileIcon(entry.name); iconSpan.style.cssText = 'flex-shrink:0;font-size:1.35em;'; const nameSpan = document.createElement('span'); nameSpan.textContent = entry.name + (entry.type === 'dir' ? '/' : ''); nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:1em;cursor:pointer;'; nameSpan.title = entry.name; const metaSpan = document.createElement('span'); metaSpan.style.cssText = 'font-size:0.8em;color:var(--text-muted);flex-shrink:0;min-width:130px;text-align:right;display:flex;flex-direction:column;'; if (entry.type === 'file') { metaSpan.innerHTML = '' + self._formatSize(entry.size) + '' + (entry.mtime ? '' + self._formatDate(entry.mtime) + '' : ''); } else if (entry.mtime) { metaSpan.innerHTML = '' + self._formatDate(entry.mtime) + ''; } // Inline rename on double click nameSpan.ondblclick = function(e) { e.stopPropagation(); const input = document.createElement('input'); input.type = 'text'; input.value = entry.name; input.style.cssText = 'width:100%;background:var(--bg-secondary);border:1px solid var(--success-color);color:var(--text-color);padding:3px 8px;border-radius:3px;font-size:0.95em;'; input.onblur = function() { const newName = this.value.trim(); if (newName && newName !== entry.name) { const parentPath = path.replace(/\/+$/, '') || ''; const oldFull = parentPath + '/' + entry.name; const newFull = parentPath + '/' + newName; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { self.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success'); self.openWebdavBrowser(path); } else { self.terminal.print('Rename failed: HTTP ' + r.status, 'error'); nameSpan.textContent = entry.name; } }); } else { nameSpan.textContent = entry.name; } }; input.onkeydown = function(ev) { if (ev.key === 'Enter') this.blur(); if (ev.key === 'Escape') { nameSpan.textContent = entry.name; } }; nameSpan.textContent = ''; nameSpan.appendChild(input); input.focus(); input.select(); }; // Click = navigate dir if (entry.type === 'dir') { row.onclick = function(e) { if (e.target.type === 'checkbox' || e.target.closest('.wdav-preview-btn')) return; // nginx autoindex hrefs are relative to the CURRENT dir — resolve against path let target = entry.href; if (target && !target.startsWith('/')) { target = (path.replace(/\/+$/, '') || '') + '/' + target; } self.openWebdavBrowser(target); }; } else { row.onclick = function(e) { if (e.target.type === 'checkbox' || e.target.closest('.wdav-preview-btn')) return; self.previewWebdavFile(entry); }; } // Preview button next to file const previewBtn = document.createElement('button'); previewBtn.className = 'btn btn-sm wdav-preview-btn'; previewBtn.textContent = '👁'; previewBtn.title = 'Preview ' + entry.name; previewBtn.style.cssText = 'font-size:0.85em; padding:3px 8px; flex-shrink:0; display:' + (entry.type === 'file' ? 'inline-block' : 'none') + ';'; previewBtn.onclick = function(e) { e.stopPropagation(); self.previewWebdavFile(entry); }; // Open in new tab button next to file const newTabBtn = document.createElement('button'); newTabBtn.className = 'btn btn-sm wdav-newtab-btn'; newTabBtn.textContent = '⛶'; newTabBtn.title = 'Open ' + entry.name + ' in new tab'; newTabBtn.style.cssText = 'font-size:0.85em; padding:3px 8px; flex-shrink:0; display:' + (entry.type === 'file' ? 'inline-block' : 'none') + ';'; newTabBtn.onclick = function(e) { e.stopPropagation(); self.previewInNewTab(entry); }; // Double-click downloads files like dashboard row.ondblclick = function(e) { if (entry.type !== 'file' || e.target.type === 'checkbox' || e.target.tagName === 'INPUT' || e.target.closest('.wdav-preview-btn')) return; self.downloadFromWebdav(entry); }; row.oncontextmenu = function(e) { e.preventDefault(); self._showContextMenu(e, entry, path); }; row.appendChild(iconSpan); row.appendChild(nameSpan); row.appendChild(previewBtn); row.appendChild(newTabBtn); row.appendChild(metaSpan); fileListDiv.appendChild(row); }); } // Keyboard navigation filePane.tabIndex = -1; filePane.style.outline = 'none'; let focusIdx = 0; filePane.onkeydown = function(e) { const rows = fileListDiv.querySelectorAll('.wdav-row'); if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); focusIdx = Math.min(focusIdx + 1, rows.length - 1); rows[focusIdx].scrollIntoView({ block: 'nearest' }); rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); } else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); focusIdx = Math.max(focusIdx - 1, 0); rows[focusIdx].scrollIntoView({ block: 'nearest' }); rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); } else if (e.key === ' ') { e.preventDefault(); const cb = rows[focusIdx]?.querySelector('.wdav-checkbox'); if (cb) { cb.checked = !cb.checked; cb.onchange({ stopPropagation: function(){} }); } } else if (e.key === 'Enter') { e.preventDefault(); rows[focusIdx]?.click(); } }; setTimeout(function() { filePane.focus(); }, 100); // Wire bulk actions setTimeout(function() { document.getElementById('bulk-delete')?.addEventListener('click', function() { const names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); if (!names.length || !confirm('Delete ' + names.length + ' item(s) from ' + path + '?')) return; names.forEach(function(name) { const fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; self._deleteFile(fullPath).then(function(ok) { if (ok) self.terminal.print('Deleted: ' + name, 'success'); else self.terminal.print('Delete failed: ' + name, 'error'); }); }); setTimeout(function() { self.openWebdavBrowser(path); }, 500); }); document.getElementById('bulk-move')?.addEventListener('click', function() { const names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); if (!names.length) return; let target = prompt('Move ' + names.length + ' item(s) to directory:', '/'); if (!target) return; target = target.replace(/\/+$/, ''); names.forEach(function(name) { const oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; const newFull = target + '/' + name; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name, 'success'); else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error'); }); }); setTimeout(function() { self.openWebdavBrowser(path); }, 500); }); document.getElementById('bulk-download')?.addEventListener('click', function() { const names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); names.forEach(function(name) { const entry = allEntries.find(function(e) { return e.name === name; }); if (entry && entry.type === 'file') self.downloadFromWebdav(entry); }); }); }, 50); self._updateSelectionCount = function() { const count = Object.values(self._selected).filter(Boolean).length; var el = document.getElementById('sel-count'); if (el) el.textContent = count > 0 ? count + ' selected' : ''; var bar = document.getElementById('wdav-bulk-bar'); var cnt = document.getElementById('bulk-count'); if (bar) bar.style.display = count > 0 ? 'flex' : 'none'; if (cnt) cnt.textContent = count + ' selected'; }; renderList(); this.terminal.print('📂 Opened WebDAV file browser: ' + path, 'info'); } _showContextMenu(e, entry, currentPath) { var existing = document.getElementById('wdav-context-menu'); if (existing) existing.remove(); var menu = document.createElement('div'); menu.id = 'wdav-context-menu'; menu.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;background:#111;border:1px solid #333;border-radius:6px;padding:4px 0;z-index:1001;min-width:160px;box-shadow:0 4px 12px rgba(0,0,0,0.5);'; menu.onclick = function() { this.remove(); }; var items = []; if (entry.type === 'file') { items.push({ label: '📄 Preview', fn: function() { this.previewWebdavFile(entry); }.bind(this) }); items.push({ label: '⬇ Download', fn: function() { this.downloadFromWebdav(entry); }.bind(this) }); items.push({ label: '📋 Copy Path', fn: function() { var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); }.bind(this) }); } else { items.push({ label: '📂 Open', fn: function() { var target = entry.href; if (target && !target.startsWith('/')) { target = (currentPath.replace(/\/+$/, '') || '') + '/' + target; } this.openWebdavBrowser(target); }.bind(this) }); items.push({ label: '📋 Copy Path', fn: function() { var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name + '/'; navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); }.bind(this) }); } items.push({ label: '✏️ Rename', fn: function() { var newName = prompt('Rename "' + entry.name + '" to:'); if (newName && newName !== entry.name) { var oldFull = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; var newFull = (currentPath.replace(/\/+$/, '') || '') + '/' + newName; this._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }).then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { this.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success'); this.openWebdavBrowser(currentPath); } else { this.terminal.print('Rename failed: HTTP ' + r.status, 'error'); } }.bind(this)); } }.bind(this) }); items.push({ label: '🗑 Delete', fn: function() { if (!confirm('Delete ' + entry.name + '?')) return; var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; this._deleteFile(full).then(function(ok) { if (ok) { this.terminal.print('Deleted: ' + entry.name, 'success'); this.openWebdavBrowser(currentPath); } else { this.terminal.print('Delete failed', 'error'); } }.bind(this)); }.bind(this) }); items.forEach(function(item) { var div = document.createElement('div'); div.textContent = item.label; div.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:0.85em;'; div.onmouseover = function() { this.style.background = '#222'; }; div.onmouseout = function() { this.style.background = ''; }; div.onclick = function(e) { e.stopPropagation(); menu.remove(); item.fn(); }; menu.appendChild(div); }); document.body.appendChild(menu); // Close on click outside var closeMenu = function(ev) { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', closeMenu); } }; setTimeout(function() { document.addEventListener('click', closeMenu); }, 10); } 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'); } previewInNewTab(entry) { // Build direct URL to the file within the configured space const spaceName = this._resolveSpaceName(); let base = ''; if (this.config && this.config.url) { try { base = new URL(this.config.url).origin; } catch (e) {} } base = base || 'https://webx11.duckdns.org'; let rel = entry.href || entry.name; // href from autoindex is relative to the current directory — prepend browsing path if (rel && !rel.startsWith('/')) { const dir = (this._browsingPath || '/').replace(/\/+$/, '') + '/'; rel = dir.replace(/^\/+/, '') + rel; } rel = rel.replace(/^\/+/, ''); const url = base + '/spaces/' + (spaceName || '') + '/' + rel; const w = window.open(url, '_blank'); if (!w) this.terminal.print('Popup blocked — open manually: ' + url, 'warning'); return url; } async previewWebdavFile(entry) { try { var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); var fullPath = basePath + '/' + entry.name; var resp = await this._webdavRequest('GET', fullPath); if (!resp.ok) { this.terminal.print('Failed to fetch: HTTP ' + resp.status, 'error'); return; } var isHtml = entry.name.match(/\.html?$/i); var isImage = entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i); var size = parseInt(resp.headers.get('content-length') || '0'); // Ensure shared preview modal exists (defined in terminal.js) var previewModal = document.getElementById('preview-modal'); var previewTitle = document.getElementById('preview-title'); var previewIframe = document.getElementById('preview-iframe'); if (!previewModal || !previewTitle || !previewIframe) { this.terminal.print('Preview modal not available.', 'error'); return; } // Always render above the WebDAV browser modal (z-index 9999) previewModal.style.zIndex = '10001'; if (size > 512000 && !isHtml && !isImage) { // Large non-html file — print truncated text to terminal instead this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — previewing first 2000 chars.', 'warning'); var textPreview = await resp.text(); previewTitle.textContent = '📄 ' + entry.name; previewIframe.srcdoc = '
' + this._escapeHtml(textPreview.substring(0, 2000)) + (textPreview.length > 2000 ? '\n...(truncated ' + this._formatSize(size) + ' total)' : '') + '
'; previewModal.style.display = 'flex'; return; } if (isImage) { var blob = await resp.blob(); var dataUrl = await new Promise(function(resolve) { var reader = new FileReader(); reader.onloadend = function() { resolve(reader.result); }; reader.readAsDataURL(blob); }); previewTitle.textContent = '🖼 ' + entry.name; previewIframe.srcdoc = ''; previewModal.style.display = 'flex'; return; } if (isHtml) { var content = await resp.text(); previewTitle.textContent = '📄 ' + entry.name; previewIframe.srcdoc = content; previewModal.style.display = 'flex'; return; } // Text file — show in preview modal var content = await resp.text(); previewTitle.textContent = '📄 ' + entry.name + ' (' + this._formatSize(content.length) + ')'; previewIframe.srcdoc = '
' + this._escapeHtml(content) + '
'; previewModal.style.display = 'flex'; } catch (e) { this.terminal.print('Preview error: ' + e.message, 'error'); } } uploadToWebdav(currentPath) { const self = this; const input = document.createElement('input'); input.type = 'file'; input.onchange = async function(e) { const file = e.target.files[0]; if (!file) return; const basePath = currentPath.replace(/\/+$/, ''); const remotePath = basePath + '/' + file.name; const parentDir = remotePath.split('/').slice(0, -1).join('/') || '/'; // Progress UI — floating bar, recreated if closed let prog = document.getElementById('wdav-upload-progress'); if (!prog) { prog = document.createElement('div'); prog.id = 'wdav-upload-progress'; prog.style.cssText = 'position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:10003; width:400px; max-width:92vw; background:var(--bg-tertiary,#141414); border:1px solid var(--border,#333); border-radius:8px; padding:10px 12px; box-shadow:0 4px 16px rgba(0,0,0,.5); font-size:0.85em;'; prog.innerHTML = '
0%
' + '
' + '
'; document.body.appendChild(prog); } else { prog.style.display = 'block'; } const bar = document.getElementById('wdav-upload-bar'); const pctEl = document.getElementById('wdav-upload-pct'); const speedEl = document.getElementById('wdav-upload-speed'); const sizeEl = document.getElementById('wdav-upload-size'); document.getElementById('wdav-upload-name').textContent = file.name; bar.style.width = '0%'; pctEl.textContent = '0%'; speedEl.textContent = ''; sizeEl.textContent = self._formatSize(file.size); const startTime = Date.now(); let lastLoaded = 0, lastTime = startTime; try { // NOTE: no _ensureDir here — currentPath is the directory being // browsed, so it already exists. Skipping it avoids the MKCOL // request storm that delayed uploads (esp. on mobile) and // caused client aborts (HTTP 499). // Read the file into memory BEFORE opening the connection. // Android Chrome's lazy File streaming (xhr.send(file)) stalls // for larger files picked from the file manager (content://), // leaving the PUT half-open until nginx 408s or the browser // aborts (499). A pre-read ArrayBuffer uploads reliably. const buf = await new Promise(function(resolve, reject) { const r = new FileReader(); r.onloadend = function() { if (r.error) reject(r.error); else resolve(r.result); }; r.readAsArrayBuffer(file); }); // XHR PUT so we get upload progress events (fetch has none) await new Promise(function(resolve, reject) { const baseUrl = self.config.url.replace(/\/+$/, ''); const urlPath = remotePath.startsWith('/') ? remotePath : '/' + remotePath; const xhr = new XMLHttpRequest(); xhr.open('PUT', baseUrl + urlPath, true); const auth = self._getAuthHeader(); if (auth.Authorization) xhr.setRequestHeader('Authorization', auth.Authorization); xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); xhr.upload.onprogress = function(ev) { if (!ev.lengthComputable) return; const pct = Math.round(ev.loaded / ev.total * 100); bar.style.width = pct + '%'; pctEl.textContent = pct + '%'; const now = Date.now(); const dt = (now - lastTime) / 1000; if (dt >= 0.5) { speedEl.textContent = self._formatSize((ev.loaded - lastLoaded) / dt) + '/s'; lastLoaded = ev.loaded; lastTime = now; } }; xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error('HTTP ' + xhr.status)); }; xhr.onerror = function() { reject(new Error('Network error')); }; xhr.send(buf); // pre-read buffer — reliable on mobile }); bar.style.width = '100%'; pctEl.textContent = '100%'; const elapsed = Math.max((Date.now() - startTime) / 1000, 0.001); speedEl.textContent = self._formatSize(file.size / elapsed) + '/s'; self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); setTimeout(function() { prog.style.display = 'none'; self.openWebdavBrowser(currentPath); }, 700); } catch (err) { self.terminal.print('Upload failed: ' + err.message, 'error'); prog.style.display = 'none'; } }; input.click(); } async downloadFromWebdav(entry) { try { // Construct path from current browsing path + entry name (avoid autoindex href fragility) var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); var fullPath = basePath + '/' + entry.name; var resp = await this._webdavRequest('GET', fullPath); if (!resp.ok) { this.terminal.print('Download failed: HTTP ' + resp.status, 'error'); return; } // Use blob() instead of text() to preserve binary data var blob = await resp.blob(); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = entry.name; a.style.cssText = '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(blob.size) + ')', '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'; } }