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
+445
@@ -0,0 +1,445 @@
|
||||
"""User records, account settings, password hashing, and request authentication."""
|
||||
|
||||
|
||||
import bcrypt
|
||||
from database_service import database_connection, dict_from_row
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email_service import get_email_service
|
||||
from flask import request
|
||||
import jwt
|
||||
from runtime_config import (
|
||||
DATABASE_URL,
|
||||
JWT_SECRET,
|
||||
SALT_ROUNDS,
|
||||
logger,
|
||||
)
|
||||
import sqlite3
|
||||
from typing import Dict, Optional
|
||||
import uuid
|
||||
|
||||
__all__ = ['add_credit_transaction', 'add_credits', 'authenticate_user', 'create_user', 'generate_oauth_state', 'generate_token', 'generate_two_factor_token', 'get_bearer_token', 'get_full_user_by_id', 'get_full_user_by_username', 'get_new_user_credits', 'get_panel_settings', 'get_pricing', 'get_site_setting', 'get_user_by_apple_id', 'get_user_by_email', 'get_user_by_google_id', 'get_user_by_id', 'get_user_by_username', 'get_user_credits', 'hash_password', 'is_demo_enabled', 'require_admin', 'set_site_setting', 'update_user_credits', 'verify_oauth_state', 'verify_password', 'verify_token']
|
||||
|
||||
|
||||
|
||||
def get_site_setting(key: str, default: str = "") -> str:
|
||||
"""Get a single setting value from site_settings table"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM site_settings WHERE key = ?", (key,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['value'] is not None:
|
||||
return str(row['value'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching site setting {key}: {e}")
|
||||
return default
|
||||
|
||||
def set_site_setting(key: str, value: str) -> bool:
|
||||
"""Insert or update a setting in site_settings table"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
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:
|
||||
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 True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting site setting {key}: {e}")
|
||||
return False
|
||||
|
||||
def is_demo_enabled() -> bool:
|
||||
"""Check if demo server creation is globally enabled"""
|
||||
val = get_site_setting('demo_servers_enabled', 'true')
|
||||
return str(val).lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
def get_new_user_credits() -> int:
|
||||
"""Get the initial credits granted to new users"""
|
||||
try:
|
||||
val = get_site_setting('new_user_credits', '0')
|
||||
return max(0, int(float(val)))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
def get_panel_settings() -> Dict:
|
||||
"""Get all panel settings"""
|
||||
return {
|
||||
'demo_servers_enabled': is_demo_enabled(),
|
||||
'new_user_credits': get_new_user_credits()
|
||||
}
|
||||
|
||||
def create_user(username: str, email: str, password: str = None, role: str = 'user', credits: Optional[int] = None,
|
||||
display_name: str = None, first_name: str = None, last_name: str = None,
|
||||
address: str = None, city: str = None, country: str = None,
|
||||
postal_code: str = None, phone: str = None, google_id: str = None,
|
||||
apple_id: str = None, email_verified: bool = False) -> Optional[Dict]:
|
||||
"""Create a new user with extended profile and email verification"""
|
||||
try:
|
||||
user_id = f"user-{str(uuid.uuid4())[:8]}"
|
||||
password_hash = hash_password(password) if password else None
|
||||
email_verification_token = str(uuid.uuid4()) if not email_verified else None
|
||||
|
||||
if credits is None:
|
||||
credits = get_new_user_credits()
|
||||
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO users (id, username, email, password_hash, google_id, apple_id, role, credits,
|
||||
display_name, first_name, last_name, address, city,
|
||||
country, postal_code, phone, email_verification_token,
|
||||
email_verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (user_id, username, email, password_hash, google_id, apple_id, role, credits,
|
||||
display_name, first_name, last_name, address, city,
|
||||
country, postal_code, phone, email_verification_token, email_verified))
|
||||
conn.commit()
|
||||
|
||||
if credits > 0:
|
||||
txn_id = f"txn-{str(uuid.uuid4())[:8]}"
|
||||
cursor.execute('''
|
||||
INSERT INTO credit_transactions (id, user_id, amount, type, description)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (txn_id, user_id, credits, 'signup', 'Sign-up bonus credits'))
|
||||
conn.commit()
|
||||
|
||||
# Create email verification token record if needed
|
||||
if email_verification_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, email_verification_token, expires_at))
|
||||
conn.commit()
|
||||
|
||||
# Send verification email
|
||||
email_svc = get_email_service()
|
||||
if email_svc:
|
||||
success = email_svc.send_verification_email(email, username, email_verification_token)
|
||||
if success:
|
||||
logger.info(f"✅ Verification email sent to {email}")
|
||||
else:
|
||||
logger.error(f"❌ Failed to send verification email to {email}")
|
||||
else:
|
||||
logger.warning("⚠️ Email service not available")
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, email, role, credits, created_at, display_name,
|
||||
first_name, last_name, address, city, country, postal_code, phone,
|
||||
email_verified
|
||||
FROM users WHERE id = ?
|
||||
''', (user_id,))
|
||||
user = dict_from_row(cursor.fetchone())
|
||||
|
||||
return user
|
||||
except sqlite3.IntegrityError as e:
|
||||
logger.error(f"User creation failed - integrity error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"User creation failed: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_user_by_google_id(google_id: str) -> Optional[Dict]:
|
||||
"""Get user by Google ID"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, username, email, role, credits, created_at, display_name,
|
||||
first_name, last_name, address, city, country, postal_code, phone,
|
||||
email_verified
|
||||
FROM users WHERE google_id = ?
|
||||
''', (google_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict_from_row(row) if row else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user by Google ID: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_apple_id(apple_id: str) -> Optional[Dict]:
|
||||
"""Get user by Apple ID"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, username, email, role, credits, created_at, display_name,
|
||||
first_name, last_name, address, city, country, postal_code, phone,
|
||||
email_verified
|
||||
FROM users WHERE apple_id = ?
|
||||
''', (apple_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict_from_row(row) if row else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user by Apple ID: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_email(email: str) -> Optional[Dict]:
|
||||
"""Get user by email"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, username, email, role, credits, created_at, display_name,
|
||||
first_name, last_name, address, city, country, postal_code, phone,
|
||||
email_verified, google_id, apple_id
|
||||
FROM users WHERE email = ?
|
||||
''', (email,))
|
||||
row = cursor.fetchone()
|
||||
return dict_from_row(row) if row else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user by email: {e}")
|
||||
return None
|
||||
|
||||
def get_pricing() -> Dict[str, float]:
|
||||
"""Get all pricing and multipliers from site_settings."""
|
||||
try:
|
||||
# Defaults
|
||||
defaults = {
|
||||
'price_cpu': 35.0,
|
||||
'price_ram': 35.0,
|
||||
'price_storage': 5.0,
|
||||
'multiplier_vps': 1.0,
|
||||
'multiplier_game': 0.8,
|
||||
'multiplier_website': 1.5,
|
||||
'multiplier_database': 1.3
|
||||
}
|
||||
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT key, value FROM site_settings WHERE key LIKE 'price_%' OR key LIKE 'multiplier_%'")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
pricing = {}
|
||||
for row in rows:
|
||||
key = row['key']
|
||||
val_str = row['value']
|
||||
try:
|
||||
# Filter out 'None' or empty strings which might cause ValueError
|
||||
if val_str and val_str.lower() != 'none':
|
||||
pricing[key] = float(val_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid pricing value for {key}: {val_str}")
|
||||
continue
|
||||
|
||||
# Ensure all defaults are present if not in DB or if value was invalid
|
||||
for key, val in defaults.items():
|
||||
if key not in pricing:
|
||||
pricing[key] = val
|
||||
|
||||
return pricing
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pricing: {e}")
|
||||
return {
|
||||
'price_cpu': 35.0, 'price_ram': 35.0, 'price_storage': 5.0,
|
||||
'multiplier_vps': 1.0, 'multiplier_game': 0.8,
|
||||
'multiplier_website': 1.5, 'multiplier_database': 1.3
|
||||
}
|
||||
|
||||
def get_user_by_id(user_id: str) -> Optional[Dict]:
|
||||
"""Get user by ID"""
|
||||
try:
|
||||
# Ensure user_id is a string
|
||||
user_id = str(user_id)
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id, username, email, role, credits, created_at FROM users WHERE id = ?',
|
||||
(user_id,)
|
||||
)
|
||||
return dict_from_row(cursor.fetchone())
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def get_full_user_by_id(user_id: str) -> Optional[Dict]:
|
||||
"""Get full user data by ID, including password and 2FA secret."""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))
|
||||
return dict_from_row(cursor.fetchone())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting full user by ID: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_username(username: str) -> Optional[Dict]:
|
||||
"""Get user by username"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, username, email, password_hash, role, credits, created_at, email_verified
|
||||
FROM users WHERE username = ?
|
||||
''', (username,))
|
||||
return dict_from_row(cursor.fetchone())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user by username: {e}")
|
||||
return None
|
||||
|
||||
def get_full_user_by_username(username: str) -> Optional[Dict]:
|
||||
"""Get full user data by username, including password and 2FA secret."""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM users WHERE username = ?', (username,))
|
||||
return dict_from_row(cursor.fetchone())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting full user by username: {e}")
|
||||
return None
|
||||
|
||||
def update_user_credits(user_id: str, new_credits: int) -> bool:
|
||||
"""Update user credits"""
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE users SET credits = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(new_credits, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def add_credit_transaction(user_id: str, amount: int, transaction_type: str, description: str = "") -> bool:
|
||||
"""Add credit transaction"""
|
||||
try:
|
||||
transaction_id = f"txn-{str(uuid.uuid4())[:8]}"
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO credit_transactions (id, user_id, amount, type, description)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (transaction_id, user_id, amount, transaction_type, description))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def generate_token(user_id: str) -> str:
|
||||
payload = {
|
||||
'userId': user_id,
|
||||
'token_type': 'access',
|
||||
'exp': datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
|
||||
def generate_two_factor_token(user_id: str) -> str:
|
||||
"""Create a short-lived token usable only to complete the 2FA step."""
|
||||
payload = {
|
||||
'userId': user_id,
|
||||
'token_type': 'two_factor',
|
||||
'exp': datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
|
||||
def verify_token(token: str) -> Optional[Dict]:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def generate_oauth_state(provider: str, nonce: Optional[str] = None) -> str:
|
||||
"""Sign short-lived OAuth state so callbacks can reject forged requests."""
|
||||
payload = {
|
||||
'purpose': 'oauth_state',
|
||||
'provider': provider,
|
||||
'exp': datetime.now(timezone.utc) + timedelta(minutes=10),
|
||||
}
|
||||
if nonce:
|
||||
payload['nonce'] = nonce
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
|
||||
|
||||
def verify_oauth_state(state: Optional[str], provider: str) -> Optional[Dict]:
|
||||
"""Validate a signed OAuth state value for the expected provider."""
|
||||
if not state:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(state, JWT_SECRET, algorithms=['HS256'])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
if payload.get('purpose') != 'oauth_state' or payload.get('provider') != provider:
|
||||
return None
|
||||
return payload
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(SALT_ROUNDS)).decode('utf-8')
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||||
|
||||
def authenticate_user(token: Optional[str]) -> Optional[Dict]:
|
||||
if not token:
|
||||
return None
|
||||
payload = verify_token(token)
|
||||
if not payload or payload.get('token_type') != 'access':
|
||||
return None
|
||||
user_id = payload.get('userId')
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
with database_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, username, email, role, credits, created_at, email_verified, two_factor_enabled
|
||||
FROM users WHERE id = ?
|
||||
''', (user_id,))
|
||||
return dict_from_row(cursor.fetchone())
|
||||
except Exception as e:
|
||||
logger.error(f"Error in authenticate_user: {e}")
|
||||
return None
|
||||
|
||||
def get_bearer_token() -> Optional[str]:
|
||||
"""Return a well-formed bearer token from the current HTTP request."""
|
||||
authorization = request.headers.get('Authorization', '')
|
||||
scheme, separator, token = authorization.partition(' ')
|
||||
if not separator or scheme.casefold() != 'bearer':
|
||||
return None
|
||||
token = token.strip()
|
||||
return token or None
|
||||
|
||||
def require_admin() -> Optional[Dict]:
|
||||
"""Return the authenticated admin user, or ``None`` for other callers."""
|
||||
user = authenticate_user(get_bearer_token())
|
||||
if user and user.get('role') == 'admin':
|
||||
return user
|
||||
return None
|
||||
|
||||
def add_credits(user_id: str, amount: int) -> bool:
|
||||
"""Add credits to user account"""
|
||||
try:
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
new_credits = user.get('credits', 0) + amount
|
||||
if update_user_credits(user_id, new_credits):
|
||||
add_credit_transaction(user_id, amount, 'add', 'Credit top-up')
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding credits: {e}")
|
||||
return False
|
||||
|
||||
def get_user_credits(user_id: str) -> int:
|
||||
"""Get user's current credit balance"""
|
||||
user = get_user_by_id(user_id)
|
||||
return user.get('credits', 0) if user else 0
|
||||
Reference in new issue
Block a user