// WebSSH Client JavaScript let connections = []; let currentConnectionId = null; let authToken = localStorage.getItem('webssh_token'); let currentUser = localStorage.getItem('webssh_user'); let storedUsers = {}; // Will be loaded from /api/config let maxUploadSize = localStorage.getItem('maxUploadSize') ? parseInt(localStorage.getItem('maxUploadSize')) : 100 * 1024 * 1024; // Dynamically determine API URL based on current host (same origin since everything is in container) let apiBaseUrl = window.location.origin; let backendWsPort = null; let backendWsHost = null; let hostStatusTimer = null; // Use the frontend proxy so the browser does not need direct access to the backend port. function getWsUrl() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}/ws`; } const CONNECTIONS_KEY = 'webssh_connections'; // Load connections from server (with encrypted credentials) async function loadSavedConnections() { if (!authToken) return; try { const response = await fetch(`${apiBaseUrl}/api/connections`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (response.ok) { const data = await response.json(); console.log('API response:', data); // DEBUG connections = data.connections || []; console.log('Loaded connections:', connections); // DEBUG // Reset connection states and add UI properties connections.forEach(conn => { conn.connected = false; conn.socket = null; conn.loadAverage = null; if (!conn.terminalBuffer) conn.terminalBuffer = []; }); renderConnectionList(); refreshHostStatuses(); startHostStatusPolling(); if (connections.length > 0) { term.writeln(`[Loaded ${connections.length} connections from server]`); } } else { console.error('API error:', response.status, response.statusText); // DEBUG const errorData = await response.text(); console.error('Error details:', errorData); // DEBUG } } catch (e) { console.error('Failed to load connections from server:', e); // Fallback: try to load non-sensitive data from localStorage loadLocalConnections(); } } // Fallback: load only non-sensitive connection info from localStorage function loadLocalConnections() { const saved = localStorage.getItem(CONNECTIONS_KEY); if (saved) { try { const localConns = JSON.parse(saved); // Only use localStorage for connection metadata (no passwords/keys) connections = localConns.map(c => ({ id: c.id, name: c.name, host: c.host, port: c.port, username: c.username, authType: c.authType, password: '', // Don't load sensitive data from localStorage key: '', connected: false, socket: null, loadAverage: null, terminalBuffer: c.terminalBuffer || [] })); renderConnectionList(); term.writeln(`[Loaded ${connections.length} connections (no credentials)]`); } catch (e) { console.error('Failed to load local connections:', e); } } } // Save connection to server (with encrypted credentials) async function saveConnectionToServer(conn) { if (!authToken) return false; try { const response = await fetch(`${apiBaseUrl}/api/connections`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify(conn) }); if (!response.ok) { const errorText = await response.text(); console.error('Save connection error:', response.status, errorText); return false; } console.log('Connection saved successfully:', conn.name); return true; } catch (e) { console.error('Failed to save connection to server:', e); return false; } } // Delete connection from server async function deleteConnectionFromServer(connId) { if (!authToken) return false; try { const response = await fetch(`${apiBaseUrl}/api/connections/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ id: connId }) }); return response.ok; } catch (e) { console.error('Failed to delete connection from server:', e); return false; } } // Save non-sensitive metadata to localStorage (for offline mode) function saveLocalMetadata() { const metadata = connections.map(conn => ({ id: conn.id, name: conn.name, host: conn.host, port: conn.port, username: conn.username, authType: conn.authType, terminalBuffer: conn.terminalBuffer })); localStorage.setItem(CONNECTIONS_KEY, JSON.stringify(metadata)); } // Save connections to localStorage (deprecated - use server storage) function saveConnectionsToStorage() { // Only save non-sensitive metadata for offline mode saveLocalMetadata(); } // Auto-save when connections change function autoSaveConnections() { saveLocalMetadata(); } const term = new Terminal({ cursorBlink: true, fontSize: 14, theme: { background: '#0b0f14', foreground: '#f3f4f6' }, fontFamily: 'Consolas, Monaco, monospace' }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById('terminal')); // Debounce function to limit resize calls function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Initial fit after terminal is opened setTimeout(() => { fitAddon.fit(); term.refresh(0, term.rows - 1); }, 100); // Resize handler with debounce for better performance const handleResize = debounce(() => { // Store current cursor position and content const cursorY = term.buffer.active.cursorY; const cursorX = term.buffer.active.cursorX; // Fit terminal to container fitAddon.fit(); // Refresh terminal to ensure proper rendering term.refresh(0, term.rows - 1); // Send resize to backend sendResizeForCurrent(); // Restore cursor position if within bounds if (cursorY < term.rows && cursorX < term.cols) { term.buffer.active.cursorY = cursorY; term.buffer.active.cursorX = cursorX; } }, 250); window.addEventListener('resize', handleResize); // Also resize when terminal becomes visible (e.g., after switching from SFTP) const resizeObserver = new ResizeObserver(() => { handleResize(); }); resizeObserver.observe(document.getElementById('terminal')); // Check if already logged in if (authToken) { verifyToken(); } // Load saved connections loadSavedConnections(); // Setup single terminal input handler (called once) term.onData((data) => { const conn = getCurrentConnection(); if (conn && conn.socket && conn.socket.readyState === WebSocket.OPEN && conn.connected) { conn.socket.send(data); } }); async function login() { const username = document.getElementById('loginUsername').value; const password = document.getElementById('loginPassword').value; const apiUrl = apiBaseUrl; if (!username || !password) { document.getElementById('loginError').textContent = 'Please enter username and password'; return; } try { const response = await fetch(`${apiUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (response.ok && data.token) { authToken = data.token; localStorage.setItem('webssh_token', authToken); document.getElementById('loginModal').classList.remove('active'); document.getElementById('loggedInUser').textContent = data.username; term.writeln(`[Authenticated as ${data.username}]`); loadSavedConnections(); setTimeout(loadConfig, 500); // Load configuration after successful login with delay } else { document.getElementById('loginError').textContent = data.error || 'Login failed'; } } catch (e) { document.getElementById('loginError').textContent = 'Connection error: ' + e.message; } } async function verifyToken() { try { const response = await fetch(`${apiBaseUrl}/api/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: authToken }) }); const data = await response.json(); if (data.valid) { document.getElementById('loginModal').classList.remove('active'); document.getElementById('loggedInUser').textContent = data.username; term.writeln(`[Authenticated as ${data.username}]`); loadSavedConnections(); // Load encrypted credentials from server setTimeout(loadConfig, 500); // Load configuration after successful token verification with delay } else { localStorage.removeItem('webssh_token'); authToken = null; } } catch (e) { localStorage.removeItem('webssh_token'); authToken = null; } } function logout() { localStorage.removeItem('webssh_token'); localStorage.removeItem('webssh_api_url'); authToken = null; location.reload(); } function generateId() { return 'conn_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } function getCurrentConnection() { return connections.find(c => c.id === currentConnectionId); } function getConnection(id) { return connections.find(c => c.id === id); } function showNewConnectionModal() { document.getElementById('newConnectionModal').classList.add('active'); document.getElementById('modalName').focus(); } function hideModal() { document.getElementById('newConnectionModal').classList.remove('active'); document.getElementById('modalName').value = ''; document.getElementById('modalHost').value = ''; document.getElementById('modalPort').value = '22'; document.getElementById('modalUser').value = ''; document.getElementById('modalPassword').value = ''; document.getElementById('modalKey').value = ''; document.getElementById('modalAuthType').value = 'password'; toggleModalAuth(); } function showEditConnectionModal(id) { const conn = getConnection(id); if (!conn) return; currentConnectionId = id; selectConnection(id); document.getElementById('editModalName').value = conn.name || ''; document.getElementById('editModalHost').value = conn.host || ''; document.getElementById('editModalPort').value = conn.port || '22'; document.getElementById('editModalUser').value = conn.username || ''; document.getElementById('editModalPassword').value = conn.password || ''; document.getElementById('editModalKey').value = conn.key || ''; document.getElementById('editModalAuthType').value = conn.authType || 'password'; document.getElementById('editModalLockTimeout').value = conn.lockTimeout || 15; document.getElementById('editModalDisconnectTimeout').value = conn.disconnectTimeout || 60; toggleEditModalAuth(); document.getElementById('editConnectionModal').classList.add('active'); } function hideEditConnectionModal() { document.getElementById('editConnectionModal').classList.remove('active'); document.getElementById('editModalName').value = ''; document.getElementById('editModalHost').value = ''; document.getElementById('editModalPort').value = '22'; document.getElementById('editModalUser').value = ''; document.getElementById('editModalPassword').value = ''; document.getElementById('editModalKey').value = ''; document.getElementById('editModalAuthType').value = 'password'; toggleEditModalAuth(); } function toggleModalAuth() { const authType = document.getElementById('modalAuthType').value; document.getElementById('modalPasswordGroup').classList.toggle('hidden', authType === 'key'); document.getElementById('modalKeyGroup').classList.toggle('hidden', authType === 'password'); } function toggleEditModalAuth() { const authType = document.getElementById('editModalAuthType').value; document.getElementById('editModalPasswordGroup').classList.toggle('hidden', authType === 'key'); document.getElementById('editModalKeyGroup').classList.toggle('hidden', authType === 'password'); } function toggleAuthFields() { const authTypeField = document.getElementById('authType'); const passwordField = document.getElementById('password'); if (!authTypeField || !passwordField) return; const authType = authTypeField.value; passwordField.style.display = authType === 'key' ? 'none' : 'block'; // Don't auto-show key section - let user control it with toggle button const keySection = document.getElementById('keySection'); if (keySection) keySection.classList.remove('active'); } function toggleKeyField() { const keySection = document.getElementById('keySection'); const btn = document.getElementById('toggleKeyBtn'); keySection.classList.toggle('active'); btn.classList.toggle('active'); } function createConnection() { const lockTimeout = parseInt(document.getElementById('modalLockTimeout').value) || 15; const disconnectTimeout = parseInt(document.getElementById('modalDisconnectTimeout').value) || 60; // Validation: disconnect timeout must be greater than lock timeout (unless never) if (disconnectTimeout !== 0 && disconnectTimeout <= lockTimeout) { alert(`Disconnect timeout (${disconnectTimeout} min) must be greater than Lock timeout (${lockTimeout} min).`); return; } const conn = { id: generateId(), name: document.getElementById('modalName').value || 'New Connection', host: document.getElementById('modalHost').value, port: parseInt(document.getElementById('modalPort').value) || 22, username: document.getElementById('modalUser').value, authType: document.getElementById('modalAuthType').value, password: document.getElementById('modalPassword').value, key: document.getElementById('modalKey').value, lockTimeout: lockTimeout, disconnectTimeout: disconnectTimeout, connected: false, socket: null, loadAverage: null, terminalBuffer: [] }; if (!conn.host || !conn.username) { alert('Please enter host and username'); return; } if (conn.authType === 'password' && !conn.password) { alert('Please enter password'); return; } if (conn.authType === 'key' && !conn.key) { alert('Please paste SSH private key'); return; } connections.push(conn); hideModal(); renderConnectionList(); autoSaveConnections(); saveConnectionToServer(conn); // Save encrypted credentials to server selectConnection(conn.id); } function renderConnectionList() { const list = document.getElementById('connectionList'); list.innerHTML = ''; connections.forEach(conn => { const loadText = conn.connected ? (conn.loadAverage || 'N/A') : (conn.socket && conn.socket.readyState === WebSocket.CONNECTING ? 'Loading...' : '—'); const item = document.createElement('div'); item.className = `connection-item ${conn.id === currentConnectionId ? 'active' : ''} ${conn.connected ? 'connected' : 'disconnected'}`; item.dataset.connectionId = conn.id; item.onclick = () => selectConnection(conn.id); item.innerHTML = `
${conn.name}
Host: ${conn.hostStatus === true ? 'Up' : conn.hostStatus === false ? 'Down' : 'Checking...'}
Load: ${loadText}
${conn.username}@${conn.host}:${conn.port}
${conn.connected ? `` : `` }
`; list.appendChild(item); }); } async function refreshHostStatuses() { if (!authToken || connections.length === 0) return; connections.forEach(conn => { conn.hostStatus = null; }); renderConnectionList(); try { const response = await fetch(`${apiBaseUrl}/api/connections/status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ ids: connections.map(conn => conn.id) }) }); if (!response.ok) return; const data = await response.json(); connections.forEach(conn => { if (Object.prototype.hasOwnProperty.call(data.statuses || {}, conn.id)) { conn.hostStatus = data.statuses[conn.id]; } }); renderConnectionList(); } catch (e) { console.warn('Failed to refresh host statuses:', e); } } function startHostStatusPolling() { if (hostStatusTimer) clearInterval(hostStatusTimer); hostStatusTimer = setInterval(refreshHostStatuses, 30000); } function selectConnection(id) { currentConnectionId = id; const conn = getCurrentConnection(); if (conn) { const quickConnectFields = { connName: conn.name, host: conn.host, port: conn.port, user: conn.username, authType: conn.authType || 'password', password: conn.password || '', sshKey: conn.key || '', lockTimeout: conn.lockTimeout || 15, disconnectTimeout: conn.disconnectTimeout || 60 }; Object.entries(quickConnectFields).forEach(([fieldId, value]) => { const field = document.getElementById(fieldId); if (field) field.value = value; }); if (document.getElementById('authType')) toggleAuthFields(); document.getElementById('terminalTitle').textContent = `${conn.name} - ${conn.username}@${conn.host}:${conn.port}`; updateTerminalStatus(conn.connected ? 'connected' : 'disconnected'); // Restore terminal buffer for this connection term.clear(); conn.terminalBuffer.forEach(line => term.writeln(line)); } renderConnectionList(); } function appendToBuffer(connId, data) { const conn = getConnection(connId); if (conn) { const lines = data.split('\n'); lines.forEach(line => conn.terminalBuffer.push(line)); // Keep only last 1000 lines if (conn.terminalBuffer.length > 1000) { conn.terminalBuffer = conn.terminalBuffer.slice(-1000); } } } function saveConnectionSettings() { const conn = getCurrentConnection(); if (!conn) { alert('No connection selected'); return; } showEditConnectionModal(conn.id); } function saveEditedConnection() { const conn = getConnection(currentConnectionId); if (!conn) { alert('No connection selected'); return; } const lockTimeout = parseInt(document.getElementById('editModalLockTimeout').value) || 15; const disconnectTimeout = parseInt(document.getElementById('editModalDisconnectTimeout').value) || 60; if (disconnectTimeout !== 0 && disconnectTimeout <= lockTimeout) { alert(`Disconnect timeout (${disconnectTimeout} min) must be greater than Lock timeout (${lockTimeout} min).`); return; } conn.name = document.getElementById('editModalName').value || 'New Connection'; conn.host = document.getElementById('editModalHost').value; conn.port = parseInt(document.getElementById('editModalPort').value) || 22; conn.username = document.getElementById('editModalUser').value; conn.authType = document.getElementById('editModalAuthType').value; conn.password = document.getElementById('editModalPassword').value; conn.key = document.getElementById('editModalKey').value; conn.lockTimeout = lockTimeout; conn.disconnectTimeout = disconnectTimeout; if (!conn.host || !conn.username) { alert('Please enter host and username'); return; } if (conn.authType === 'password' && !conn.password) { alert('Please enter password'); return; } if (conn.authType === 'key' && !conn.key) { alert('Please paste SSH private key'); return; } renderConnectionList(); selectConnection(conn.id); document.getElementById('terminalTitle').textContent = `${conn.name} - ${conn.username}@${conn.host}:${conn.port}`; term.writeln('[Settings saved]'); autoSaveConnections(); saveConnectionToServer(conn); hideEditConnectionModal(); } function updateTerminalStatus(status) { const el = document.getElementById('terminalStatus'); el.className = `terminal-status ${status}`; el.textContent = status.charAt(0).toUpperCase() + status.slice(1); } function connect() { const conn = getCurrentConnection(); if (conn) { connectConnection(conn.id); } else { // Quick connect - create connection from current form data const name = document.getElementById('connName').value || 'Quick Connect'; const host = document.getElementById('host').value; const port = parseInt(document.getElementById('port').value) || 22; const username = document.getElementById('user').value; const authType = document.getElementById('authType').value; const password = document.getElementById('password').value; const key = document.getElementById('sshKey').value; if (!host || !username) { alert('Please enter host and username, or select/create a connection first'); return; } if (authType === 'password' && !password) { alert('Please enter password'); return; } if (authType === 'key' && !key) { alert('Please paste SSH private key'); return; } // Create temporary connection const newConn = { id: generateId(), name: name, host: host, port: port, username: username, authType: authType, password: password, key: key, connected: false, socket: null, terminalBuffer: [] }; connections.push(newConn); renderConnectionList(); autoSaveConnections(); saveConnectionToServer(newConn); // Save to server selectConnection(newConn.id); connectConnection(newConn.id); } } async function connectConnection(id) { const conn = getConnection(id); if (!conn) { alert('Connection not found'); return; } // Don't disconnect other connections - allow multiple! if (id !== currentConnectionId) { selectConnection(id); } if (conn.socket && conn.socket.readyState === WebSocket.OPEN) { term.writeln('[Already connected]'); return; } term.writeln(`Connecting to ${conn.name} (${conn.username}@${conn.host}:${conn.port})...`); updateTerminalStatus('connecting'); conn.loadAverage = null; renderConnectionList(); try { if (!backendWsPort) { await loadConfig(); } const wsUrl = getWsUrl(); const wsUrlWithToken = wsUrl + '?token=' + encodeURIComponent(authToken || ''); conn.socket = new WebSocket(wsUrlWithToken); conn.socket.onopen = () => { // Generate session ID for recording const sessionId = generateSessionId(); conn.sessionId = sessionId; const authData = { host: conn.host, port: conn.port, username: conn.username, password: conn.authType === 'password' ? conn.password : undefined, key: conn.authType === 'key' ? conn.key : undefined, session_id: sessionId, connection_id: conn.id, connection_name: conn.name, // Connection name for display cols: term.cols, rows: term.rows }; conn.socket.send(JSON.stringify(authData)); }; conn.socket.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === 'connected') { conn.connected = true; if (conn.id === currentConnectionId) { updateTerminalStatus('connected'); term.writeln('[Connected]'); sendResizeForConnection(conn); } renderConnectionList(); // Start inactivity timers for auto-lock resetInactivityTimers(); return; } if (msg.type === 'load_average') { conn.loadAverage = msg.value || null; renderConnectionList(); return; } if (msg.type === 'error') { if (conn.id === currentConnectionId) { term.writeln(`[ERROR] ${msg.message}`); updateTerminalStatus('disconnected'); } conn.connected = false; conn.loadAverage = null; renderConnectionList(); return; } } catch (e) { // Not JSON, it's terminal data if (conn.id === currentConnectionId) { term.write(event.data); } appendToBuffer(conn.id, event.data); } }; conn.socket.onerror = () => { if (conn.id === currentConnectionId) { term.writeln('[ERROR] WebSocket connection failed'); updateTerminalStatus('disconnected'); } conn.connected = false; conn.loadAverage = null; renderConnectionList(); }; conn.socket.onclose = (event) => { conn.connected = false; conn.loadAverage = null; conn.socket = null; if (conn.id === currentConnectionId) { term.writeln(`[DISCONNECTED] Code: ${event.code}`); updateTerminalStatus('disconnected'); } renderConnectionList(); }; } catch (e) { if (conn.id === currentConnectionId) { term.writeln(`[ERROR] Failed to create WebSocket: ${e.message}`); updateTerminalStatus('disconnected'); } } } function disconnect() { const conn = getCurrentConnection(); if (conn && conn.socket) { conn.socket.close(); } } function disconnectConnection(id) { const conn = getConnection(id); if (conn && conn.socket) { conn.socket.close(); } } async function deleteConnection(id) { if (!confirm('Delete this connection?')) return; const conn = getConnection(id); if (!await deleteConnectionFromServer(id)) { alert('Failed to delete connection from server. Please try again.'); return; } if (conn && conn.socket) conn.socket.close(); connections = connections.filter(c => c.id !== id); autoSaveConnections(); if (currentConnectionId === id) { currentConnectionId = null; term.clear(); document.getElementById('terminalTitle').textContent = 'Terminal'; updateTerminalStatus('disconnected'); ['connName','host','port','user','password','sshKey'].forEach(id => document.getElementById(id).value = ''); document.getElementById('port').value = '22'; } renderConnectionList(); } function sendResizeForCurrent() { const conn = getCurrentConnection(); if (conn) sendResizeForConnection(conn); } function sendResizeForConnection(conn) { if (conn && conn.socket && conn.connected) { conn.socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); } } // Initialize toggleAuthFields(); term.writeln('WebSSH Multi-Connection Client'); term.writeln('Click "+ New Connection" to get started'); // ============================================ // SFTP File Manager Functions // Global variables for SFTP let sftpSocket = null; let sftpCurrentPath = '.'; let sftpSelectedFile = null; let sftpSelectedFileIsDir = false; let sftpUploadQueue = []; let currentSftpConnection = null; // Load configuration from API function loadConfig() { console.log('loadConfig() called, token exists:', !!localStorage.getItem('webssh_token')); console.log('apiBaseUrl:', apiBaseUrl); fetch(`${apiBaseUrl}/api/config`, { method: 'GET', headers: { 'Authorization': `Bearer ${localStorage.getItem('webssh_token')}` } }) .then(response => { console.log('loadConfig() response status:', response.status); if (response.ok) { return response.json(); } else { console.log('Failed to load config, response:', response.status); return null; } }) .then(config => { console.log('loadConfig() config:', config); if (config && config.config) { // Load max upload size if (config.config.max_upload_size) { maxUploadSize = config.config.max_upload_size; localStorage.setItem('maxUploadSize', maxUploadSize); console.log(`Loaded max upload size: ${formatBytes(maxUploadSize)} (${maxUploadSize} bytes)`); } if (config.config.ws_port) { backendWsPort = config.config.ws_port; backendWsHost = config.config.ws_host || window.location.hostname || 'localhost'; console.log(`Loaded backend WebSocket port: ${backendWsPort}`); } // Load stored users for lock screen verification if (config.config.stored_users) { storedUsers = config.config.stored_users; console.log('Loaded stored users for lock screen'); } // Load current user if (config.config.current_user) { currentUser = config.config.current_user; } } else { console.log('No config data found'); } }) .catch(error => { console.warn('Failed to load config:', error); }); // Log current maxUploadSize for debugging console.log('Current maxUploadSize: ' + maxUploadSize + ' bytes (' + (maxUploadSize / 1024 / 1024).toFixed(1) + 'MB)'); } // Load config when page loads document.addEventListener('DOMContentLoaded', function() { // Wait a bit for token to be available setTimeout(loadConfig, 1000); }); // Local file system variables let localCurrentPath = []; let localSelectedFile = null; let localCurrentItems = []; // Store current directory items let localDirectoryHandle = null; // Added missing variable async function sftpChooseLocalFolder() { try { if (window.showDirectoryPicker) { const dirHandle = await window.showDirectoryPicker(); const structure = await sftpBuildDirectoryStructureFromHandle(dirHandle); localDirectoryHandle = { type: 'fs_access', rootHandle: dirHandle, structure: structure, currentPath: [] }; const pathInput = document.getElementById('sftpLocalPathInput'); if (pathInput) { pathInput.value = structure.name; } sftpRenderLocalDirectory(structure, []); return; } } catch (e) { console.error('Failed to pick directory via showDirectoryPicker:', e); } // Fallback: use webkitdirectory input (still requires user selection) sftpBrowseLocalFolder(); } async function sftpBuildDirectoryStructureFromHandle(dirHandle) { const root = { name: dirHandle.name || 'Local', type: 'directory', children: {}, handle: dirHandle }; for await (const entry of dirHandle.values()) { if (entry.kind === 'directory') { root.children[entry.name] = await sftpBuildDirectoryStructureFromHandle(entry); } else { root.children[entry.name] = { name: entry.name, type: 'file', handle: entry }; } } return root; } // Local File Browser Functions - Cross-browser compatible function sftpBrowseLocalFolder() { const input = document.createElement('input'); input.type = 'file'; // Fallback for browsers without showDirectoryPicker (e.g. Firefox) // Try multiple directory-picking attributes for broader compatibility. input.setAttribute('webkitdirectory', ''); input.setAttribute('directory', ''); input.setAttribute('mozdirectory', ''); input.multiple = true; input.onchange = (e) => { if (e.target.files.length > 0) { const files = Array.from(e.target.files); if (files[0].webkitRelativePath) { const structure = buildDirectoryStructure(files); sftpShowLocalDirectoryStructure(structure, files); } else { sftpShowLocalFilesFlat(files); } } }; input.click(); } async function sftpShowLocalFilesFlat(files) { const container = document.getElementById('sftpLocalFiles'); if (!container) return; // Store for later use localDirectoryHandle = { type: 'flat', allFiles: files }; // Update path const pathInput = document.getElementById('sftpLocalPathInput'); if (pathInput) { pathInput.value = 'Local Files'; } // Column headers let html = `
Filename Size Date
`; files.forEach(file => { const icon = getFileIcon(file.name); const size = formatBytes(file.size || 0); const date = file.lastModified ? new Date(file.lastModified).toLocaleString() : ''; html += `
${icon} ${escapeHtml(file.name)} ${size} ${date}
`; }); container.innerHTML = html; } function sftpSelectLocalFlatFile(element, name) { localSelectedFile = name; document.querySelectorAll('#sftpLocalFiles .sftp-file-item').forEach(el => el.classList.remove('selected')); element.classList.add('selected'); } function sftpHandleLocalFolderDragOver(e) { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; document.getElementById('sftpLocalFiles').classList.add('drag-over'); } async function sftpHandleLocalFolderDrop(e) { e.preventDefault(); e.stopPropagation(); const items = e.dataTransfer.items; if (!items || items.length === 0) return; // Check if dropped item is a folder const item = items[0]; if (item.kind === 'file') { const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; if (entry && entry.isDirectory) { // It's a folder - traverse it traverseDirectory(entry, (files) => { if (files.length > 0) { const structure = buildDirectoryStructure(files); sftpShowLocalDirectoryStructure(structure, files); } }); } else { // It's a file - just show it const files = Array.from(e.dataTransfer.files); sftpShowLocalFilesFlat(files); } } } function traverseDirectory(entry, callback) { const files = []; function readEntries(dirReader) { dirReader.readEntries((entries) => { if (entries.length === 0) { callback(files); return; } let count = entries.length; entries.forEach((entry) => { if (entry.isFile) { entry.file((file) => { // Add relative path info const relativePath = entry.fullPath.substring(1); // Remove leading / Object.defineProperty(file, 'webkitRelativePath', { value: relativePath, writable: false }); files.push(file); count--; if (count === 0) readEntries(dirReader); }); } else if (entry.isDirectory) { readEntries(entry.createReader()); count--; if (count === 0) readEntries(dirReader); } }); }); } if (entry.isDirectory) { readEntries(entry.createReader()); } else { entry.file((file) => { files.push(file); callback(files); }); } } function buildDirectoryStructure(files) { const root = { name: 'Home', type: 'directory', children: {} }; files.forEach(file => { const pathParts = file.webkitRelativePath.split('/'); let current = root; pathParts.forEach((part, index) => { if (index === pathParts.length - 1) { // File current.children[part] = { name: part, type: 'file', file: file, size: file.size, lastModified: file.lastModified }; } else { // Directory if (!current.children[part]) { current.children[part] = { name: part, type: 'directory', children: {} }; } current = current.children[part]; } }); }); return root; } function sftpShowLocalDirectoryStructure(structure, allFiles) { const container = document.getElementById('sftpLocalFiles'); if (!container) return; // Store for later use localDirectoryHandle = { type: 'fallback', structure: structure, currentPath: [], allFiles: allFiles }; // Update path const pathInput = document.getElementById('sftpLocalPathInput'); if (pathInput) { pathInput.value = structure.name; } sftpRenderLocalDirectory(structure, []); } async function sftpRenderLocalDirectory(dir, path) { const container = document.getElementById('sftpLocalFiles'); if (!container) return; const children = Object.values(dir.children || {}); const dirs = children.filter(c => c.type === 'directory').sort((a, b) => a.name.localeCompare(b.name)); const files = children.filter(c => c.type === 'file').sort((a, b) => a.name.localeCompare(b.name)); const sorted = [...dirs, ...files]; // Column headers let html = `
Filename Size Date
`; // Parent directory if (path.length > 0) { html += `
📁 ..
`; } // Get file sizes and dates for File System Access API files for (const item of sorted) { const icon = item.type === 'directory' ? '📁' : getFileIcon(item.name); // Get file size - for File System Access API, we need to get the file handle and query its size let size = item.type === 'directory' ? '' : formatBytes(item.size || 0); let date = item.lastModified ? new Date(item.lastModified).toLocaleString() : ''; // For File System Access API files, try to get actual file size and date if (item.type === 'file' && item.handle && !item.size) { try { const file = await item.handle.getFile(); item.size = file.size; // Cache the size for future use size = formatBytes(file.size); console.log(`Got file size for ${item.name}: ${file.size} bytes`); } catch (error) { console.warn(`Could not get file size for ${item.name}:`, error); } } // Also get file modification date if missing if (item.type === 'file' && item.handle && !item.lastModified) { try { const file = await item.handle.getFile(); item.lastModified = file.lastModified; date = new Date(file.lastModified).toLocaleString(); console.log(`Got file date for ${item.name}: ${file.lastModified}`); } catch (error) { console.warn(`Could not get file date for ${item.name}:`, error); } } const pathStr = path.concat(item.name).join('/'); if (item.type === 'directory') { html += `
${icon} ${escapeHtml(item.name)} ${size} ${date}
`; } else { html += `
${icon} ${escapeHtml(item.name)} ${size} ${date}
`; } } container.innerHTML = html; } function sftpOpenLocalStructureDir(pathStr) { if (!localDirectoryHandle || !localDirectoryHandle.structure) return; console.log('sftpOpenLocalStructureDir called with path:', pathStr); console.log('Current structure:', localDirectoryHandle.structure); const parts = pathStr.split('/'); let current = localDirectoryHandle.structure; for (const part of parts) { if (part === '') continue; // Skip empty parts from leading/trailing slashes if (current.children && current.children[part]) { current = current.children[part]; console.log('Found part:', part, 'current:', current); } else { console.log('Part not found:', part, 'in current:', current); return; } } localDirectoryHandle.currentPath = parts; // Update path display const pathInput = document.getElementById('sftpLocalPathInput'); if (pathInput) { const fullPath = [localDirectoryHandle.structure.name, ...parts].join('/'); pathInput.value = fullPath; } console.log('Calling sftpRenderLocalDirectory with:', current, parts); sftpRenderLocalDirectory(current, parts); } function sftpNavigateLocalStructureUp() { if (!localDirectoryHandle || !localDirectoryHandle.structure) return; const path = localDirectoryHandle.currentPath; if (path.length === 0) return; path.pop(); let current = localDirectoryHandle.structure; for (const part of path) { if (current.children && current.children[part]) { current = current.children[part]; } } localDirectoryHandle.currentPath = path; // Update path display const pathInput = document.getElementById('sftpLocalPathInput'); if (pathInput) { const fullPath = [localDirectoryHandle.structure.name, ...path].join('/'); pathInput.value = fullPath; } sftpRenderLocalDirectory(current, path); } function sftpSelectLocalStructureFile(element, name) { localSelectedFile = name; document.querySelectorAll('#sftpLocalFiles .sftp-file-item').forEach(el => el.classList.remove('selected')); element.classList.add('selected'); } async function sftpUploadFromLocal() { // Check if we have a file selected from structure view const selectedEl = document.querySelector('#sftpLocalFiles .sftp-file-item.selected'); if (selectedEl) { // Check for path-based selection (directory structure mode) if (selectedEl.dataset.path) { const path = selectedEl.dataset.path; const file = await findFileInStructure(path); if (file) { // Check file size before upload if (file.size > maxUploadSize) { // Show error in Transfer Queue const queueId = sftpAddToQueue(file.name, file.size, 'upload'); sftpUpdateQueueProgress(queueId, 0, 'Failed: File too large. Max size: ' + formatBytes(maxUploadSize)); return; } sftpUploadFiles([file]); return; } } // Check for filename-based selection (flat file mode) if (selectedEl.dataset.filename) { const filename = selectedEl.dataset.filename; if (localDirectoryHandle && localDirectoryHandle.allFiles) { const file = localDirectoryHandle.allFiles.find(f => f.name === filename); if (file) { // Check file size before upload if (file.size > maxUploadSize) { alert(`File "${file.name}" is too large for upload.\n\nSize: ${formatBytes(file.size)}\nMaximum size: ${formatBytes(maxUploadSize)}\n\nPlease select a smaller file.`); return; } sftpUploadFiles([file]); return; } } } } // No file selected in UI, so open a picker for direct upload const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.onchange = (e) => { if (e.target.files.length > 0) { sftpUploadFiles(e.target.files); } }; input.click(); } function findFileInStructure(path) { if (!localDirectoryHandle) return null; if (localDirectoryHandle.type === 'fs_access') { const parts = path.split('/').filter(Boolean); let current = localDirectoryHandle.structure; for (const part of parts) { if (!current.children || !current.children[part]) return null; current = current.children[part]; } if (current.type !== 'file' || !current.handle) return null; return current.handle.getFile(); } if (!localDirectoryHandle.allFiles) return null; // Find file by matching path (webkitdirectory mode) return localDirectoryHandle.allFiles.find(f => f.webkitRelativePath === path); } function toggleSftpView() { const terminalView = document.getElementById('terminalView'); const sftpView = document.getElementById('sftpView'); const sftpToggleBtn = document.getElementById('sftpToggleBtn'); if (window.getComputedStyle(sftpView).display === 'none') { // Show SFTP, hide terminal const conn = getCurrentConnection() || connections.find(connection => connection.id === document.querySelector('.connection-item.active')?.dataset.connectionId ); if (!conn) { alert('Please select a connection first'); return; } if (currentConnectionId !== conn.id) { selectConnection(conn.id); } currentSftpConnection = conn; terminalView.style.display = 'none'; sftpView.style.display = 'flex'; sftpToggleBtn.classList.add('active'); document.getElementById('terminalToggleBtn').classList.remove('active'); // Connect SFTP sftpConnect(conn); } else { // Show terminal, hide SFTP terminalView.style.display = 'flex'; sftpView.style.display = 'none'; sftpToggleBtn.classList.remove('active'); document.getElementById('terminalToggleBtn').classList.add('active'); } } function hideSftpPanel() { // Hide SFTP view and show terminal const terminalView = document.getElementById('terminalView'); const sftpView = document.getElementById('sftpView'); const sftpToggleBtn = document.getElementById('sftpToggleBtn'); terminalView.style.display = 'flex'; sftpView.style.display = 'none'; sftpToggleBtn.classList.remove('active'); document.getElementById('terminalToggleBtn').classList.add('active'); } function showSftpPanel() { // Legacy function - delegates to toggleSftpView const sftpView = document.getElementById('sftpView'); if (sftpView.style.display === 'none') { toggleSftpView(); } } function sftpConnect(conn) { currentSftpConnection = conn; // Directly list the root directory sftpList('.'); } async function sftpList(path) { if (!currentSftpConnection) return; try { const response = await fetch(`${apiBaseUrl}/api/sftp/list`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ conn_id: currentSftpConnection.id, path: path }) }); if (!response.ok) { throw new Error('Failed to list directory'); } const data = await response.json(); sftpRenderFiles(data.files, data.path); } catch (err) { console.error('List error:', err); document.getElementById('sftpRemoteFiles').innerHTML = '
Failed to load directory
'; } } function sftpRenderFiles(files, path) { sftpCurrentPath = path; // Update path input const pathInput = document.getElementById('sftpRemotePathInput'); if (pathInput) { pathInput.value = path === '.' ? '/' : path; } const container = document.getElementById('sftpRemoteFiles'); if (!container) return; if (files.length === 0) { container.innerHTML = '
Empty directory
'; return; } // Column headers let html = `
Filename Size Date
`; // Parent directory entry if (path !== '.' && path !== '/') { html += `
📁 ..
`; } files.forEach(file => { const icon = file.is_dir ? '📁' : getFileIcon(file.name); const size = file.is_dir ? '' : formatBytes(file.size); const date = new Date(file.mtime * 1000).toLocaleString(); html += `
${icon} ${escapeHtml(file.name)} ${size} ${date}
`; }); container.innerHTML = html; // Update info in header const dirs = files.filter(f => f.is_dir).length; const files_ = files.filter(f => !f.is_dir).length; const infoEl = document.getElementById('sftpRemoteInfo'); if (infoEl) { infoEl.textContent = `${dirs} dirs, ${files_} files`; } } function sftpHandlePathKey(event) { if (event.key === 'Enter') { const path = event.target.value; sftpList(path); } } function sftpUploadSelected() { // Use the new local file browser function sftpUploadFromLocal(); } async function sftpDownloadSelected() { if (!sftpSelectedFile) { alert('Please select a file to download'); return; } if (sftpSelectedFileIsDir) { alert('Cannot download directories. Please select individual files.'); return; } await sftpDownload(sftpSelectedFile); } function sftpClearCompleted() { const queueList = document.getElementById('sftpQueueList'); if (queueList) { queueList.innerHTML = '
No transfers in queue
'; } } // Updated upload progress for queue function sftpAddToQueue(filename, size, type) { const queueList = document.getElementById('sftpQueueList'); if (!queueList) return; // Remove empty message if present const emptyMsg = queueList.querySelector('.sftp-queue-empty'); if (emptyMsg) emptyMsg.remove(); const itemId = 'queue-' + Date.now(); const item = document.createElement('div'); item.id = itemId; item.className = 'sftp-queue-item'; item.innerHTML = ` ${type === 'upload' ? '⬆️' : '⬇️'} ${escapeHtml(filename)}
Queued ${formatBytes(size)} `; queueList.appendChild(item); return itemId; } function sftpUpdateQueueProgress(itemId, progress, status) { const item = document.getElementById(itemId); if (!item) return; const bar = item.querySelector('.queue-progress-bar'); const statusEl = item.querySelector('.queue-status'); if (bar) bar.style.width = progress + '%'; if (statusEl) statusEl.textContent = status; if (progress >= 100) { item.classList.add('completed'); if (statusEl) statusEl.textContent = 'Complete'; } } function sftpHandleLocalDrop(e) { e.preventDefault(); const files = e.dataTransfer.files; if (files.length > 0) { sftpUploadFiles(files); } } function getFileIcon(filename) { const ext = filename.split('.').pop().toLowerCase(); const icons = { 'txt': '📄', 'md': '📝', 'json': '📋', 'js': '📜', 'py': '🐍', 'html': '🌐', 'css': '🎨', 'xml': '📋', 'yml': '📋', 'yaml': '📋', 'sh': '⚡', 'bash': '⚡', 'zsh': '⚡', 'fish': '⚡', 'zip': '📦', 'tar': '📦', 'gz': '📦', 'bz2': '📦', '7z': '📦', 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'svg': '🖼️', 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', 'mov': '🎬', 'mp3': '🎵', 'wav': '🎵', 'flac': '🎵', 'ogg': '🎵', 'pdf': '📕', 'doc': '📘', 'docx': '📘', 'xls': '📗', 'xlsx': '📗', 'exe': '⚙️', 'bin': '⚙️', 'app': '⚙️' }; return icons[ext] || '📄'; } function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function sftpSelectFile(name, isDir) { sftpSelectedFile = name; sftpSelectedFileIsDir = isDir; document.querySelectorAll('.sftp-file-item').forEach(el => el.classList.remove('selected')); event.currentTarget.classList.add('selected'); } function sftpOpenFile(name, isDir) { if (isDir) { const newPath = sftpCurrentPath === '.' ? name : sftpCurrentPath + '/' + name; sftpList(newPath); } else { sftpDownload(name); } } function sftpNavigateUp() { if (sftpCurrentPath === '.' || sftpCurrentPath === '/') return; const parts = sftpCurrentPath.split('/'); parts.pop(); const newPath = parts.length === 0 ? '.' : parts.join('/'); sftpList(newPath); } function sftpRefresh() { sftpList(sftpCurrentPath); } async function sftpRefreshLocalFiles() { // Refresh local file list based on current mode if (localDirectoryHandle) { if (localDirectoryHandle.type === 'fs_access') { // File System Access API mode - refresh only current directory console.log('Refreshing File System Access API directory...'); console.log('Current path:', localDirectoryHandle.currentPath); // Navigate to current directory and rebuild only its contents let currentDir = localDirectoryHandle.rootHandle; if (localDirectoryHandle.currentPath.length > 0) { console.log('Navigating to path:', localDirectoryHandle.currentPath); for (const pathPart of localDirectoryHandle.currentPath) { currentDir = await currentDir.getDirectoryHandle(pathPart); } } // Build structure only for current directory const structure = { name: currentDir.name || 'current', type: 'directory', children: {} }; console.log('Rebuilding structure for current directory:', currentDir.name); for await (const [name, handle] of currentDir.entries()) { if (handle.kind === 'directory') { structure.children[name] = { name: name, type: 'directory', handle: handle }; } else { structure.children[name] = { name: name, type: 'file', handle: handle, webkitRelativePath: name }; } } console.log('New structure:', structure); // Update only the current directory in the hierarchy if (localDirectoryHandle.currentPath.length === 0) { // We're at root, replace the entire structure localDirectoryHandle.structure = structure; } else { // We're in a subdirectory, update only this directory in the hierarchy let parent = localDirectoryHandle.structure; for (let i = 0; i < localDirectoryHandle.currentPath.length - 1; i++) { const pathPart = localDirectoryHandle.currentPath[i]; parent = parent.children[pathPart]; } // Update the current directory const currentDirName = localDirectoryHandle.currentPath[localDirectoryHandle.currentPath.length - 1]; parent.children[currentDirName] = structure; } // Re-render current directory sftpOpenLocalStructureDir(localDirectoryHandle.currentPath.join('/')); } else if (localDirectoryHandle.type === 'flat') { // Flat file mode - just show the same files (they're already loaded) console.log('Refreshing flat file mode'); sftpShowLocalFilesFlat(localDirectoryHandle.allFiles); } else if (localDirectoryHandle.type === 'fallback') { // Fallback mode - refresh current directory console.log('Refreshing fallback mode'); sftpOpenLocalStructureDir(localDirectoryHandle.currentPath.join('/')); } } else { console.log('No local directory handle available'); } } async function sftpMkdir() { const name = prompt('Enter folder name:'); if (!name) return; const path = sftpCurrentPath === '.' ? name : sftpCurrentPath + '/' + name; try { const response = await fetch(`${apiBaseUrl}/api/sftp/mkdir`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ conn_id: currentSftpConnection.id, path: path }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to create directory'); } // Refresh the directory listing sftpRefresh(); } catch (err) { console.error('Mkdir error:', err); alert('Failed to create folder: ' + err.message); } } async function sftpDelete() { if (!sftpSelectedFile) { alert('Please select a file or folder first'); return; } const isDir = document.querySelector('.sftp-file-item.selected .icon').textContent === '📁'; if (!confirm(`Delete ${sftpSelectedFile}?`)) return; const path = sftpCurrentPath === '.' ? sftpSelectedFile : sftpCurrentPath + '/' + sftpSelectedFile; try { const response = await fetch(`${apiBaseUrl}/api/sftp/rm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ conn_id: currentSftpConnection.id, path: path, is_dir: isDir }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete'); } // Refresh the directory listing sftpRefresh(); } catch (err) { console.error('Delete error:', err); alert('Failed to delete: ' + err.message); } } async function sftpRename() { if (!sftpSelectedFile) { alert('Please select a file or folder first'); return; } const newName = prompt('Enter new name:', sftpSelectedFile); if (!newName || newName === sftpSelectedFile) return; const srcPath = sftpCurrentPath === '.' ? sftpSelectedFile : sftpCurrentPath + '/' + sftpSelectedFile; const dstPath = sftpCurrentPath === '.' ? newName : sftpCurrentPath + '/' + newName; try { const response = await fetch(`${apiBaseUrl}/api/sftp/mv`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ conn_id: currentSftpConnection.id, src: srcPath, dst: dstPath }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to rename'); } // Refresh the directory listing sftpRefresh(); } catch (err) { console.error('Rename error:', err); alert('Failed to rename: ' + err.message); } } async function sftpDownload(filename) { if (!currentSftpConnection) return; const remotePath = sftpCurrentPath === '.' ? filename : sftpCurrentPath + '/' + filename; console.log('Attempting download:', { filename, remotePath, connId: currentSftpConnection.id }); try { const response = await fetch(`${apiBaseUrl}/api/sftp/download`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ conn_id: currentSftpConnection.id, path: remotePath }) }); console.log('Download response status:', response.status); console.log('Download response headers:', response.headers); if (!response.ok) { const errorText = await response.text(); console.error('Download failed with response:', errorText); throw new Error(`Download failed: ${response.status} ${errorText}`); } const blob = await response.blob(); console.log('Download blob size:', blob.size, 'type:', blob.type); // Try to save directly to the open local directory using File System Access API if (localDirectoryHandle && localDirectoryHandle.type === 'fs_access' && localDirectoryHandle.rootHandle) { try { console.log('Attempting to save to local directory...'); // Get the current directory handle let currentDir = localDirectoryHandle.rootHandle; if (localDirectoryHandle.currentPath.length > 0) { for (const pathPart of localDirectoryHandle.currentPath) { currentDir = await currentDir.getDirectoryHandle(pathPart); } } // Create/get the file handle and write the content const fileHandle = await currentDir.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); console.log('Successfully saved to local directory'); // Show success message const queueId = sftpAddToQueue(filename, blob.size, 'download'); sftpUpdateQueueProgress(queueId, 100, 'Complete'); // Refresh local file list to show the newly downloaded file console.log('About to call sftpRefreshLocalFiles()...'); try { await sftpRefreshLocalFiles(); console.log('sftpRefreshLocalFiles() completed successfully'); } catch (error) { console.error('Error in sftpRefreshLocalFiles():', error); } return; } catch (fsError) { console.error('Failed to write to local directory:', fsError); // Fall back to browser download if File System Access fails } } // Fallback: traditional browser download console.log('Using fallback browser download'); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); a.remove(); } catch (err) { console.error('Download error:', err); alert('Download failed: ' + err.message); } } // Drag & Drop Upload function sftpHandleDragOver(e) { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; const dropZone = document.getElementById('sftpRemoteFiles'); if (dropZone) dropZone.classList.add('drag-over'); } function sftpHandleDrop(e) { e.preventDefault(); e.stopPropagation(); const dropZone = document.getElementById('sftpRemoteFiles'); if (dropZone) dropZone.classList.remove('drag-over'); const files = e.dataTransfer.files; if (files.length > 0) { sftpUploadFiles(files); } } function sftpHandleFileSelect(e) { const files = e.target.files; if (files.length > 0) { sftpUploadFiles(files); } } async function sftpUploadFiles(files) { if (!currentSftpConnection) { alert('No connection selected'); return; } for (const file of Array.from(files)) { const remotePath = sftpCurrentPath === '.' ? file.name : sftpCurrentPath + '/' + file.name; const queueId = sftpAddToQueue(file.name, file.size, 'upload'); try { // Create FormData for multipart upload const formData = new FormData(); formData.append('conn_id', currentSftpConnection.id); formData.append('remote_path', remotePath); formData.append('file', file); // Update queue status sftpUpdateQueueProgress(queueId, 0, 'Starting...'); const response = await fetch(`${apiBaseUrl}/api/sftp/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}` }, body: formData }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Upload failed'); } // Update queue to complete sftpUpdateQueueProgress(queueId, 100, 'Complete'); // Refresh remote directory sftpRefresh(); } catch (err) { console.error('Upload error:', err); sftpUpdateQueueProgress(queueId, 0, 'Failed: ' + err.message); } } } // Help functions function showHelp() { document.getElementById('helpModal').classList.add('active'); } function hideHelp() { document.getElementById('helpModal').classList.remove('active'); } // Session ID generator for recordings function generateSessionId() { return 'sess_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } // Session Recording Functions let currentPlayer = null; let currentRecording = null; let currentConnectionRecordings = null; // Store recordings for current connection view function showRecordings() { document.getElementById('recordingsModal').classList.add('active'); loadConnectionsWithRecordings(); } function hideRecordings() { document.getElementById('recordingsModal').classList.remove('active'); currentConnectionRecordings = null; } function hidePlayer() { document.getElementById('playerModal').classList.remove('active'); if (currentPlayer) { currentPlayer.dispose(); currentPlayer = null; } } // Load connections that have recordings (grouped view) async function loadConnectionsWithRecordings() { const container = document.getElementById('recordingsList'); container.innerHTML = '

