"""Admin API routes for AnchorHost.""" import bcrypt from database_service import database_connection, dict_from_row from domain_runtime import domain_manager from flask import Blueprint, jsonify, request import json from kubernetes import client from kubernetes.client.rest import ApiException from kubernetes_service import custom_objects_api, k8s_connected, k8s_manager, v1 from runtime_config import ( DATABASE_URL, logger, ) from server_billing import charge_monthly_fees, get_user_credit_history, get_user_monthly_fees from server_capacity import parse_resource_to_mebibytes, parse_resource_to_millicores, parse_storage_to_gb from server_lifecycle import delete_server, restart_server, start_server, stop_server from server_records import get_server_by_id, get_user_servers from showcase_service import _is_valid_http_url, _normalize_showcase_url, get_homepage_showcase_links, save_homepage_showcase_links from user_service import authenticate_user, get_bearer_token, get_panel_settings, get_pricing, require_admin, set_site_setting import uuid __all__ = ['admin_ban_user', 'admin_delete_server', 'admin_delete_user', 'admin_disable_user_2fa', 'admin_get_all_servers', 'admin_get_cluster_nodes', 'admin_get_stats', 'admin_get_user_billing_history', 'admin_get_user_credit_history', 'admin_get_user_servers', 'admin_get_users', 'admin_homepage_showcase', 'admin_panel_settings', 'admin_pricing', 'admin_reset_user_password', 'admin_restart_server', 'admin_start_server', 'admin_stop_server', 'admin_unban_user', 'admin_update_credits', 'create_announcement', 'debug_domains', 'debug_k8s', 'debug_servers', 'debug_users', 'delete_announcement', 'list_namespaces', 'run_manual_billing', 'update_announcement'] bp = Blueprint("admin", __name__) @bp.route('/api/admin/billing/run-manual', methods=['POST']) def run_manual_billing(): """Run manual billing collection (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 result = charge_monthly_fees() return jsonify(result), 200 except Exception as e: logger.error(f"Error running manual billing: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users', methods=['GET']) def admin_get_users(): """Get all users (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 with database_connection() as conn: cursor = conn.cursor() # Get all users cursor.execute(''' SELECT id, username, email, role, credits, created_at, email_verified, last_login, login_count FROM users ORDER BY created_at DESC ''') users = [] for row in cursor.fetchall(): user_dict = dict_from_row(row) users.append({ 'id': user_dict['id'], 'username': user_dict['username'], 'email': user_dict['email'], 'role': user_dict['role'], 'credits': user_dict['credits'], 'created_at': user_dict['created_at'], 'email_verified': bool(user_dict['email_verified']), 'last_login': user_dict['last_login'], 'login_count': user_dict['login_count'] }) return jsonify({'success': True, 'users': users}), 200 except Exception as e: logger.error(f"Error getting users: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//ban', methods=['POST']) def admin_ban_user(user_id): """Ban a user (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 if user['id'] == user_id: return jsonify({'success': False, 'error': 'Cannot ban yourself'}), 400 with database_connection() as conn: cursor = conn.cursor() cursor.execute('UPDATE users SET role = ? WHERE id = ?', ('banned', user_id)) conn.commit() logger.info(f"User {user_id} banned by admin {user['username']}") return jsonify({'success': True, 'message': 'User banned'}), 200 except Exception as e: logger.error(f"Error banning user: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//unban', methods=['POST']) def admin_unban_user(user_id): """Unban a user (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 with database_connection() as conn: cursor = conn.cursor() cursor.execute('UPDATE users SET role = ? WHERE id = ?', ('user', user_id)) conn.commit() logger.info(f"User {user_id} unbanned by admin {user['username']}") return jsonify({'success': True, 'message': 'User unbanned'}), 200 except Exception as e: logger.error(f"Error unbanning user: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//credits', methods=['POST']) def admin_update_credits(user_id): """Update user credits (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 data = request.get_json() amount = data.get('amount') operation = data.get('operation', 'add') # add, set, subtract if amount is None: return jsonify({'success': False, 'error': 'Amount required'}), 400 try: amount = int(amount) except ValueError: return jsonify({'success': False, 'error': 'Amount must be an integer'}), 400 with database_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT credits, username FROM users WHERE id = ?', (user_id,)) current_user = dict_from_row(cursor.fetchone()) if not current_user: return jsonify({'success': False, 'error': 'User not found'}), 404 current_credits = current_user['credits'] new_credits = current_credits if operation == 'add': new_credits += amount elif operation == 'subtract': new_credits -= amount elif operation == 'set': new_credits = amount cursor.execute('UPDATE users SET credits = ? WHERE id = ?', (new_credits, user_id)) conn.commit() logger.info(f"Credits updated for user {current_user['username']} by admin {user['username']}: {current_credits} -> {new_credits}") return jsonify({'success': True, 'message': 'Credits updated', 'new_credits': new_credits}), 200 except Exception as e: logger.error(f"Error updating credits: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//servers', methods=['GET']) def admin_get_user_servers(user_id): """Get servers for a specific user (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 servers = get_user_servers(user_id) return jsonify({'success': True, 'servers': servers}), 200 except Exception as e: logger.error(f"Error getting user servers: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//credits/history', methods=['GET']) def admin_get_user_credit_history(user_id): """Get credit transaction history for a specific user (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 history = get_user_credit_history(user_id, limit=50) # Use existing function return jsonify({'success': True, 'history': history}), 200 except Exception as e: logger.error(f"Error getting user credit history: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//billing/history', methods=['GET']) def admin_get_user_billing_history(user_id): """Get billing history for a specific user (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 history = get_user_monthly_fees(user_id, limit=50) # Use existing function return jsonify({'success': True, 'history': history}), 200 except Exception as e: logger.error(f"Error getting user billing history: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/servers//start', methods=['POST']) def admin_start_server(server_id): """Start a server (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 # Get server to find owner server = get_server_by_id(server_id) if not server: return jsonify({'success': False, 'error': 'Server not found'}), 404 if start_server(server_id, server['owner_id']): return jsonify({'success': True, 'message': 'Server started'}), 200 return jsonify({'success': False, 'error': 'Failed to start server'}), 500 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/servers//stop', methods=['POST']) def admin_stop_server(server_id): """Stop a server (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 # Get server to find owner server = get_server_by_id(server_id) if not server: return jsonify({'success': False, 'error': 'Server not found'}), 404 if stop_server(server_id, server['owner_id']): return jsonify({'success': True, 'message': 'Server stopped'}), 200 return jsonify({'success': False, 'error': 'Failed to stop server'}), 500 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/servers//restart', methods=['POST']) def admin_restart_server(server_id): """Restart a server (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 # Get server to find owner server = get_server_by_id(server_id) if not server: return jsonify({'success': False, 'error': 'Server not found'}), 404 if restart_server(server_id, server['owner_id']): return jsonify({'success': True, 'message': 'Server restarted'}), 200 return jsonify({'success': False, 'error': 'Failed to restart server'}), 500 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/servers/', methods=['DELETE']) def admin_delete_server(server_id): """Delete a server (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 # Get server to find owner server = get_server_by_id(server_id) if not server: return jsonify({'success': False, 'error': 'Server not found'}), 404 if delete_server(server_id, server['owner_id']): return jsonify({'success': True, 'message': 'Server deleted'}), 200 return jsonify({'success': False, 'error': 'Failed to delete server'}), 500 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/servers', methods=['GET']) def admin_get_all_servers(): """Get all servers with owner details (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT s.id, s.name, s.cpu, s.ram, s.storage, s.image, s.type, s.version, s.loader, s.java_version, s.status, s.ports, s.created_at, u.username as owner, u.id as owner_id, s.sftp_username, s.sftp_password, s.sftp_port FROM servers s JOIN users u ON s.owner_id = u.id ORDER BY u.username, s.name ''') servers = [dict_from_row(row) for row in cursor.fetchall()] return jsonify({'success': True, 'servers': servers}), 200 except Exception as e: logger.error(f"Error getting all servers: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/stats', methods=['GET']) def admin_get_stats(): """Get platform stats (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 with database_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT COUNT(*) as count FROM users') res = dict_from_row(cursor.fetchone()) total_users = res['count'] if res else 0 cursor.execute('SELECT COUNT(*) as count FROM servers') res = dict_from_row(cursor.fetchone()) total_servers = res['count'] if res else 0 cursor.execute("SELECT COUNT(*) as count FROM servers WHERE status = 'Running'") res = dict_from_row(cursor.fetchone()) active_servers = res['count'] if res else 0 cursor.execute('SELECT SUM(credits) as total FROM users') res = dict_from_row(cursor.fetchone()) total_credits = res['total'] if res and res['total'] is not None else 0 stats = { 'total_users': total_users, 'total_servers': total_servers, 'active_servers': active_servers, 'total_credits': total_credits } return jsonify({'success': True, 'stats': stats}), 200 except Exception as e: logger.error(f"Error getting stats: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users/', methods=['DELETE']) def admin_delete_user(user_id): """Delete a user (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 with database_connection() as conn: cursor = conn.cursor() # Check if user exists cursor.execute('SELECT username FROM users WHERE id = ?', (user_id,)) target_user = cursor.fetchone() if not target_user: return jsonify({'success': False, 'error': 'User not found'}), 404 # Delete user cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) conn.commit() logger.info(f"User {user_id} deleted by admin {user['username']}") return jsonify({'success': True}), 200 except Exception as e: logger.error(f"Error deleting user: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//password', methods=['POST']) def admin_reset_user_password(user_id): """Reset user password (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 data = request.json new_password = data.get('password') if not new_password or len(new_password) < 6: return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400 hashed_password = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') with database_connection() as conn: cursor = conn.cursor() cursor.execute('UPDATE users SET password_hash = ?, password_changed_at = CURRENT_TIMESTAMP WHERE id = ?', (hashed_password, user_id)) conn.commit() logger.info(f"Password reset for user {user_id} by admin {user['username']}") return jsonify({'success': True}), 200 except Exception as e: logger.error(f"Error resetting password: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/users//2fa/disable', methods=['POST']) def admin_disable_user_2fa(user_id): """Disable user 2FA (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 with database_connection() as conn: cursor = conn.cursor() cursor.execute('UPDATE users SET two_factor_enabled = FALSE, two_factor_secret = NULL WHERE id = ?', (user_id,)) conn.commit() logger.info(f"2FA disabled for user {user_id} by admin {user['username']}") return jsonify({'success': True}), 200 except Exception as e: logger.error(f"Error disabling 2FA: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/cluster/nodes', methods=['GET']) def admin_get_cluster_nodes(): """Get detailed cluster node stats (admin only)""" try: token = get_bearer_token() user = authenticate_user(token) if not user or user.get('role') != 'admin': return jsonify({'success': False, 'error': 'Admin access required'}), 403 if not k8s_connected or not v1: return jsonify({'success': False, 'error': 'Kubernetes not connected'}), 503 nodes = v1.list_node() node_stats = [] # Try to get metrics if available metrics = {} try: # Use global custom_objects_api if available, else try to instantiate api_instance = custom_objects_api if custom_objects_api else client.CustomObjectsApi() metrics_data = api_instance.list_cluster_custom_object( group="metrics.k8s.io", version="v1beta1", plural="nodes" ) for item in metrics_data.get('items', []): metrics[item['metadata']['name']] = item['usage'] except Exception as e: logger.warning(f"Could not fetch node metrics: {e}") # Get pod allocations for allocated stats all_pods = v1.list_pod_for_all_namespaces() pod_allocations = {} for pod in all_pods.items: if pod.spec.node_name and pod.status.phase in ['Running', 'Pending']: node_name = pod.spec.node_name if node_name not in pod_allocations: pod_allocations[node_name] = {'cpu': 0, 'memory': 0, 'storage': 0.0} for container in pod.spec.containers: if container.resources and container.resources.requests: if 'cpu' in container.resources.requests: pod_allocations[node_name]['cpu'] += parse_resource_to_millicores(container.resources.requests['cpu']) if 'memory' in container.resources.requests: pod_allocations[node_name]['memory'] += parse_resource_to_mebibytes(container.resources.requests['memory']) if 'ephemeral-storage' in container.resources.requests: pod_allocations[node_name]['storage'] += parse_storage_to_gb(container.resources.requests['ephemeral-storage']) for node in nodes.items: name = node.metadata.name # Specs allocatable = node.status.allocatable or {} capacity = node.status.capacity or {} node_info = node.status.node_info cpu_allocatable = parse_resource_to_millicores(allocatable.get('cpu', '0')) memory_allocatable = parse_resource_to_mebibytes(allocatable.get('memory', '0')) storage_capacity = parse_storage_to_gb(capacity.get('ephemeral-storage', '0Gi')) storage_allocatable = parse_storage_to_gb(allocatable.get('ephemeral-storage', capacity.get('ephemeral-storage', '0Gi'))) # Roles labels = node.metadata.labels or {} roles = [] if 'node-role.kubernetes.io/control-plane' in labels or 'node-role.kubernetes.io/master' in labels: roles.append('Control Plane') else: roles.append('Worker') # OS Info os_image = node_info.os_image kernel_version = node_info.kernel_version # Extract CPU Model (if available from labels) cpu_model = "Unknown CPU" if 'cpu-model.node.kubevirt.io/Haswell-noTSX' in labels: cpu_model = "Intel Haswell" elif 'cpu-model.node.kubevirt.io/Nehalem' in labels: cpu_model = "Intel Nehalem" elif any(k.startswith('cpu-model.node.kubevirt.io/') for k in labels): cpu_model = [k.split('/')[-1] for k in labels if k.startswith('cpu-model.node.kubevirt.io/')][0] # Fallback if vendor is known if cpu_model == "Unknown CPU": if 'cpu-vendor.node.kubevirt.io/Intel' in labels: cpu_model = "Intel CPU" elif 'cpu-vendor.node.kubevirt.io/AMD' in labels: cpu_model = "AMD CPU" # Allocated allocated = pod_allocations.get(name, {'cpu': 0, 'memory': 0, 'storage': 0.0}) storage_allocated = allocated.get('storage', 0.0) disk_alloc_percent = round((storage_allocated / storage_allocatable * 100), 1) if storage_allocatable > 0 else 0.0 # Usage (from metrics if available) usage = {'cpu': 0, 'memory': 0} if name in metrics: usage['cpu'] = parse_resource_to_millicores(metrics[name].get('cpu', '0')) usage['memory'] = parse_resource_to_mebibytes(metrics[name].get('memory', '0')) node_stats.append({ 'name': name, 'status': 'Ready' if any(c.type == 'Ready' and c.status == 'True' for c in node.status.conditions) else 'NotReady', 'role': roles[0], 'os': { 'name': node_info.operating_system, 'image': os_image, 'kernel': kernel_version, 'arch': node_info.architecture, 'icon': 'linux' if node_info.operating_system.lower() == 'linux' else 'windows' }, 'specs': { 'cpu': f"{int(cpu_allocatable/1000)} Cores", 'cpu_model': cpu_model, 'ram': f"{memory_allocatable/1024:.1f} GB", 'disk': f"{storage_capacity:.1f} GB" }, 'usage': { 'cpu': f"{usage['cpu']/1000:.2f} Cores", 'cpu_display': f"{usage['cpu']/1000:.2f} Cores" if usage['cpu'] > 0 else "0.00 Cores", 'cpu_percent': round(usage['cpu'] / cpu_allocatable * 100, 1) if cpu_allocatable > 0 else 0, 'ram': f"{usage['memory']/1024:.2f} GB", 'ram_display': f"{usage['memory']/1024:.2f} GB" if usage['memory'] > 0 else "0.00 GB", 'ram_percent': round(usage['memory'] / memory_allocatable * 100, 1) if memory_allocatable > 0 else 0 }, 'allocated': { 'cpu': f"{allocated['cpu']/1000:.2f} Cores", 'cpu_display': f"{allocated['cpu']/1000:.2f} Cores", 'cpu_percent': round(allocated['cpu'] / cpu_allocatable * 100, 1) if cpu_allocatable > 0 else 0, 'ram': f"{allocated['memory']/1024:.2f} GB", 'ram_display': f"{allocated['memory']/1024:.2f} GB", 'ram_percent': round(allocated['memory'] / memory_allocatable * 100, 1) if memory_allocatable > 0 else 0, 'disk': f"{storage_allocated:.2f} GB", 'disk_display': f"{storage_allocated:.2f} GB / {storage_capacity:.1f} GB", 'disk_percent': disk_alloc_percent } }) return jsonify({'success': True, 'nodes': node_stats}), 200 except Exception as e: logger.error(f"Error getting cluster nodes: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/debug/users', methods=['GET']) def debug_users(): if not require_admin(): return jsonify({'error': 'Unauthorized'}), 403 try: with database_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT id, username, email, role, credits, created_at FROM users') users_list = [dict_from_row(row) for row in cursor.fetchall()] return jsonify({ 'users': users_list, 'count': len(users_list), 'kubernetes': { 'connected': k8s_manager.connected, 'lastError': str(k8s_manager.last_error) if k8s_manager.last_error else None } }), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @bp.route('/debug/servers', methods=['GET']) def debug_servers(): if not require_admin(): return jsonify({'error': 'Unauthorized'}), 403 try: with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT id, name, type, status, cpu, ram, storage, ip, ports, owner_id, namespace, k8s_name, is_demo, demo_expires_at, created_at FROM servers ''') servers_list = [dict_from_row(row) for row in cursor.fetchall()] for server in servers_list: if server and 'ports' in server: server['ports'] = json.loads(server['ports']) return jsonify({ 'servers': servers_list, 'count': len(servers_list) }), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @bp.route('/debug/domains', methods=['GET']) def debug_domains(): if not require_admin(): return jsonify({'error': 'Unauthorized'}), 403 try: with database_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT * FROM user_domains') domains_list = [dict_from_row(row) for row in cursor.fetchall()] return jsonify({ 'domains': domains_list, 'count': len(domains_list), 'domain_manager': 'available' if domain_manager else 'unavailable' }), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @bp.route('/debug/k8s', methods=['GET']) def debug_k8s(): if not require_admin(): return jsonify({'error': 'Unauthorized'}), 403 return jsonify({ 'connected': k8s_manager.connected, 'lastError': str(k8s_manager.last_error) if k8s_manager.last_error else None }), 200 @bp.route('/namespaces', methods=['GET']) def list_namespaces(): if not require_admin(): return jsonify({'error': 'Unauthorized'}), 403 try: if k8s_manager.connected: namespaces = v1.list_namespace() namespace_list = [ns.metadata.name for ns in namespaces.items] return jsonify({ 'success': True, 'namespaces': namespace_list, 'count': len(namespace_list) }), 200 else: return jsonify({ 'success': True, 'namespaces': ['user-testuser'], 'count': 1, 'message': 'Kubernetes not connected' }), 200 except ApiException as e: return jsonify({ 'success': False, 'error': f'Failed to list namespaces: {e.reason}', 'status': e.status }), e.status @bp.route('/api/admin/announcements', methods=['POST']) def create_announcement(): """Create a new announcement (Admin only)""" try: user = require_admin() if not user: return jsonify({'success': False, 'error': 'Unauthorized'}), 403 data = request.json if not data or not data.get('title') or not data.get('content'): return jsonify({'success': False, 'error': 'Title and content are required'}), 400 title = data['title'] content = data['content'] tags = data.get('tags', []) # Ensure tags is a list if isinstance(tags, str): tags = [tags] # Serialize tags to JSON for storage tags_json = json.dumps(tags) announcement_id = f"ann-{str(uuid.uuid4())[:8]}" with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' INSERT INTO announcements (id, title, content, tags, author_id) VALUES (?, ?, ?, ?, ?) ''', (announcement_id, title, content, tags_json, user['id'])) conn.commit() # Fetch the created announcement to return it cursor.execute(''' SELECT a.*, u.username as author_name FROM announcements a JOIN users u ON a.author_id = u.id WHERE a.id = ? ''', (announcement_id,)) new_announcement = dict_from_row(cursor.fetchone()) if new_announcement and new_announcement.get('tags'): try: new_announcement['tags'] = json.loads(new_announcement['tags']) except: new_announcement['tags'] = [] return jsonify({'success': True, 'announcement': new_announcement}) except Exception as e: logger.error(f"Error creating announcement: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/announcements/', methods=['PUT']) def update_announcement(announcement_id): """Update an announcement (Admin only)""" try: user = require_admin() if not user: return jsonify({'success': False, 'error': 'Unauthorized'}), 403 data = request.json updates = [] params = [] if 'title' in data: updates.append("title = ?") params.append(data['title']) if 'content' in data: updates.append("content = ?") params.append(data['content']) if 'tags' in data: tags = data['tags'] if isinstance(tags, str): tags = [tags] updates.append("tags = ?") params.append(json.dumps(tags)) if not updates: return jsonify({'success': False, 'error': 'No fields to update'}), 400 updates.append("updated_at = CURRENT_TIMESTAMP") params.append(announcement_id) with database_connection() as conn: cursor = conn.cursor() cursor.execute(f''' UPDATE announcements SET {', '.join(updates)} WHERE id = ? ''', params) conn.commit() if cursor.rowcount == 0: return jsonify({'success': False, 'error': 'Announcement not found'}), 404 return jsonify({'success': True, 'message': 'Announcement updated successfully'}) except Exception as e: logger.error(f"Error updating announcement: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/announcements/', methods=['DELETE']) def delete_announcement(announcement_id): """Delete an announcement (Admin only)""" try: user = require_admin() if not user: return jsonify({'success': False, 'error': 'Unauthorized'}), 403 with database_connection() as conn: cursor = conn.cursor() cursor.execute('DELETE FROM announcements WHERE id = ?', (announcement_id,)) conn.commit() if cursor.rowcount == 0: return jsonify({'success': False, 'error': 'Announcement not found'}), 404 return jsonify({'success': True, 'message': 'Announcement deleted successfully'}) except Exception as e: logger.error(f"Error deleting announcement: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/homepage-showcase', methods=['GET', 'PUT']) def admin_homepage_showcase(): """Read or update homepage showcase links (admin only).""" try: user = require_admin() if not user: return jsonify({'success': False, 'error': 'Unauthorized'}), 403 if request.method == 'GET': return jsonify({'success': True, 'links': get_homepage_showcase_links()}) data = request.json or {} raw_links = data.get('links') if not isinstance(raw_links, list): return jsonify({'success': False, 'error': 'links must be an array'}), 400 normalized = [] for i in range(3): entry = raw_links[i] if i < len(raw_links) and isinstance(raw_links[i], dict) else {} label = (entry.get('label') or f'Site {i + 1}').strip()[:160] url = _normalize_showcase_url(entry.get('url', '')) if not _is_valid_http_url(url): return jsonify({'success': False, 'error': f'Invalid URL for slot {i + 1}'}), 400 normalized.append({"label": label, "url": url}) save_homepage_showcase_links(normalized) return jsonify({'success': True, 'links': normalized}) except Exception as e: logger.error(f"admin_homepage_showcase: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/pricing', methods=['GET', 'POST']) def admin_pricing(): """Read or update service pricing (admin only).""" try: user = require_admin() if not user: return jsonify({'success': False, 'error': 'Unauthorized'}), 403 if request.method == 'GET': return jsonify({'success': True, 'pricing': get_pricing()}) data = request.json or {} with database_connection() as conn: cursor = conn.cursor() for key, value in data.items(): if key.startswith('price_') or key.startswith('multiplier_'): # Skip None values (JSON NaN becomes None) if value is None: continue if 'postgres' in DATABASE_URL: cursor.execute( "INSERT INTO site_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", (key, str(value)) ) else: # Check if key exists cursor.execute("SELECT 1 FROM site_settings WHERE key = ?", (key,)) if cursor.fetchone(): cursor.execute( "UPDATE site_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", (str(value), key) ) else: cursor.execute( "INSERT INTO site_settings (key, value) VALUES (?, ?)", (key, str(value)) ) conn.commit() return jsonify({'success': True, 'pricing': get_pricing()}) except Exception as e: logger.error(f"admin_pricing: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/api/admin/settings', methods=['GET', 'POST', 'PUT']) @bp.route('/api/admin/panel-settings', methods=['GET', 'POST', 'PUT']) def admin_panel_settings(): """Read or update panel settings (admin only).""" try: user = require_admin() if not user: return jsonify({'success': False, 'error': 'Unauthorized'}), 403 if request.method == 'GET': return jsonify({'success': True, 'settings': get_panel_settings()}) data = request.json or {} if 'demo_servers_enabled' in data: demo_val = 'true' if data['demo_servers_enabled'] in (True, 'true', '1', 1) else 'false' set_site_setting('demo_servers_enabled', demo_val) if 'new_user_credits' in data: try: credits_val = max(0, int(float(data['new_user_credits']))) set_site_setting('new_user_credits', str(credits_val)) except (ValueError, TypeError): pass return jsonify({'success': True, 'settings': get_panel_settings()}) except Exception as e: logger.error(f"admin_panel_settings: {e}") return jsonify({'success': False, 'error': str(e)}), 500