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>\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 = //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 [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 + ' ', '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 [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 [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 [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 ', '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 | Pull: webdav-pull ', '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';
}
}