Loading connections...

'; try { const response = await fetch(`${apiBaseUrl}/api/recordings/connections`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (!response.ok) { throw new Error('Failed to load connections'); } const data = await response.json(); renderConnectionsWithRecordings(data.connections || []); } catch (err) { console.error('Error loading connections:', err); container.innerHTML = `

Error: ${err.message}

`; } } // Render connections list with recordings count function renderConnectionsWithRecordings(connections) { const container = document.getElementById('recordingsList'); if (connections.length === 0) { container.innerHTML = '

No recordings found

'; return; } container.innerHTML = `
Connections with Recordings
${connections.map(conn => { const totalSize = conn.total_size_bytes ? formatBytes(conn.total_size_bytes) : '0 B'; const lastRecording = conn.last_recording ? new Date(conn.last_recording).toLocaleDateString() : 'N/A'; const displayName = conn.connection_name || conn.host; return `
${displayName} (${conn.ssh_username})
${conn.recording_count} recordings • ${totalSize} • Last: ${lastRecording}
▶
`; }).join('')}
`; } // Show recordings for a specific connection async function showConnectionRecordings(connectionId) { const container = document.getElementById('recordingsList'); container.innerHTML = '

Loading recordings...

'; try { const response = await fetch(`${apiBaseUrl}/api/recordings?connection_id=${connectionId}`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (!response.ok) { throw new Error('Failed to load recordings'); } const data = await response.json(); currentConnectionRecordings = data.recordings || []; renderConnectionRecordings(currentConnectionRecordings, connectionId); } catch (err) { console.error('Error loading recordings:', err); container.innerHTML = `

