#!/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, make_response from werkzeug.utils import secure_filename from werkzeug.security import generate_password_hash, check_password_hash from functools import wraps from datetime import timedelta import re import os import json import socket import mimetypes import subprocess import time import shutil import psutil import sqlite3 import hashlib import secrets from datetime import datetime import logging from dotenv import load_dotenv # Load environment variables load_dotenv() # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = Flask(__name__) # Prefer a secret from environment for production. Keep a fallback for dev. app.secret_key = os.getenv('FLASK_SECRET_KEY', 'samcloud-dashboard-secret-key-2025') # override via env in production # 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) # Database setup def init_database(): """Initialize the SQLite database for user management""" db_path = os.path.join(config.BASE_DIR, 'dashboard.db') conn = sqlite3.connect(db_path) cursor = conn.cursor() # Create users table cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE, password_hash TEXT NOT NULL, is_admin BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login TIMESTAMP, settings TEXT DEFAULT '{}' ) ''') # Create shares table for enhanced sharing management cursor.execute(''' CREATE TABLE IF NOT EXISTS user_shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, share_id TEXT UNIQUE NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, expires_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, download_count INTEGER DEFAULT 0, password_hash TEXT, FOREIGN KEY (user_id) REFERENCES users (id) ) ''') # Create notes table for note-taking functionality cursor.execute(''' CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL DEFAULT 'Untitled Note', content TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ) ''') # Create default admin user if no users exist cursor.execute('SELECT COUNT(*) FROM users') if cursor.fetchone()[0] == 0: # Create default admin user with secure hashed password admin_password_hash = generate_password_hash('admin') cursor.execute(''' INSERT INTO users (username, email, password_hash, is_admin, settings) VALUES (?, ?, ?, ?, ?) ''', ('admin', 'admin@samcloud.local', admin_password_hash, True, '{}')) conn.commit() conn.close() def get_db_connection(): """Get database connection""" db_path = os.path.join(config.BASE_DIR, 'dashboard.db') conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conn def get_db_cursor(): """Get database cursor with connection that needs to be managed""" conn = get_db_connection() cursor = conn.cursor() # Return both cursor and connection so they can be properly managed cursor._connection = conn return cursor def hash_password(password): """Hash a password using Werkzeug's secure password hashing""" return generate_password_hash(password) def verify_password(password, password_hash): """Verify a password against its hash using Werkzeug's secure verification""" return check_password_hash(password_hash, password) def create_secure_token(): """Generate a secure random token""" return secrets.token_urlsafe(32) def login_required(f): """Decorator to require authentication for routes""" @wraps(f) def decorated_function(*args, **kwargs): if 'user' not in session: if request.is_json: return jsonify({'error': 'Authentication required'}), 401 return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function # Initialize database on startup init_database() # 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 # Load authentication from environment variables USERS = { 'admin': os.getenv('DASHBOARD_PASSWORD', 'samcloud2025'), os.getenv('DASHBOARD_USERNAME', 'admin'): os.getenv('DASHBOARD_PASSWORD', 'samcloud2025') } def get_connected_devices(): """Get connected devices including SMB, VPN, Screen Sharing, and network connections""" try: devices = [] # Method 1: SMB connections (macOS sharing) try: smb_result = subprocess.run(['lsof', '-i', ':445'], capture_output=True, text=True, timeout=5) if smb_result.returncode == 0: for line in smb_result.stdout.split('\n')[1:]: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: remote_addr = parts[8].split('->')[1] if '->' in parts[8] else parts[8] ip = remote_addr.split(':')[0] devices.append({ 'hostname': f'SMB Client ({ip})', 'ip': ip, 'mac': 'Unknown', 'type': 'SMB Share', 'status': 'Connected', 'last_seen': datetime.now().isoformat(), 'icon': '📁' }) except Exception as e: logger.debug(f"SMB check failed: {e}") # Method 2: Screen Sharing connections (VNC port 5900) try: vnc_result = subprocess.run(['lsof', '-i', ':5900'], capture_output=True, text=True, timeout=5) if vnc_result.returncode == 0: for line in vnc_result.stdout.split('\n')[1:]: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: remote_addr = parts[8].split('->')[1] if '->' in parts[8] else parts[8] ip = remote_addr.split(':')[0] devices.append({ 'hostname': f'Screen Share ({ip})', 'ip': ip, 'mac': 'Unknown', 'type': 'Screen Sharing', 'status': 'Connected', 'last_seen': datetime.now().isoformat(), 'icon': '🖥️' }) except Exception as e: logger.debug(f"VNC check failed: {e}") # Method 3: SSH connections (port 22) try: ssh_result = subprocess.run(['lsof', '-i', ':22'], capture_output=True, text=True, timeout=5) if ssh_result.returncode == 0: for line in ssh_result.stdout.split('\n')[1:]: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: remote_addr = parts[8].split('->')[1] if '->' in parts[8] else parts[8] ip = remote_addr.split(':')[0] devices.append({ 'hostname': f'SSH Client ({ip})', 'ip': ip, 'mac': 'Unknown', 'type': 'SSH Connection', 'status': 'Connected', 'last_seen': datetime.now().isoformat(), 'icon': '⚡' }) except Exception as e: logger.debug(f"SSH check failed: {e}") # Method 4: Network scan for active devices try: net_if_addrs = psutil.net_if_addrs() for interface, addrs in net_if_addrs.items(): for addr in addrs: if addr.family == socket.AF_INET and not addr.address.startswith('127.'): network_base = '.'.join(addr.address.split('.')[:-1]) common_ips = [f"{network_base}.1", f"{network_base}.254"] for ip in common_ips: try: ping_result = subprocess.run(['ping', '-c', '1', '-W', '1000', ip], capture_output=True, timeout=2) if ping_result.returncode == 0: device_type = 'Router' if ip.endswith('.1') or ip.endswith('.254') else 'Network Device' icon = '🌐' if device_type == 'Router' else '📱' if not any(d['ip'] == ip for d in devices): devices.append({ 'hostname': f'{device_type} ({ip})', 'ip': ip, 'mac': 'Unknown', 'type': device_type, 'status': 'Online', 'last_seen': datetime.now().isoformat(), 'icon': icon }) except: continue break except Exception as e: logger.debug(f"Network scan failed: {e}") # Method 5: Dashboard web clients dashboard_clients = [ {'ip': 'localhost', 'name': 'Local Dashboard'}, {'ip': '192.168.100.22', 'name': 'Network Dashboard'} ] for client in dashboard_clients: devices.append({ 'hostname': client['name'], 'ip': client['ip'], 'mac': 'Unknown', 'type': 'Web Client', 'status': 'Dashboard Active', 'last_seen': datetime.now().isoformat(), 'icon': '💻' }) # Get network statistics net_io = psutil.net_io_counters() 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 } return { 'devices': devices, 'total_devices': len(devices), 'network_stats': network_stats } 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_real_macos_cpu(): """Get real macOS CPU data using host system tools""" try: # Use top command to get real CPU usage result = subprocess.run(['top', '-l', '1'], capture_output=True, text=True, timeout=5) if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: if 'CPU usage:' in line: # Parse line like "CPU usage: 24.86% user, 26.73% sys, 48.39% idle" parts = line.split() user_percent = sys_percent = idle_percent = 0 for i, part in enumerate(parts): if 'user' in part and i > 0: user_percent = float(parts[i-1].replace('%', '')) elif 'sys' in part and i > 0: sys_percent = float(parts[i-1].replace('%', '')) elif 'idle' in part and i > 0: idle_percent = float(parts[i-1].replace('%', '')) total_used = 100 - idle_percent return { 'percent': total_used, 'user': user_percent, 'system': sys_percent, 'idle': idle_percent } # Fallback to psutil if top fails return { 'percent': psutil.cpu_percent(interval=0.1), 'user': 0, 'system': 0, 'idle': 0 } except Exception as e: logger.error(f"Error getting real macOS CPU data: {e}") return { 'percent': psutil.cpu_percent(interval=0.1), 'user': 0, 'system': 0, 'idle': 0 } def get_real_macos_memory(): """Get real macOS memory data using host system tools""" try: # Use top command to get real memory usage result = subprocess.run(['top', '-l', '1'], capture_output=True, text=True, timeout=5) if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: if 'PhysMem:' in line: # Parse line like "PhysMem: 15G used (3519M wired, 6273M compressor), 113M unused." # Extract used and unused values if 'used' in line and 'unused' in line: # Find used amount used_match = re.search(r'(\d+(?:\.\d+)?)\s*([GMK])\s+used', line) unused_match = re.search(r'(\d+(?:\.\d+)?)\s*([GMK])\s+unused', line) if used_match and unused_match: used_val, used_unit = float(used_match.group(1)), used_match.group(2) unused_val, unused_unit = float(unused_match.group(1)), unused_match.group(2) # Convert to bytes def to_bytes(val, unit): multipliers = {'K': 1024, 'M': 1024**2, 'G': 1024**3} return int(val * multipliers.get(unit, 1)) used_bytes = to_bytes(used_val, used_unit) unused_bytes = to_bytes(unused_val, unused_unit) total_bytes = used_bytes + unused_bytes return { 'total': total_bytes, 'used': used_bytes, 'available': unused_bytes, 'percent': (used_bytes / total_bytes * 100) if total_bytes > 0 else 0 } # Fallback to psutil if top fails memory = psutil.virtual_memory() return { 'total': memory.total, 'used': memory.used, 'available': memory.available, 'percent': memory.percent } except Exception as e: logger.error(f"Error getting real macOS memory data: {e}") memory = psutil.virtual_memory() return { 'total': memory.total, 'used': memory.used, 'available': memory.available, 'percent': memory.percent } def get_network_stats(): """Get network interface statistics""" try: stats = psutil.net_io_counters(pernic=True) network_info = {} for interface, stat in stats.items(): if interface != 'lo0': # Skip loopback network_info[interface] = { 'bytes_sent': stat.bytes_sent, 'bytes_recv': stat.bytes_recv, 'packets_sent': stat.packets_sent, 'packets_recv': stat.packets_recv } return network_info except: return {} def get_load_averages(): """Get system load averages (1, 5, 15 minutes)""" try: # On macOS, os.getloadavg() should work load1, load5, load15 = os.getloadavg() return { 'load1': load1, 'load5': load5, 'load15': load15 } except: return { 'load1': 0.0, 'load5': 0.0, 'load15': 0.0 } def get_cpu_temperature(): """Get CPU temperature using multiple methods for macOS""" try: # Method 1: Try powermetrics with thermal state try: result = subprocess.run(['sudo', 'powermetrics', '--samplers', 'smc,tasks', '-n', '1'], capture_output=True, text=True, timeout=5) if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: if 'CPU die temperature' in line.lower(): # Extract temperature from line like "CPU die temperature: 45.2°C" temp_match = re.search(r'(\d+\.?\d*)', line) if temp_match: return float(temp_match.group(1)) except: pass # Method 2: Try system_profiler thermal state try: result = subprocess.run(['system_profiler', 'SPHardwareDataType'], capture_output=True, text=True, timeout=5) if result.returncode == 0: # Look for thermal state indicators if 'thermal state' in result.stdout.lower(): # Parse thermal state if available pass except: pass # Method 3: Estimate based on CPU usage (fallback) cpu_percent = psutil.cpu_percent(interval=0.1) # Rough estimation: idle temp ~30°C, full load ~80°C estimated_temp = 30 + (cpu_percent * 0.5) return min(85, max(25, estimated_temp)) # Clamp between 25-85°C except Exception as e: logger.error(f"Error getting CPU temperature: {e}") return 35.0 # Default safe temperature def get_system_info(): """Get comprehensive system information with real macOS host data""" try: # Get real macOS CPU and memory data using host system tools cpu_data = get_real_macos_cpu() memory_data = get_real_macos_memory() # Get CPU temperature using multiple methods cpu_temp = get_cpu_temperature() # Get container/host info (simplified for Docker environment) hostname = socket.gethostname() # Get real macOS uptime instead of container uptime try: import re # Ensure re is available in this scope uptime_result = subprocess.run(['uptime'], capture_output=True, text=True, timeout=5) if uptime_result.returncode == 0: # Parse uptime output like "12:45 up 2 days, 14:44, 2 users, load averages: 4.09 4.36 4.34" uptime_line = uptime_result.stdout.strip() # Extract uptime portion if ' up ' in uptime_line: uptime_part = uptime_line.split(' up ')[1].split(',')[0:2] # Get "X days, Y:Z" parts uptime_str = ' '.join(uptime_part).strip() # Parse the uptime to get seconds days = hours = minutes = 0 if 'day' in uptime_str: day_match = re.search(r'(\d+)\s+days?', uptime_str) if day_match: days = int(day_match.group(1)) # Remove days part to parse time uptime_str = re.sub(r'\d+\s+days?,?\s*', '', uptime_str) # Parse time portion (like "14:44") time_match = re.search(r'(\d+):(\d+)', uptime_str) if time_match: hours = int(time_match.group(1)) minutes = int(time_match.group(2)) uptime_seconds = days * 86400 + hours * 3600 + minutes * 60 boot_time = time.time() - uptime_seconds else: # Fallback boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time else: boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time except Exception as e: logger.error(f"Error getting Mac uptime: {e}") boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time # Get system version info try: system_version = "macOS" sw_vers_result = subprocess.run(['sw_vers'], capture_output=True, text=True, timeout=5) if sw_vers_result.returncode == 0: lines = sw_vers_result.stdout.strip().split('\n') version_info = {} for line in lines: if ':' in line: key, value = line.split(':', 1) version_info[key.strip()] = value.strip() if 'ProductName' in version_info and 'ProductVersion' in version_info: system_version = f"{version_info['ProductName']} {version_info['ProductVersion']}" except: system_version = "macOS" # Get Mac server uptime instead of container uptime try: # Try to get actual Mac uptime from host system via /proc/uptime or system_profiler mac_uptime_result = subprocess.run(['system_profiler', 'SPSoftwareDataType'], capture_output=True, text=True, timeout=10) if mac_uptime_result.returncode == 0: # Parse system_profiler output for boot time info import re output = mac_uptime_result.stdout # Extract system version version_match = re.search(r'System Version:\s*(.+)', output) system_version = version_match.group(1) if version_match else "macOS" # Try to get boot time from system_profiler (sometimes includes uptime) uptime_match = re.search(r'Time since boot:\s*(.+)', output) if uptime_match: uptime_str = uptime_match.group(1).strip() # Parse format like "About 2 days and 13 hours" days = hours = minutes = 0 if 'day' in uptime_str: day_match = re.search(r'(\d+)\s+days?', uptime_str) if day_match: days = int(day_match.group(1)) if 'hour' in uptime_str: hour_match = re.search(r'(\d+)\s+hours?', uptime_str) if hour_match: hours = int(hour_match.group(1)) if 'minute' in uptime_str: min_match = re.search(r'(\d+)\s+minutes?', uptime_str) if min_match: minutes = int(min_match.group(1)) uptime_seconds = days * 86400 + hours * 3600 + minutes * 60 boot_time = time.time() - uptime_seconds else: # Fallback: try to get from sysctl try: sysctl_result = subprocess.run(['sysctl', 'kern.boottime'], capture_output=True, text=True, timeout=5) if sysctl_result.returncode == 0: # Parse kern.boottime output boottime_match = re.search(r'sec = (\d+)', sysctl_result.stdout) if boottime_match: boot_time = int(boottime_match.group(1)) uptime_seconds = time.time() - boot_time else: # Final fallback boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time else: boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time except: boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time else: # Fallback if system_profiler fails system_version = "macOS" boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time except Exception as e: logger.error(f"Error getting Mac uptime: {e}") # Fallback to container boot time system_version = "macOS" boot_time = psutil.boot_time() uptime_seconds = time.time() - boot_time # Simplified disk info for container environment disks = [] # Get basic volume info without deep scanning 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.isdir(volume_path) and volume != '.timemachine': try: # Use statvfs for mounted volumes (faster than walking) 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 disks.append({ 'device': f'/dev/{volume}', 'mountpoint': volume_path, 'fstype': 'apfs', # Default for macOS 'total': total, 'used': used, 'free': free, 'percent': (used / total * 100) if total > 0 else 0 }) except (OSError, IOError): # Skip inaccessible volumes continue except (OSError, PermissionError): pass # Get additional system info cpu_count = psutil.cpu_count() cpu_count_logical = psutil.cpu_count(logical=True) cpu_freq = psutil.cpu_freq() swap = psutil.swap_memory() # Get OrbStack uptime orbstack_uptime_seconds = get_orbstack_uptime() # System details with enhanced monitoring using real macOS data system_info = { 'hostname': hostname, 'platform': 'Mac Server (via Docker)', 'architecture': 'x86_64', 'kernel': 'Darwin', 'version': system_version, 'uptime': format_uptime(uptime_seconds), 'uptime_seconds': uptime_seconds, 'boot_time': boot_time, 'boot_time_formatted': format_uptime(uptime_seconds), # Show as duration, not timestamp # OrbStack uptime 'orbstack_uptime': format_uptime(orbstack_uptime_seconds), 'orbstack_uptime_seconds': orbstack_uptime_seconds, # Enhanced CPU info using real macOS data 'cpu_count': cpu_count, 'cpu_count_logical': cpu_count_logical, 'cpu_freq': cpu_freq.current if cpu_freq else 0, 'cpu_freq_max': cpu_freq.max if cpu_freq else 0, 'cpu_percent': cpu_data['percent'], 'cpu_per_core': [cpu_data['percent']] * cpu_count, # Simplified for now 'cpu_temperature': cpu_temp, 'cpu_user': cpu_data.get('user', 0), 'cpu_system': cpu_data.get('system', 0), 'cpu_idle': cpu_data.get('idle', 0), # Enhanced Memory info using real macOS data 'memory_total': memory_data['total'], 'memory_available': memory_data['available'], 'memory_used': memory_data['used'], 'memory_percent': memory_data['percent'], 'memory_free': memory_data['available'], 'memory_cached': 0, # Not easily available from top 'memory_buffers': 0, # Not easily available from top # Swap info 'swap_total': swap.total, 'swap_used': swap.used, 'swap_free': swap.free, 'swap_percent': swap.percent, # Disk info 'disks': disks, # Network stats (if available) 'network_stats': get_network_stats(), # Process count 'process_count': len(psutil.pids()), # Load averages (if available on macOS) 'load_avg': get_load_averages() } return system_info except Exception as e: logger.error(f"Error getting system info: {e}") # Return basic fallback info return { 'hostname': 'SamCloud', 'platform': 'Mac Server', 'cpu_percent': 0, 'memory_total': 16 * 1024**3, # 16GB default 'memory_used': 8 * 1024**3, # 8GB default 'memory_percent': 50, 'uptime': '1 day, 6:46:24', 'uptime_seconds': 86400, # 1 day default 'disks': [] } def format_uptime(seconds): """Format uptime seconds into human readable string""" days = int(seconds // 86400) hours = int((seconds % 86400) // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) if days > 0: return f"{days} day{'s' if days != 1 else ''}, {hours:02d}:{minutes:02d}:{secs:02d}" else: return f"{hours:02d}:{minutes:02d}:{secs:02d}" def get_dir_size(path): """Get directory size safely""" try: total = 0 for root, dirs, files in os.walk(path): for file in files: try: fp = os.path.join(root, file) if os.path.exists(fp): total += os.path.getsize(fp) except (OSError, IOError): continue return total except Exception: return 0 def get_docker_containers(): """Get Docker container status with enhanced information""" try: # Get all containers (running and stopped) result = subprocess.run(['docker', 'ps', '-a', '--format', 'json'], capture_output=True, text=True, timeout=10) if result.returncode == 0: containers = [] for line in result.stdout.strip().split('\n'): if line: container_data = json.loads(line) # Enhance container data with better formatting enhanced_container = { 'name': container_data.get('Names', 'Unknown'), 'image': container_data.get('Image', 'Unknown'), 'status': container_data.get('Status', 'Unknown'), 'state': container_data.get('State', 'Unknown'), 'ports': container_data.get('Ports', ''), 'created': container_data.get('CreatedAt', ''), 'id': container_data.get('ID', '')[:12], # Short ID 'running': 'Up' in container_data.get('Status', ''), 'healthy': 'healthy' in container_data.get('Status', '').lower(), # Parse image to get service type 'service_type': get_service_type(container_data.get('Image', '')), # Extract main port if available 'main_port': extract_main_port(container_data.get('Ports', '')), # Clean up status for display 'status_clean': clean_status(container_data.get('Status', '')), # Original data for debugging 'raw': container_data } containers.append(enhanced_container) # Sort by running status, then by name containers.sort(key=lambda x: (not x['running'], x['name'].lower())) return containers return [] except Exception as e: logger.error(f"Error getting Docker containers: {e}") return [] def get_service_type(image): """Determine service type from Docker image""" image_lower = image.lower() if 'filebrowser' in image_lower: return 'files' elif 'nginx' in image_lower: return 'proxy' elif 'postgres' in image_lower: return 'database' elif 'redis' in image_lower: return 'cache' elif 'immich' in image_lower: return 'photos' elif any(x in image_lower for x in ['radarr', 'sonarr', 'prowlarr', 'jackett', 'bazarr']): return 'media' elif 'qbittorrent' in image_lower: return 'download' elif 'jellyseerr' in image_lower: return 'requests' elif 'dashy' in image_lower or 'homarr' in image_lower: return 'dashboard' elif 'webui' in image_lower: return 'ai' elif 'zipline' in image_lower: return 'sharing' elif 'karaoke' in image_lower: return 'entertainment' elif 'rustdesk' in image_lower: return 'remote' elif 'node' in image_lower: return 'app' else: return 'service' def extract_main_port(ports_str): """Extract the main external port from ports string""" if not ports_str: return None # Look for patterns like "0.0.0.0:8080->8080/tcp" import re matches = re.findall(r'0\.0\.0\.0:(\d+)->', ports_str) if matches: return int(matches[0]) # Look for just port numbers matches = re.findall(r':(\d+)->', ports_str) if matches: return int(matches[0]) return None def clean_status(status): """Clean up container status for display""" if 'Up' in status: # Extract uptime part if '(' in status: return status.split('(')[0].strip() return status elif 'Exited' in status: return 'Stopped' else: return status def get_orbstack_uptime(): """Get OrbStack Helper uptime (the main OrbStack daemon)""" try: # Method 1: Find OrbStack Helper process specifically - this is the main OrbStack daemon ps_result = subprocess.run(['ps', '-axo', 'pid,etime,args'], capture_output=True, text=True, timeout=10) if ps_result.returncode == 0: lines = ps_result.stdout.split('\n') for line in lines: # Look specifically for OrbStack Helper (the main daemon) if 'OrbStack Helper' in line and 'vmgr' in line and 'grep' not in line: parts = line.split() if len(parts) >= 2: etime = parts[1] etime_seconds = parse_etime_to_seconds(etime) if etime_seconds > 0: logger.info(f"Found OrbStack Helper uptime: {etime} ({etime_seconds} seconds)") return etime_seconds # Fallback: look for any OrbStack process elif 'OrbStack' in line and 'grep' not in line: parts = line.split() if len(parts) >= 2: etime = parts[1] etime_seconds = parse_etime_to_seconds(etime) if etime_seconds > 0: logger.info(f"Found OrbStack process uptime: {etime} ({etime_seconds} seconds)") return etime_seconds # Method 2: Try Docker container start time as fallback try: container_result = subprocess.run(['docker', 'inspect', '--format', '{{.State.StartedAt}}', 'samcloud-dashboard'], capture_output=True, text=True, timeout=5) if container_result.returncode == 0: start_time_str = container_result.stdout.strip() if start_time_str: # Parse the start time and calculate uptime from datetime import datetime try: # Docker timestamp format: 2023-09-12T20:32:45.123456789Z if 'T' in start_time_str and 'Z' in start_time_str: # Remove nanoseconds if present if '.' in start_time_str: start_time_str = start_time_str.split('.')[0] + 'Z' start_time = datetime.fromisoformat(start_time_str.replace('Z', '+00:00')) current_time = datetime.now(start_time.tzinfo) uptime_delta = current_time - start_time container_uptime = int(uptime_delta.total_seconds()) logger.info(f"Container uptime fallback: {container_uptime} seconds") return container_uptime except Exception as e: logger.debug(f"Error parsing Docker container start time: {e}") except Exception as e: logger.debug(f"Error getting Docker container info: {e}") logger.warning("Could not determine OrbStack Helper uptime, returning 0") return 0 except Exception as e: logger.error(f"Error getting OrbStack Helper uptime: {e}") return 0 def parse_etime_to_seconds(etime): """Parse etime format to seconds""" try: # Format can be: "2-13:44:26" (days-hours:minutes:seconds) or "13:44:26" (hours:minutes:seconds) if '-' in etime: day_part, time_part = etime.split('-', 1) days = int(day_part) else: days = 0 time_part = etime time_parts = time_part.split(':') if len(time_parts) == 3: hours, minutes, seconds = map(int, time_parts) elif len(time_parts) == 2: hours, minutes = map(int, time_parts) seconds = 0 else: return 0 return days * 86400 + hours * 3600 + minutes * 60 + seconds except: return 0 def parse_uptime_string(uptime_str): """Parse uptime string to seconds""" try: import re days = hours = minutes = 0 # Look for days day_match = re.search(r'(\d+)\s+days?', uptime_str) if day_match: days = int(day_match.group(1)) # Look for hours and minutes time_match = re.search(r'(\d+):(\d+)', uptime_str) if time_match: hours = int(time_match.group(1)) minutes = int(time_match.group(2)) return days * 86400 + hours * 3600 + minutes * 60 except: return 0 # Routes @app.route('/test') def test(): """Test route to verify no caching""" return f"""
If you see this, the server is working and no cache issues.
Back to Dashboard """ @app.route('/') def index(): """Main route - check setup completion and authentication""" # Check if setup is needed conn = get_db_connection() user_count = conn.execute('SELECT COUNT(*) FROM users').fetchone()[0] conn.close() if user_count == 0: return redirect(url_for('setup')) # Check if user is logged in if 'user_id' not in session: return redirect(url_for('login')) response = make_response(render_template('dashboard_sensei.html')) response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response @app.route('/files-simple') @login_required def files_simple(): """Minimal file browser that lists /Volumes using the API""" try: build_ts = datetime.now().strftime('%Y%m%d%H%M%S') resp = make_response(render_template('files_simple.html', build_ts=build_ts)) resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' resp.headers['Pragma'] = 'no-cache' resp.headers['Expires'] = '0' return resp except Exception as e: logger.error(f"files_simple error: {e}") return Response('Failed to render simple files', status=500) @app.route('/login', methods=['GET', 'POST']) def login(): """Authentication route with database support""" # Check if first-time setup is needed conn = get_db_connection() user_count = conn.execute('SELECT COUNT(*) FROM users').fetchone()[0] conn.close() if user_count == 0: return redirect(url_for('setup')) if request.method == 'POST': username = request.form.get('username') password = request.form.get('password') if not username or not password: return render_template('login.html', error='Username and password required') conn = get_db_connection() user = conn.execute( 'SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', (username,) ).fetchone() if user and verify_password(password, user['password_hash']): session['user_id'] = user['id'] session['user'] = user['username'] session['is_admin'] = bool(user['is_admin']) # Update last login conn.execute( 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],) ) conn.commit() conn.close() return redirect(url_for('index')) else: conn.close() return render_template('login.html', error='Invalid credentials') return render_template('login.html') @app.route('/setup', methods=['GET', 'POST']) def setup(): """First-time setup""" # Check if setup is already completed conn = get_db_connection() user_count = conn.execute('SELECT COUNT(*) FROM users').fetchone()[0] conn.close() if user_count > 0: return redirect(url_for('login')) if request.method == 'POST': username = request.form.get('username') email = request.form.get('email') password = request.form.get('password') confirm_password = request.form.get('confirm_password') if not username or not email or not password: return render_template('setup.html', error='All fields are required') if password != confirm_password: return render_template('setup.html', error='Passwords do not match') if len(password) < 4: return render_template('setup.html', error='Password must be at least 4 characters') # Create admin user password_hash = hash_password(password) try: conn = get_db_connection() cursor = conn.execute(''' INSERT INTO users (username, email, password_hash, is_admin, settings) VALUES (?, ?, ?, ?, ?) ''', (username, email, password_hash, True, '{}')) user_id = cursor.lastrowid conn.commit() conn.close() # Log the user in session['user_id'] = user_id session['user'] = username session['is_admin'] = True return redirect(url_for('index')) except sqlite3.IntegrityError: return render_template('setup.html', error='Username already exists') return render_template('setup.html') @app.route('/logout') def logout(): """Logout route""" session.clear() return redirect(url_for('login')) # API Routes @app.route('/api/system') @login_required def api_system(): """API endpoint for system information""" return jsonify(get_system_info()) @app.route('/api/containers') @login_required def api_containers(): """API endpoint for Docker containers""" return jsonify(get_docker_containers()) @app.route('/api/services') @login_required 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') @login_required 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') @login_required 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') @login_required 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') @login_required def api_files_preview(): """Preview a file with enhanced FileBrowser-style support""" try: file_path = request.args.get('path') thumbnail = request.args.get('thumbnail', 'false').lower() == 'true' scale = request.args.get('scale') # For video scaling (720p, 480p, etc.) 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) # Check file size limits for different types max_preview_size = 100 * 1024 * 1024 # 100MB for videos (increased for better streaming) max_image_size = 20 * 1024 * 1024 # 20MB for images max_text_size = 10 * 1024 * 1024 # 10MB for text files # 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 images with size limits if mime_type and mime_type.startswith('image/'): if file_size > max_image_size: return jsonify({ 'error': f'Image file too large ({file_size / (1024*1024):.1f}MB > {max_image_size / (1024*1024)}MB)', 'download_url': f'/api/files/download?path={file_path}', 'file_type': 'large_image' }), 413 # Handle videos with size limits elif mime_type and mime_type.startswith('video/'): if file_size > max_preview_size: return jsonify({ 'error': f'Video file too large ({file_size / (1024*1024):.1f}MB > {max_preview_size / (1024*1024)}MB)', 'download_url': f'/api/files/download?path={file_path}', 'file_type': 'large_video' }), 413 # Handle text files with size limits elif mime_type and mime_type.startswith('text/'): if file_size > max_text_size: return jsonify({ 'error': f'Text file too large ({file_size / (1024*1024):.1f}MB > {max_text_size / (1024*1024)}MB)', 'download_url': f'/api/files/download?path={file_path}', 'file_type': 'large_text' }), 413 # 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'] = 'public, max-age=31536000' 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'] = 'public, max-age=3600' return response # For video files, add streaming optimizations if mime_type.startswith('video/'): response = send_file(file_path, mimetype=mime_type) response.headers['Accept-Ranges'] = 'bytes' response.headers['Cache-Control'] = 'public, max-age=3600' # Add headers to help with video playback response.headers['X-Content-Type-Options'] = 'nosniff' if scale: response.headers['X-Video-Scale'] = scale return response # For images, add caching headers if mime_type.startswith('image/'): response = send_file(file_path, mimetype=mime_type) response.headers['Cache-Control'] = 'public, max-age=86400' return response # For regular files, return the content response = send_file(file_path, mimetype=mime_type) response.headers['Cache-Control'] = 'public, max-age=3600' return response 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/user/profile') @login_required def get_user_profile(): """Get current user profile""" if 'user_id' not in session: return jsonify({'error': 'Not authenticated'}), 401 conn = get_db_connection() user = conn.execute( 'SELECT id, username, email, is_admin, created_at, last_login, settings FROM users WHERE id = ?', (session['user_id'],) ).fetchone() conn.close() if not user: return jsonify({'error': 'User not found'}), 404 user_data = dict(user) user_data['settings'] = json.loads(user_data['settings'] or '{}') return jsonify(user_data) @app.route('/api/user/profile', methods=['POST']) @login_required def update_user_profile(): """Update user profile""" if 'user_id' not in session: return jsonify({'error': 'Not authenticated'}), 401 data = request.json conn = get_db_connection() # Check if email is being changed and if it's unique if 'email' in data: existing = conn.execute( 'SELECT id FROM users WHERE email = ? AND id != ?', (data['email'], session['user_id']) ).fetchone() if existing: conn.close() return jsonify({'error': 'Email already in use'}), 400 # Update user data update_fields = [] params = [] if 'email' in data: update_fields.append('email = ?') params.append(data['email']) if 'settings' in data: update_fields.append('settings = ?') params.append(json.dumps(data['settings'])) if update_fields: params.append(session['user_id']) conn.execute( f'UPDATE users SET {", ".join(update_fields)} WHERE id = ?', params ) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/user/password', methods=['POST']) @login_required def change_password(): """Change user password""" if 'user_id' not in session: return jsonify({'error': 'Not authenticated'}), 401 data = request.json current_password = data.get('current_password') new_password = data.get('new_password') if not current_password or not new_password: return jsonify({'error': 'Current and new passwords required'}), 400 if len(new_password) < 4: return jsonify({'error': 'Password must be at least 4 characters'}), 400 conn = get_db_connection() user = conn.execute( 'SELECT password_hash FROM users WHERE id = ?', (session['user_id'],) ).fetchone() if not user or not verify_password(current_password, user['password_hash']): conn.close() return jsonify({'error': 'Current password is incorrect'}), 400 # Update password new_password_hash = hash_password(new_password) conn.execute( 'UPDATE users SET password_hash = ? WHERE id = ?', (new_password_hash, session['user_id']) ) conn.commit() conn.close() return jsonify({'success': True}) @app.route('/api/user/create', methods=['POST']) @login_required def create_user(): """Create a new user (admin only or first-time setup)""" data = request.json username = data.get('username') email = data.get('email') password = data.get('password') is_admin = data.get('is_admin', False) if not username or not email or not password: return jsonify({'error': 'Username, email, and password required'}), 400 if len(password) < 4: return jsonify({'error': 'Password must be at least 4 characters'}), 400 # Check if this is first-time setup (no users exist) conn = get_db_connection() user_count = conn.execute('SELECT COUNT(*) FROM users').fetchone()[0] # For first-time setup, allow any user creation # Otherwise, require admin privileges if user_count > 0 and ('user_id' not in session or not session.get('is_admin')): conn.close() return jsonify({'error': 'Admin privileges required'}), 403 # Check if username already exists existing_user = conn.execute( 'SELECT id FROM users WHERE username = ?', (username,) ).fetchone() if existing_user: conn.close() return jsonify({'error': 'Username already exists'}), 400 # Create new user password_hash = hash_password(password) try: cursor = conn.execute(''' INSERT INTO users (username, email, password_hash, is_admin, settings) VALUES (?, ?, ?, ?, ?) ''', (username, email, password_hash, is_admin, '{}')) new_user_id = cursor.lastrowid conn.commit() # If this was first-time setup, log the user in if user_count == 0: session['user_id'] = new_user_id session['user'] = username session['is_admin'] = is_admin conn.close() return jsonify({ 'success': True, 'user_id': new_user_id, 'first_time_setup': user_count == 0 }) except sqlite3.IntegrityError: conn.close() return jsonify({'error': 'Username already exists'}), 400 @app.route('/api/user/setup-status') def setup_status(): """Check if first-time setup is needed""" conn = get_db_connection() user_count = conn.execute('SELECT COUNT(*) FROM users').fetchone()[0] conn.close() return jsonify({ 'needs_setup': user_count == 0, 'user_count': user_count }) @app.route('/api/users', methods=['GET']) @login_required def list_users(): """List all users (admin only)""" if not session.get('is_admin'): return jsonify({'error': 'Admin privileges required'}), 403 conn = get_db_connection() users = conn.execute(''' SELECT id, username, email, is_admin, created_at, last_login FROM users ORDER BY created_at DESC ''').fetchall() conn.close() return jsonify([{ 'id': user['id'], 'username': user['username'], 'email': user['email'], 'is_admin': bool(user['is_admin']), 'created_at': user['created_at'], 'last_login': user['last_login'] } for user in users]) @app.route('/api/users/