Refactor backend and add registry build workflow
Build and publish Docker image / build-and-publish (push) Successful in 2m45s

This commit is contained in:
wfalkowski committed 2026-10-20 16:44:49 +00:00
commit c177d7981d
43 files changed
+18124

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
"""Blueprint registration for the AnchorHost HTTP API."""
from flask import Flask
from .account import bp as account_blueprint
from .admin import bp as admin_blueprint
from .auth import bp as auth_blueprint
from .billing import bp as billing_blueprint
from .domains import bp as domains_blueprint
from .public import bp as public_blueprint
from .servers import bp as servers_blueprint
from .tickets import bp as tickets_blueprint
BLUEPRINTS = (
account_blueprint,
admin_blueprint,
auth_blueprint,
billing_blueprint,
domains_blueprint,
public_blueprint,
servers_blueprint,
tickets_blueprint,
)
def register_routes(application: Flask) -> None:
"""Register all HTTP API blueprints on the supplied Flask app."""
for blueprint in BLUEPRINTS:
application.register_blueprint(blueprint)
+363
View File
@@ -0,0 +1,363 @@
"""Account API routes for AnchorHost."""
from database_service import database_connection, dict_from_row
from datetime import datetime, timedelta
from email_service import get_email_service
from flask import Blueprint, jsonify, request
from runtime_config import (
logger,
)
from server_lifecycle import delete_server
from user_service import authenticate_user, get_bearer_token, get_user_by_username, hash_password, verify_password
import uuid
__all__ = ['change_user_email', 'change_user_password', 'delete_user_account', 'export_user_data', 'get_user_profile', 'resend_verification', 'update_user_profile']
bp = Blueprint("account", __name__)
@bp.route('/api/user/profile', methods=['GET'])
def get_user_profile():
"""Get user profile data"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
with database_connection() as conn:
cursor = conn.cursor()
# Get full user data including profile fields
cursor.execute('''
SELECT id, username, email, role, credits, created_at, updated_at,
display_name, first_name, last_name, address, city,
country, postal_code, phone, email_verified,
email_verification_token, password_changed_at,
two_factor_enabled, last_login, login_count, google_id, apple_id
FROM users WHERE id = ?
''', (user['id'],))
user_data = dict_from_row(cursor.fetchone())
if user_data:
# Remove sensitive fields
if 'email_verification_token' in user_data:
user_data.pop('email_verification_token')
if 'two_factor_secret' in user_data:
user_data.pop('two_factor_secret', None)
return jsonify({
'success': True,
'user': user_data
}), 200
else:
return jsonify({'success': False, 'error': 'User not found'}), 404
except Exception as e:
logger.error(f"Error getting user profile: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/profile', methods=['PUT'])
def update_user_profile():
"""Update user profile"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
with database_connection() as conn:
cursor = conn.cursor()
# Build update query dynamically based on provided fields
update_fields = []
params = []
allowed_fields = ['display_name', 'first_name', 'last_name', 'address',
'city', 'country', 'postal_code', 'phone']
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = ?")
params.append(data[field])
if not update_fields:
return jsonify({'success': False, 'error': 'No fields to update'}), 400
# Add updated_at
update_fields.append("updated_at = CURRENT_TIMESTAMP")
params.append(user['id'])
query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = ?"
cursor.execute(query, params)
conn.commit()
logger.info(f"✓ Profile updated for user: {user['username']}")
return jsonify({'success': True, 'message': 'Profile updated successfully'}), 200
except Exception as e:
logger.error(f"Error updating profile: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/email', methods=['PUT'])
def change_user_email():
"""Change user email address"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
new_email = data.get('email')
if not new_email:
return jsonify({'success': False, 'error': 'Email required'}), 400
# Validate email format
import re
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', new_email):
return jsonify({'success': False, 'error': 'Invalid email format'}), 400
with database_connection() as conn:
cursor = conn.cursor()
# Check if email is already in use
cursor.execute('SELECT id FROM users WHERE email = ? AND id != ?', (new_email, user['id']))
if cursor.fetchone():
return jsonify({'success': False, 'error': 'Email already in use'}), 400
# Generate new verification token
verification_token = str(uuid.uuid4())
# Update user email and set as unverified
cursor.execute('''
UPDATE users
SET email = ?, email_verified = FALSE, email_verification_token = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (new_email, verification_token, user['id']))
# Create verification token record
token_id = f"token-{str(uuid.uuid4())[:8]}"
expires_at = (datetime.now() + timedelta(hours=24)).isoformat()
cursor.execute('''
INSERT INTO email_verification_tokens (id, user_id, token, expires_at)
VALUES (?, ?, ?, ?)
''', (token_id, user['id'], verification_token, expires_at))
conn.commit()
# Send verification email to new address
email_svc = get_email_service()
if email_svc:
email_svc.send_verification_email(new_email, user['username'], verification_token)
logger.info(f"✓ Email changed for user: {user['username']} -> {new_email}")
return jsonify({
'success': True,
'message': 'Email changed. Please check your new email for verification link.'
}), 200
except Exception as e:
logger.error(f"Error changing email: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/password', methods=['PUT'])
def change_user_password():
"""Change user password"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
current_password = data.get('currentPassword')
new_password = data.get('newPassword')
if not current_password or not new_password:
return jsonify({'success': False, 'error': 'Current and new passwords required'}), 400
# Validate new password length
if len(new_password) < 6:
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
# Verify current password
user_full = get_user_by_username(user['username'])
if not verify_password(current_password, user_full['password_hash']):
return jsonify({'success': False, 'error': 'Current password is incorrect'}), 401
# Hash new password
new_password_hash = hash_password(new_password)
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE users
SET password_hash = ?, password_changed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (new_password_hash, user['id']))
conn.commit()
logger.info(f"✓ Password changed for user: {user['username']}")
return jsonify({'success': True, 'message': 'Password changed successfully'}), 200
except Exception as e:
logger.error(f"Error changing password: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/export-data', methods=['GET'])
def export_user_data():
"""Export all user data"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
with database_connection() as conn:
cursor = conn.cursor()
# Get user data
cursor.execute('SELECT * FROM users WHERE id = ?', (user['id'],))
user_data = dict_from_row(cursor.fetchone())
# Remove sensitive fields
if user_data:
user_data.pop('password_hash', None)
user_data.pop('email_verification_token', None)
user_data.pop('two_factor_secret', None)
# Get servers
cursor.execute('SELECT * FROM servers WHERE owner_id = ?', (user['id'],))
servers = [dict_from_row(row) for row in cursor.fetchall()]
# Get domains
cursor.execute('SELECT * FROM user_domains WHERE user_id = ?', (user['id'],))
domains = [dict_from_row(row) for row in cursor.fetchall()]
# Get tickets
cursor.execute('SELECT * FROM tickets WHERE user_id = ?', (user['id'],))
tickets = [dict_from_row(row) for row in cursor.fetchall()]
# Get credit transactions
cursor.execute('SELECT * FROM credit_transactions WHERE user_id = ?', (user['id'],))
transactions = [dict_from_row(row) for row in cursor.fetchall()]
export_data = {
'user': user_data,
'servers': servers,
'domains': domains,
'tickets': tickets,
'credit_transactions': transactions,
'exported_at': datetime.now().isoformat()
}
logger.info(f"✓ Data exported for user: {user['username']}")
return jsonify({'success': True, 'data': export_data}), 200
except Exception as e:
logger.error(f"Error exporting data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/account', methods=['DELETE'])
def delete_user_account():
"""Delete user account and all associated data"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
confirmation = data.get('confirmation')
if confirmation != 'DELETE MY ACCOUNT':
return jsonify({'success': False, 'error': 'Invalid confirmation text'}), 400
with database_connection() as conn:
cursor = conn.cursor()
# Delete all user's servers (cascade will handle related data)
cursor.execute('SELECT id FROM servers WHERE owner_id = ?', (user['id'],))
server_ids = [row['id'] for row in cursor.fetchall()]
# Delete servers from Kubernetes
for server_id in server_ids:
try:
delete_server(server_id, user['id'])
except Exception as e:
logger.error(f"Error deleting server {server_id}: {e}")
# Delete user (cascade will handle most related data)
cursor.execute('DELETE FROM users WHERE id = ?', (user['id'],))
conn.commit()
logger.info(f"✓ Account deleted for user: {user['username']}")
return jsonify({'success': True, 'message': 'Account deleted successfully'}), 200
except Exception as e:
logger.error(f"Error deleting account: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/resend-verification', methods=['POST'])
def resend_verification():
"""Resend verification email"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
# Check if already verified
if user.get('email_verified'):
return jsonify({'success': False, 'error': 'Email already verified'}), 400
# Generate new token
new_token = str(uuid.uuid4())
with database_connection() as conn:
cursor = conn.cursor()
# Delete old tokens
cursor.execute('DELETE FROM email_verification_tokens WHERE user_id = ?', (user['id'],))
# Create new token
token_id = f"token-{str(uuid.uuid4())[:8]}"
expires_at = (datetime.now() + timedelta(hours=24)).isoformat()
cursor.execute('''
INSERT INTO email_verification_tokens (id, user_id, token, expires_at)
VALUES (?, ?, ?, ?)
''', (token_id, user['id'], new_token, expires_at))
# Update user
cursor.execute('''
UPDATE users
SET email_verification_token = ?
WHERE id = ?
''', (new_token, user['id']))
conn.commit()
# Send email
email_svc = get_email_service()
if email_svc:
success = email_svc.send_verification_email(user['email'], user['username'], new_token)
if success:
return jsonify({'success': True, 'message': 'Verification email sent'}), 200
else:
return jsonify({'success': False, 'error': 'Failed to send email'}), 500
else:
return jsonify({'success': False, 'error': 'Email service unavailable'}), 503
except Exception as e:
logger.error(f"Error resending verification: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
+948
View File
@@ -0,0 +1,948 @@
"""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/<user_id>/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/<user_id>/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/<user_id>/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/<user_id>/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/<user_id>/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/<user_id>/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/<server_id>/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/<server_id>/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/<server_id>/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/<server_id>', 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/<user_id>', 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/<user_id>/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/<user_id>/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/<announcement_id>', 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/<announcement_id>', 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
+602
View File
@@ -0,0 +1,602 @@
"""Auth API routes for AnchorHost."""
import asyncio
from database_service import database_connection, dict_from_row
from datetime import datetime, timezone
from email_service import get_email_service
from flask import Blueprint, jsonify, redirect, request
import json
import jwt
import os
import requests
from runtime_config import (
APPLE_CLIENT_ID,
APPLE_KEY_ID,
APPLE_PRIVATE_KEY,
APPLE_REDIRECT_URI,
APPLE_TEAM_ID,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
connected_clients,
logger,
)
import secrets
import urllib.parse
from user_service import create_user, generate_oauth_state, generate_token, get_user_by_apple_id, get_user_by_email, get_user_by_google_id, verify_oauth_state
from websocket_service import send_message
__all__ = ['apple_callback', 'apple_login', 'get_auth_config', 'google_callback', 'google_login', 'verify_email', 'verify_email_api']
bp = Blueprint("auth", __name__)
@bp.route('/api/auth/google/login', methods=['GET'])
def google_login():
"""Initiate Google OAuth login"""
if not GOOGLE_CLIENT_ID:
return jsonify({'error': 'Google OAuth not configured'}), 500
# Generate state token for CSRF protection
state = generate_oauth_state('google')
# Store state in session or return it to frontend
# For simplicity, we'll include it in the redirect URL
auth_url = (
f"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={GOOGLE_CLIENT_ID}&"
f"redirect_uri={urllib.parse.quote(GOOGLE_REDIRECT_URI)}&"
f"response_type=code&"
f"scope=openid email profile&"
f"state={state}&"
f"access_type=offline&"
f"prompt=consent"
)
return jsonify({'auth_url': auth_url, 'state': state})
@bp.route('/api/auth/google/callback', methods=['GET', 'POST'])
def google_callback():
"""Handle Google OAuth callback"""
try:
code = request.args.get('code')
state = request.args.get('state')
if not verify_oauth_state(state, 'google'):
return jsonify({'error': 'Invalid OAuth state'}), 400
if not code:
return jsonify({'error': 'Authorization code not provided'}), 400
# Exchange authorization code for access token
token_url = 'https://oauth2.googleapis.com/token'
token_data = {
'code': code,
'client_id': GOOGLE_CLIENT_ID,
'client_secret': GOOGLE_CLIENT_SECRET,
'redirect_uri': GOOGLE_REDIRECT_URI,
'grant_type': 'authorization_code'
}
token_response = requests.post(token_url, data=token_data)
token_response.raise_for_status()
tokens = token_response.json()
access_token = tokens.get('access_token')
if not access_token:
return jsonify({'error': 'Failed to obtain access token'}), 400
# Get user info from Google
user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo'
headers = {'Authorization': f'Bearer {access_token}'}
user_info_response = requests.get(user_info_url, headers=headers)
user_info_response.raise_for_status()
google_user = user_info_response.json()
google_id = google_user.get('id')
email = google_user.get('email')
name = google_user.get('name', '')
given_name = google_user.get('given_name', '')
family_name = google_user.get('family_name', '')
picture = google_user.get('picture', '')
verified_email = google_user.get('verified_email', False)
if not google_id or not email:
return jsonify({'error': 'Failed to retrieve user information from Google'}), 400
# Check if user exists by Google ID
user = get_user_by_google_id(google_id)
if not user:
# Check if user exists by email (for linking accounts)
existing_user = get_user_by_email(email)
if existing_user:
# Link Google account to existing user
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE users SET google_id = ?, email_verified = ? WHERE id = ?
''', (google_id, True, existing_user['id']))
conn.commit()
user = get_user_by_google_id(google_id)
else:
# Create new user with Google account
# Generate username from email
username_base = email.split('@')[0]
username = username_base
counter = 1
# Ensure username is unique
with database_connection() as conn:
cursor = conn.cursor()
while True:
cursor.execute('SELECT id FROM users WHERE username = ?', (username,))
if not cursor.fetchone():
break
username = f"{username_base}{counter}"
counter += 1
user = create_user(
username=username,
email=email,
password=None, # No password for OAuth users
google_id=google_id,
display_name=name,
first_name=given_name,
last_name=family_name,
email_verified=verified_email
)
if not user:
return jsonify({'error': 'Failed to create or retrieve user'}), 500
# Update last login
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE users SET last_login = CURRENT_TIMESTAMP,
login_count = login_count + 1,
email_verified = ?
WHERE id = ?
''', (True, user['id']))
conn.commit()
# Generate JWT token
token = generate_token(user['id'])
# Redirect to frontend with token
frontend_url = os.environ.get('FRONTEND_URL', 'https://anchorhost.eu')
redirect_url = f"{frontend_url}/?token={token}&user={urllib.parse.quote(json.dumps(user))}"
# Redirect to frontend
return redirect(redirect_url)
except requests.exceptions.RequestException as e:
logger.error(f"Google OAuth error: {e}")
return jsonify({'error': 'Failed to authenticate with Google'}), 500
except Exception as e:
logger.error(f"Error in Google OAuth callback: {e}", exc_info=True)
return jsonify({'error': 'An error occurred during authentication'}), 500
@bp.route('/api/auth/apple/login', methods=['GET'])
def apple_login():
"""Initiate Apple OAuth login"""
if not APPLE_CLIENT_ID:
return jsonify({'error': 'Apple OAuth not configured'}), 500
# Generate state token for CSRF protection
nonce = secrets.token_urlsafe(32)
state = generate_oauth_state('apple', nonce)
# Store state in session or return it to frontend
# Apple authorization URL
auth_url = (
f"https://appleid.apple.com/auth/authorize?"
f"client_id={APPLE_CLIENT_ID}&"
f"redirect_uri={urllib.parse.quote(APPLE_REDIRECT_URI)}&"
f"response_type=code id_token&"
f"scope=name email&"
f"response_mode=form_post&"
f"nonce={urllib.parse.quote(nonce)}&"
f"state={state}"
)
return jsonify({'auth_url': auth_url, 'state': state})
@bp.route('/api/auth/apple/callback', methods=['POST'])
def apple_callback():
"""Handle Apple OAuth callback"""
try:
# Apple sends data as form_post (application/x-www-form-urlencoded)
code = request.form.get('code')
id_token = request.form.get('id_token')
state = request.form.get('state')
user_json = request.form.get('user') # Only sent on first login
state_payload = verify_oauth_state(state, 'apple')
if not state_payload or not state_payload.get('nonce'):
return jsonify({'error': 'Invalid OAuth state'}), 400
if not id_token:
return jsonify({'error': 'ID token not provided'}), 400
# Validate Apple's signature, issuer, audience, expiry, and state-bound nonce.
try:
signing_key = jwt.PyJWKClient(
'https://appleid.apple.com/auth/keys',
cache_jwk_set=True,
).get_signing_key_from_jwt(id_token).key
decoded_id_token = jwt.decode(
id_token,
signing_key,
algorithms=['RS256'],
audience=APPLE_CLIENT_ID,
issuer='https://appleid.apple.com',
options={'require': ['exp', 'iat', 'sub', 'nonce']},
)
if not secrets.compare_digest(decoded_id_token['nonce'], state_payload['nonce']):
return jsonify({'error': 'Invalid Apple identity token nonce'}), 400
except Exception as e:
logger.warning(f"Apple identity token validation failed: {e}")
return jsonify({'error': 'Invalid ID token'}), 400
apple_id = decoded_id_token.get('sub')
email = decoded_id_token.get('email')
if not apple_id or not email:
return jsonify({'error': 'Failed to retrieve user information from Apple'}), 400
# Parse user info if provided (name is only sent on first login)
first_name = ""
last_name = ""
display_name = ""
if user_json:
try:
user_data = json.loads(user_json)
name_data = user_data.get('name', {})
first_name = name_data.get('firstName', '')
last_name = name_data.get('lastName', '')
if first_name or last_name:
display_name = f"{first_name} {last_name}".strip()
except:
pass
# Check if user exists by Apple ID
user = get_user_by_apple_id(apple_id)
if not user:
# Check if user exists by email (for linking accounts)
existing_user = get_user_by_email(email)
if existing_user:
# Link Apple account to existing user
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE users SET apple_id = ?, email_verified = ? WHERE id = ?
''', (apple_id, True, existing_user['id']))
conn.commit()
user = get_user_by_apple_id(apple_id)
else:
# Create new user with Apple account
# Generate username from email
username_base = email.split('@')[0]
username = username_base
counter = 1
# Ensure username is unique
with database_connection() as conn:
cursor = conn.cursor()
while True:
cursor.execute('SELECT id FROM users WHERE username = ?', (username,))
if not cursor.fetchone():
break
username = f"{username_base}{counter}"
counter += 1
user = create_user(
username=username,
email=email,
password=None, # No password for OAuth users
apple_id=apple_id,
display_name=display_name,
first_name=first_name,
last_name=last_name,
email_verified=True # Apple verified email
)
if not user:
return jsonify({'error': 'Failed to create or retrieve user'}), 500
# Update last login
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE users SET last_login = CURRENT_TIMESTAMP,
login_count = login_count + 1,
email_verified = ?
WHERE id = ?
''', (True, user['id']))
conn.commit()
# Generate JWT token
token = generate_token(user['id'])
# Redirect to frontend with token
frontend_url = os.environ.get('FRONTEND_URL', 'https://anchorhost.eu')
redirect_url = f"{frontend_url}/?token={token}&user={urllib.parse.quote(json.dumps(user))}"
# Redirect to frontend
return redirect(redirect_url)
except Exception as e:
logger.error(f"Error in Apple OAuth callback: {e}", exc_info=True)
return jsonify({'error': 'An error occurred during authentication'}), 500
@bp.route('/verify-email', methods=['GET'])
def verify_email():
"""Email verification page"""
token = request.args.get('token')
if not token:
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verification Failed</title>
<style>
body { font-family: Arial, sans-serif; background: #0f0f0f; color: #cbcbcd; margin: 0; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 40px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
h1 { color: #a83a40; margin: 0 0 20px 0; }
p { color: #8a8a8a; line-height: 1.6; }
.btn { display: inline-block; padding: 12px 30px; background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%); color: #fff; text-decoration: none; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>❌ Verification Failed</h1>
<p>The verification link is invalid or missing.</p>
<a href="/" class="btn">Go to Home</a>
</div>
</body>
</html>
''', 400
try:
with database_connection() as conn:
cursor = conn.cursor()
# Find verification token
cursor.execute('''
SELECT vt.*, u.username, u.email
FROM email_verification_tokens vt
JOIN users u ON vt.user_id = u.id
WHERE vt.token = ?
''', (token,))
token_data = dict_from_row(cursor.fetchone())
if not token_data:
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Invalid Token</title>
<style>
body { font-family: Arial, sans-serif; background: #0f0f0f; color: #cbcbcd; margin: 0; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 40px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
h1 { color: #a83a40; margin: 0 0 20px 0; }
p { color: #8a8a8a; line-height: 1.6; }
.btn { display: inline-block; padding: 12px 30px; background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%); color: #fff; text-decoration: none; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>❌ Invalid Token</h1>
<p>This verification link is invalid or has already been used.</p>
<a href="/" class="btn">Go to Home</a>
</div>
</body>
</html>
''', 404
# Check if token expired
expires_at = datetime.fromisoformat(token_data['expires_at'])
# Fix for offset-naive vs offset-aware comparison
now = datetime.now(timezone.utc) if expires_at.tzinfo else datetime.now()
if now > expires_at:
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Token Expired</title>
<style>
body { font-family: Arial, sans-serif; background: #0f0f0f; color: #cbcbcd; margin: 0; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 40px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
h1 { color: #9c6d2f; margin: 0 0 20px 0; }
p { color: #8a8a8a; line-height: 1.6; }
.btn { display: inline-block; padding: 12px 30px; background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%); color: #fff; text-decoration: none; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>⏰ Token Expired</h1>
<p>This verification link has expired. Please request a new verification email.</p>
<a href="/" class="btn">Go to Home</a>
</div>
</body>
</html>
''', 410
# Verify the email
cursor.execute('''
UPDATE users
SET email_verified = TRUE, email_verification_token = NULL
WHERE id = ?
''', (token_data['user_id'],))
user_updated = cursor.rowcount > 0
# Delete the token
cursor.execute('DELETE FROM email_verification_tokens WHERE token = ?', (token,))
conn.commit()
if user_updated:
# After successful verification, notify the user via WebSocket if they're connected
for client_id, client_info in connected_clients.items():
if (client_info.get('authenticated') and
client_info.get('user_id') == token_data['user_id']):
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def send_notification():
await send_message(client_id, {
'type': 'email_verified',
'message': 'Your email has been verified successfully!',
'user_id': token_data['user_id']
})
loop.run_until_complete(send_notification())
loop.close()
except Exception as e:
logger.error(f"Error sending verification notification: {e}")
# Send welcome email
email_svc = get_email_service()
if email_svc:
email_svc.send_welcome_email(token_data['email'], token_data['username'])
logger.info(f"✅ Email verified for user: {token_data['username']}")
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Email Verified</title>
<style>
body { font-family: Arial, sans-serif; background: #0f0f0f; color: #cbcbcd; margin: 0; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 40px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
h1 { color: #6b7d55; margin: 0 0 20px 0; font-size: 32px; }
.icon { font-size: 64px; margin-bottom: 20px; }
p { color: #8a8a8a; line-height: 1.6; margin-bottom: 30px; }
.btn { display: inline-block; padding: 16px 40px; background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%); color: #fff; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; }
.btn:hover { background: linear-gradient(135deg, #4a6385 0%, #3a5375 100%); }
</style>
</head>
<body>
<div class="container">
<div class="icon">✅</div>
<h1>Email Verified!</h1>
<p>Your email has been successfully verified. You can now log in and start using AnchorHost!</p>
<a href="/" class="btn">Go to Dashboard</a>
</div>
</body>
</html>
''', 200
except Exception as e:
logger.error(f"Error verifying email: {e}", exc_info=True)
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verification Error</title>
<style>
body { font-family: Arial, sans-serif; background: #0f0f0f; color: #cbcbcd; margin: 0; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 40px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
h1 { color: #a83a40; margin: 0 0 20px 0; }
p { color: #8a8a8a; line-height: 1.6; }
.btn { display: inline-block; padding: 12px 30px; background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%); color: #fff; text-decoration: none; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>❌ Error</h1>
<p>An error occurred during verification. Please try again or contact support.</p>
<a href="/" class="btn">Go to Home</a>
</div>
</body>
</html>
''', 500
@bp.route('/api/verify-email', methods=['GET', 'POST'])
def verify_email_api():
"""Verify email with token"""
# Handle GET requests by redirecting to the HTML page
if request.method == 'GET':
token = request.args.get('token')
if token:
return redirect(f"/verify-email?token={token}")
return redirect("/verify-email")
try:
data = request.get_json()
token = data.get('token')
if not token:
return jsonify({'success': False, 'error': 'Token required'}), 400
with database_connection() as conn:
cursor = conn.cursor()
# Find token
cursor.execute('SELECT user_id, expires_at FROM email_verification_tokens WHERE token = ?', (token,))
token_data = dict_from_row(cursor.fetchone())
if not token_data:
return jsonify({'success': False, 'error': 'Invalid or expired token'}), 400
expires_at = datetime.fromisoformat(token_data['expires_at'])
# Fix for offset-naive vs offset-aware comparison
now = datetime.now(timezone.utc) if expires_at.tzinfo else datetime.now()
if now > expires_at:
return jsonify({'success': False, 'error': 'Token expired'}), 400
user_id = token_data['user_id']
# Verify user
cursor.execute('UPDATE users SET email_verified = TRUE, email_verification_token = NULL WHERE id = ?', (user_id,))
# Delete token
cursor.execute('DELETE FROM email_verification_tokens WHERE token = ?', (token,))
conn.commit()
# Get user info for welcome email
cursor.execute('SELECT email, username FROM users WHERE id = ?', (user_id,))
user = dict_from_row(cursor.fetchone())
# Send welcome email
if user:
try:
email_svc = get_email_service()
if email_svc:
email_svc.send_welcome_email(user['email'], user['username'])
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
return jsonify({'success': True, 'message': 'Email verified successfully'}), 200
except Exception as e:
logger.error(f"Error verifying email: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/auth/config', methods=['GET'])
def get_auth_config():
"""Get authentication configuration (which OAuth providers are available)"""
def is_valid(val):
return bool(val and str(val).strip() and str(val) not in ['', 'None', 'undefined', 'your.service.identifier', 'your_team_id', 'your_key_id'])
return jsonify({
'google_enabled': is_valid(GOOGLE_CLIENT_ID) and is_valid(GOOGLE_CLIENT_SECRET),
'apple_enabled': is_valid(APPLE_CLIENT_ID) and is_valid(APPLE_TEAM_ID) and is_valid(APPLE_KEY_ID) and is_valid(APPLE_PRIVATE_KEY)
}), 200
+484
View File
@@ -0,0 +1,484 @@
"""Billing API routes for AnchorHost."""
from currency_service import calculate_stripe_amount, get_cached_exchange_rates
from database_service import database_connection, dict_from_row
from email_service import get_email_service
from flask import Blueprint, jsonify, request
from runtime_config import (
CACHE_DURATION,
EXCHANGE_RATE_CACHE,
STRIPE_CURRENCY_MAP,
STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET,
logger,
)
from server_billing import get_user_monthly_fees
from server_capacity import calculate_monthly_server_cost
from server_records import get_user_servers
import stripe
from user_service import add_credit_transaction, authenticate_user, get_bearer_token, get_user_by_id, update_user_credits
__all__ = ['create_checkout_session', 'estimate_monthly_cost', 'get_billing_history', 'get_exchange_rates_api', 'get_session_status', 'payment_cancelled', 'payment_success', 'stripe_webhook']
stripe.api_key = STRIPE_SECRET_KEY
bp = Blueprint("billing", __name__)
@bp.route('/api/user/billing/history', methods=['GET'])
def get_billing_history():
"""Get user's billing history"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
limit = request.args.get('limit', 10, type=int)
# Pobierz opłaty miesięczne
monthly_fees = get_user_monthly_fees(user['id'], limit)
# Pobierz informacje o serwerach i datach rozliczeniowych
servers = get_user_servers(user['id'])
server_billing_info = []
for server in servers:
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM server_billing WHERE server_id = ?
''', (server['id'],))
billing = dict_from_row(cursor.fetchone())
if billing:
server['billing_info'] = billing
server_billing_info.append(server)
return jsonify({
'success': True,
'monthly_fees': monthly_fees,
'active_servers': server_billing_info,
'total_active_servers': len([s for s in servers if s['status'] in ['Running', 'Starting']])
}), 200
except Exception as e:
logger.error(f"Error getting billing history: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/user/billing/estimate', methods=['POST'])
def estimate_monthly_cost():
"""Estimate monthly cost for a server configuration"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No configuration provided'}), 400
# Utwórz mock serwer do kalkulacji
mock_server = {
'cpu': data.get('cpu', '1 vCPU'),
'ram': data.get('ram', '1GB'),
'storage': data.get('storage', '10GB'),
'type': data.get('serverType', 'vps'),
'name': 'Estimation'
}
monthly_cost = calculate_monthly_server_cost(mock_server)
return jsonify({
'success': True,
'monthly_cost_credits': monthly_cost,
'monthly_cost_euro': monthly_cost / 100, # 100 kredytów = 1 euro
'estimated_daily_cost': monthly_cost / 30, # Dzienny koszt
'currency': 'EUR',
'pricing_model': {
'cpu_per_vcpu': '0.25 EUR',
'ram_per_gb': '0.25 EUR',
'storage_per_gb': '0.05 EUR',
'server_type_multipliers': {
'vps': '1.0x',
'game': '1.5x',
'website': '1.2x',
'database': '1.3x'
}
}
}), 200
except Exception as e:
logger.error(f"Error estimating monthly cost: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/stripe/create-checkout-session', methods=['POST'])
def create_checkout_session():
"""Create a Stripe checkout session"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
credits = data.get('credits', 1000)
currency = data.get('currency', 'USD').upper()
if credits < 100:
return jsonify({'error': 'Minimum purchase is 100 credits'}), 400
if currency not in STRIPE_CURRENCY_MAP:
return jsonify({'error': f'Currency {currency} not supported'}), 400
# Calculate amount in smallest currency unit
amount = calculate_stripe_amount(credits, currency)
stripe_currency = STRIPE_CURRENCY_MAP[currency]
# Get currency symbol for display
currency_data = next((c for c in [
{'code': 'USD', 'symbol': '$'},
{'code': 'EUR', 'symbol': '€'},
{'code': 'GBP', 'symbol': '£'},
{'code': 'PLN', 'symbol': 'zł'},
{'code': 'PHP', 'symbol': '₱'},
{'code': 'CZK', 'symbol': 'Kč'}
] if c['code'] == currency), {'code': currency, 'symbol': currency})
# Create Stripe checkout session
session = stripe.checkout.Session.create(
payment_method_types=[
'card',
'paypal',
'blik',
'revolut_pay',
'ideal',
'bancontact',
'eps',
'twint',
'link',
'multibanco',
'klarna'
],
line_items=[{
'price_data': {
'currency': stripe_currency,
'product_data': {
'name': f'{credits} AnchorHost Credits',
'description': f'Purchase {credits} credits for your AnchorHost account',
'images': ['https://your-logo-url.com/logo.png'], # Optional: Add your logo
},
'unit_amount': amount,
},
'quantity': 1,
}],
mode='payment',
success_url=request.host_url + f'payment-success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=request.host_url + 'payment-cancelled',
metadata={
'user_id': user['id'],
'username': user['username'],
'credits': str(credits),
'currency': currency
},
customer_email=user['email'],
)
logger.info(f"Created Stripe checkout session for user {user['username']}: {credits} credits, {currency}")
return jsonify({
'success': True,
'sessionId': session.id,
'url': session.url
}), 200
except stripe.error.StripeError as e:
logger.error(f"Stripe error: {e}")
return jsonify({'error': f'Payment service error: {str(e)}'}), 500
except Exception as e:
logger.error(f"Error creating checkout session: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/stripe/webhook', methods=['POST'])
def stripe_webhook():
"""Handle Stripe webhook events"""
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError as e:
logger.error(f"Invalid payload: {e}")
return jsonify({'error': 'Invalid payload'}), 400
except stripe.error.SignatureVerificationError as e:
logger.error(f"Invalid signature: {e}")
return jsonify({'error': 'Invalid signature'}), 400
# Handle the event
if event['type'] == 'checkout.session.completed':
session = event['data']['object']
# Get metadata
user_id = session['metadata'].get('user_id')
username = session['metadata'].get('username')
credits = int(session['metadata'].get('credits', 0))
currency = session['metadata'].get('currency', 'USD')
logger.info(f"Payment completed: {username} purchased {credits} credits for {session['amount_total']/100} {currency}")
# Add credits to user account
try:
user = get_user_by_id(user_id)
if user:
new_credits = user.get('credits', 0) + credits
if update_user_credits(user_id, new_credits):
add_credit_transaction(
user_id,
credits,
'purchase',
f'Stripe payment: {credits} credits purchased for {session["amount_total"]/100} {currency}'
)
logger.info(f"✅ Credits added to {username}: +{credits} (new balance: {new_credits})")
# Send confirmation email
email_svc = get_email_service()
if email_svc:
try:
email_svc.send_payment_confirmation(
user['email'],
username,
credits,
session['amount_total'] / 100,
currency
)
except Exception as e:
logger.error(f"Failed to send confirmation email: {e}")
else:
logger.error(f"Failed to update credits for {username}")
else:
logger.error(f"User not found: {user_id}")
except Exception as e:
logger.error(f"Error processing payment for {username}: {e}")
elif event['type'] == 'checkout.session.async_payment_succeeded':
# Handle async payment success (e.g., bank transfers)
session = event['data']['object']
logger.info(f"Async payment succeeded: {session['id']}")
elif event['type'] == 'checkout.session.async_payment_failed':
# Handle async payment failure
session = event['data']['object']
logger.warning(f"Async payment failed: {session['id']}")
return jsonify({'success': True}), 200
@bp.route('/api/stripe/session-status/<session_id>', methods=['GET'])
def get_session_status(session_id):
"""Get the status of a Stripe checkout session"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
session = stripe.checkout.Session.retrieve(session_id)
return jsonify({
'success': True,
'status': session.status,
'payment_status': session.payment_status,
'amount_total': session.amount_total,
'currency': session.currency,
'customer_email': session.customer_email
}), 200
except stripe.error.StripeError as e:
logger.error(f"Stripe error: {e}")
return jsonify({'error': str(e)}), 500
except Exception as e:
logger.error(f"Error getting session status: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/payment-success')
def payment_success():
"""Payment success page"""
session_id = request.args.get('session_id')
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Payment Successful</title>
<style>
body {{
font-family: Arial, sans-serif;
background: #0f0f0f;
color: #cbcbcd;
margin: 0;
padding: 40px;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}}
.container {{
max-width: 600px;
background: #1a1a1a;
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}}
h1 {{ color: #6b7d55; margin: 0 0 20px 0; font-size: 32px; }}
.icon {{ font-size: 64px; margin-bottom: 20px; }}
p {{ color: #8a8a8a; line-height: 1.6; margin-bottom: 30px; }}
.btn {{
display: inline-block;
padding: 16px 40px;
background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%);
color: #fff;
text-decoration: none;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
transition: all 0.3s;
}}
.btn:hover {{
background: linear-gradient(135deg, #4a6385 0%, #3a5375 100%);
transform: translateY(-2px);
}}
.session-id {{
background: #0f0f0f;
padding: 10px;
border-radius: 6px;
font-family: monospace;
font-size: 12px;
color: #5a7ca7;
margin-top: 20px;
word-break: break-all;
}}
</style>
<script>
// Auto-close after 5 seconds and notify parent window
setTimeout(() => {{
if (window.opener) {{
window.opener.postMessage({{
type: 'payment_success',
sessionId: '{session_id}'
}}, '*');
}}
window.close();
}}, 5000);
</script>
</head>
<body>
<div class="container">
<div class="icon">✅</div>
<h1>Payment Successful!</h1>
<p>Your credits have been added to your account. Thank you for your purchase!</p>
<p style="font-size: 14px;">This window will close automatically in 5 seconds...</p>
<a href="/" class="btn" onclick="window.close(); return false;">Return to Dashboard</a>
<div class="session-id">Session ID: {session_id}</div>
</div>
</body>
</html>
'''
@bp.route('/payment-cancelled')
def payment_cancelled():
"""Payment cancelled page"""
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Payment Cancelled</title>
<style>
body {{
font-family: Arial, sans-serif;
background: #0f0f0f;
color: #cbcbcd;
margin: 0;
padding: 40px;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}}
.container {{
max-width: 600px;
background: #1a1a1a;
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}}
h1 {{ color: #9c6d2f; margin: 0 0 20px 0; font-size: 32px; }}
.icon {{ font-size: 64px; margin-bottom: 20px; }}
p {{ color: #8a8a8a; line-height: 1.6; margin-bottom: 30px; }}
.btn {{
display: inline-block;
padding: 16px 40px;
background: linear-gradient(135deg, #5a7ca7 0%, #4a6385 100%);
color: #fff;
text-decoration: none;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
transition: all 0.3s;
}}
.btn:hover {{
background: linear-gradient(135deg, #4a6385 0%, #3a5375 100%);
transform: translateY(-2px);
}}
</style>
<script>
// Auto-close after 3 seconds
setTimeout(() => {
if (window.opener) {
window.opener.postMessage({type: 'payment_cancelled'}, '*');
}
window.close();
}, 3000);
</script>
</head>
<body>
<div class="container">
<div class="icon">❌</div>
<h1>Payment Cancelled</h1>
<p>Your payment was cancelled. No charges were made to your account.</p>
<p style="font-size: 14px;">This window will close automatically in 3 seconds...</p>
<a href="/" class="btn" onclick="window.close(); return false;">Return to Dashboard</a>
</div>
</body>
</html>
'''
@bp.route('/api/exchange-rates', methods=['GET'])
def get_exchange_rates_api():
"""Get current exchange rates"""
try:
rates = get_cached_exchange_rates()
return jsonify({
'success': True,
'rates': rates,
'base_currency': 'USD',
'last_updated': EXCHANGE_RATE_CACHE.get('last_updated'),
'cache_duration_seconds': CACHE_DURATION
}), 200
except Exception as e:
logger.error(f"Error getting exchange rates: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
+298
View File
@@ -0,0 +1,298 @@
"""Domains API routes for AnchorHost."""
from domain_runtime import domain_manager
from domain_service import create_domain_record, delete_domain_record, get_domain_by_id, get_user_domains, link_domain_to_server, unlink_domain_from_server
from flask import Blueprint, jsonify, request
from runtime_config import (
logger,
)
from server_records import get_server_by_id
from user_service import authenticate_user, get_bearer_token, require_admin
from websocket_service import sync_user_domains_with_dns
__all__ = ['create_domain_api', 'delete_domain_api', 'fix_all_dnssec_api', 'get_domains_api', 'link_domain_api', 'sync_domains_api', 'test_domain_api', 'unlink_domain_api', 'verify_dnssec_api']
bp = Blueprint("domains", __name__)
@bp.route('/api/dnssec/fix-all', methods=['POST'])
def fix_all_dnssec_api():
"""Fix DNSSEC delegation for all user domains"""
try:
token = get_bearer_token()
user = authenticate_user(token)
# Only allow admin users
if not user or user.get('role') != 'admin':
return jsonify({'error': 'Admin access required'}), 403
if not domain_manager:
return jsonify({'error': 'Domain manager not available'}), 503
# Run the fix
domain_manager.fix_all_dnssec_delegations()
return jsonify({
'success': True,
'message': 'DNSSEC fix process initiated. Check logs for details.'
}), 200
except Exception as e:
logger.error(f"Error fixing DNSSEC: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/dnssec/verify/<username>', methods=['GET'])
def verify_dnssec_api(username):
"""Verify DNSSEC delegation for a user"""
try:
token = get_bearer_token()
user = authenticate_user(token)
# Users can only check their own DNSSEC, admins can check any
if not user or (user.get('role') != 'admin' and user.get('username') != username):
return jsonify({'error': 'Unauthorized'}), 403
if not domain_manager:
return jsonify({'error': 'Domain manager not available'}), 503
result = domain_manager.setup_dnssec_delegation(username)
return jsonify(result), 200 if result.get('success') else 500
except Exception as e:
logger.error(f"Error verifying DNSSEC: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains', methods=['GET'])
def get_domains_api():
"""Get domains API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
include_serverless = request.args.get('include_serverless', 'true').lower() == 'true'
domains = get_user_domains(user['id'], include_serverless)
return jsonify({
'success': True,
'domains': domains,
'count': len(domains)
}), 200
except Exception as e:
logger.error(f"Error getting domains: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains/sync', methods=['POST'])
def sync_domains_api():
"""Sync domains between database and DNS"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
success = sync_user_domains_with_dns(user['id'])
if success:
domains = get_user_domains(user['id'], True)
return jsonify({
'success': True,
'message': 'Domains synced successfully',
'domains': domains,
'count': len(domains)
}), 200
else:
return jsonify({'error': 'Failed to sync domains'}), 500
except Exception as e:
logger.error(f"Error syncing domains: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains', methods=['POST'])
def create_domain_api():
"""Create domain API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
if not domain_manager:
return jsonify({'error': 'Domain service not available'}), 503
data = request.get_json()
subdomain = data.get('subdomain')
server_id = data.get('serverId')
if not subdomain:
return jsonify({'error': 'Subdomain name required'}), 400
# Check if subdomain is available
availability_result = domain_manager.check_domain_availability(subdomain, user['username'])
if not availability_result.get('available', False):
return jsonify({'error': availability_result.get('message', 'Domain not available')}), 400
# Create the subdomain
result = domain_manager.create_subdomain(user['username'], subdomain)
if result['success'] and result.get('domain_data'):
# Save to database
domain_record = {
'user_id': user['id'],
'subdomain': result['domain_data']['subdomain'],
'full_domain': result['domain_data']['domain'],
'record_type': result['domain_data']['record_type'],
'ip_address': result['domain_data']['ip_address'],
'ttl': result['domain_data']['ttl'],
'server_id': server_id,
'created_at': result['domain_data']['created_at']
}
db_domain = create_domain_record(domain_record)
if db_domain:
return jsonify({
'success': True,
'domain': db_domain,
'message': result['message']
}), 201
else:
return jsonify({'error': 'Domain created in DNS but failed to save to database'}), 500
else:
return jsonify({'error': result.get('message', 'Failed to create domain')}), 500
except Exception as e:
logger.error(f"Error creating domain: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains/<domain_id>', methods=['DELETE'])
def delete_domain_api(domain_id):
"""Delete domain API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
if not domain_manager:
return jsonify({'error': 'Domain service not available'}), 503
# Get domain from database
domain = get_domain_by_id(domain_id)
if not domain or domain['user_id'] != user['id']:
return jsonify({'error': 'Domain not found or access denied'}), 404
# Delete from DNS
result = domain_manager.delete_subdomain(user['username'], domain['subdomain'])
if result['success']:
# Delete from database
if delete_domain_record(domain_id):
return jsonify({
'success': True,
'message': result['message']
}), 200
else:
return jsonify({'error': 'Domain deleted from DNS but failed to delete from database'}), 500
else:
return jsonify({'error': result.get('message', 'Failed to delete domain')}), 500
except Exception as e:
logger.error(f"Error deleting domain: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains/<domain_id>/link', methods=['POST'])
def link_domain_api(domain_id):
"""Link domain to server API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
server_id = data.get('serverId')
if not server_id:
return jsonify({'error': 'Server ID required'}), 400
# Verify domain belongs to user
domain = get_domain_by_id(domain_id)
if not domain or domain['user_id'] != user['id']:
return jsonify({'error': 'Domain not found or access denied'}), 404
# Verify server belongs to user
server = get_server_by_id(server_id)
if not server or server['owner_id'] != user['id']:
return jsonify({'error': 'Server not found or access denied'}), 404
# Check if server is a website or VPS
if server['type'] not in ['website', 'vps']:
return jsonify({'error': 'Domain can only be linked to Website or VPS servers'}), 400
# Link domain to server (this will create IngressRoute)
if link_domain_to_server(domain_id, server_id):
return jsonify({
'success': True,
'message': f'Domain linked to server {server["name"]} with IngressRoute created'
}), 200
else:
return jsonify({'error': 'Failed to link domain'}), 500
except Exception as e:
logger.error(f"Error linking domain: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains/<domain_id>/unlink', methods=['POST'])
def unlink_domain_api(domain_id):
"""Unlink domain from server API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
# Verify domain belongs to user
domain = get_domain_by_id(domain_id)
if not domain or domain['user_id'] != user['id']:
return jsonify({'error': 'Domain not found or access denied'}), 404
# Unlink domain from server (this will delete IngressRoute)
if unlink_domain_from_server(domain_id):
return jsonify({
'success': True,
'message': 'Domain unlinked from server and IngressRoute removed'
}), 200
else:
return jsonify({'error': 'Failed to unlink domain'}), 500
except Exception as e:
logger.error(f"Error unlinking domain: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/domains/test', methods=['GET'])
def test_domain_api():
"""Test domain API connection"""
if not require_admin():
return jsonify({'error': 'Unauthorized'}), 403
try:
if not domain_manager:
return jsonify({'success': False, 'message': 'Domain manager not initialized'}), 503
success = domain_manager.test_connection()
return jsonify({
'success': success,
'message': 'Domain API connection test completed',
'connected': success
}), 200
except Exception as e:
logger.error(f"Error testing domain API: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
+126
View File
@@ -0,0 +1,126 @@
"""Public API routes for AnchorHost."""
from database_service import database_connection, dict_from_row
from datetime import datetime
from domain_runtime import domain_manager
from flask import Blueprint, jsonify, request
import json
from kubernetes_service import k8s_manager
from runtime_config import (
logger,
)
from showcase_service import get_homepage_showcase_links
from user_service import get_pricing, is_demo_enabled
__all__ = ['get_announcements', 'get_public_pricing', 'get_public_settings', 'health_check', 'public_homepage_showcase', 'public_stats']
bp = Blueprint("public", __name__)
@bp.route('/health', methods=['GET'])
def health_check():
database_status = 'connected'
try:
with database_connection() as conn:
conn.cursor().execute('SELECT 1')
except Exception:
logger.exception("Health check could not reach the database")
database_status = 'disconnected'
return jsonify({
'status': 'ok' if database_status == 'connected' else 'unavailable',
'kubernetes': 'connected' if k8s_manager.connected else 'disconnected',
'database': database_status,
'domain_manager': 'available' if domain_manager else 'unavailable',
'timestamp': datetime.now().isoformat()
}), 200 if database_status == 'connected' else 503
@bp.route('/api/announcements', methods=['GET'])
def get_announcements():
"""Get public announcements"""
try:
limit = request.args.get('limit', default=10, type=int)
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT a.*, u.username as author_name
FROM announcements a
JOIN users u ON a.author_id = u.id
ORDER BY a.created_at DESC
LIMIT ?
''', (limit,))
announcements = [dict_from_row(row) for row in cursor.fetchall()]
# Parse tags if they are stored as JSON string or comma-separated
for ann in announcements:
if ann.get('tags'):
try:
# Try parsing as JSON first
ann['tags'] = json.loads(ann['tags'])
except:
# Fallback to comma-separated if simple string
if ',' in ann['tags']:
ann['tags'] = [tag.strip() for tag in ann['tags'].split(',')]
else:
ann['tags'] = [ann['tags']]
else:
ann['tags'] = []
return jsonify({'success': True, 'announcements': announcements})
except Exception as e:
logger.error(f"Error fetching announcements: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/public/homepage-showcase', methods=['GET'])
def public_homepage_showcase():
"""Public: three configurable customer-site URLs for the marketing homepage."""
try:
links = get_homepage_showcase_links()
return jsonify({'success': True, 'links': links})
except Exception as e:
logger.error(f"public_homepage_showcase: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/public/stats', methods=['GET'])
def public_stats():
"""Return public platform statistics."""
try:
with database_connection() as conn:
cursor = conn.cursor()
# Use a named column 'count' to ensure compatibility with DictCursor
cursor.execute("SELECT COUNT(*) as count FROM users")
row = cursor.fetchone()
user_count = row['count'] if row else 0
# For a more impressive number, add a base number if it's a new installation
# or just return the real count. User asked for "how much users there are".
return jsonify({
'success': True,
'user_count': user_count,
# Add other stats if helpful
'total_customers': user_count
})
except Exception as e:
logger.error(f"public_stats: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/pricing', methods=['GET'])
def get_public_pricing():
"""Get current pricing (publicly accessible)."""
return jsonify(get_pricing()), 200
@bp.route('/api/settings', methods=['GET'])
def get_public_settings():
"""Get public panel settings."""
return jsonify({
'success': True,
'settings': {
'demo_servers_enabled': is_demo_enabled()
}
}), 200
+468
View File
@@ -0,0 +1,468 @@
"""Servers API routes for AnchorHost."""
from datetime import datetime
from flask import Blueprint, jsonify, request
import json
from kubernetes_service import k8s_manager
import requests
from runtime_config import (
MODRINTH_API_BASE,
logger,
)
from server_access import expose_port, remove_port
from server_capacity import get_cluster_capacity, validate_server_capacity
from server_lifecycle import adjust_server_parameters
from server_provisioning import reinstall_server, sync_server_ports_from_k8s, sync_server_status_from_k8s
from server_records import get_server_by_id, get_user_servers
from user_service import add_credits, authenticate_user, get_bearer_token, get_user_credits, require_admin
__all__ = ['add_credits_api', 'adjust_server_parameters_api', 'check_capacity_api', 'download_plugin_to_server', 'expose_port_api', 'get_cluster_capacity_api', 'get_modrinth_project', 'get_modrinth_tags', 'get_modrinth_version', 'get_modrinth_versions', 'reinstall_server_api', 'remove_port_api', 'search_modrinth_plugins', 'sync_server_status_api']
bp = Blueprint("servers", __name__)
@bp.route('/api/capacity/check', methods=['POST'])
def check_capacity_api():
"""API endpoint to check capacity for a server configuration"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No configuration provided'}), 400
# Validate required fields
required_fields = ['cpu', 'ram', 'storage', 'serverType']
for field in required_fields:
if field not in data:
return jsonify({'success': False, 'error': f'Missing field: {field}'}), 400
# Run capacity validation
capacity_result = validate_server_capacity(data)
# Also get cluster overview
cluster_capacity = get_cluster_capacity()
# Extract useful information for frontend
response_data = {
'success': True,
'capacity_check': {
'success': capacity_result.get('success', False),
'message': capacity_result.get('message', ''),
'suitable_nodes': capacity_result.get('suitable_nodes', []),
'node_details': capacity_result.get('node_details', []),
'details': capacity_result.get('details', []),
'error_details': capacity_result.get('error_details', []),
'summary': capacity_result.get('summary', {})
},
'cluster_capacity': cluster_capacity,
'request': {
'cpu': data['cpu'],
'ram': data['ram'],
'storage': data['storage'],
'server_type': data['serverType']
}
}
return jsonify(response_data), 200
except Exception as e:
logger.error(f"Error in capacity check API: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/capacity/cluster', methods=['GET'])
def get_cluster_capacity_api():
"""API endpoint to get cluster capacity overview"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
capacity = get_cluster_capacity()
return jsonify(capacity), 200 if capacity['success'] else 500
except Exception as e:
logger.error(f"Error getting cluster capacity: {e}")
return jsonify({
'success': False,
'error': str(e),
'timestamp': datetime.now().isoformat()
}), 500
@bp.route('/api/servers/<server_id>/sync', methods=['POST'])
def sync_server_status_api(server_id):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
sync_server_status_from_k8s(server_id, user['id'])
sync_server_ports_from_k8s(server_id, user['id'])
server = get_server_by_id(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
return jsonify({
'success': True,
'server': server
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/reinstall', methods=['POST'])
def reinstall_server_api(server_id):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
success = reinstall_server(server_id, user['id'])
if success:
return jsonify({
'success': True,
'message': 'Server reinstall started'
}), 200
else:
return jsonify({'error': 'Failed to reinstall server'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/users/<user_id>/credits', methods=['POST'])
def add_credits_api(user_id):
try:
if not require_admin():
return jsonify({'error': 'Unauthorized'}), 403
data = request.get_json(silent=True) or {}
amount = data.get('amount')
if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
return jsonify({'error': 'Invalid amount'}), 400
success = add_credits(user_id, amount)
if success:
return jsonify({
'success': True,
'message': f'Added {amount} credits',
'new_balance': get_user_credits(user_id)
}), 200
else:
return jsonify({'error': 'Failed to add credits'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/search', methods=['GET'])
def search_modrinth_plugins():
"""Search for plugins on Modrinth"""
try:
query = request.args.get('query', '')
limit = int(request.args.get('limit', 20))
offset = int(request.args.get('offset', 0))
project_type = request.args.get('project_type', 'plugin') # plugin, mod, modpack, etc.
loaders = request.args.get('loaders', '') # paper, spigot, bukkit, etc.
game_versions = request.args.get('game_versions', '')
# Build facets array for Modrinth API
# Facets format: [['category:value1', 'category:value2'], ['category2:value3']]
# Each inner array represents OR conditions, outer array represents AND conditions
facets = [['project_type:' + project_type]]
if loaders:
loader_list = [loader.strip() for loader in loaders.split(',') if loader.strip()]
if loader_list:
# Create a single array with all loader filters (OR condition)
loader_facets = ['loaders:' + loader for loader in loader_list]
facets.append(loader_facets)
if game_versions:
version_list = [version.strip() for version in game_versions.split(',') if version.strip()]
if version_list:
# Create a single array with all version filters (OR condition)
version_facets = ['versions:' + version for version in version_list]
facets.append(version_facets)
# Build query parameters
params = {
'query': query,
'limit': limit,
'offset': offset,
'facets': json.dumps(facets)
}
response = requests.get(f'{MODRINTH_API_BASE}/search', params=params, headers={
'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'
})
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Failed to search Modrinth', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error searching Modrinth: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/project/<project_id>', methods=['GET'])
def get_modrinth_project(project_id):
"""Get project details from Modrinth"""
try:
response = requests.get(
f'{MODRINTH_API_BASE}/project/{project_id}',
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Project not found', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth project: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/project/<project_id>/versions', methods=['GET'])
def get_modrinth_versions(project_id):
"""Get versions for a Modrinth project"""
try:
game_versions = request.args.get('game_versions', '')
loaders = request.args.get('loaders', '')
params = {}
if game_versions:
params['game_versions'] = game_versions.split(',')
if loaders:
params['loaders'] = loaders.split(',')
response = requests.get(
f'{MODRINTH_API_BASE}/project/{project_id}/version',
params=params,
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Failed to fetch versions', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth versions: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/project/<project_id>/version/<version_id>', methods=['GET'])
def get_modrinth_version(project_id, version_id):
"""Get specific version details"""
try:
response = requests.get(
f'{MODRINTH_API_BASE}/version/{version_id}',
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Version not found', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth version: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/tags', methods=['GET'])
def get_modrinth_tags():
"""Get available tags/categories from Modrinth"""
try:
response = requests.get(
f'{MODRINTH_API_BASE}/tag',
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Failed to fetch tags', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth tags: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/plugins/download-to-server', methods=['POST'])
def download_plugin_to_server():
"""Download a plugin from URL and upload it to a server's plugins directory"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Not authenticated'}), 401
data = request.get_json()
server_id = data.get('server_id')
plugin_url = data.get('plugin_url')
plugin_name = data.get('plugin_name', 'plugin')
file_name = data.get('file_name', 'plugin.jar')
if not server_id or not plugin_url:
return jsonify({'error': 'Server ID and plugin URL are required'}), 400
# Verify server belongs to user
user_servers = get_user_servers(user['id'])
server = next((s for s in user_servers if s['id'] == server_id), None)
if not server:
return jsonify({'error': 'Server not found or access denied'}), 404
# Check if server is Minecraft server
if server.get('image') != 'minecraft' or server.get('type') != 'game':
return jsonify({'error': 'Plugin downloads are only available for Minecraft game servers'}), 400
# Download plugin file
logger.info(f"Downloading plugin from {plugin_url} for server {server_id}")
plugin_response = requests.get(plugin_url, stream=True, timeout=30)
if plugin_response.status_code != 200:
return jsonify({'error': f'Failed to download plugin: HTTP {plugin_response.status_code}'}), 500
# Determine plugins directory based on server type
# For Minecraft servers, plugins go in /data/plugins directory
plugins_dir = '/data/plugins'
file_path = f'{plugins_dir}/{file_name}'
# Save plugin to temporary file first
import tempfile
import os
with tempfile.NamedTemporaryFile(delete=False, suffix='.jar') as temp_file:
temp_file_path = temp_file.name
for chunk in plugin_response.iter_content(chunk_size=8192):
if chunk:
temp_file.write(chunk)
try:
# Upload to server
namespace = f"user-{user['username']}".lower()
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return jsonify({'error': 'Server pod not found'}), 404
# Ensure plugins directory exists
k8s_manager.exec_command(namespace, pods[0], ['mkdir', '-p', plugins_dir])
# Upload file using write_file_binary_from_disk
success = k8s_manager.write_file_binary_from_disk(namespace, pods[0], file_path, temp_file_path)
if success:
logger.info(f"Plugin {file_name} successfully uploaded to server {server_id}")
return jsonify({
'success': True,
'message': f'Plugin {plugin_name} downloaded and installed successfully',
'file_path': file_path
}), 200
else:
return jsonify({'error': 'Failed to upload plugin to server'}), 500
finally:
# Clean up temporary file
try:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
except Exception as e:
logger.warning(f"Failed to remove temporary file: {e}")
except requests.exceptions.RequestException as e:
logger.error(f"Error downloading plugin: {e}")
return jsonify({'error': f'Failed to download plugin: {str(e)}'}), 500
except Exception as e:
logger.error(f"Error downloading plugin to server: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/ports', methods=['POST'])
def expose_port_api(server_id):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
internal_port = data.get('internalPort')
protocol = data.get('protocol', 'TCP')
if not internal_port:
return jsonify({'error': 'Internal port required'}), 400
success = expose_port(server_id, user['id'], internal_port, protocol)
if success:
return jsonify({
'success': True,
'message': f'Port {internal_port} exposed successfully'
}), 200
else:
return jsonify({'error': 'Failed to expose port'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/ports/<int:internal_port>', methods=['DELETE'])
def remove_port_api(server_id, internal_port):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json() or {}
protocol = data.get('protocol', 'TCP')
success = remove_port(server_id, user['id'], internal_port, protocol)
if success:
return jsonify({
'success': True,
'message': f'Port {internal_port}/{protocol} removed successfully'
}), 200
else:
return jsonify({'error': 'Failed to remove port'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/adjust-parameters', methods=['POST'])
def adjust_server_parameters_api(server_id):
"""API endpoint to adjust server parameters (upgrade/downgrade)"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
if not data:
return jsonify({'error': 'Request body required'}), 400
new_cpu = data.get('cpu')
new_ram = data.get('ram')
new_storage = data.get('storage')
if not new_cpu or not new_ram or not new_storage:
return jsonify({'error': 'cpu, ram, and storage are required'}), 400
result = adjust_server_parameters(server_id, user['id'], new_cpu, new_ram, new_storage)
return jsonify(result), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
+179
View File
@@ -0,0 +1,179 @@
"""Tickets API routes for AnchorHost."""
from flask import Blueprint, jsonify, request
from ticket_service import add_ticket_message, create_ticket, get_all_tickets, get_ticket, get_ticket_stats, get_user_tickets, update_ticket_status
from user_service import authenticate_user, get_bearer_token
__all__ = ['add_ticket_message_api', 'create_ticket_api', 'get_ticket_api', 'get_ticket_stats_api', 'get_tickets_api', 'update_ticket_status_api']
bp = Blueprint("tickets", __name__)
@bp.route('/api/tickets', methods=['GET'])
def get_tickets_api():
"""Get tickets API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
status_filter = request.args.get('status')
priority_filter = request.args.get('priority')
if user['role'] == 'admin':
tickets = get_all_tickets(status_filter, priority_filter)
else:
include_closed = request.args.get('include_closed', 'false').lower() == 'true'
tickets = get_user_tickets(user['id'], include_closed)
return jsonify({
'success': True,
'tickets': tickets
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/tickets', methods=['POST'])
def create_ticket_api():
"""Create ticket API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
subject = data.get('subject')
description = data.get('description')
priority = data.get('priority', 'medium')
category = data.get('category', 'general')
if not subject or not description:
return jsonify({'error': 'Subject and description required'}), 400
ticket = create_ticket(user['id'], subject, description, priority, category)
if ticket:
return jsonify({
'success': True,
'ticket': ticket
}), 201
else:
return jsonify({'error': 'Failed to create ticket'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/tickets/<ticket_id>', methods=['GET'])
def get_ticket_api(ticket_id):
"""Get ticket details API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
ticket = get_ticket(ticket_id)
if not ticket:
return jsonify({'error': 'Ticket not found'}), 404
# Check access
if user['role'] != 'admin' and ticket['user_id'] != user['id']:
return jsonify({'error': 'Access denied'}), 403
return jsonify({
'success': True,
'ticket': ticket
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/tickets/<ticket_id>/messages', methods=['POST'])
def add_ticket_message_api(ticket_id):
"""Add message to ticket API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
message = data.get('message')
if not message:
return jsonify({'error': 'Message required'}), 400
# Verify ticket access
ticket = get_ticket(ticket_id)
if not ticket:
return jsonify({'error': 'Ticket not found'}), 404
if user['role'] != 'admin' and ticket['user_id'] != user['id']:
return jsonify({'error': 'Access denied'}), 403
is_admin = (user['role'] == 'admin')
message_data = add_ticket_message(ticket_id, user['id'], message, is_admin)
if message_data:
return jsonify({
'success': True,
'message': message_data
}), 201
else:
return jsonify({'error': 'Failed to send message'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/tickets/<ticket_id>/status', methods=['PUT'])
def update_ticket_status_api(ticket_id):
"""Update ticket status API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user or user['role'] != 'admin':
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
status = data.get('status')
if not status:
return jsonify({'error': 'Status required'}), 400
success = update_ticket_status(ticket_id, status, user['id'])
if success:
return jsonify({
'success': True,
'message': 'Ticket status updated'
}), 200
else:
return jsonify({'error': 'Failed to update ticket status'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/tickets/stats', methods=['GET'])
def get_ticket_stats_api():
"""Get ticket statistics API endpoint"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
stats = get_ticket_stats()
return jsonify({
'success': True,
'stats': stats
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500