Error: ${err.message}

`; } } // Render recordings for a single connection function renderConnectionRecordings(recordings, connectionId) { const container = document.getElementById('recordingsList'); if (recordings.length === 0) { container.innerHTML = `

No recordings found for this connection

`; return; } // Get connection info from first recording const firstRec = recordings[0]; const displayName = firstRec.connection_name || firstRec.host; container.innerHTML = `
${displayName} - ${recordings.length} Recordings
${recordings.map(rec => { const duration = rec.duration_seconds ? formatDuration(rec.duration_seconds) : 'N/A'; const size = rec.file_size_bytes ? formatBytes(rec.file_size_bytes) : 'N/A'; const date = new Date(rec.started_at).toLocaleString(); return `
${date}
${duration} • ${size}
`; }).join('')}
`; } // Download all recordings for a connection as zip async function downloadAllRecordings(connectionId, connectionName) { if (!confirm(`Download all recordings for ${connectionName} as a zip file?`)) { return; } try { const response = await fetch(`${apiBaseUrl}/api/recordings/download-all?connection_id=${connectionId}`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to download recordings'); } // Get the blob and create download link const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${connectionName}_recordings.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (err) { console.error('Error downloading all recordings:', err); alert('Failed to download recordings: ' + err.message); } } // Delete all recordings for a connection async function deleteAllRecordings(connectionId, connectionName) { if (!confirm(`Are you sure you want to delete ALL recordings for ${connectionName}?\n\nThis action cannot be undone!`)) { return; } try { const response = await fetch(`${apiBaseUrl}/api/recordings/delete-all`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ connection_id: connectionId }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete recordings'); } const result = await response.json(); alert(`Deleted ${result.deleted_count} recordings successfully.`); // Go back to connections list loadConnectionsWithRecordings(); } catch (err) { console.error('Error deleting all recordings:', err); alert('Failed to delete recordings: ' + err.message); } } async function playRecording(recordingId) { try { const response = await fetch(`${apiBaseUrl}/api/recordings/${recordingId}`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (!response.ok) { throw new Error('Failed to load recording'); } const data = await response.json(); currentRecording = data.recording; // Show player modal document.getElementById('playerModal').classList.add('active'); // Update recording info const infoDiv = document.getElementById('recordingInfo'); const duration = currentRecording.duration_seconds ? formatDuration(currentRecording.duration_seconds) : 'N/A'; const displayName = currentRecording.connection_name || currentRecording.host; infoDiv.innerHTML = `

