aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/webdav.js430
2 files changed, 425 insertions, 7 deletions
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index ef3e2ee..256d87c 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.18';
+ const LATEST_VER = 'v4.6.25';
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 e989ac9..5cacea0 100644
--- a/modules/ext/webdav.js
+++ b/modules/ext/webdav.js
@@ -7,7 +7,7 @@ class WebDAVModule {
'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-config': { argType: 'file' }, 'webdav-login': { argType: 'none' },
};
this.terminal = null;
this.config = null; // { url, username, password }
@@ -71,6 +71,25 @@ class WebDAVModule {
} 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();
@@ -92,6 +111,7 @@ class WebDAVModule {
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',
@@ -164,9 +184,117 @@ class WebDAVModule {
_getAuthHeader() {
if (!this.config || !this.config.password) return {};
const token = btoa(this.config.username + ':' + this.config.password);
- return { 'Authorization': 'Basic ' + token };
+ // 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/<user>/file.txt') or even a
@@ -224,6 +352,8 @@ class WebDAVModule {
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;
@@ -239,7 +369,23 @@ class WebDAVModule {
body: options.body || undefined
};
- const response = await fetch(url, fetchOpts);
+ 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;
}
@@ -513,12 +659,14 @@ class WebDAVModule {
const sessionToken = self._detectSessionToken();
if (sessionUser && sessionToken) {
- // Auto-mount the logged-in user's own space
+ // 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: sessionToken
+ password: freshTok || sessionToken
};
await self._saveConfig();
self._autoConfigured = true;
@@ -560,6 +708,11 @@ class WebDAVModule {
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,
@@ -615,6 +768,228 @@ class WebDAVModule {
}
};
+ // --- 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 <username> <password>
+ 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 = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><b>Log in to WebX11</b><span id="wdav-login-close" style="cursor:pointer;opacity:.7;padding:2px 6px;">✕</span></div>' +
+ '<div style="text-align:center;margin-bottom:10px;"><button id="wdav-code-btn" style="width:100%;padding:10px;background:var(--accent,#4a9eff);border:none;border-radius:6px;color:#fff;cursor:pointer;font-weight:600;">📲 Login with a code — no popup</button></div>' +
+ '<div id="wdav-code-area" style="display:none;margin-bottom:10px;">' +
+ '<div id="wdav-code-value" style="font-size:1.35em;font-family:monospace;letter-spacing:2px;text-align:center;padding:8px;background:var(--bg-secondary,#222);border-radius:6px;">WX11-XXXXXX</div>' +
+ '<div style="font-size:0.82em;color:var(--text-muted,#888);margin-top:6px;text-align:center;">Open Telegram → <b>@roducbot</b> → send this code.<br>This terminal logs in automatically (waits up to 10 min).</div>' +
+ '<div id="wdav-code-status" style="margin-top:6px;font-size:0.85em;text-align:center;color:var(--warning,#ffaa00);">Requesting code…</div>' +
+ '</div>' +
+ '<div style="text-align:center;color:var(--text-muted,#888);margin:6px 0;">— or —</div>' +
+ '<div id="wdav-tg-mount" style="display:flex;justify-content:center;margin-bottom:6px;min-height:40px;"></div>' +
+ '<div id="wdav-tg-status" style="text-align:center;color:var(--text-muted,#888);margin-bottom:10px;font-size:0.85em;">Telegram widget (in some apps this opens another browser)</div>' +
+ '<div style="text-align:center;color:var(--text-muted,#888);margin:6px 0;">— or —</div>' +
+ '<input id="wdav-user" placeholder="username" autocomplete="username" style="width:100%;margin-bottom:6px;padding:8px;background:var(--bg-secondary,#222);border:1px solid var(--border,#333);border-radius:6px;color:inherit;box-sizing:border-box;">' +
+ '<input id="wdav-pass" type="password" placeholder="password" autocomplete="current-password" style="width:100%;margin-bottom:8px;padding:8px;background:var(--bg-secondary,#222);border:1px solid var(--border,#333);border-radius:6px;color:inherit;box-sizing:border-box;">' +
+ '<button id="wdav-login-btn" style="width:100%;padding:9px;background:var(--accent,#4a9eff);border:none;border-radius:6px;color:#fff;cursor:pointer;">Log in</button>' +
+ '<div id="wdav-login-msg" style="margin-top:8px;color:var(--warning,#ffaa00);min-height:14px;font-size:0.85em;"></div>';
+ 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) {
@@ -668,6 +1043,8 @@ class WebDAVModule {
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);
@@ -683,9 +1060,20 @@ class WebDAVModule {
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');
+ }
}
};
@@ -1802,6 +2190,9 @@ class WebDAVModule {
});
// 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);
@@ -1809,6 +2200,7 @@ class WebDAVModule {
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;
@@ -1823,7 +2215,33 @@ class WebDAVModule {
lastTime = now;
}
};
- xhr.onload = function() {
+ 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));
};