Files
wfalkowski c177d7981d
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
Refactor backend and add registry build workflow
2026-10-20 16:44:49 +00:00

364 lines
14 KiB
Python

"""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