Refactor backend and add registry build workflow
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
This commit is contained in:
commit
c177d7981d
43 files changed
+18124
No files matched your search
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user