aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/webdav.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-05-25 18:02:02 +0200
committerAgando <roduccomm@gmail.com>2026-05-25 18:02:02 +0200
commit4baa484b84c5184286cac2743575251454a5ee2b (patch)
treecb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/ext/webdav.js
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/ext/webdav.js')
-rw-r--r--modules/ext/webdav.js532
1 files changed, 532 insertions, 0 deletions
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
new file mode 100644
index 0000000..63dfb05
--- /dev/null
+++ b/modules/ext/webdav.js
@@ -0,0 +1,532 @@
+class WebDAVModule {
+ constructor() {
+ this.commands = {};
+ 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 '';
+ }
+
+ 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-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-explorer': 'webdav-explorer [path] — Interactive remote file browser',
+ };
+ }
+
+ _loadConfig() {
+ try {
+ const saved = localStorage.getItem(this._STORAGE_KEY);
+ if (saved) {
+ this.config = JSON.parse(saved);
+ // Try to get password from SecretVault
+ if (window.webx11_secretVault && this.config && this.config.username) {
+ const encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + this.config.username);
+ if (encryptedPass !== null && encryptedPass !== undefined) {
+ this.config.password = encryptedPass;
+ }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ _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) {
+ window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password);
+ // Also store space token
+ if (this._spaceName) {
+ window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password);
+ }
+ }
+ // Also store in localStorage for backward compat (SecretVault decrypts later)
+ // Store only url and username hint in plaintext lS, encrypted password handled above
+ 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);
+ return { 'Authorization': 'Basic ' + token };
+ }
+
+ async _webdavRequest(method, path, options = {}) {
+ if (!this.config || !this.config.url) {
+ throw new Error('WebDAV not configured. Use webdav-config first.');
+ }
+ const baseUrl = this.config.url.replace(/\/+$/, '');
+ const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
+ const url = baseUrl + urlPath;
+
+ const headers = {
+ ...this._getAuthHeader(),
+ ...(options.headers || {})
+ };
+
+ const fetchOpts = {
+ method: method,
+ headers: headers,
+ body: options.body || undefined
+ };
+
+ const response = await fetch(url, fetchOpts);
+ return response;
+ }
+
+ async _listDir(path) {
+ // Try PROPFIND first (works with WebDAV servers), fallback to nginx 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 */ }
+ // Fallback: use nginx __list__ endpoint
+ return this._listDirViaAutoIndex(path);
+ }
+
+ async _listDirViaAutoIndex(path) {
+ const baseUrl = this.config.url.replace(/\/+$/, '');
+ const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
+ // Use __list__ for directory listing
+ const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, '');
+ try {
+ const 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
+ const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{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 sizeStr = match[4];
+ if (name === '..' || name === '../') continue;
+ if (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,
+ href: href
+ });
+ }
+ // Fallback: if regex failed, try simpler parse
+ if (entries.length === 0) {
+ const simpleRegex = /<a href="([^"]+)">/g;
+ const names = [];
+ while ((match = simpleRegex.exec(html)) !== null) {
+ const href = match[1];
+ if (href === '../' || href === '/' || href === './') continue;
+ if (href.startsWith('?')) continue;
+ names.push(href);
+ }
+ for (const href of names) {
+ const isDir = href.endsWith('/');
+ entries.push({
+ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
+ type: isDir ? 'dir' : 'file',
+ size: 0,
+ href: href
+ });
+ }
+ }
+ return entries;
+ }
+
+ _parsePropfind(xml) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, 'text/xml');
+ const responses = doc.querySelectorAll('D\\:response, response');
+ const entries = [];
+ responses.forEach((resp) => {
+ 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) {
+ // Try MKCOL — ignore 405 (already exists) and 409 (parent missing)
+ 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 — try creating parent recursively
+ const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean);
+ if (parts.length > 1) {
+ const parent = parts.slice(0, -1).join('/');
+ await this._ensureDir(parent);
+ // Retry MKCOL
+ const retry = await this._webdavRequest('MKCOL', remotePath);
+ if (retry.ok || retry.status === 405) return true;
+ }
+ // Try creating path component by component as a fallback
+ let current = '';
+ for (const part of parts) {
+ current += '/' + part;
+ try {
+ const attempt = await this._webdavRequest('MKCOL', current);
+ if (!attempt.ok && attempt.status !== 405) {
+ // Ignore — it likely exists already or we can't create it
+ }
+ } catch (e) { /* ignore */ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ registerCommands() {
+ const self = this;
+
+ // --- webdav-config: set WebDAV endpoint and credentials ---
+ this.commands['webdav-config'] = async (args) => {
+ // No args: auto-detect space name and try to configure from token
+ if (!args || args.length === 0) {
+ const spaceName = self._spaceName;
+ if (!spaceName) {
+ self.terminal.print('Could not detect space name from URL.', 'warning');
+ self.terminal.print('Usage: webdav-config <url> [username] [password]', 'info');
+ return;
+ }
+
+ // Auto-configure URL from space name
+ const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/';
+ let token = null;
+
+ // Check SecretVault first
+ if (window.webx11_secretVault) {
+ token = window.webx11_secretVault.decrypt('webdav_token_' + spaceName);
+ }
+
+ // Fallback to localStorage
+ if (!token) {
+ token = localStorage.getItem('webx11_spaces_' + spaceName);
+ }
+
+ if (token) {
+ self.config = {
+ url: autoUrl,
+ username: spaceName,
+ password: token
+ };
+ 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 {
+ self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning');
+ self.terminal.print('Get your token from /spaces/' + spaceName + '/.token or the dashboard.', 'info');
+ self.terminal.print('Auto-setting URL: ' + autoUrl, 'info');
+ self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'info');
+ // At minimum set the URL
+ self.config = {
+ url: autoUrl,
+ username: spaceName,
+ password: ''
+ };
+ self._saveConfig();
+ self._autoConfigured = true;
+ }
+ 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 : '')
+ };
+ 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-status: show current WebDAV config ---
+ this.commands['webdav-status'] = () => {
+ if (!self.config || !self.config.url) {
+ self.terminal.print('WebDAV not configured. Use webdav-config <url> [username] [password]', 'warning');
+ return;
+ }
+ 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 <vfs-path> [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;
+ }
+ 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');
+ }
+ } catch (e) {
+ self.terminal.print('Push error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-pull: download a WebDAV file to VFS ---
+ this.commands['webdav-pull'] = async (args) => {
+ if (!args[0]) {
+ self.terminal.print('Usage: webdav-pull <remote-path> [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) => {
+ const name = (args[0] || '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');
+ const filename = name + '-' + dateStr + '.json';
+ const remotePath = 'backups/' + filename;
+
+ try {
+ await self._ensureDir('backups');
+ const serialized = JSON.stringify(self.terminal.fs.root, null, 2);
+ const resp = await self._webdavRequest('PUT', remotePath, {
+ body: serialized,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ if (resp.ok || resp.status === 201 || resp.status === 204) {
+ self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success');
+ } 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 <backup-filename>', 'warning');
+ self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info');
+ return;
+ }
+ let remotePath = args[0];
+ 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);
+ const content = await resp.text();
+ 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-explorer: interactive file browser via WebDAV ---
+ this.commands['webdav-explorer'] = async (args) => {
+ const startPath = args[0] || '/';
+ try {
+ const entries = await self._listDir(startPath);
+ self.terminal.print('📁 WebDAV: ' + startPath, 'info');
+ if (entries.length === 0) {
+ self.terminal.print('(empty)', 'info');
+ return;
+ }
+ entries.forEach(e => {
+ const icon = e.type === 'dir' ? '📁' : '📄';
+ const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/';
+ self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size);
+ });
+ self.terminal.print('---');
+ self.terminal.print('↳ Navigate: webdav-explorer <dir> | Pull: webdav-pull <file>', 'text-muted');
+ self.terminal.print(entries.length + ' item(s)', 'text-muted');
+ } catch (e) {
+ self.terminal.print('WebDAV error: ' + e.message, 'error');
+ }
+ };
+ }
+
+ _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';
+ }
+}