aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-02 21:21:15 +0200
committerAgando <roduccomm@gmail.com>2026-10-02 21:21:15 +0200
commit9a81c96b5a3941898f99665335ad679ff2105666 (patch)
tree75c8dbf1b059dee8ff67e37a7cc3e9cb4dfd298d /modules/ext
parentbaedca20a4c50845525a7490cd8bf513c6785d69 (diff)
newOS4 webdav path fix + WebX11 resilience package (2026-10-02)
Root cause fixes (mindtech incident): - webdav.js: _normalizeRemotePath strips full URLs/space prefixes, applied to all request entry points + _absoluteUrl for MOVE/COPY Destinations - _ensureDir: __list__ existence fast-path (kills MKCOL 409 storms) - dashboard-files.js: updateFilesJson parses __list__ autoindex (no more '#' entries), ensureSpaceExists only re-inits on genuine 404 - 20-test regression suite: scripts/test-webdav-paths.mjs Resilience: - dav-auth-check.py: server-side WebDAV write auth microservice (nginx auth_request on 8904) — anonymous/wrong-token writes now 401 - fail2ban-telegram.py: ban alerts + auto-unban for known space users - backup-spaces.sh: daily hardlink snapshots, 14-day retention - space-health-check.py: junk dirs / myfiles.json / version drift / session sanity → Telegram alerts - dashboard-files.js: visible 503 error panel with Retry + auto-retry - v4.6.16: main.html + module-loader LATEST_VER aligned (was drifted)
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/webdav.js111
1 files changed, 85 insertions, 26 deletions
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();