aboutsummaryrefslogtreecommitdiff
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
commitca7cd43de05620f372b55f49147eb6820d858fac (patch)
tree27b4638677af6030beb33911e92bf7969b196d66
parent06874424c2bdc1103fa37ea65d87587425c3ceb3 (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)
-rw-r--r--main.html6
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/webdav.js111
-rw-r--r--scripts/test-webdav-paths.mjs136
4 files changed, 225 insertions, 30 deletions
diff --git a/main.html b/main.html
index 4b79775..3ec6a94 100644
--- a/main.html
+++ b/main.html
@@ -1345,13 +1345,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.15"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.16"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.15"></script>
+<script src="modules/core/module-loader.js?v=v4.6.16"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.15"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.16"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
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();
diff --git a/scripts/test-webdav-paths.mjs b/scripts/test-webdav-paths.mjs
new file mode 100644
index 0000000..a8340c0
--- /dev/null
+++ b/scripts/test-webdav-paths.mjs
@@ -0,0 +1,136 @@
+// v4.6.16 regression test: WebDAV remote-path normalization.
+// Reproduces the mindtech incident: AI passed full URLs / absolute
+// '/spaces/<user>/' paths which got blindly joined onto the config URL,
+// creating literal 'https:/' and nested 'spaces/<user>/' junk dirs.
+// Run: node scripts/test-webdav-paths.mjs (repo root = spaces/newos4)
+
+// Run: node scripts/test-webdav-paths.mjs (repo root = spaces/newos4)
+
+import { readFileSync } from 'fs';
+
+const src = readFileSync(new URL('../modules/ext/webdav.js', import.meta.url), 'utf8');
+
+class TerminalStub {
+ print() {}
+}
+
+// Mirrors ModuleManager.createModuleClass(): extract the first top-level class.
+const cleanCode = src.replace(/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*\s*/, '');
+const classMatch = cleanCode.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/);
+if (!classMatch) throw new Error('no module class found in webdav.js');
+const WebDAVModule = new Function('window', 'localStorage', 'fetch', 'XMLHttpRequest', 'URL', 'crypto',
+ 'return ' + cleanCode + '\nreturn ' + classMatch[1])(
+ { location: { pathname: '/spaces/mindtech/newos4/main.html' } },
+ { getItem: () => null, setItem: () => {} },
+ async () => { throw new Error('network disabled in test'); },
+ function () { this.open = () => {}; this.setRequestHeader = () => {}; this.send = () => {}; },
+ URL, undefined);
+
+function makeMod() {
+ const m = new WebDAVModule(new TerminalStub());
+ m.config = { url: 'https://webx11.duckdns.org/spaces/mindtech/', username: 'mindtech', password: 'tok' };
+ return m;
+}
+
+let pass = 0, fail = 0;
+function check(name, cond, got) {
+ if (cond) { pass++; console.log(' ok - ' + name); }
+ else { fail++; console.log(' FAIL - ' + name + (got !== undefined ? ' got: ' + JSON.stringify(got) : '')); }
+}
+
+// --- _normalizeRemotePath ---
+{
+ const m = makeMod();
+ const cases = [
+ // [input, expected]
+ [null, '/'],
+ ['', '/'],
+ ['file.txt', '/file.txt'],
+ ['/file.txt', '/file.txt'],
+ // the mindtech incident shapes:
+ ['https://webx11.duckdns.org/spaces/mindtech/thaimassage-kiefersfelden/index-traditional.html',
+ '/thaimassage-kiefersfelden/index-traditional.html'],
+ ['/spaces/mindtech/thaimassage-kiefersfelden/index-professional.html',
+ '/thaimassage-kiefersfelden/index-professional.html'],
+ ['https://webx11.duckdns.org/spaces/mindtech', '/'],
+ ['spaces/mindtech/skills/navigating-the-terminal.json', '/skills/navigating-the-terminal.json'],
+ // relative + traversal + dupes:
+ ['./a//b/../c.txt', '/a/c.txt'],
+ ['/a/b/../../d.txt', '/d.txt'],
+ ['/skills/', '/skills'],
+ ['backups', '/backups'],
+ // foreign space stays intact (only own space prefix is stripped):
+ ['/spaces/otheruser/file.txt', '/spaces/otheruser/file.txt'],
+ ['https://evil.example.com/spaces/mindtech/x?query=1#frag', '/x'],
+ ];
+ for (const [input, expected] of cases) {
+ const got = m._normalizeRemotePath(input);
+ check(`normalize(${JSON.stringify(input)}) => ${expected}`, got === expected, got);
+ }
+}
+
+// --- _absoluteUrl (MOVE/COPY Destination) ---
+{
+ const m = makeMod();
+ const got = m._absoluteUrl('https://webx11.duckdns.org/spaces/mindtech/a/b.html');
+ check('_absoluteUrl strips to full absolute URI',
+ got === 'https://webx11.duckdns.org/spaces/mindtech/a/b.html', got);
+ const got2 = m._absoluteUrl('c.html');
+ check('_absoluteUrl builds from config base',
+ got2 === 'https://webx11.duckdns.org/spaces/mindtech/c.html', got2);
+}
+
+// --- _ensureDir normalizes before MKCOL walk ---
+{
+ const m = makeMod();
+ const mkcols = [];
+ m._webdavRequest = async (method, path) => {
+ if (method === 'MKCOL') mkcols.push(path);
+ return { ok: true, status: 201 };
+ };
+ m._listDir = async () => { throw new Error('ENOENT'); };
+ await m._ensureDir('https://webx11.duckdns.org/spaces/mindtech/thaimassage-kiefersfelden');
+ check('_ensureDir MKCOLs normalized path (no https:/ junk)',
+ mkcols.length === 1 && mkcols[0] === '/thaimassage-kiefersfelden', mkcols);
+
+ // Existing dir fast-path: __list__ probe succeeds -> no MKCOL at all
+ const m2 = makeMod();
+ const mkcols2 = [];
+ m2._listDir = async (p) => { if (p === '/skills') return []; throw new Error('ENOENT'); };
+ m2._webdavRequest = async (method, path) => { if (method === 'MKCOL') mkcols2.push(path); return { ok: true, status: 201 }; };
+ await m2._ensureDir('/skills');
+ check('_ensureDir skips MKCOL when dir already exists', mkcols2.length === 0, mkcols2);
+
+ // Partial existing: /backups exists, /backups/2026 does not
+ const m3 = makeMod();
+ const mkcols3 = [];
+ m3._listDir = async (p) => { if (p === '/backups') return []; throw new Error('ENOENT'); };
+ m3._webdavRequest = async (method, path) => { if (method === 'MKCOL') mkcols3.push(path); return { ok: true, status: 201 }; };
+ await m3._ensureDir('/backups/2026');
+ check('_ensureDir MKCOLs only missing tail', mkcols3.length === 1 && mkcols3[0] === '/backups/2026', mkcols3);
+}
+
+// --- PUT via _webdavRequest hits normalized URL ---
+{
+ // The class closes over the injected fetch param — rebuild with a
+ // recording fetch so we can assert the request URL.
+ let requested = null;
+ const recordFetch = async (url) => { requested = url; return { ok: true, status: 201 }; };
+ const WebDAVModule2 = new Function('window', 'localStorage', 'fetch', 'XMLHttpRequest', 'URL', 'crypto',
+ 'return ' + cleanCode + '\nreturn ' + classMatch[1])(
+ { location: { pathname: '/spaces/mindtech/newos4/main.html' } },
+ { getItem: () => null, setItem: () => {} },
+ recordFetch,
+ function () { this.open = () => {}; this.setRequestHeader = () => {}; this.send = () => {}; },
+ URL, undefined);
+ const m = new WebDAVModule2(new TerminalStub());
+ m.config = { url: 'https://webx11.duckdns.org/spaces/mindtech/', username: 'mindtech', password: 'tok' };
+ m._resolvePassword = async () => {};
+ m._getAuthHeader = () => ({});
+ await m._webdavRequest('PUT', 'https://webx11.duckdns.org/spaces/mindtech/site/index.html', { body: 'x' });
+ check('PUT goes to normalized URL',
+ requested === 'https://webx11.duckdns.org/spaces/mindtech/site/index.html', requested);
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0); \ No newline at end of file