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' }, 'webdav-login': { argType: 'none' }, }; 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 ''; } } async _fetchSpaceToken(spaceName) { // Fetch the CURRENT token for a space via the session-authenticated // endpoint (self-heals after server-side rotations). Admins may // fetch any space; regular users only their own. try { let sid = ''; try { sid = localStorage.getItem('webx11_session_id') || ''; } catch (e) {} const res = await fetch('/api/auth/webdav-token?space=' + encodeURIComponent(spaceName), { credentials: 'same-origin', headers: sid ? { 'X-Session-Id': sid } : {} }); if (res.ok) { const d = await res.json(); return (d.webdav_token || '').trim() || null; } } catch (e) { /* offline or not logged in */ } return null; } _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-login': 'webdav-login [username] [password] — Open the login panel (Telegram or password) in THIS browser and configure WebDAV', '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); // Send BOTH: dav-auth prefers X-WebDAV-Token, so a stale browser-cached // Basic credential can never outrank the fresh token (2026-10-05). return { 'Authorization': 'Basic ' + token, 'X-WebDAV-Token': this.config.password }; } async _ensureToken() { // Guarantee a token BEFORE any request goes out: a credential-less // write makes dav-auth answer 401 + a Basic challenge, and the browser // then pops its native username/password dialog mid-upload ("username // and password query" — the exact complaint fixed 2026-10-05). if (!this.config || this.config.password) return; // 1) SecretVault-stored token (lazy decrypt) if (this._cachedPassUser) { try { await this._resolvePassword(); } catch (e) { /* vault miss */ } } if (this.config.password) return; // 2) Server-side: current space token via session-authenticated API. const spaceName = this._resolveSpaceName(); if (spaceName) { const fresh = await this._fetchSpaceToken(spaceName); if (fresh) { this.config.password = fresh; await this._saveConfig(); } } if (this.config.password) return; // Last resort (2026-10-05): a device-code login may have been approved // in Telegram while this page was frozen (Android suspends timers), so // the panel's 3s poll never saw it. Pick it up now — the next push // (or visibilitychange) completes the login by itself. await this._pollPendingDevice(); } async _pollPendingDevice() { let pend = null; try { pend = JSON.parse(localStorage.getItem('webx11_pending_device') || 'null'); } catch (e) { pend = null; } if (!pend || !pend.code || !pend.poll_key) return false; if (Date.now() - (pend.started || 0) > ((pend.expires_in || 600) + 10) * 1000) { try { localStorage.removeItem('webx11_pending_device'); } catch (e0) {} return false; } try { const pr = await fetch('/api/auth/device-poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ code: pend.code, poll_key: pend.poll_key }) }); const pd = await pr.json().catch(function () { return {}; }); if (pd.status === 'approved') { try { localStorage.removeItem('webx11_pending_device'); } catch (e1) {} const p = document.getElementById('wdav-login-panel'); if (p) p.remove(); if (this.terminal && this.terminal.print) { this.terminal.print('\uD83D\uDD13 Telegram code approved \u2014 logging in\u2026', 'info'); } await this._applySession(pd); return true; } if (pd.status === 'expired' || pd.status === 'invalid') { try { localStorage.removeItem('webx11_pending_device'); } catch (e2) {} } } catch (e3) { /* transient \u2014 keep pending for the next attempt */ } return false; } async _autoBootstrap() { // Zero-command auto-configuration (2026-10-05). Restores the // "it configured automatically" flow: when no config is saved and // this browser holds a WebX11 session, fetch the current token // silently and configure WebDAV for the page space (fallback: the // logged-in user's own space). Called on module load and again as a // fallback before a push — after ONE login in a browser, every later // load auto-configures itself; no webdav-config / webdav-login needed. try { if (this.config && this.config.url) return; // already configured const candidates = []; if (this._spaceName) candidates.push(this._spaceName); let su = this._detectSessionUser(); if (!su) { // HttpOnly cookie -> JS cannot read it, but the server can. // Resolve the logged-in user via me-cookie (2026-10-05). try { const mr = await fetch('/api/auth/me-cookie', { credentials: 'same-origin' }); if (mr.ok) { const md = await mr.json(); if (md && md.username) { su = md.username; try { localStorage.setItem('webx11_username', su); } catch (eS) {} } } } catch (em) { /* not logged in */ } } if (su && candidates.indexOf(su) === -1) candidates.push(su); for (const space of candidates) { const tok = await this._fetchSpaceToken(space); if (!tok) continue; // no session in this browser — stay quiet this.config = { url: 'https://webx11.duckdns.org/spaces/' + space + '/', username: space, password: tok }; await this._saveConfig(); this._autoConfigured = true; if (this.terminal && this.terminal.print) { this.terminal.print('\u2705 WebDAV auto-configured for space "' + space + '" (browser session detected)', 'success'); } return; } } catch (e) { /* never break load */ } } _normalizeRemotePath(path) { // v4.6.16: Paths can arrive as VFS-relative ('file.txt'), space-relative // ('/file.txt'), space-prefixed ('/spaces//file.txt') or even a // full URL ('https://host/spaces//file.txt'). Blindly joining // such paths to the config URL previously created literal 'https:/' // and nested 'spaces//' junk directories inside the space. if (!path) return '/'; let p = String(path).trim(); if (!p) return '/'; // Strip full URL: keep only the pathname (and drop any query/hash) if (/^https?:\/\//i.test(p)) { try { const u = new URL(p); p = u.pathname; } catch (e) { p = p.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, ''); } } // Strip the space prefix if present ('/spaces//...', 'spaces//...') const spaceName = this._resolveSpaceName(); if (spaceName) { const re = new RegExp('^\\/*spaces/' + spaceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=/|$)'); const m = p.match(re); if (m) p = p.substring(m[0].length); } p = p.split('?')[0].split('#')[0]; if (!p.startsWith('/')) p = '/' + p; // Collapse duplicate slashes and resolve '..' / '.' segments. // PRESERVE a trailing slash — nginx's dav module needs it for // collection operations (MKCOL/DELETE on directories). const trailing = /\/+$/.test(p); const out = []; for (const seg of p.split('/')) { if (seg === '' || seg === '.') continue; if (seg === '..') { out.pop(); continue; } out.push(seg); } let res = '/' + out.join('/'); if (trailing && res !== '/') res += '/'; return res; } _absoluteUrl(remotePath) { // Destination headers (MOVE/COPY) must be absolute URIs — build from // the configured base URL + normalized path. const baseUrl = (this.config && this.config.url) ? this.config.url.replace(/\/+$/, '') : ''; return baseUrl + this._normalizeRemotePath(remotePath); } 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(); } // Never send a credential-less write — fetch the token first (2026-10-05) await this._ensureToken(); const baseUrl = this.config.url.replace(/\/+$/, ''); const urlPath = this._normalizeRemotePath(path); const url = baseUrl + urlPath; const headers = { ...this._getAuthHeader(), ...(options.headers || {}) }; const fetchOpts = { method: method, headers: headers, body: options.body || undefined }; let response = await fetch(url, fetchOpts); // Self-heal on 401: fetch the space's current token once and retry. if (response.status === 401) { const spaceName = this._resolveSpaceName(); if (spaceName) { const fresh = await this._fetchSpaceToken(spaceName); if (fresh && fresh !== this.config.password) { this.config.password = fresh; await this._saveConfig(); response = await fetch(url, { method: method, headers: { ...this._getAuthHeader(), ...(options.headers || {}) }, body: options.body || undefined }); } } } 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 = this._normalizeRemotePath(path); // Avoid double slash when path starts with / const listUrl = host + '/spaces/' + spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); try { // 2026-10-11: __list__ is owner-gated — send the space token when we // have one; if it is stale (401/403), retry cookie-only before failing. const authHeaders = this._getAuthHeader(); let resp = await fetch(listUrl, { method: 'GET', headers: authHeaders }); if ((resp.status === 401 || resp.status === 403) && Object.keys(authHeaders).length) { 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) { // v4.6.16: normalize FIRST — absolute/URL/junk paths previously built // MKCOL chains into literal 'https:/' and nested 'spaces//' dirs. const path = this._normalizeRemotePath(remotePath).replace(/\/+$/, ''); if (!path || path === '/') return true; const segments = path.split('/').filter(Boolean); // Fast path: probe with cheap __list__ GETs for the deepest existing // ancestor, then MKCOL only the missing tail. nginx returns 409 for // BOTH 'already exists' and 'parent missing', so a blind MKCOL walk // fires 2-8 wasted 409 requests per call even when nothing is missing. let deepest = 0; for (let i = segments.length - 1; i >= 0; i--) { const prefix = '/' + segments.slice(0, i + 1).join('/'); try { await this._listDir(prefix); deepest = i + 1; break; } catch (e) { /* not present — try shallower */ } } if (deepest === segments.length) return true; // Create each missing component once, bottom-up — seed with the // existing prefix so paths stay absolute. let current = deepest > 0 ? '/' + segments.slice(0, deepest).join('/') : ''; let created = false; for (let i = deepest; i < segments.length; i++) { current += '/' + segments[i]; try { // nginx dav requires a TRAILING SLASH on MKCOL for the // collection itself (no-slash → 409 'conflict'). Verified // live 2026-10-02: no-slash 409, with-slash 201. const attempt = await this._webdavRequest('MKCOL', current + '/'); if (attempt.ok || attempt.status === 405 || attempt.status === 301 || attempt.status === 302) created = true; } catch (e) { /* ignore — component may exist */ } } return created; } async _deleteFile(remotePath) { const p = this._normalizeRemotePath(remotePath); const resp = await this._webdavRequest('DELETE', remotePath); if (resp.ok || resp.status === 204 || resp.status === 404) return true; // nginx dav needs a trailing slash to delete a COLLECTION; a no-slash // DELETE on a directory returns 409/404. Retry with slash. if ((resp.status === 409 || resp.status === 404) && !p.endsWith('/')) { const retry = await this._webdavRequest('DELETE', p + '/'); return retry.ok || retry.status === 204 || retry.status === 404; } return false; } 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. // Prefer a freshly fetched token (self-heals after rotations). const freshTok = await self._fetchSpaceToken(sessionUser); const autoUrl = autoUrlFor(sessionUser); self.config = { url: autoUrl, username: sessionUser, password: freshTok || 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); } // Logged-in session: fetch this space's current token server-side if (!token && sessionUserOther && spaceName) { token = await self._fetchSpaceToken(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-login: authenticate in THIS browser + configure WebDAV --- // Sessions do NOT cross browser contexts: being logged in in Chrome or // the dashboard does not log in the browser that runs this terminal, // so the session-authenticated token fetch returns "No session found" // and pushes fail with a bare 401 (2026-10-05). // Two ways in: Telegram widget (primary) or username+password. this._applySession = async function (data) { const uname = ((data && data.username) || '').toLowerCase(); if (!uname) return false; try { if (data.session_id) localStorage.setItem('webx11_session_id', data.session_id); } catch (e) {} try { localStorage.setItem('webx11_username', uname); } catch (e) {} try { if (data.webdav_token) localStorage.setItem('webx11_webdav_token', data.webdav_token); } catch (e) {} const pageSpace = self._spaceName || ''; let token = data.webdav_token || ''; let targetSpace = uname; // Admin on a space page (e.g. /spaces/newos4/main.html): prefer the // PAGE space token, so pushes land inside that space (their docs // subfolder keeps working). Non-admins fall back to their own. if (pageSpace && pageSpace !== uname) { const pt = await self._fetchSpaceToken(pageSpace); if (pt) { token = pt; targetSpace = pageSpace; } } else if (pageSpace === uname && !token) { token = (await self._fetchSpaceToken(pageSpace)) || ''; } let url = 'https://webx11.duckdns.org/spaces/' + targetSpace + '/'; if (self.config && self.config.url && self.config.url.indexOf('/spaces/' + targetSpace + '/') !== -1) { url = self.config.url; // preserve an existing sub-folder target (e.g. /spaces/newos4/agando/) } self.config = { url: url, username: targetSpace, password: token }; await self._saveConfig(); self._autoConfigured = true; self.terminal.print('✅ Logged in as ' + uname + ' — WebDAV configured for space "' + targetSpace + '"', 'success'); if (!token) { self.terminal.print('No WebDAV token returned for this space — try: webdav-config', 'warning'); return true; } try { const r = await self._webdavRequest('OPTIONS', '/'); self.terminal.print('Connection check: HTTP ' + r.status, r.status < 400 ? 'success' : 'warning'); } catch (e) { self.terminal.print('Connection check failed: ' + e.message, 'warning'); } return true; }; this.commands['webdav-login'] = async (args) => { // 1) Direct form: webdav-login if (args && args[0] && args[1]) { self.terminal.print('Logging in as "' + args[0] + '"...', 'info'); try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: String(args[0]).trim().toLowerCase(), password: args[1] }) }); const data = await res.json().catch(function () { return {}; }); if (!res.ok) { self.terminal.print('❌ Login failed: ' + (data.error || ('HTTP ' + res.status)), 'error'); return; } await self._applySession(data); } catch (e) { self.terminal.print('Login error: ' + e.message, 'error'); } return; } // 2) Login panel: Telegram button (preferred) + password form const oldPanel = document.getElementById('wdav-login-panel'); if (oldPanel) oldPanel.remove(); const panel = document.createElement('div'); panel.id = 'wdav-login-panel'; panel.style.cssText = 'position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:10010; width:340px; max-width:92vw; background:var(--bg-tertiary,#141414); border:1px solid var(--border,#333); border-radius:10px; padding:16px; box-shadow:0 8px 32px rgba(0,0,0,.6); font-size:0.9em;'; panel.innerHTML = '
Log in to WebX11✕
' + '
' + '' + '
— or —
' + '
' + '
Telegram widget (in some apps this opens another browser)
' + '
— or —
' + '' + '' + '' + '
'; document.body.appendChild(panel); let _wdavIv = null; document.getElementById('wdav-login-close').onclick = function () { if (_wdavIv) clearInterval(_wdavIv); panel.remove(); }; document.getElementById('wdav-code-btn').onclick = async function () { const area = document.getElementById('wdav-code-area'); const val = document.getElementById('wdav-code-value'); const stt = document.getElementById('wdav-code-status'); area.style.display = 'block'; stt.textContent = 'Requesting code…'; try { const r = await fetch('/api/auth/device-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: '{}' }); const dd = await r.json().catch(function () { return {}; }); if (!r.ok) { stt.textContent = '❌ ' + (dd.error || ('HTTP ' + r.status)); return; } val.textContent = dd.code; try { localStorage.setItem('webx11_pending_device', JSON.stringify({ code: dd.code, poll_key: dd.poll_key, started: Date.now(), expires_in: dd.expires_in || 600 })); } catch (eS) {} try { var dl = document.getElementById('wdav-code-link'); if (!dl) { dl = document.createElement('a'); dl.id = 'wdav-code-link'; dl.target = '_blank'; dl.rel = 'noopener'; dl.style.cssText = 'display:block;margin-top:8px;text-align:center;font-size:0.9em;color:var(--accent,#4a9eff);'; dl.textContent = '\uD83D\uDC49 Open @roducbot in Telegram (one tap)'; var areaEl = document.getElementById('wdav-code-area'); if (areaEl) areaEl.appendChild(dl); } dl.href = 'https://t.me/roducbot?start=' + encodeURIComponent(dd.code); } catch (eL) {} try { await navigator.clipboard.writeText(dd.code); stt.textContent = 'Code copied — send it to @roducbot. Waiting…'; } catch (e2) { stt.textContent = 'Waiting for approval…'; } if (_wdavIv) clearInterval(_wdavIv); const started = Date.now(); _wdavIv = setInterval(async function () { if (Date.now() - started > ((dd.expires_in || 600) + 5) * 1000) { clearInterval(_wdavIv); try { localStorage.removeItem('webx11_pending_device'); } catch (eT) {} stt.textContent = '❌ Code expired. Press the button to get a new one.'; return; } try { const pr = await fetch('/api/auth/device-poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ code: dd.code, poll_key: dd.poll_key }) }); const pd = await pr.json().catch(function () { return {}; }); if (pd.status === 'approved') { clearInterval(_wdavIv); try { localStorage.removeItem('webx11_pending_device'); } catch (e0) {} panel.remove(); self.terminal.print('🔓 Telegram code approved — logging in…', 'info'); await self._applySession(pd); } else if (pd.status === 'expired') { clearInterval(_wdavIv); try { localStorage.removeItem('webx11_pending_device'); } catch (eX) {} stt.textContent = '❌ Code expired. Press the button to get a new one.'; } } catch (e3) { /* transient — keep polling */ } }, 3000); } catch (e) { stt.textContent = 'Error: ' + e.message; } }; window.webx11TgLogin = async function (user) { const st = document.getElementById('wdav-tg-status'); if (st) st.textContent = 'Verifying Telegram login...'; try { const res = await fetch('/api/auth/telegram-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(user) }); const data = await res.json().catch(function () { return {}; }); if (!res.ok) { if (st) st.textContent = '❌ Telegram login failed: ' + (data.error || res.status); return; } const p = document.getElementById('wdav-login-panel'); if (p) p.remove(); await self._applySession(data); } catch (e) { if (st) st.textContent = 'Error: ' + e.message; } }; const mount = document.getElementById('wdav-tg-mount'); const tw = document.createElement('script'); tw.src = 'https://telegram.org/js/telegram-widget.js?22'; tw.setAttribute('data-telegram-login', 'roducbot'); tw.setAttribute('data-size', 'large'); tw.setAttribute('data-onauth', 'webx11TgLogin(user)'); tw.setAttribute('data-request-access', 'write'); tw.async = true; mount.appendChild(tw); document.getElementById('wdav-login-btn').onclick = async function () { const u = (document.getElementById('wdav-user').value || '').trim().toLowerCase(); const pw = document.getElementById('wdav-pass').value || ''; const msg = document.getElementById('wdav-login-msg'); if (!u || !pw) { msg.textContent = 'Enter username and password.'; return; } msg.textContent = 'Logging in...'; try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: u, password: pw }) }); const data = await res.json().catch(function () { return {}; }); if (!res.ok) { msg.textContent = '❌ ' + (data.error || ('HTTP ' + res.status)); return; } panel.remove(); await self._applySession(data); } catch (e) { msg.textContent = 'Error: ' + e.message; } }; self.terminal.print('🔐 Login panel opened — use the Telegram button, or the username/password form.', 'info'); }; // Resume-pending-login hooks (2026-10-05): Android freezes timers while // the user is in Telegram approving the code; on return the 3s interval // may never fire again. Poll immediately when visible + once on load, so // a reload after approval still completes the login. try { document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { self._pollPendingDevice(); } }); } catch (eV) {} try { self._pollPendingDevice(); } catch (eI) {} // Zero-command auto-config: configure WebDAV silently whenever // this browser has a WebX11 session (2026-10-05) try { self._autoBootstrap(); } catch (eB) {} // --- 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; } // No saved config? Try session auto-config first (2026-10-05) if (!self.config || !self.config.url) { await self._autoBootstrap(); } 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'); if (resp.status === 401) { if (!self.config.password) { self.terminal.print('Hint: this browser has no WebX11 session (sessions do not cross browsers).', 'info'); } else { self.terminal.print('Hint: token rejected — the browser session may be stale.', 'info'); } self.terminal.print(' Run: webdav-login (opens the login panel)', 'info'); } } } catch (e) { self.terminal.print('Push error: ' + e.message, 'error'); if (/not configured/i.test(e.message || '')) { self.terminal.print('Hint: no saved WebDAV config in this browser. Log in once with: webdav-login (Telegram code \u2014 no popup).', 'info'); } } }; // --- 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 { // Destination must be an absolute URI — build it from the // configured base URL + normalized path so junk prefixes // (full URLs, '/spaces//...') can't smuggle through. const dst = self._absoluteUrl(args[1]); const resp = await self._webdavRequest('MOVE', args[0], { headers: { 'Destination': dst } }); 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 the public URL of the selected FILE(s). Folder listings need the // owner session, so a folder URL is a guaranteed 403 for the recipient (2026-10-21). document.getElementById('wdav-share').onclick = async function() { const space = self._resolveSpaceName() || ''; const sel = Object.keys(self._selected || {}).filter(function(n) { return self._selected[n]; }); const files = sel.filter(function(n) { const e = (allEntries || []).find(function(x) { return x.name === n; }); return e && e.type === 'file'; }); if (!files.length) { const dirName = String(path || '/').replace(/^\/+/, '').replace(/\/+$/, ''); const _mods = self.terminal && self.terminal.moduleManager && self.terminal.moduleManager.modules; const gz = _mods && _mods.CryptoGzipModule; if (gz && dirName) { self.terminal.print('🔗 Folders cannot be shared directly — archiving ' + dirName + ' …', 'info'); try { await self.terminal.security.checkAndRun('tar -c ' + dirName); } catch (e) {} self.terminal.print('Now select the resulting .tar and press 🔗 (it is a file).', 'info'); } else { self.terminal.print('🔗 Select one or more FILES to share — folder listings require the owner session and return 403 for everyone else.', 'warning'); } return; } const dir = String(path || '/').replace(/^\/+/, '').replace(/\/+$/, ''); const prefix = 'https://webx11.duckdns.org/spaces/' + space + '/' + (dir ? dir + '/' : ''); const urls = files.map(function(n) { return prefix + encodeURIComponent(n); }); const verified = []; for (let i = 0; i < urls.length; i++) { let st = null; try { const h = await fetch(urls[i], { method: 'HEAD', credentials: 'omit' }); st = h.status; } catch (e) {} if (st === 200) verified.push(urls[i]); else self.terminal.print('⚠️ Not shareable: ' + urls[i] + ' → HTTP ' + (st === null ? 'no network' : st), 'warning'); } if (!verified.length) return; const text = verified.join('\n'); try { await navigator.clipboard.writeText(text); } catch (e) { prompt('Copy share link(s):', text); } self.terminal.print('🔗 Copied ' + verified.length + ' verified link(s): ' + verified.join(' , '), 'success'); }; 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': self._absoluteUrl(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; // 2026-10-26 (phone test): prompt() is suppressed in iOS standalone PWAs and most // Android WebViews — it returns null, so "Move" looked like a missing feature. // Ask for the destination inline in the bulk bar instead. const doMove = function(rawTarget) { const target = String(rawTarget || '').trim().replace(/\/+$/, ''); if (!target) { self.terminal.print('Move cancelled — no destination', 'error'); return; } names.forEach(function(name) { const oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; const newFull = target + '/' + name; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': self._absoluteUrl(newFull) } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name + ' → ' + newFull, 'success'); else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error'); }) .catch(function(e) { self.terminal.print('Move failed: ' + name + ' — ' + (e && e.message ? e.message : e), 'error'); }); }); setTimeout(function() { self.openWebdavBrowser(target); }, 600); }; const bar = document.getElementById('wdav-bulk-bar'); let row = document.getElementById('wdav-move-row'); if (row) { row.remove(); row = null; } if (!bar) { doMove(path || '/'); return; } row = document.createElement('div'); row.id = 'wdav-move-row'; row.style.cssText = 'display:flex;gap:8px;align-items:center;width:100%;padding-top:8px;flex-wrap:wrap'; row.innerHTML = 'Move ' + names.length + ' to:' + '' + ''; bar.appendChild(row); const dest = row.querySelector('#wdav-move-dest'); dest.value = (path || '/').replace(/\/+$/, '') || '/'; row.querySelector('#wdav-move-go').onclick = function() { const v = dest.value; row.remove(); doMove(v); }; row.querySelector('#wdav-move-cancel').onclick = function() { row.remove(); }; dest.focus(); dest.select(); }); 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': this._absoluteUrl(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) // Ensure a token BEFORE connecting: a credential-less write // triggers the browser's native Basic-auth dialog (2026-10-05). await self._ensureToken(); await new Promise(function(resolve, reject) { const baseUrl = self.config.url.replace(/\/+$/, ''); const urlPath = self._normalizeRemotePath(remotePath); const xhr = new XMLHttpRequest(); xhr.open('PUT', baseUrl + urlPath, true); const auth = self._getAuthHeader(); if (auth.Authorization) xhr.setRequestHeader('Authorization', auth.Authorization); if (auth['X-WebDAV-Token']) xhr.setRequestHeader('X-WebDAV-Token', auth['X-WebDAV-Token']); 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 = async function() { // 401 self-heal: refresh the space token once, then retry. // The XHR path previously had NO retry — a stale token // failed the upload permanently (fixed 2026-10-05). if (xhr.status === 401 && !xhr._wdavRetried) { xhr._wdavRetried = true; const sp = self._resolveSpaceName(); const fresh = sp ? await self._fetchSpaceToken(sp) : null; if (fresh && fresh !== (self.config && self.config.password)) { self.config.password = fresh; await self._saveConfig(); const retry = new XMLHttpRequest(); retry.open('PUT', baseUrl + urlPath, true); const a2 = self._getAuthHeader(); if (a2.Authorization) retry.setRequestHeader('Authorization', a2.Authorization); if (a2['X-WebDAV-Token']) retry.setRequestHeader('X-WebDAV-Token', a2['X-WebDAV-Token']); retry.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); retry.upload.onprogress = xhr.upload.onprogress; retry.onload = function() { if (retry.status >= 200 && retry.status < 300) resolve(); else reject(new Error('HTTP ' + retry.status)); }; retry.onerror = function() { reject(new Error('Network error')); }; retry.send(buf); return; } } 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'; } }