${displayName} (${currentRecording.ssh_username})

Started: ${new Date(currentRecording.started_at).toLocaleString()}

Duration: ${duration} • Size: ${formatBytes(currentRecording.file_size_bytes || 0)}

`; // Load and play recording const playerContainer = document.getElementById('asciinemaPlayer'); playerContainer.innerHTML = '

Loading recording...

'; // Download the recording file const fileResponse = await fetch(`${apiBaseUrl}/api/recordings/${recordingId}/download`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (!fileResponse.ok) { throw new Error('Failed to download recording file'); } // Get the array buffer and decompress with pako const compressedData = await fileResponse.arrayBuffer(); const decompressedData = pako.ungzip(new Uint8Array(compressedData)); const castContent = new TextDecoder().decode(decompressedData); // Create a blob with the decompressed content const blob = new Blob([castContent], { type: 'application/x-asciicast' }); const recordingUrl = URL.createObjectURL(blob); playerContainer.innerHTML = ''; // Create asciinema player currentPlayer = AsciinemaPlayer.create(recordingUrl, playerContainer, { autoPlay: true, loop: false, theme: 'monokai', fit: 'width', terminalFontSize: '14px' }); } catch (err) { console.error('Error playing recording:', err); alert('Failed to play recording: ' + err.message); } } async function downloadRecording(recordingId) { try { const response = await fetch(`${apiBaseUrl}/api/recordings/${recordingId}`, { headers: { 'Authorization': `Bearer ${authToken}` } }); if (!response.ok) { throw new Error('Failed to get recording info'); } const data = await response.json(); const rec = data.recording; // Create download link const downloadUrl = `${apiBaseUrl}/api/recordings/${recordingId}/download`; const link = document.createElement('a'); link.href = downloadUrl; link.download = `${rec.host}_${rec.session_id}.cast.gz`; link.setAttribute('data-authorization', `Bearer ${authToken}`); // Set auth header for download fetch(downloadUrl, { headers: { 'Authorization': `Bearer ${authToken}` } }) .then(response => response.blob()) .then(blob => { const url = window.URL.createObjectURL(blob); link.href = url; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); }); } catch (err) { console.error('Error downloading recording:', err); alert('Failed to download recording: ' + err.message); } } async function deleteRecording(recordingId) { if (!confirm('Are you sure you want to delete this recording?')) { return; } try { const response = await fetch(`${apiBaseUrl}/api/recordings/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` }, body: JSON.stringify({ recording_id: recordingId }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete recording'); } // Reload recordings list loadRecordings(); } catch (err) { console.error('Error deleting recording:', err); alert('Failed to delete recording: ' + err.message); } } function formatDuration(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } // ==================== AUTO-LOCK & TIMEOUT ==================== let inactivityTimer = null; let lockTimer = null; let lastActivityTime = Date.now(); let isLocked = false; let currentLockTimeout = 15; // minutes let currentDisconnectTimeout = 60; // minutes // Track user activity function trackActivity() { lastActivityTime = Date.now(); // If locked, don't reset timers if (isLocked) return; // Reset timers resetInactivityTimers(); } // Setup activity listeners function setupActivityTracking() { // Track mouse/keyboard activity on document document.addEventListener('mousemove', trackActivity, { passive: true }); document.addEventListener('keydown', trackActivity, { passive: true }); document.addEventListener('click', trackActivity, { passive: true }); document.addEventListener('touchstart', trackActivity, { passive: true }); // Track terminal input if (term) { term.onData(() => trackActivity()); } // Start inactivity check interval setInterval(checkInactivity, 60000); // Check every minute } // Reset inactivity timers function resetInactivityTimers() { // Clear existing timers if (lockTimer) clearTimeout(lockTimer); if (inactivityTimer) clearTimeout(inactivityTimer); const conn = getCurrentConnection(); if (!conn || !conn.connected) return; const lockTimeoutMs = (conn.lockTimeout || currentLockTimeout) * 60 * 1000; const disconnectTimeoutMs = conn.disconnectTimeout === 0 ? 0 : (conn.disconnectTimeout || currentDisconnectTimeout) * 60 * 1000; // Set lock timer lockTimer = setTimeout(() => { lockSession(); }, lockTimeoutMs); // Set disconnect timer (only if not "never" = 0) if (disconnectTimeoutMs > 0) { inactivityTimer = setTimeout(() => { autoDisconnect(); }, disconnectTimeoutMs); } } // Check inactivity (backup in case timers fail) function checkInactivity() { if (isLocked) return; const conn = getCurrentConnection(); if (!conn || !conn.connected) return; const elapsedMinutes = (Date.now() - lastActivityTime) / 1000 / 60; const lockTimeout = conn.lockTimeout || currentLockTimeout; const disconnectTimeout = conn.disconnectTimeout || currentDisconnectTimeout; if (elapsedMinutes >= lockTimeout && !isLocked) { lockSession(); } else if (disconnectTimeout !== 0 && elapsedMinutes >= disconnectTimeout) { autoDisconnect(); } } // Lock the session function lockSession() { if (isLocked) return; isLocked = true; document.getElementById('lockScreenModal').style.display = 'flex'; document.getElementById('unlockPassword').value = ''; document.getElementById('unlockError').style.display = 'none'; document.getElementById('unlockPassword').focus(); // Clear timers if (lockTimer) clearTimeout(lockTimer); if (inactivityTimer) clearTimeout(inactivityTimer); console.log('Session locked due to inactivity'); } // Unlock the session async function unlockSession() { const password = document.getElementById('unlockPassword').value; if (!password) { document.getElementById('unlockError').textContent = 'Please enter password'; document.getElementById('unlockError').style.display = 'block'; return; } // Hash the password and verify against stored users const hashedInput = await hashPassword(password); // Try to find a matching user let validUser = null; for (const [username, storedHash] of Object.entries(storedUsers || {})) { if (storedHash === hashedInput) { validUser = username; break; } } if (!validUser) { // Also try current session const response = await fetch(`${apiBaseUrl}/api/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: authToken }) }); if (response.ok) { validUser = currentUser; } } if (validUser) { isLocked = false; lastActivityTime = Date.now(); document.getElementById('lockScreenModal').style.display = 'none'; resetInactivityTimers(); console.log('Session unlocked'); } else { document.getElementById('unlockError').textContent = 'Incorrect password'; document.getElementById('unlockError').style.display = 'block'; document.getElementById('unlockPassword').value = ''; document.getElementById('unlockPassword').focus(); } } // Hash password for verification async function hashPassword(password) { const encoder = new TextEncoder(); const data = encoder.encode(password); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } // Auto-disconnect due to inactivity function autoDisconnect() { console.log('Auto-disconnecting due to inactivity'); disconnect(); // Show notification const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #f59e0b; color: white; padding: 15px 20px; border-radius: 8px; z-index: 10001; box-shadow: 0 4px 12px rgba(0,0,0,0.3); `; notification.textContent = 'Disconnected due to inactivity'; document.body.appendChild(notification); setTimeout(() => { notification.remove(); }, 5000); } document.addEventListener('DOMContentLoaded', setupActivityTracking);