UNPKG

@kadi.build/local-remote-file-manager-ability

Version:

Local & Remote File Management System with S3-compatible container registry, HTTP server provider, file streaming, and comprehensive testing suite

1,572 lines (1,339 loc) 56.3 kB
import { promises as fs } from 'fs'; import path from 'path'; import crypto from 'crypto'; import EventEmitter from 'events'; import { createServer } from 'http'; import { spawn, execSync } from 'child_process'; import { URL } from 'url'; class TunnelProvider extends EventEmitter { constructor(config) { super(); this.config = config || {}; // Configuration from ConfigManager.getTunnelConfig() this.primaryService = this.config.service || 'pinggy'; // Updated fallback order - Serveo is more reliable than localhost.run now this.fallbackServices = this.parseFallbackServices(this.config.fallbackServices || 'serveo,localtunnel'); this.autoFallback = this.config.autoFallback !== false; this.subdomain = this.config.subdomain || ''; this.region = this.config.region || 'us'; this.localRoot = this.config.localRoot || process.cwd(); // Rest of constructor stays the same... this.activeTunnels = new Map(); this.activeUrls = new Map(); this.localServer = null; this.serverPort = null; this.currentTunnelProcess = null; this.activeOperations = new Map(); this.operationCount = 0; this.defaultExpiration = 60 * 60 * 1000; // 1 hour in ms this.allowedPaths = new Set(); this.accessTokens = new Map(); this.isServerRunning = false; this.currentTunnelUrl = null; // Updated service configurations this.serviceConfigs = { pinggy: { requiresAuth: false, requiresInstall: false, supportsBrowser: true, command: 'ssh', warningPage: false, freeLimit: '60 minutes timeout', notes: 'Best quality, 60min timeout on free plan' }, serveo: { requiresAuth: false, requiresInstall: false, supportsBrowser: true, command: 'ssh', warningPage: false, freeLimit: 'Generous', notes: 'Reliable fallback, no time limits' }, localtunnel: { requiresAuth: false, requiresInstall: true, // Requires npm install -g localtunnel supportsBrowser: true, command: 'lt', warningPage: false, freeLimit: 'Unlimited', notes: 'Requires npm install but very reliable' }, 'localhost.run': { requiresAuth: false, requiresInstall: false, supportsBrowser: true, command: 'ssh', warningPage: false, freeLimit: 'Email verification required', notes: 'Now requires email verification - less suitable' } }; } // ============================================================================ // CONNECTION AND VALIDATION (Required Pattern) // ============================================================================ async testConnection() { try { // Test local root access const stats = await fs.stat(this.localRoot); if (!stats.isDirectory()) { throw new Error(`Local root '${this.localRoot}' is not a directory`); } // Test SSH availability await this.testSSHAvailability(); // Test local server creation capability await this.testLocalServerCreation(); return { provider: 'tunnel', service: this.primaryService, localRoot: this.localRoot, region: this.region, subdomain: this.subdomain, sshAvailable: true, activeTunnels: this.activeTunnels.size, activeUrls: this.activeUrls.size, serverRunning: this.isServerRunning, currentTunnelUrl: this.currentTunnelUrl, supportedServices: Object.keys(this.serviceConfigs), fallbackEnabled: this.autoFallback }; } catch (error) { throw new Error(`Tunnel provider connection test failed: ${error.message}`); } } validateConfig() { const errors = []; const warnings = []; // Required configuration validation if (!this.localRoot) { errors.push('Local root directory is required for tunneling'); } if (!this.primaryService) { errors.push('Primary tunnel service must be specified'); } // Service availability validation if (!this.serviceConfigs[this.primaryService]) { errors.push(`Unsupported primary service: ${this.primaryService}`); } // SSH dependency check if (this.primaryService !== 'ngrok' && !this.isSSHAvailable()) { warnings.push('SSH not found in PATH. Required for most tunnel services.'); } // Security warnings if (this.allowedPaths.size === 0) { warnings.push('No allowed paths configured. All files in local root will be shareable.'); } return { isValid: errors.length === 0, errors: errors, warnings: warnings }; } // ============================================================================ // TUNNEL LIFECYCLE MANAGEMENT // ============================================================================ async createTunnel(options = {}) { const operationId = this.generateOperationId(); const tunnelId = this.generateTunnelId(); try { this.trackOperation(operationId, 'createTunnel', { tunnelId }); this.emit('tunnelProgress', { operationId, tunnelId, status: 'creating', message: 'Initializing tunnel creation...' }); // Start local server if not running and not using external server if (!options.useExternalServer && !this.isServerRunning) { await this.startLocalServer(); } // If using external server, use the provided localPort const targetPort = options.useExternalServer ? options.localPort : this.serverPort; // Try to create tunnel with primary service let tunnelInfo = null; try { tunnelInfo = await this.createServiceTunnel(this.primaryService, tunnelId, options, targetPort); } catch (error) { if (!this.autoFallback || this.fallbackServices.length === 0) { throw error; } // Try fallback services console.log(`⚠️ Primary service ${this.primaryService} failed, trying fallbacks...`); for (const fallbackService of this.fallbackServices) { try { console.log(`🔄 Trying ${fallbackService}...`); tunnelInfo = await this.createServiceTunnel(fallbackService, tunnelId, options, targetPort); console.log(`✅ Successfully connected via ${fallbackService}`); break; } catch (fallbackError) { console.warn(`❌ ${fallbackService} failed: ${fallbackError.message}`); continue; } } if (!tunnelInfo) { throw new Error(`All tunnel services failed. Primary: ${error.message}`); } } // Store tunnel information this.activeTunnels.set(tunnelId, tunnelInfo); this.currentTunnelUrl = tunnelInfo.url; this.emit('tunnelProgress', { operationId, tunnelId, status: 'created', url: tunnelInfo.url, service: tunnelInfo.service, message: `Tunnel created successfully via ${tunnelInfo.service}` }); return { tunnelId: tunnelId, url: tunnelInfo.url, service: tunnelInfo.service, createdAt: new Date().toISOString(), operationId: operationId }; } catch (error) { this.emit('tunnelError', { operationId, tunnelId, error: error.message }); throw new Error(`Failed to create tunnel: ${error.message}`); } finally { this.activeOperations.delete(operationId); } } async destroyTunnel(tunnelId) { const operationId = this.generateOperationId(); try { this.trackOperation(operationId, 'destroyTunnel', { tunnelId }); const tunnelInfo = this.activeTunnels.get(tunnelId); if (!tunnelInfo) { throw new Error(`Tunnel '${tunnelId}' not found`); } this.emit('tunnelProgress', { operationId, tunnelId, status: 'destroying', message: 'Destroying tunnel...' }); // Kill the SSH process if (this.currentTunnelProcess) { this.currentTunnelProcess.kill('SIGTERM'); this.currentTunnelProcess = null; } // Clean up associated URLs await this.cleanupTunnelUrls(tunnelId); // Remove from active tunnels this.activeTunnels.delete(tunnelId); if (this.currentTunnelUrl === tunnelInfo.url) { this.currentTunnelUrl = null; } this.emit('tunnelProgress', { operationId, tunnelId, status: 'destroyed', message: 'Tunnel destroyed successfully' }); return { tunnelId, destroyedAt: new Date().toISOString() }; } catch (error) { this.emit('tunnelError', { operationId, tunnelId, error: error.message }); throw new Error(`Failed to destroy tunnel: ${error.message}`); } finally { this.activeOperations.delete(operationId); } } // ============================================================================ // SERVICE-SPECIFIC TUNNEL CREATION // ============================================================================ async createServiceTunnel(serviceName, tunnelId, options = {}, targetPort = null) { const portToUse = targetPort || this.serverPort; switch (serviceName) { case 'pinggy': return await this.createPinggyTunnel(tunnelId, options, portToUse); case 'serveo': return await this.createServeoTunnel(tunnelId, options, portToUse); case 'localtunnel': return await this.createLocalTunnel(tunnelId, options, portToUse); case 'localhost.run': return await this.createLocalhostRunTunnel(tunnelId, options, portToUse); default: throw new Error(`Unsupported tunnel service: ${serviceName}`); } } async createPinggyTunnel(tunnelId, options, targetPort) { return new Promise((resolve, reject) => { const subdomain = options.subdomain || this.subdomain; const host = subdomain ? `${subdomain}.a.pinggy.io` : 'a.pinggy.io'; const portToUse = targetPort || this.serverPort; const sshArgs = [ '-p', '443', '-R', `0:localhost:${portToUse}`, `qr@${host}`, '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=30', '-o', 'ExitOnForwardFailure=yes' ]; console.log(`🔗 Creating Pinggy tunnel: ssh ${sshArgs.join(' ')}`); this.currentTunnelProcess = spawn('ssh', sshArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; let tunnelUrl = null; let errorOutput = ''; this.currentTunnelProcess.stdout.on('data', (data) => { const text = data.toString(); output += text; console.log('Pinggy stdout:', text.trim()); tunnelUrl = this.extractPinggyUrl(output); if (tunnelUrl) { console.log('⚠️ Note: Pinggy free tunnels timeout after 60 minutes'); resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'pinggy', process: this.currentTunnelProcess, timeoutWarning: 'Tunnel will timeout after 60 minutes on free plan' }); } }); this.currentTunnelProcess.stderr.on('data', (data) => { const text = data.toString(); errorOutput += text; output += text; console.log('Pinggy stderr:', text.trim()); tunnelUrl = this.extractPinggyUrl(output); if (tunnelUrl) { console.log('⚠️ Note: Pinggy free tunnels timeout after 60 minutes'); resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'pinggy', process: this.currentTunnelProcess, timeoutWarning: 'Tunnel will timeout after 60 minutes on free plan' }); } }); this.currentTunnelProcess.on('error', (error) => { reject(new Error(`Pinggy tunnel failed: ${error.message}`)); }); this.currentTunnelProcess.on('exit', (code) => { if (code !== 0 && !tunnelUrl) { reject(new Error(`Pinggy tunnel exited with code ${code}. This might be due to the 60-minute timeout.`)); } }); setTimeout(() => { if (!tunnelUrl) { this.currentTunnelProcess.kill(); reject(new Error('Pinggy tunnel creation timeout')); } }, 30000); }); } async createLocalhostRunTunnel(tunnelId, options) { return new Promise((resolve, reject) => { const sshArgs = [ '-R', `80:localhost:${this.serverPort}`, 'ssh.localhost.run', '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=30', '-o', 'ExitOnForwardFailure=yes' ]; console.log(`🔗 Creating localhost.run tunnel: ssh ${sshArgs.join(' ')}`); this.currentTunnelProcess = spawn('ssh', sshArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; let tunnelUrl = null; this.currentTunnelProcess.stdout.on('data', (data) => { output += data.toString(); tunnelUrl = this.extractLocalhostRunUrl(output); if (tunnelUrl) { resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'localhost.run', process: this.currentTunnelProcess }); } }); this.currentTunnelProcess.stderr.on('data', (data) => { output += data.toString(); tunnelUrl = this.extractLocalhostRunUrl(output); if (tunnelUrl) { resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'localhost.run', process: this.currentTunnelProcess }); } }); this.currentTunnelProcess.on('error', (error) => { reject(new Error(`localhost.run tunnel failed: ${error.message}`)); }); this.currentTunnelProcess.on('exit', (code) => { if (code !== 0 && !tunnelUrl) { reject(new Error(`localhost.run tunnel exited with code ${code}`)); } }); setTimeout(() => { if (!tunnelUrl) { this.currentTunnelProcess.kill(); reject(new Error('localhost.run tunnel creation timeout')); } }, 30000); }); } async createServeoTunnel(tunnelId, options, targetPort) { return new Promise((resolve, reject) => { const subdomain = options.subdomain || this.subdomain; const portToUse = targetPort || this.serverPort; let sshArgs; if (subdomain) { sshArgs = [ '-R', `${subdomain}.serveo.net:80:localhost:${portToUse}`, 'serveo.net', '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=30', '-o', 'ExitOnForwardFailure=yes' ]; } else { sshArgs = [ '-R', `80:localhost:${portToUse}`, 'serveo.net', '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=30', '-o', 'ExitOnForwardFailure=yes' ]; } console.log(`🔗 Creating Serveo tunnel: ssh ${sshArgs.join(' ')}`); this.currentTunnelProcess = spawn('ssh', sshArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; let tunnelUrl = null; let errorOutput = ''; this.currentTunnelProcess.stdout.on('data', (data) => { const text = data.toString(); output += text; console.log('Serveo stdout:', text.trim()); tunnelUrl = this.extractServeoUrl(output); if (tunnelUrl) { resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'serveo', process: this.currentTunnelProcess }); } }); this.currentTunnelProcess.stderr.on('data', (data) => { const text = data.toString(); errorOutput += text; output += text; console.log('Serveo stderr:', text.trim()); // Serveo often sends the URL via stderr tunnelUrl = this.extractServeoUrl(output); if (tunnelUrl) { resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'serveo', process: this.currentTunnelProcess }); } }); this.currentTunnelProcess.on('error', (error) => { reject(new Error(`Serveo tunnel failed: ${error.message}`)); }); this.currentTunnelProcess.on('exit', (code) => { if (code !== 0 && !tunnelUrl) { reject(new Error(`Serveo tunnel exited with code ${code}. Output: ${errorOutput}`)); } }); // Timeout after 30 seconds setTimeout(() => { if (!tunnelUrl) { this.currentTunnelProcess.kill(); reject(new Error(`Serveo tunnel creation timeout. Output: ${output}`)); } }, 30000); }); } async createLocalTunnel(tunnelId, options) { return new Promise((resolve, reject) => { const subdomain = options.subdomain || this.subdomain; let ltArgs = ['--port', this.serverPort.toString()]; if (subdomain) { ltArgs.push('--subdomain', subdomain); } console.log(`🔗 Creating LocalTunnel: lt ${ltArgs.join(' ')}`); this.currentTunnelProcess = spawn('npx', ['lt', ...ltArgs], { stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; let tunnelUrl = null; this.currentTunnelProcess.stdout.on('data', (data) => { const text = data.toString(); output += text; console.log('LocalTunnel stdout:', text.trim()); tunnelUrl = this.extractLocalTunnelUrl(output); if (tunnelUrl) { resolve({ tunnelId: tunnelId, url: tunnelUrl, localPort: this.serverPort, createdAt: new Date(), status: 'active', service: 'localtunnel', process: this.currentTunnelProcess }); } }); this.currentTunnelProcess.stderr.on('data', (data) => { const text = data.toString(); output += text; console.log('LocalTunnel stderr:', text.trim()); }); this.currentTunnelProcess.on('error', (error) => { if (error.code === 'ENOENT') { reject(new Error('LocalTunnel not installed. Run: npm install -g localtunnel')); } else { reject(new Error(`LocalTunnel failed: ${error.message}`)); } }); this.currentTunnelProcess.on('exit', (code) => { if (code !== 0 && !tunnelUrl) { reject(new Error(`LocalTunnel exited with code ${code}`)); } }); setTimeout(() => { if (!tunnelUrl) { this.currentTunnelProcess.kill(); reject(new Error('LocalTunnel creation timeout')); } }, 30000); }); } // ============================================================================ // URL EXTRACTION HELPERS // ============================================================================ extractPinggyUrl(output) { // Pinggy outputs URL like: "https://randomstring.a.pinggy.io" const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.a\.pinggy\.io/); return match ? match[0] : null; } extractLocalhostRunUrl(output) { // localhost.run outputs: "Connect to https://randomstring.localhost.run" const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.localhost\.run/); return match ? match[0] : null; } extractServeoUrl(output) { // Serveo outputs: "Forwarding HTTP traffic from https://randomstring.serveo.net" const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.serveo\.net/); return match ? match[0] : null; } extractServeoUrl(output) { // Serveo can output URLs in different formats const patterns = [ /Forwarding HTTP traffic from (https:\/\/[a-zA-Z0-9-]+\.serveo\.net)/, /Forwarding to (https:\/\/[a-zA-Z0-9-]+\.serveo\.net)/, /(https:\/\/[a-zA-Z0-9-]+\.serveo\.net)/ ]; for (const pattern of patterns) { const match = output.match(pattern); if (match) { return match[1] || match[0]; } } return null; } extractLocalTunnelUrl(output) { // LocalTunnel outputs: "your url is: https://abc123.loca.lt" const match = output.match(/your url is:\s*(https:\/\/[a-zA-Z0-9-]+\.loca\.lt)/); return match ? match[1] : null; } // ============================================================================ // TEMPORARY URL MANAGEMENT // ============================================================================ async createTemporaryUrl(filePath, options = {}) { const operationId = this.generateOperationId(); const urlId = this.generateUrlId(); try { this.trackOperation(operationId, 'createUrl', { urlId, filePath }); // Validate file path and access await this.validateFileAccess(filePath); // Set up URL configuration const expiresAt = options.expiresAt || new Date(Date.now() + this.defaultExpiration); const accessToken = this.generateAccessToken(); const permissions = options.permissions || ['read']; // Ensure tunnel exists if (!this.currentTunnelUrl) { const tunnel = await this.createTunnel(); this.currentTunnelUrl = tunnel.url; } // Create URL info const urlInfo = { urlId: urlId, filePath: this.normalizePath(filePath), tunnelUrl: this.currentTunnelUrl, accessToken: accessToken, expiresAt: expiresAt, permissions: permissions, createdAt: new Date(), accessCount: 0, lastAccessed: null }; // Store URL and access token this.activeUrls.set(urlId, urlInfo); this.accessTokens.set(accessToken, { urlId: urlId, expiresAt: expiresAt, permissions: permissions }); // Add to allowed paths this.allowedPaths.add(urlInfo.filePath); const shareableUrl = this.buildShareableUrl(urlInfo); this.emit('urlCreated', { operationId, urlId, shareableUrl, expiresAt, filePath: urlInfo.filePath }); return { urlId: urlId, shareableUrl: shareableUrl, expiresAt: expiresAt, accessToken: accessToken, permissions: permissions, operationId: operationId }; } catch (error) { this.emit('urlError', { operationId, urlId, error: error.message }); throw new Error(`Failed to create temporary URL: ${error.message}`); } finally { this.activeOperations.delete(operationId); } } async revokeTemporaryUrl(urlId) { const urlInfo = this.activeUrls.get(urlId); if (!urlInfo) { throw new Error(`URL '${urlId}' not found`); } // Remove access token this.accessTokens.delete(urlInfo.accessToken); // Remove from active URLs this.activeUrls.delete(urlId); // Remove from allowed paths if no other URLs use it const pathStillUsed = Array.from(this.activeUrls.values()) .some(url => url.filePath === urlInfo.filePath); if (!pathStillUsed) { this.allowedPaths.delete(urlInfo.filePath); } this.emit('urlRevoked', { urlId, filePath: urlInfo.filePath }); return { urlId, revokedAt: new Date().toISOString() }; } // ============================================================================ // LOCAL SERVER MANAGEMENT (HTTP Server Implementation) // ============================================================================ async startLocalServer() { if (this.isServerRunning) { return this.serverPort; } return new Promise((resolve, reject) => { this.localServer = createServer((req, res) => { this.handleRequest(req, res); }); this.localServer.listen(0, 'localhost', () => { this.serverPort = this.localServer.address().port; this.isServerRunning = true; console.log(`🌐 Local server started on port ${this.serverPort}`); resolve(this.serverPort); }); this.localServer.on('error', (error) => { reject(new Error(`Failed to start local server: ${error.message}`)); }); }); } async stopLocalServer() { if (!this.isServerRunning || !this.localServer) { return; } return new Promise((resolve) => { this.localServer.close(() => { this.isServerRunning = false; this.serverPort = null; this.localServer = null; console.log('🔴 Local server stopped'); resolve(); }); }); } async handleRequest(req, res) { const startTime = Date.now(); try { // Log the request console.log(`📡 ${req.method} ${req.url} from ${req.connection.remoteAddress}`); // Parse URL const urlParts = new URL(req.url, `http://${req.headers.host}`); const pathname = urlParts.pathname; const query = urlParts.searchParams; // Set basic security headers res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); // Handle different routes if (pathname === '/') { await this.handleRootRequest(req, res); } else if (pathname.startsWith('/share/')) { await this.handleShareRequest(req, res, pathname, query); } else if (pathname === '/status') { await this.handleStatusRequest(req, res); } else if (pathname === '/health') { await this.handleHealthRequest(req, res); } else { await this.handleNotFound(req, res); } // Log response time const duration = Date.now() - startTime; console.log(`📊 Request completed in ${duration}ms`); } catch (error) { console.error(`❌ Request handling error: ${error.message}`); await this.handleError(req, res, error); } } async handleShareRequest(req, res, pathname, query) { try { // Extract URL ID from path: /share/{urlId} const urlId = pathname.split('/')[2]; if (!urlId) { throw new Error('Invalid share URL format'); } console.log(`🔍 Share request for URL ID: ${urlId}`); // Get access token from query const token = query.get('token'); if (!token) { throw new Error('Access token required'); } console.log(`🔑 Token provided: ${token.substring(0, 8)}...`); // Validate access token const tokenInfo = this.accessTokens.get(token); if (!tokenInfo) { throw new Error('Invalid access token'); } // Check token expiration if (new Date() > tokenInfo.expiresAt) { this.accessTokens.delete(token); throw new Error('Access token expired'); } // Validate URL ID matches token if (tokenInfo.urlId !== urlId) { throw new Error('Token does not match requested URL'); } // Get URL info const urlInfo = this.activeUrls.get(urlId); if (!urlInfo) { throw new Error('Shared URL not found or revoked'); } console.log(`📄 File to serve: ${urlInfo.filePath}`); // Check URL expiration if (new Date() > urlInfo.expiresAt) { await this.revokeTemporaryUrl(urlId); throw new Error('Shared URL expired'); } // Validate file still exists and is accessible try { await fs.access(urlInfo.filePath, fs.constants.R_OK); const stats = await fs.stat(urlInfo.filePath); if (!stats.isFile()) { throw new Error('Shared resource is not a file'); } console.log(`✅ File verified: ${urlInfo.filePath} (${stats.size} bytes)`); } catch (fileError) { console.error(`❌ File access error: ${fileError.message}`); throw new Error(`File not accessible: ${fileError.message}`); } // Serve the file (this will handle all the stats and event emission) await this.serveFile(req, res, urlInfo.filePath, urlInfo); } catch (error) { console.error(`❌ Share request error: ${error.message}`); // Return appropriate error response if (error.message.includes('token') || error.message.includes('expired')) { res.writeHead(401, { 'Content-Type': 'text/plain' }); res.end('Unauthorized: ' + error.message); } else if (error.message.includes('not found') || error.message.includes('not accessible')) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found: ' + error.message); } else { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Server Error: ' + error.message); } } } async serveFile(req, res, filePath, urlInfo) { const mimeTypes = { '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.pdf': 'application/pdf', '.zip': 'application/zip', '.tar': 'application/x-tar', '.gz': 'application/gzip', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }; console.log(`🚀 Starting file serve for: ${filePath}`); try { // Get file stats const stats = await fs.stat(filePath); const fileSize = stats.size; const fileName = path.basename(filePath); const fileExt = path.extname(filePath).toLowerCase(); const mimeType = mimeTypes[fileExt] || 'application/octet-stream'; // Track download start const downloadId = this.generateOperationId(); const startTime = Date.now(); const clientIP = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.connection.remoteAddress || req.socket.remoteAddress || 'unknown'; console.log(`📁 Starting download: ${fileName} (${this.formatBytes(fileSize)}) for ${clientIP}`); console.log(`📄 File path: ${filePath}`); console.log(`🎭 MIME type: ${mimeType}`); // Set response headers for download res.setHeader('Content-Type', mimeType); res.setHeader('Content-Length', fileSize); res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('X-Download-ID', downloadId); // Handle range requests for resume support const range = req.headers.range; if (range) { console.log(`📊 Range request: ${range}`); return await this.serveFileRange(req, res, filePath, fileSize, range, urlInfo, downloadId); } // Serve complete file with proper streaming and error handling console.log(`📤 Starting file stream...`); res.writeHead(200); const stream = fsSync.createReadStream(filePath); let bytesTransferred = 0; let lastProgressUpdate = 0; let downloadCompleted = false; let downloadStarted = false; // Track when streaming actually begins stream.on('open', () => { downloadStarted = true; console.log(`🌊 File stream opened successfully`); }); // Track progress stream.on('data', (chunk) => { bytesTransferred += chunk.length; // Log first chunk if (bytesTransferred === chunk.length) { console.log(`📦 First chunk: ${chunk.length} bytes`); console.log(`📝 First 100 chars: ${chunk.toString().substring(0, 100)}...`); } // Emit progress every 1MB or 5% progress, whichever is more frequent const progressInterval = Math.min(1024 * 1024, fileSize * 0.05); if (bytesTransferred - lastProgressUpdate >= progressInterval) { this.emitDownloadProgress(urlInfo, downloadId, bytesTransferred, fileSize); lastProgressUpdate = bytesTransferred; } }); // Handle successful completion stream.on('end', () => { if (downloadCompleted) return; // Prevent duplicate events downloadCompleted = true; const duration = Date.now() - startTime; const speed = fileSize / (duration / 1000); // bytes per second console.log(`✅ Download completed: ${fileName} (${this.formatBytes(fileSize)}) in ${this.formatDuration(duration)}`); console.log(` Speed: ${this.formatBytes(speed)}/s | Client: ${clientIP}`); console.log(` Bytes transferred: ${bytesTransferred}/${fileSize}`); // Update URL access statistics BEFORE emitting event this.updateUrlStats(urlInfo.urlId, fileSize, duration, clientIP); // Update URL info urlInfo.accessCount = (urlInfo.accessCount || 0) + 1; urlInfo.lastAccessed = new Date(); this.activeUrls.set(urlInfo.urlId, urlInfo); // Emit download completion event AFTER successful completion this.emit('fileAccessed', { urlId: urlInfo.urlId, filePath: filePath, fileName: fileName, fileSize: fileSize, duration: duration, speed: speed, clientIP: clientIP, downloadId: downloadId, timestamp: new Date(), bytesTransferred: fileSize, success: true }); }); // Handle stream errors stream.on('error', (error) => { console.error(`❌ Stream error for ${fileName}: ${error.message}`); console.error(` Error code: ${error.code}`); console.error(` Bytes transferred before error: ${bytesTransferred}`); this.emit('downloadError', { urlId: urlInfo.urlId, filePath: filePath, error: error.message, downloadId: downloadId, bytesTransferred: bytesTransferred }); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error: File read error'); } }); // Handle response errors (client disconnect, etc.) res.on('error', (error) => { console.error(`❌ Response error for ${fileName}: ${error.message}`); stream.destroy(); // Clean up the file stream }); // Handle response finish (successful completion from client perspective) res.on('finish', () => { console.log(`🏁 Response finished for ${fileName}`); }); // Handle client disconnect req.on('close', () => { if (!downloadCompleted && bytesTransferred < fileSize) { console.log(`⚠️ Download interrupted: ${fileName} (${this.formatBytes(bytesTransferred)}/${this.formatBytes(fileSize)})`); this.emit('downloadInterrupted', { urlId: urlInfo.urlId, filePath: filePath, downloadId: downloadId, bytesTransferred: bytesTransferred, totalSize: fileSize, percentComplete: (bytesTransferred / fileSize) * 100 }); stream.destroy(); // Clean up the file stream } }); // Start streaming the file console.log(`🔄 Piping stream to response...`); stream.pipe(res); } catch (error) { console.error(`❌ File serving error: ${error.message}`); console.error(` Stack: ${error.stack}`); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error: ' + error.message); } throw new Error(`Failed to serve file: ${error.message}`); } } // Enhanced range request handling async serveFileRange(req, res, filePath, fileSize, range, urlInfo, downloadId) { try { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunkSize = (end - start) + 1; console.log(`📊 Serving range: ${start}-${end}/${fileSize} (${this.formatBytes(chunkSize)})`); // Validate range if (start >= fileSize || end >= fileSize || start > end) { res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` }); res.end(); return; } res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize, 'Content-Type': 'application/octet-stream' }); const stream = fs.createReadStream(filePath, { start, end }); let bytesTransferred = 0; let rangeCompleted = false; stream.on('data', (chunk) => { bytesTransferred += chunk.length; console.log(`📦 Range chunk: ${chunk.length} bytes (total: ${bytesTransferred}/${chunkSize})`); }); stream.on('end', () => { if (rangeCompleted) return; rangeCompleted = true; console.log(`✅ Range download completed: ${this.formatBytes(chunkSize)}`); // If this completes the entire file, emit completion event if (start === 0 && end === fileSize - 1) { urlInfo.accessCount = (urlInfo.accessCount || 0) + 1; urlInfo.lastAccessed = new Date(); this.activeUrls.set(urlInfo.urlId, urlInfo); this.emit('fileAccessed', { urlId: urlInfo.urlId, filePath: filePath, fileName: path.basename(filePath), fileSize: fileSize, downloadId: downloadId, isRangeRequest: true, timestamp: new Date(), bytesTransferred: chunkSize, success: true }); } }); stream.on('error', (error) => { console.error(`❌ Range request error: ${error.message}`); if (!res.headersSent) { res.writeHead(500); res.end(); } }); stream.pipe(res); } catch (error) { console.error(`❌ Range request handling error: ${error.message}`); if (!res.headersSent) { res.writeHead(500); res.end(); } } } // Helper methods (add these if they don't exist) emitDownloadProgress(urlInfo, downloadId, bytesTransferred, totalSize) { const percentage = Math.round((bytesTransferred / totalSize) * 100); this.emit('downloadProgress', { urlId: urlInfo.urlId, downloadId: downloadId, progress: { percentage: percentage, transferred: this.formatBytes(bytesTransferred), total: this.formatBytes(totalSize), bytesTransferred: bytesTransferred, totalBytes: totalSize }, timestamp: new Date() }); } updateUrlStats(urlId, fileSize, duration, clientIP) { const urlInfo = this.activeUrls.get(urlId); if (!urlInfo) return; // Initialize stats if not present if (!urlInfo.stats) { urlInfo.stats = { totalDownloads: 0, totalBytes: 0, totalDuration: 0, averageSpeed: 0, firstAccess: null, lastAccess: null, uniqueIPs: new Set(), downloadHistory: [] }; } // Update statistics urlInfo.stats.totalDownloads++; urlInfo.stats.totalBytes += fileSize; urlInfo.stats.totalDuration += duration; urlInfo.stats.averageSpeed = urlInfo.stats.totalBytes / (urlInfo.stats.totalDuration / 1000); urlInfo.stats.uniqueIPs.add(clientIP); if (!urlInfo.stats.firstAccess) { urlInfo.stats.firstAccess = urlInfo.lastAccessed || new Date(); } urlInfo.stats.lastAccess = new Date(); // Add to download history (keep last 10 downloads) urlInfo.stats.downloadHistory.push({ timestamp: new Date(), fileSize: fileSize, duration: duration, speed: fileSize / (duration / 1000), clientIP: clientIP }); if (urlInfo.stats.downloadHistory.length > 10) { urlInfo.stats.downloadHistory.shift(); // Remove oldest } // Update the stored URL info this.activeUrls.set(urlId, urlInfo); } // Enhanced URL listing with download statistics listActiveUrls() { return Array.from(this.activeUrls.entries()).map(([id, info]) => { const baseInfo = { urlId: id, filePath: info.filePath, shareableUrl: this.buildShareableUrl(info), expiresAt: info.expiresAt, permissions: info.permissions, accessCount: info.accessCount || 0, lastAccessed: info.lastAccessed, createdAt: info.createdAt }; // Add detailed stats if available if (info.stats) { baseInfo.downloadStats = { totalDownloads: info.stats.totalDownloads, totalDataTransferred: this.formatBytes(info.stats.totalBytes), averageSpeed: this.formatBytes(info.stats.averageSpeed) + '/s', uniqueClients: info.stats.uniqueIPs.size, firstAccess: info.stats.firstAccess, totalDuration: this.formatDuration(info.stats.totalDuration), recentDownloads: info.stats.downloadHistory.slice(-3).map(download => ({ timestamp: download.timestamp, size: this.formatBytes(download.fileSize), duration: this.formatDuration(download.duration), speed: this.formatBytes(download.speed) + '/s' })) }; } return baseInfo; }); } // Enhanced tunnel status with download metrics getTunnelStatus() { const baseStatus = { activeTunnels: this.activeTunnels.size, activeUrls: this.activeUrls.size, serverRunning: this.isServerRunning, serverPort: this.serverPort, currentTunnelUrl: this.currentTunnelUrl, activeOperations: this.activeOperations.size, allowedPaths: this.allowedPaths.size, currentService: this.primaryService, fallbackEnabled: this.autoFallback }; // Add download statistics let totalDownloads = 0; let totalDataTransferred = 0; let activeDownloads = 0; for (const [urlId, urlInfo] of this.activeUrls) { if (urlInfo.stats) { totalDownloads += urlInfo.stats.totalDownloads; totalDataTransferred += urlInfo.stats.totalBytes; } } baseStatus.downloadMetrics = { totalDownloads: totalDownloads, totalDataTransferred: this.formatBytes(totalDataTransferred), activeDownloads: activeDownloads, averageFileSize: totalDownloads > 0 ? this.formatBytes(totalDataTransferred / totalDownloads) : '0 Bytes' }; return baseStatus; } // Add method to get download statistics for a specific URL getUrlDownloadStats(urlId) { const urlInfo = this.activeUrls.get(urlId); if (!urlInfo) { throw new Error(`URL '${urlId}' not found`); } return { urlId: urlId, filePath: urlInfo.filePath, fileName: path.basename(urlInfo.filePath), accessCount: urlInfo.accessCount || 0, lastAccessed: urlInfo.lastAccessed, createdAt: urlInfo.createdAt, expiresAt: urlInfo.expiresAt, isExpired: new Date() > new Date(urlInfo.expiresAt), stats: urlInfo.stats || null }; } async handleRootRequest(req, res) { const html = ` <!DOCTYPE html> <html> <head> <title>Local File Tunnel</title> <style> body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; } .container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } h1 { color: #333; } .status { background: #e8f5e8; padding: 15px; border-radius: 4px; margin: 20px 0; } .info { color: #666; font-size: 14px; } </style> </head> <body> <div class="container"> <h1>🌐 Local File Tunnel</h1> <div class="status"> <strong>✅ Tunnel Active</strong><br> This tunnel is serving files from your local system. </div> <div class="info"> <p><strong>Active URLs:</strong> ${this.activeUrls.size}</p> <p><strong>Total Access Tokens:</strong> ${this.accessTokens.size}</p> <p><strong>Tunnel Service:</strong> ${this.primaryService}</p> <p><strong>Server Started:</strong> ${new Date().toISOString()}</p> </div> <p>To access files, use a valid shareable URL with proper authentication.</p> </div> </body> </html> `; res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Length': Buffer.byteLength(html) }); res.end(html); } async handleStatusRequest(req, res) { const status = { tunnel: { provider: 'tunnel', service: this.primaryService, activeTunnels: this.activeTunnels.size, activeUrls: this.activeUrls.size, serverRunning: this.isServerRunning, serverPort: this.serverPort, currentTunnelUrl: this.currentTunnelUrl }, server: { uptime: process.uptime(), memory: process.memoryUsage(), platform: process.platform, nodeVersion: process.version }, stats: { totalRequests: Array.from(this.activeUrls.values()).reduce((sum, url) => sum + url.accessCount, 0), allowedPaths: this.allowedPaths.size, activeOperations: this.activeOperations.size } }; res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify(status, null, 2)); } async handleHealthRequest(req, res) { const health = { status: 'healthy', timestamp: new Date().toISOString(), tunnelActive: this.isServerRunning, serviceConnected: !!this.currentTunnelUrl }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(health)); } async handleNotFound(req, res) { const html = ` <!DOCTYPE html> <html> <head><title>404 - Not Found</title></head> <body style="font-family: Arial, sans-serif; margin: 40px; text-align: center;"> <h1>🚫 404 - Not Found</h1> <p>The requested resource was not found on this tunnel.</p> <p><a href="/">← Back to Home</a></p> </body> </html> `; res.writeHead(404, { 'Content-Type': 'text/html' }); res.end(html); } async handleError(req, res, error) { if (res.headersSent) { return; // Response already sent } const html = ` <!DOCTYPE html> <html> <head><title>500 - Server Error</title></head> <body style="font-family: Arial, sans-serif; margin: 40px; text-align: center;"> <h1>❌ 500 - Server Error</h1> <p>An internal server error occurred.</p> <p><a href="/">← Back to Home</a></p> </body> </html> `; res.writeHead(500, { 'Content-Type': 'text/html' }); res.end(html); } // ============================================================================ // UTILITY METHODS (Following established patterns) // ============================================================================ async testSSHAvailability() { return new Promise((resolve, reject) => { const testProcess = spawn('ssh', ['-V'], { stdio: ['pipe', 'pipe', 'pipe'] }); testProcess.on('close', (code) => { if (code === 0) { resolve(true); } else { reject(new Error('SSH not available in PATH')); } }); testProcess.on('error', () => { reject(new Error('SSH not found')); }); setTimeout(() => { testProcess.kill(); reject(new Error('SSH test timeout')); }, 5000); }); } isSSHAvailable() { try { execSync('ssh -V', { stdio: ['pipe', 'pipe', 'pipe'] }); return true; } catch (error) { return false; } } async testLocalServerCreation() { const testServer = createServer(); return new Promise((resolve, reject) => { testServer.listen(0, 'localhost', () => { testServer.close(() => resolve()); }); testServer.on('error', reject); }); } parseFallbackServices(fallbackString) { if (!fallbackString) return []; return fallbackString.split(',').map(s => s.trim()).filter(s => s); } generateOperationId() { return `tunnel_op_${++this.operationCount}_${Date.now()}`; } generateTunnelId() { return `tunnel_${crypto.randomBytes(8).toString('hex')}`; } generateUrlId() { return `url_${crypto.randomBytes(8).toString('hex')}`; } generateAccessToken() { return crypto.randomBytes(32).toString('hex'); } trackOperation(operationId, type, metadata = {}) { this.activeOperations.set(operationId, { type: type, startTime: Date.now(), metadata: metadata }); } normalizePath(filePath) { // Handle absolute paths