diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 111 |
2 files changed, 86 insertions, 27 deletions
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 5a62884..16723ad 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.10'; + const LATEST_VER = 'v4.6.16'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index a84eb12..5bcc4ee 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -167,6 +167,50 @@ class WebDAVModule { return { 'Authorization': 'Basic ' + token }; } + _normalizeRemotePath(path) { + // v4.6.16: Paths can arrive as VFS-relative ('file.txt'), space-relative + // ('/file.txt'), space-prefixed ('/spaces/<user>/file.txt') or even a + // full URL ('https://host/spaces/<user>/file.txt'). Blindly joining + // such paths to the config URL previously created literal 'https:/' + // and nested 'spaces/<user>/' 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/<user>/...', 'spaces/<user>/...') + 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 + const out = []; + for (const seg of p.split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { out.pop(); continue; } + out.push(seg); + } + return '/' + out.join('/'); + } + + _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.'); @@ -176,7 +220,7 @@ class WebDAVModule { await this._resolvePassword(); } const baseUrl = this.config.url.replace(/\/+$/, ''); - const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + const urlPath = this._normalizeRemotePath(path); const url = baseUrl + urlPath; const headers = { @@ -225,7 +269,7 @@ class WebDAVModule { host = u.origin; } catch (e) {} } - const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + const cleanPath = this._normalizeRemotePath(path); // Avoid double slash when path starts with / const listUrl = host + '/spaces/' + spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); try { @@ -344,26 +388,37 @@ class WebDAVModule { } 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; + // v4.6.16: normalize FIRST — absolute/URL/junk paths previously built + // MKCOL chains into literal 'https:/' and nested 'spaces/<user>/' 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 { + 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 false; + return created; } async _deleteFile(remotePath) { @@ -881,8 +936,12 @@ class WebDAVModule { return; } try { + // Destination must be an absolute URI — build it from the + // configured base URL + normalized path so junk prefixes + // (full URLs, '/spaces/<user>/...') can't smuggle through. + const dst = self._absoluteUrl(args[1]); const resp = await self._webdavRequest('MOVE', args[0], { - headers: { 'Destination': args[1] } + headers: { 'Destination': dst } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Renamed: ' + args[0] + ' → ' + args[1], 'success'); @@ -1343,7 +1402,7 @@ class WebDAVModule { const parentPath = path.replace(/\/+$/, '') || ''; const oldFull = parentPath + '/' + entry.name; const newFull = parentPath + '/' + newName; - self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) + 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'); @@ -1477,7 +1536,7 @@ class WebDAVModule { names.forEach(function(name) { const oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; const newFull = target + '/' + name; - self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) + 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, 'success'); else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error'); @@ -1544,7 +1603,7 @@ class WebDAVModule { 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) { + 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); @@ -1729,7 +1788,7 @@ class WebDAVModule { // 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 urlPath = self._normalizeRemotePath(remotePath); const xhr = new XMLHttpRequest(); xhr.open('PUT', baseUrl + urlPath, true); const auth = self._getAuthHeader(); |