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',
'webdav-browser': 'webdav-browser [path] — Visual WebDAV file explorer modal',
};
}
_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) {
let encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + this.config.username);
if (encryptedPass !== null && encryptedPass !== undefined) {
this.config.password = encryptedPass;
} else {
// Fallback to old token key
encryptedPass = window.webx11_secretVault.decrypt('webdav_token_' + 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;
}
async _deleteFile(remotePath) {
const resp = await this._webdavRequest('DELETE', remotePath);
return resp.ok || resp.status === 204 || resp.status === 404;
}
async _cleanupRetention() {
// Smart retention: keep newest 10 + 1 per day for last 7 days
try {
const entries = await this._listDir('backups');
const files = entries.filter(e => e.type === 'file' && e.name.endsWith('.json'));
if (files.length <= 17) return; // 10 newest + 7 daily = 17 max needed
// Sort by date descending (newest first)
files.sort((a, b) => {
const da = this._extractDateFromBackup(a.name);
const db = this._extractDateFromBackup(b.name);
return db - da;
});
const keep = new Set();
// Always keep newest 10
for (let i = 0; i < Math.min(10, files.length); i++) keep.add(files[i].name);
// Keep 1 per day for last 7 days (outside the newest 10)
const dayMap = new Map(); // YYYY-MM-DD -> first backup of that day
for (let i = 10; i < files.length; i++) {
const f = files[i];
const day = this._extractDayString(f.name);
if (!dayMap.has(day)) dayMap.set(day, f);
}
// Get last 7 unique days
const sortedDays = Array.from(dayMap.keys()).sort().slice(-7);
for (const day of sortedDays) keep.add(dayMap.get(day).name);
// Delete the rest
let deleted = 0;
for (const f of files) {
if (!keep.has(f.name)) {
const ok = await this._deleteFile('backups/' + f.name);
if (ok) deleted++;
}
}
if (deleted > 0) {
this.terminal.print(`Retention cleanup: removed ${deleted} old backup(s)`, 'info');
}
} catch (e) {
this.terminal.print('Retention cleanup skipped: ' + e.message, 'info');
}
}
_extractDateFromBackup(name) {
// Match YYYY-MM-DD_HHMMSS in filename
const m = name.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})/);
if (!m) return 0;
return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`).getTime();
}
_extractDayString(name) {
const m = name.match(/(\d{4}-\d{2}-\d{2})_/);
return m ? m[1] : '';
}
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);
}
// Fallback: fetch .token directly from server
if (!token) {
try {
const tokenResp = await fetch('/spaces/' + spaceName + '/.token');
if (tokenResp.ok) {
token = (await tokenResp.text()).trim();
// Cache in SecretVault for future use
if (window.webx11_secretVault) {
window.webx11_secretVault.encrypt('webdav_token_' + spaceName, token);
}
}
} catch (e) { /* .token not accessible */ }
}
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) => {
// Parse flags
let encryptPass = null;
let nameIdx = 0;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--encrypt' || args[i] === '-e') {
encryptPass = args[i + 1];
i++;
} else if (!args[i].startsWith('-')) {
nameIdx = i;
}
}
const name = (args[nameIdx] || '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');
let filename = name + '-' + dateStr + '.json';
let remotePath = 'backups/' + filename;
try {
await self._ensureDir('backups');
// Build structured export: root + currentPath + aliases + moduleRegistry
const exportData = {
type: 'webx11-backup',
version: 2,
savedAt: new Date().toISOString(),
root: self.terminal.fs.root,
currentPath: self.terminal.fs.currentPath,
aliases: self.terminal.moduleManager.aliases || {},
moduleRegistry: self.terminal.moduleManager.moduleRegistry || []
};
const serialized = JSON.stringify(exportData, null, 2);
// Gzip payload
let payload = serialized;
let isGzipped = false;
if (typeof CompressionStream !== 'undefined') {
try {
const blob = new Blob([serialized]);
const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
const resp = new Response(stream);
const buf = await resp.arrayBuffer();
payload = new Uint8Array(buf);
isGzipped = true;
} catch (e) { /* fallback to plain */ }
}
// Encrypt if requested
if (encryptPass) {
if (!isGzipped) {
self.terminal.print('Encryption requires gzip compression. CompressionStream unavailable — aborting.', 'error');
return;
}
const wrapper = await self._encryptPayload(payload, encryptPass);
payload = JSON.stringify(wrapper);
filename = name + '-' + dateStr + '.json.enc';
remotePath = 'backups/' + filename;
}
const resp = await self._webdavRequest('PUT', remotePath, {
body: payload,
headers: { 'Content-Type': encryptPass ? 'application/json' : (isGzipped ? 'application/gzip' : 'application/json') }
});
if (resp.ok || resp.status === 201 || resp.status === 204) {
const sizeStr = isGzipped ? self._formatSize(serialized.length) + ' → ' + self._formatSize(typeof payload === 'string' ? payload.length : payload.length) : self._formatSize(serialized.length);
const encMark = encryptPass ? ' [🔒 encrypted]' : '';
self.terminal.print('Backup saved: ' + remotePath + ' (' + sizeStr + ')' + encMark, 'success');
// Run retention cleanup in background
self._cleanupRetention().catch(() => {});
} 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 [--passphrase ]', 'warning');
self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info');
return;
}
let remotePath = args[0];
let passphrase = null;
for (let i = 1; i < args.length; i++) {
if (args[i] === '--passphrase' || args[i] === '-p') passphrase = args[i + 1];
}
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);
// Read raw bytes first (resp.text() corrupts gzip binary)
const arrayBuf = await resp.arrayBuffer();
const bytes = new Uint8Array(arrayBuf);
let text;
// Detect gzip by magic bytes 0x1f 0x8b
if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
self.terminal.print('Detected gzip-compressed backup, decompressing...', 'info');
try {
const blob = new Blob([bytes]);
const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
const decompressed = await new Response(stream).text();
text = decompressed;
} catch (e) {
throw new Error('Gzip decompression failed: ' + e.message);
}
} else {
text = new TextDecoder().decode(bytes);
}
// Detect encrypted wrapper
let isEncrypted = false;
let wrapper = null;
try {
wrapper = JSON.parse(text);
if (wrapper && wrapper._encrypted) isEncrypted = true;
} catch (e) { /* not JSON wrapper */ }
let content = text;
if (isEncrypted) {
if (!passphrase) {
self.terminal.print('🔒 This backup is encrypted. Run: webdav-restore ' + args[0] + ' --passphrase ', 'warning');
return;
}
self.terminal.print('Decrypting backup...', 'info');
const decryptedU8 = await self._decryptPayload(wrapper, passphrase);
content = new TextDecoder().decode(decryptedU8);
}
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-browser: visual modal file browser ---
this.commands['webdav-browser'] = async (args) => {
const startPath = args[0] || self._browsingPath || '/';
await self.openWebdavBrowser(startPath);
};
// --- 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');
}
};
}
/* ── Visual WebDAV Browser Modal ── */
async openWebdavBrowser(path) {
if (!this.config || !this.config.url) {
if (this.terminal) this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning');
return;
}
this._browsingPath = path;
const self = this;
// Remove existing modal if any
const existing = document.getElementById('webdav-browser-modal');
if (existing) existing.remove();
// Fetch listing
let entries = [];
let errorMsg = '';
try {
entries = await this._listDir(path);
} catch (e) {
errorMsg = e.message;
}
// Build modal
const overlay = document.createElement('div');
overlay.id = 'webdav-browser-modal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:999;';
overlay.onclick = function(e) { if (e.target === this) self.closeWebdavBrowser(); };
const content = document.createElement('div');
content.className = 'modal-content';
content.style.cssText = 'max-width:650px; max-height:80vh; width:92%;';
// Header
const header = document.createElement('div');
header.className = 'modal-header';
header.innerHTML = '📁 WebDAV Browser✕';
content.appendChild(header);
// Body
const body = document.createElement('div');
body.className = 'modal-body';
body.style.cssText = 'padding:8px; overflow-y:auto; flex-grow:1;';
if (errorMsg) {
const errDiv = document.createElement('div');
errDiv.style.cssText = 'color:var(--error-color); padding:16px; text-align:center;';
errDiv.textContent = '❌ ' + errorMsg;
body.appendChild(errDiv);
} else if (entries.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'text-center';
emptyDiv.style.cssText = 'padding:20px; color:var(--text-muted);';
emptyDiv.textContent = '(empty) — upload files using the button below';
body.appendChild(emptyDiv);
} else {
// Sort: dirs first, then files, alphabetically
entries.sort(function(a, b) {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
var frag = document.createDocumentFragment();
entries.forEach(function(entry) {
var item = document.createElement('div');
item.className = entry.type === 'dir' ? 'file-item dir' : 'file-item';
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:8px; border-bottom:1px solid #111; cursor:pointer; min-height:36px;';
item.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; };
item.onmouseout = function() { this.style.background = ''; };
var nameSpan = document.createElement('span');
var icon = entry.type === 'dir' ? '📁' : '📄';
nameSpan.textContent = icon + ' ' + entry.name + (entry.type === 'dir' ? '/' : '');
var sizeSpan = document.createElement('span');
sizeSpan.style.cssText = 'font-size:0.8em; color:var(--text-muted);';
if (entry.type === 'file') {
sizeSpan.textContent = self._formatSize(entry.size);
}
item.appendChild(nameSpan);
item.appendChild(sizeSpan);
if (entry.type === 'dir') {
item.onclick = function() { self.openWebdavBrowser(entry.href); };
} else {
item.onclick = function() { self.previewWebdavFile(entry); };
// Right-click or context menu could download — but for simplicity, long tap or regular click previews
// Double-click to download
item.ondblclick = function() { self.downloadFromWebdav(entry); };
}
frag.appendChild(item);
});
body.appendChild(frag);
}
content.appendChild(body);
// Footer with breadcrumb + buttons
const footer = document.createElement('div');
footer.className = 'modal-footer';
footer.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap; padding:8px 10px;';
// Breadcrumb
var breadDiv = document.createElement('div');
breadDiv.style.cssText = 'font-size:0.8em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0;';
var cleanPath = path.replace(/\/+$/, '') || '/';
var parts = cleanPath.split('/').filter(Boolean);
var breadHtml = '📁 /';
var cumulative = '';
for (var i = 0; i < parts.length; i++) {
cumulative += '/' + parts[i];
var seg = parts[i];
breadHtml += '' + seg + '';
if (i < parts.length - 1) breadHtml += ' ▸ ';
}
breadDiv.innerHTML = breadHtml;
// Attach nav function to modal element for breadcrumb clicks
overlay.__nav = function(p) { self.openWebdavBrowser(p); };
// Button group
var btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex; gap:4px; flex-shrink:0;';
var uploadBtn = document.createElement('button');
uploadBtn.className = 'btn';
uploadBtn.textContent = '⬆ Upload';
uploadBtn.onclick = function() { self.uploadToWebdav(path); };
var refreshBtn = document.createElement('button');
refreshBtn.className = 'btn';
refreshBtn.textContent = '🔄';
refreshBtn.title = 'Refresh';
refreshBtn.onclick = function() { self.openWebdavBrowser(path); };
var closeBtn = document.createElement('button');
closeBtn.className = 'btn';
closeBtn.textContent = 'Close';
closeBtn.onclick = function() { self.closeWebdavBrowser(); };
btnGroup.appendChild(uploadBtn);
btnGroup.appendChild(refreshBtn);
btnGroup.appendChild(closeBtn);
footer.appendChild(breadDiv);
footer.appendChild(btnGroup);
content.appendChild(footer);
overlay.appendChild(content);
document.body.appendChild(overlay);
this.terminal.print('📂 Opened WebDAV browser: ' + path, 'info');
}
closeWebdavBrowser() {
var modal = document.getElementById('webdav-browser-modal');
if (modal) modal.remove();
if (this.terminal && this.terminal.inputField) this.terminal.inputField.focus();
this.terminal.print('WebDAV browser closed', 'text-muted');
}
async previewWebdavFile(entry) {
try {
var resp = await this._webdavRequest('GET', entry.href);
if (!resp.ok) { this.terminal.print('Failed to fetch: HTTP ' + resp.status, 'error'); return; }
var content = await resp.text();
var isHtml = entry.name.match(/\.html?$/i);
var isImage = entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i);
var size = content.length;
if (size > 512000 && !isHtml && !isImage) {
// Large non-html file — offer download instead
this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — double-click to download instead of preview.', 'warning');
this.terminal.print('--- ' + entry.name + ' (first 2000 chars) ---');
this.terminal.print(content.substring(0, 2000));
if (content.length > 2000) this.terminal.print('...(truncated ' + this._formatSize(size) + ' total)');
return;
}
if (isImage) {
var dataUrl = 'data:image/' + (entry.name.split('.').pop()) + ';base64,' + btoa(unescape(encodeURIComponent(content)));
// Use preview modal
var previewTitle = document.getElementById('preview-title');
var previewIframe = document.getElementById('preview-iframe');
if (previewTitle && previewIframe) {
previewTitle.textContent = '🖼 ' + entry.name;
previewIframe.srcdoc = '
';
document.getElementById('preview-modal').style.display = 'flex';
return;
}
}
if (isHtml) {
var previewTitle = document.getElementById('preview-title');
var previewIframe = document.getElementById('preview-iframe');
if (previewTitle && previewIframe) {
previewTitle.textContent = '📄 ' + entry.name;
previewIframe.srcdoc = content;
document.getElementById('preview-modal').style.display = 'flex';
return;
}
}
// Text file — print to terminal
this.terminal.print('--- ' + entry.name + ' (' + this._formatSize(size) + ') ---', 'info');
this.terminal.print(content);
this.terminal.print('--- End: ' + entry.name + ' ---', 'text-muted');
} catch (e) {
this.terminal.print('Preview error: ' + e.message, 'error');
}
}
uploadToWebdav(currentPath) {
var self = this;
var input = document.createElement('input');
input.type = 'file';
input.onchange = async function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = async function(evt) {
var basePath = currentPath.replace(/\/+$/, '');
var remotePath = basePath + '/' + file.name;
var parentDir = remotePath.split('/').slice(0, -1).join('/');
try {
await self._ensureDir(parentDir);
var resp = await self._webdavRequest('PUT', remotePath, {
body: evt.target.result,
headers: { 'Content-Type': 'application/octet-stream' }
});
if (resp.ok || resp.status === 201 || resp.status === 204) {
self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success');
self.openWebdavBrowser(currentPath);
} else {
self.terminal.print('Upload failed: HTTP ' + resp.status, 'error');
}
} catch (e) {
self.terminal.print('Upload error: ' + e.message, 'error');
}
};
reader.readAsText(file);
};
input.click();
}
async downloadFromWebdav(entry) {
try {
var resp = await this._webdavRequest('GET', entry.href);
if (!resp.ok) { this.terminal.print('Download failed: HTTP ' + resp.status, 'error'); return; }
var content = await resp.text();
var blob = new Blob([content], { type: 'application/octet-stream' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = entry.name;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 200);
this.terminal.print('⬇ Downloaded: ' + entry.name + ' (' + this._formatSize(content.length) + ')', 'success');
} catch (e) {
this.terminal.print('Download error: ' + e.message, 'error');
}
}
/* ── Encryption helpers (AES-GCM for backups) ── */
_u8ToBase64(u8) {
let s = '';
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
return btoa(s);
}
_base64ToU8(b64) {
const s = atob(b64);
const u8 = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
return u8;
}
async _deriveKey(passphrase, salt) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
}
async _encryptPayload(gzipU8, passphrase) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await this._deriveKey(passphrase, salt);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, gzipU8);
return {
_encrypted: true,
salt: this._u8ToBase64(salt),
iv: this._u8ToBase64(iv),
iterations: 100000,
cipher: this._u8ToBase64(new Uint8Array(ciphertext))
};
}
async _decryptPayload(wrapper, passphrase) {
const salt = this._base64ToU8(wrapper.salt);
const iv = this._base64ToU8(wrapper.iv);
const key = await this._deriveKey(passphrase, salt);
const ciphertext = this._base64ToU8(wrapper.cipher);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new Uint8Array(decrypted);
}
_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';
}
}