#!/usr/bin/env python3 """ SamCloud Dashboard - Modern Flask Application A unified Apple-styled web application for Mac Mini server management """ from flask import Flask, render_template, request, jsonify, session, redirect, url_for, send_file, Response import re import os import json import subprocess import time import shutil import psutil from datetime import datetime import logging # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = Flask(__name__) app.secret_key = 'samcloud-dashboard-secret-key-2025' # In production, use environment variable # Configuration class Config: _fd = os.getenv('FLASK_DEBUG', '0') DEBUG = (_fd in ('1', 'true', 'True', 'TRUE')) or (os.getenv('FLASK_ENV') == 'development') HOST = '0.0.0.0' PORT = 8383 # Paths BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(BASE_DIR, 'static') TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates') config = Config() # Create directories if they don't exist os.makedirs(config.STATIC_DIR, exist_ok=True) os.makedirs(config.TEMPLATES_DIR, exist_ok=True) # Better live reload in development if config.DEBUG: app.config['TEMPLATES_AUTO_RELOAD'] = True app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 try: app.jinja_env.auto_reload = True except Exception: pass @app.after_request def add_no_cache_headers(response): """Prevent aggressive caching during development for live updates.""" if config.DEBUG: response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response # Simple authentication (for development - replace with proper auth in production) USERS = { 'admin': 'samcloud2025', 'SamCloud': '639208' # Add your Mac username here } def get_connected_devices(): """Get connected devices on the network including screen sharing connections""" try: devices = [] # Method 1: ARP table for network devices try: arp_result = subprocess.run(['arp', '-a'], capture_output=True, text=True, timeout=3) if arp_result.returncode == 0: lines = arp_result.stdout.strip().split('\n') for line in lines: if '(' in line and ')' in line and 'incomplete' not in line: try: # Parse ARP output: hostname (ip) at mac_address parts = line.split() if len(parts) >= 4: hostname = parts[0] if parts[0] != '?' else 'Unknown Device' ip = parts[1].strip('()') mac = parts[3] if len(parts) > 3 else 'Unknown' # Enhanced device type detection device_type = "Unknown Device" device_icon = "🖥️" if mac != 'Unknown' and mac != 'incomplete': mac_upper = mac.upper() # Apple device patterns (more comprehensive) apple_prefixes = [ '00:1B:63', '00:25:00', 'D8:30:62', '3C:15:C2', '00:23:DF', '00:26:08', '00:03:93', 'AC:DE:48', 'B8:09:8A', 'B8:C7:5D', '14:BD:61', '8C:85:90', 'F0:DB:C1', '38:C9:86', '6C:40:08', 'A4:5E:60', 'F4:0F:24', '2C:B4:3A', '5C:F9:38', '60:C5:47' ] if any(mac_upper.startswith(prefix) for prefix in apple_prefixes): device_type = "Apple Device" device_icon = "🍎" # Try to determine specific Apple device type try: ping_result = subprocess.run(['ping', '-c', '1', '-W', '1000', ip], capture_output=True, timeout=2) if ping_result.returncode == 0: # Device is responding, likely active if 'macbook' in hostname.lower(): device_type = "MacBook" device_icon = "💻" elif 'imac' in hostname.lower(): device_type = "iMac" device_icon = "🖥️" elif 'iphone' in hostname.lower(): device_type = "iPhone" device_icon = "📱" elif 'ipad' in hostname.lower(): device_type = "iPad" device_icon = "📱" except: pass elif mac_upper.startswith(('B8:27:EB', 'DC:A6:32', 'E4:5F:01')): device_type = "Raspberry Pi" device_icon = "🥧" else: # Check for other common manufacturers if mac_upper.startswith(('00:50:56', '00:0C:29')): # VMware device_type = "Virtual Machine" device_icon = "💻" elif mac_upper.startswith(('08:00:27')): # VirtualBox device_type = "Virtual Machine" device_icon = "💻" else: device_type = "Network Device" device_icon = "🌐" devices.append({ 'hostname': hostname, 'ip': ip, 'mac': mac, 'type': device_type, 'icon': device_icon, 'status': 'Connected', 'last_seen': datetime.now().isoformat() }) except (IndexError, ValueError): continue except subprocess.TimeoutExpired: logger.warning("ARP command timed out") # Add current active connections as fallback try: # Get network connections to find active devices netstat_result = subprocess.run(['netstat', '-rn'], capture_output=True, text=True, timeout=2) if netstat_result.returncode == 0: # Look for default gateway and add it as a device lines = netstat_result.stdout.split('\n') for line in lines: if 'default' in line: parts = line.split() if len(parts) > 1: gateway_ip = parts[1] devices.append({ 'hostname': 'Network Gateway', 'ip': gateway_ip, 'mac': 'Unknown', 'type': 'Router', 'icon': '🌐', 'status': 'Connected', 'last_seen': datetime.now().isoformat() }) break except: pass except Exception as e: logger.error(f"Error getting ARP table: {e}") # Method 2: Check for active screen sharing connections try: # Check for Screen Sharing (VNC) connections vnc_result = subprocess.run(['lsof', '-i', ':5900'], capture_output=True, text=True, timeout=5) if vnc_result.returncode == 0 and vnc_result.stdout.strip(): lines = vnc_result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: connection = parts[8] # Format: ip:port->ip:port if '->' in connection: remote_ip = connection.split('->')[0].split(':')[0] # Add screen sharing device if not already in devices if not any(d['ip'] == remote_ip for d in devices): devices.append({ 'hostname': f'Screen Sharing Client', 'ip': remote_ip, 'mac': 'Unknown', 'type': 'Screen Sharing', 'icon': '🖥️', 'status': 'Screen Sharing Active', 'last_seen': datetime.now().isoformat() }) except Exception as e: logger.error(f"Error checking screen sharing: {e}") # Method 3: Check SSH connections try: ssh_result = subprocess.run(['lsof', '-i', ':22'], capture_output=True, text=True, timeout=5) if ssh_result.returncode == 0 and ssh_result.stdout.strip(): lines = ssh_result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: connection = parts[8] if '->' in connection: remote_ip = connection.split('->')[0].split(':')[0] if not any(d['ip'] == remote_ip for d in devices): devices.append({ 'hostname': 'SSH Client', 'ip': remote_ip, 'mac': 'Unknown', 'type': 'SSH Connection', 'icon': '💻', 'status': 'SSH Active', 'last_seen': datetime.now().isoformat() }) except Exception as e: logger.error(f"Error checking SSH connections: {e}") # Method 4: Add devices based on active HTTP connections try: # Check for HTTP connections on port 8383 (this dashboard) http_result = subprocess.run(['lsof', '-i', ':8383'], capture_output=True, text=True, timeout=3) if http_result.returncode == 0 and http_result.stdout.strip(): lines = http_result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: connection = parts[8] if '->' in connection: remote_ip = connection.split('->')[0].split(':')[0] if not any(d['ip'] == remote_ip for d in devices): # Determine device type based on IP pattern or other indicators device_type = 'Web Client' device_icon = '💻' hostname = 'Dashboard Client' # Check if this is a macOS device by IP pattern if remote_ip.startswith('192.168.100.90'): device_type = 'MacBook' device_icon = '💻' hostname = 'MacBook (Dashboard Client)' elif remote_ip.startswith('192.168.100.22'): device_type = 'Mac mini' device_icon = '🖥️' hostname = 'Mac mini (Local)' devices.append({ 'hostname': hostname, 'ip': remote_ip, 'mac': 'Unknown', 'type': device_type, 'icon': device_icon, 'status': 'Dashboard Active', 'last_seen': datetime.now().isoformat() }) except Exception as e: logger.error(f"Error checking HTTP connections: {e}") # Get network statistics net_io = psutil.net_io_counters() # Sort devices by IP for consistent ordering devices.sort(key=lambda x: x['ip']) return { 'devices': devices[:20], # Show up to 20 devices 'total_devices': len(devices), 'network_stats': { 'bytes_sent': net_io.bytes_sent, 'bytes_recv': net_io.bytes_recv, 'packets_sent': net_io.packets_sent, 'packets_recv': net_io.packets_recv } } except Exception as e: logger.error(f"Error getting connected devices: {e}") return {'devices': [], 'total_devices': 0, 'network_stats': {}} def get_mac_info(): """Get Mac-specific information using robust JSON parsing and sysctl fallbacks""" import json mac_info = { 'model': 'Unknown', 'processor': 'Unknown', 'memory': 'Unknown', 'serial': 'Unknown', 'computer_name': 'Unknown' } def get_sysctl_value(name): """Get sysctl value with fallback paths""" sysctl_paths = ['/usr/sbin/sysctl', 'sysctl'] for path in sysctl_paths: try: result = subprocess.run([path, '-n', name], capture_output=True, text=True, timeout=5) return result.stdout.strip() if result.returncode == 0 else None except (FileNotFoundError, subprocess.TimeoutExpired): continue return None # Method 1: Try JSON output from system_profiler (most reliable) system_profiler_paths = ['/usr/sbin/system_profiler', 'system_profiler'] for sp_path in system_profiler_paths: try: result = subprocess.run([sp_path, 'SPHardwareDataType', '-json'], capture_output=True, text=True, timeout=10) if result.returncode == 0: data = json.loads(result.stdout) hardware_info = data.get('SPHardwareDataType', [{}])[0] # Extract structured data mac_info['model'] = (hardware_info.get('machine_name') or hardware_info.get('model_identifier') or hardware_info.get('_name', 'Unknown')) mac_info['processor'] = (hardware_info.get('processor_name') or hardware_info.get('chip_type') or hardware_info.get('cpu_type', 'Unknown')) mac_info['memory'] = hardware_info.get('physical_memory', 'Unknown') mac_info['serial'] = hardware_info.get('serial_number', 'Unknown') # Add core count if available cores = hardware_info.get('number_processors') if cores and mac_info['processor'] != 'Unknown': mac_info['processor'] += f" ({cores} cores)" break # Success, exit loop except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e: print(f"JSON system_profiler failed with {sp_path}: {e}") continue # Method 2: sysctl fallbacks for missing data (faster and more reliable) if mac_info['model'] == 'Unknown': model_id = get_sysctl_value('hw.model') if model_id: mac_info['model'] = model_id if mac_info['memory'] == 'Unknown': mem_bytes = get_sysctl_value('hw.memsize') if mem_bytes: try: mem_gb = int(mem_bytes) / (1024**3) mac_info['memory'] = f"{mem_gb:.0f} GB" except ValueError: pass if mac_info['processor'] == 'Unknown': cpu_brand = get_sysctl_value('machdep.cpu.brand_string') if cpu_brand: mac_info['processor'] = cpu_brand # Add core count cores = get_sysctl_value('hw.ncpu') if cores: mac_info['processor'] += f" ({cores} cores)" # Method 3: Get macOS version using sw_vers try: sw_vers_paths = ['/usr/bin/sw_vers', 'sw_vers'] for sw_path in sw_vers_paths: try: result = subprocess.run([sw_path], capture_output=True, text=True, timeout=5) if result.returncode == 0: for line in result.stdout.splitlines(): if 'ProductVersion:' in line: mac_info['os_version'] = line.split(':', 1)[1].strip() elif 'BuildVersion:' in line: mac_info['build_version'] = line.split(':', 1)[1].strip() break except (FileNotFoundError, subprocess.TimeoutExpired): continue except Exception as e: print(f"sw_vers failed: {e}") # Method 4: Get computer name scutil_paths = ['/usr/sbin/scutil', 'scutil'] for scutil_path in scutil_paths: try: result = subprocess.run([scutil_path, '--get', 'ComputerName'], capture_output=True, text=True, timeout=5) if result.returncode == 0: mac_info['computer_name'] = result.stdout.strip() break except (FileNotFoundError, subprocess.TimeoutExpired): continue # Final fallback to hostname if mac_info['computer_name'] == 'Unknown': try: result = subprocess.run(['hostname'], capture_output=True, text=True, timeout=5) if result.returncode == 0: mac_info['computer_name'] = result.stdout.strip() except: pass return mac_info def get_macos_version(): """Get macOS version information""" try: result = subprocess.run(['sw_vers'], capture_output=True, text=True, timeout=5) version_info = { 'name': 'macOS', 'version': 'Unknown', 'build': 'Unknown' } if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: if 'ProductName:' in line: version_info['name'] = line.split(':', 1)[1].strip() elif 'ProductVersion:' in line: version_info['version'] = line.split(':', 1)[1].strip() elif 'BuildVersion:' in line: version_info['build'] = line.split(':', 1)[1].strip() return version_info except Exception as e: logger.error(f"Error getting macOS version: {e}") return {'name': 'macOS', 'version': 'Unknown', 'build': 'Unknown'} def get_system_info(): """Get comprehensive system information""" try: # CPU and Memory with real-time data cpu_percent = psutil.cpu_percent(interval=0.1) # Faster response cpu_freq = psutil.cpu_freq() memory = psutil.virtual_memory() # Get Mac-specific info mac_info = get_mac_info() macos_info = get_macos_version() # Get real Mac volumes and disk info disks = [] # First try to get real Mac volumes from /Volumes volumes_path = '/Volumes' if os.path.exists(volumes_path): try: for volume in os.listdir(volumes_path): volume_path = os.path.join(volumes_path, volume) if os.path.ismount(volume_path): try: usage = psutil.disk_usage(volume_path) # Get volume name and type volume_name = volume device_path = f"/Volumes/{volume}" disks.append({ 'device': device_path, 'mountpoint': volume_path, 'fstype': 'APFS', # Most Mac volumes are APFS 'name': volume_name, 'total': usage.total, 'used': usage.used, 'free': usage.free, 'percent': round((usage.used / usage.total) * 100, 1) if usage.total > 0 else 0 }) except (PermissionError, OSError): continue except (PermissionError, OSError): pass # Also get main system disk for partition in psutil.disk_partitions(): try: # Include main system disk and important mounts if (partition.mountpoint == '/' or partition.mountpoint.startswith('/Volumes') or (partition.mountpoint not in [p['mountpoint'] for p in disks] and not partition.mountpoint.startswith('/System') and not partition.mountpoint.startswith('/private') and partition.mountpoint not in ['/dev', '/proc', '/sys'])): usage = psutil.disk_usage(partition.mountpoint) disk_info = { 'device': partition.device, 'mountpoint': partition.mountpoint, 'fstype': partition.fstype, 'name': os.path.basename(partition.mountpoint) or 'System', 'total': usage.total, 'used': usage.used, 'free': usage.free, 'percent': round((usage.used / usage.total) * 100, 1) if usage.total > 0 else 0 } # Avoid duplicates if not any(d['mountpoint'] == partition.mountpoint for d in disks): disks.append(disk_info) except (PermissionError, OSError): continue # Network with current speed calculation network = psutil.net_io_counters() # Get network interfaces for speed calculation net_interfaces = psutil.net_if_stats() network_speed = 0 for interface, stats in net_interfaces.items(): if stats.isup and not interface.startswith('lo'): network_speed = max(network_speed, stats.speed or 0) # Uptime boot_time = psutil.boot_time() uptime = datetime.now() - datetime.fromtimestamp(boot_time) # CPU temperature - use calculated temperature based on load # (Real temperature monitoring requires additional tools/permissions) return { 'mac_info': mac_info, 'macos_info': macos_info, 'cpu': { 'percent': round(cpu_percent, 1), 'cores': psutil.cpu_count(logical=False), 'threads': psutil.cpu_count(logical=True), 'frequency': { 'current': round(cpu_freq.current, 2) if cpu_freq else 0, 'min': round(cpu_freq.min, 2) if cpu_freq else 0, 'max': round(cpu_freq.max, 2) if cpu_freq else 0 } }, 'memory': { 'total': memory.total, 'used': memory.used, 'available': memory.available, 'percent': round(memory.percent, 1), 'free': memory.free, 'cached': getattr(memory, 'cached', 0), 'buffers': getattr(memory, 'buffers', 0) }, 'disks': disks, 'network': { 'bytes_sent': network.bytes_sent, 'bytes_recv': network.bytes_recv, 'packets_sent': network.packets_sent, 'packets_recv': network.packets_recv, 'max_speed': network_speed }, 'uptime': str(uptime).split('.')[0], # Remove microseconds 'uptime_seconds': int(uptime.total_seconds()), 'timestamp': datetime.now().isoformat() } except Exception as e: logger.error(f"Error getting system info: {e}") return {'error': str(e)} def get_docker_containers(): """Get Docker container status""" try: result = subprocess.run(['docker', 'ps', '--format', 'json'], capture_output=True, text=True, timeout=10) if result.returncode == 0: containers = [] for line in result.stdout.strip().split('\n'): if line: containers.append(json.loads(line)) return containers return [] except Exception as e: logger.error(f"Error getting Docker containers: {e}") return [] # Routes @app.route('/') def index(): """Main route - check authentication and serve dashboard""" # Remove auth for development # if 'user' not in session: # return redirect(url_for('login')) return render_template('dashboard_sensei.html') @app.route('/login', methods=['GET', 'POST']) def login(): """Authentication route""" if request.method == 'POST': username = request.form.get('username') password = request.form.get('password') if username in USERS and USERS[username] == password: session['user'] = username return redirect(url_for('index')) else: return render_template('login.html', error='Invalid credentials') return render_template('login.html') @app.route('/logout') def logout(): """Logout route""" session.pop('user', None) return redirect(url_for('login')) # API Routes @app.route('/api/system') def api_system(): """API endpoint for system information""" # Remove auth for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 return jsonify(get_system_info()) @app.route('/api/containers') def api_containers(): """API endpoint for Docker containers""" # Remove auth for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 return jsonify(get_docker_containers()) @app.route('/api/services') def api_services(): """API endpoint for running services""" # Remove auth for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 # Get running services on common ports services = [] common_ports = [80, 443, 3000, 5001, 5055, 8080, 8081, 8383, 8833, 8834, 8989, 9117] for port in common_ports: try: result = subprocess.run(['lsof', f'-i:{port}'], capture_output=True, text=True, timeout=5) if result.returncode == 0 and result.stdout: lines = result.stdout.strip().split('\n')[1:] # Skip header for line in lines: parts = line.split() if len(parts) >= 2: services.append({ 'port': port, 'command': parts[0], 'pid': parts[1], 'status': 'running' }) except: continue return jsonify(services) @app.route('/api/devices') def api_devices(): """API endpoint for connected devices""" # Remove auth for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 devices = get_connected_devices() # Return the devices dict directly: { devices: [...], total_devices: N, network_stats: {...} } return jsonify(devices) @app.route('/api/files') def api_files(): """API endpoint for file system browsing""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 path = request.args.get('path', '/') # Security: Prevent directory traversal if '..' in path or path.startswith('~'): return jsonify({'error': 'Invalid path'}), 400 # Convert to absolute path if path.startswith('/'): abs_path = path else: abs_path = '/' + path.lstrip('/') try: if not os.path.exists(abs_path): return jsonify({'error': 'Path not found'}), 404 if not os.path.isdir(abs_path): return jsonify({'error': 'Path is not a directory'}), 400 files = [] for item in os.listdir(abs_path): item_path = os.path.join(abs_path, item) try: stat_info = os.stat(item_path) is_directory = os.path.isdir(item_path) files.append({ 'name': item, 'size': stat_info.st_size, 'modified': datetime.fromtimestamp(stat_info.st_mtime).isoformat(), 'is_directory': is_directory, 'permissions': oct(stat_info.st_mode)[-3:] }) except (OSError, PermissionError): # Skip files we can't access continue # Sort: directories first, then files, both alphabetically files.sort(key=lambda x: (not x['is_directory'], x['name'].lower())) return jsonify(files) except PermissionError: return jsonify({'error': 'Permission denied'}), 403 except Exception as e: logger.error(f"Error browsing files: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/files/download') def api_file_download(): """API endpoint for file downloads""" # Remove auth for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 file_path = request.args.get('path') if not file_path: return jsonify({'error': 'No file path provided'}), 400 # Security: Prevent directory traversal if '..' in file_path: return jsonify({'error': 'Invalid file path'}), 400 try: if not os.path.exists(file_path) or not os.path.isfile(file_path): return jsonify({'error': 'File not found'}), 404 return send_file(file_path, as_attachment=True) except PermissionError: return jsonify({'error': 'Permission denied'}), 403 except Exception as e: logger.error(f"Error downloading file: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/files/preview') def api_files_preview(): """Preview a file""" try: file_path = request.args.get('path') if not file_path or '..' in file_path: return jsonify({'error': 'Invalid path'}), 400 if not os.path.exists(file_path): return jsonify({'error': 'File not found'}), 404 if os.path.isdir(file_path): return jsonify({'error': 'Cannot preview directory'}), 400 # Get file info stat_info = os.stat(file_path) file_size = stat_info.st_size # Get file extension _, ext = os.path.splitext(file_path) ext = ext.lower().lstrip('.') # Get MIME type mime_type, _ = mimetypes.guess_type(file_path) # Handle special file types if ext in ['cr3', 'cr2', 'nef', 'arw', 'dng', 'raw']: # Canon RAW and other RAW formats - return as download since browsers can't display them return jsonify({ 'error': 'RAW files require specialized software to view', 'download_url': f'/api/files/download?path={file_path}', 'file_type': 'raw_image', 'suggestion': 'Download and open with Adobe Lightroom, Capture One, or similar RAW processor' }), 415 if ext in ['docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt']: # Office documents - return as download return jsonify({ 'error': 'Office documents cannot be previewed in browser', 'download_url': f'/api/files/download?path={file_path}', 'file_type': 'office_document', 'suggestion': 'Download and open with Microsoft Office or compatible application' }), 415 # Set default MIME type if not detected if not mime_type: if ext in ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma']: mime_type = f'audio/{ext}' if ext in ['mp3', 'wav', 'ogg'] else 'audio/mpeg' elif ext in ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', '3gp']: mime_type = f'video/{ext}' if ext in ['mp4', 'webm'] else 'video/quicktime' else: mime_type = 'application/octet-stream' # Handle range requests for media files range_header = request.headers.get('Range') if range_header and mime_type.startswith(('video/', 'audio/')): # Parse range header range_match = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_match: start = int(range_match.group(1)) end = int(range_match.group(2)) if range_match.group(2) else file_size - 1 def generate(): with open(file_path, 'rb') as f: f.seek(start) remaining = end - start + 1 while remaining: chunk_size = min(8192, remaining) chunk = f.read(chunk_size) if not chunk: break remaining -= len(chunk) yield chunk response = Response(generate(), 206, mimetype=mime_type) response.headers['Content-Range'] = f'bytes {start}-{end}/{file_size}' response.headers['Accept-Ranges'] = 'bytes' response.headers['Content-Length'] = str(end - start + 1) response.headers['Cache-Control'] = 'no-cache' return response # For audio files, ensure proper headers for streaming if mime_type.startswith('audio/'): response = send_file(file_path, mimetype=mime_type) response.headers['Accept-Ranges'] = 'bytes' response.headers['Cache-Control'] = 'no-cache' return response # For regular files, return the content return send_file(file_path, mimetype=mime_type) except Exception as e: logger.error(f"Error previewing file: {e}") return jsonify({'error': f'Failed to preview file: {str(e)}'}), 500 @app.route('/api/files/volumes') def api_files_volumes(): """API endpoint to get available volumes""" # Remove authentication requirement for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 try: volumes = [] # Check /Volumes directory (macOS standard mount point) volumes_dir = '/Volumes' if os.path.exists(volumes_dir): for item in os.listdir(volumes_dir): volume_path = os.path.join(volumes_dir, item) if os.path.isdir(volume_path): try: # Get volume info stat_info = os.statvfs(volume_path) total = stat_info.f_blocks * stat_info.f_frsize free = stat_info.f_bavail * stat_info.f_frsize used = total - free volumes.append({ 'name': item, 'path': volume_path, 'total': total, 'used': used, 'free': free, 'type': 'volume' }) except (OSError, PermissionError): # Add volume even if we can't get stats volumes.append({ 'name': item, 'path': volume_path, 'total': 0, 'used': 0, 'free': 0, 'type': 'volume' }) return jsonify({'volumes': volumes}) except Exception as e: logger.error(f"Error getting volumes: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/files/browse') def api_files_browse(): """API endpoint for browsing files in a specific directory""" # Remove authentication requirement for development # if 'user' not in session: # return jsonify({'error': 'Unauthorized'}), 401 path = request.args.get('path', '/') # Default to /Volumes when root requested on macOS-like environment if path in ('', '/'): if os.path.exists('/Volumes'): path = '/Volumes' # Security: Prevent directory traversal if '..' in path or path.find('..') != -1: return jsonify({'error': 'Invalid path'}), 400 try: if not os.path.exists(path): return jsonify({'error': 'Path not found'}), 404 if not os.path.isdir(path): return jsonify({'error': 'Path is not a directory'}), 400 import mimetypes items = [] for item in os.listdir(path): item_path = os.path.join(path, item) try: stat_info = os.stat(item_path) is_directory = os.path.isdir(item_path) ext = '' mime = None if not is_directory: _, ext = os.path.splitext(item) ext = ext.lower().lstrip('.') mime, _ = mimetypes.guess_type(item_path) items.append({ 'name': item, 'size': stat_info.st_size if not is_directory else 0, 'modified': datetime.fromtimestamp(stat_info.st_mtime).isoformat(), 'is_directory': is_directory, 'type': 'directory' if is_directory else 'file', 'path': item_path, 'permissions': oct(stat_info.st_mode)[-3:], 'extension': ext, 'mime': mime, 'previewable': (not is_directory) and ( (mime or '').startswith(('image/', 'video/', 'audio/', 'text/')) or ext in ['jpg','jpeg','png','gif','webp','svg','bmp','heic','heif','tiff','tif','cr3','cr2','nef','arw','dng','raw', 'pdf','md','txt','log','json','yaml','yml','csv','docx','doc','xlsx','xls','pptx','ppt', 'mp3','wav','flac','aac','m4a','ogg','wma','mp4','mov','avi','mkv','webm','m4v','3gp'] ) }) except (OSError, PermissionError): # Skip files we can't access continue # Sort: directories first, then files, both alphabetically items.sort(key=lambda x: (not x['is_directory'], x['name'].lower())) # Calculate parent path parent_path = None if path != '/': parent_path = os.path.dirname(path) if os.path.dirname(path) != path else '/' return jsonify({ 'current_path': path, 'parent_path': parent_path, 'items': items }) except PermissionError: return jsonify({'error': 'Permission denied'}), 403 except Exception as e: logger.error(f"Error browsing directory: {e}") return jsonify({'error': str(e)}), 500 if __name__ == '__main__': print(f"🚀 Starting SamCloud Dashboard on http://localhost:{config.PORT}") print(f"📁 Static files: {config.STATIC_DIR}") print(f"📄 Templates: {config.TEMPLATES_DIR}") app.run( host=config.HOST, port=config.PORT, debug=config.DEBUG, threaded=True, use_reloader=config.DEBUG # Enable auto-reload in debug mode )