Files
2026-10-19 11:15:05 +01:00

386 lines
16 KiB
Python

import logging
import os
import time
from typing import Any, List, Union
import sentry_sdk
from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import LoginManager
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy
from flask_talisman import Talisman
from flask_wtf.csrf import CSRFProtect
from config.config import config, validate_production_config
# SocketIO async mode selection.
#
# Default is 'threading' so the Werkzeug dev server (run.py) and any plain
# WSGI host work correctly. Production uses eventlet — but ONLY when wsgi.py
# has run first and called eventlet.monkey_patch() + psycogreen.patch_psycopg().
# wsgi.py signals that by setting SOCKETIO_ASYNC_MODE=eventlet in the
# environment before importing this module.
#
# DO NOT auto-detect from sys.modules. flask_socketio / python-engineio
# import eventlet at load time whenever it's installed, which would false-
# positive the detection and put a non-monkey-patched eventlet server in
# front of blocking stdlib I/O (that was the "site locks up during API
# requests" bug).
_SOCKETIO_ASYNC_MODE = os.environ.get('SOCKETIO_ASYNC_MODE', 'threading')
# SQLAlchemy - database interface
db = SQLAlchemy()
login_manager = LoginManager()
socketio = SocketIO(cors_allowed_origins="*")
csrf = CSRFProtect()
# Rate limiter. Uses in-memory storage; when scaling to multiple workers or
# processes, set storage_uri to a Redis URL so limits are shared. Per-route
# limits are declared in the route files with @limiter.limit(...).
limiter = Limiter(
key_func=get_remote_address,
default_limits=[],
storage_uri="memory://",
strategy="fixed-window",
)
def create_app(config_name: str = 'default') -> Flask:
# Fail fast in production if required secrets are missing
if config_name == 'production':
validate_production_config()
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize Sentry
if app.config.get('SENTRY_DSN'):
def before_send(event, hint):
"""Drop expected lifecycle exceptions from Engine.IO / Socket.IO.
Engine.IO uses a couple of raises as in-band signals to the WSGI
layer; they aren't real errors but Sentry's WSGI instrumentation
picks them up anyway. Filtering them here keeps the noise out of
the issues list.
"""
# Request URL context (used by multiple filters below)
request_url = (event.get('request') or {}).get('url', '') or ''
if 'exception' in event:
for exception in event.get('exception', {}).get('values', []):
exc_type = exception.get('type', '')
exc_value = exception.get('value', '') or ''
# (a) Clients reconnecting with stale session ids.
if 'Invalid session' in exc_value:
return None
# (b) engineio/async_drivers/_websocket_wsgi.py intentionally
# raises StopIteration on successful WebSocket upgrade so
# gunicorn keeps the connection open. Only drop it on the
# socket.io path so we don't accidentally hide a real
# StopIteration from elsewhere.
if exc_type == 'StopIteration' and '/socket.io/' in request_url:
return None
# Filter out log messages about invalid sessions
if 'logentry' in event:
message = event.get('logentry', {}).get('message', '') or ''
if 'Invalid session' in message:
return None
return event
# Build kwargs defensively — sentry-sdk periodically adds/removes
# kwargs across major versions, and a kwarg mismatch here takes the
# whole app down at boot. Pin + fall back instead of pretending
# otherwise. `enable_logs` requires sentry-sdk>=2.40.
sentry_kwargs = dict(
dsn=app.config['SENTRY_DSN'],
send_default_pii=False,
include_local_variables=False,
traces_sample_rate=1.0,
environment=app.config.get('FLASK_ENV', 'development'),
before_send=before_send,
)
try:
sentry_sdk.init(enable_logs=True, **sentry_kwargs)
except TypeError as e:
# Older / stripped sentry-sdk that doesn't know about enable_logs.
# Fall back to the core init so error reporting still works.
logging.getLogger(__name__).warning(
"sentry_sdk.init rejected enable_logs=True (%s); initialising "
"without it. Upgrade sentry-sdk to >=2.40 to re-enable logs.",
e,
)
sentry_sdk.init(**sentry_kwargs)
# Handle HTTPS proxy headers (for production behind reverse proxy)
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
# Initialize SQLAlchemy with PostgreSQL
db.init_app(app)
use_direct_postgres = app.config.get('USE_DIRECT_POSTGRES', False)
if use_direct_postgres:
app.logger.info("Using direct PostgreSQL connection via SQLAlchemy")
app.logger.info(f"Pool configuration: size={app.config['SQLALCHEMY_ENGINE_OPTIONS']['pool_size']}, "
f"max_overflow={app.config['SQLALCHEMY_ENGINE_OPTIONS']['max_overflow']}")
else:
app.logger.info("Using SQLAlchemy database")
# Initialize Flask-Login
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
# Initialize SocketIO with increased timeouts to handle browser tab throttling
# ping_timeout: Time to wait for client response before considering connection dead
# ping_interval: Time between server pings to check client connection
# async_mode: 'threading' in both dev and prod (gunicorn gthread worker) —
# see the SOCKETIO_ASYNC_MODE note above and wsgi.py for why eventlet was dropped
app.logger.info(f"Initialising SocketIO with async_mode={_SOCKETIO_ASYNC_MODE}")
socketio.init_app(
app,
async_mode=_SOCKETIO_ASYNC_MODE,
ping_timeout=120, # Increased from default 60s to 2 minutes
ping_interval=25, # Keep default 25s interval
logger=False, # Disable SocketIO logger to reduce noise
engineio_logger=False # Disable Engine.IO logger
)
# Disable verbose SocketIO logs
logging.getLogger('socketio').setLevel(logging.ERROR)
logging.getLogger('engineio').setLevel(logging.ERROR)
# Suppress Werkzeug assertion errors from WebSocket disconnections
# These occur when clients disconnect abruptly and are harmless
werkzeug_logger = logging.getLogger('werkzeug')
original_werkzeug_log = werkzeug_logger.error
def filtered_werkzeug_log(msg, *args, **kwargs):
"""Filter out write() before start_response errors"""
if isinstance(msg, str) and 'write() before start_response' in msg:
return # Suppress this specific error
return original_werkzeug_log(msg, *args, **kwargs)
werkzeug_logger.error = filtered_werkzeug_log
# Initialize CSRF protection
csrf.init_app(app)
# Initialize rate limiter (limits declared on individual routes)
limiter.init_app(app)
# Configure CSRF header names for AJAX requests
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token']
# Initialize security headers with Talisman
csp = {
'default-src': "'self'",
'script-src': [
"'self'",
"'unsafe-inline'", # Required for inline scripts and Bootstrap functionality
"https://cdn.jsdelivr.net", # Bootstrap, Chart.js
"https://cdnjs.cloudflare.com", # FontAwesome, Socket.IO
"https://cdn.socket.io", # Socket.IO CDN
"https://static.cloudflareinsights.com" # Cloudflare Analytics
],
'style-src': [
"'self'",
"'unsafe-inline'", # Required for inline styles and Bootstrap
"https://cdn.jsdelivr.net", # Bootstrap CSS
"https://cdnjs.cloudflare.com", # FontAwesome CSS
"https://fonts.googleapis.com" # Google Fonts (if used)
],
'font-src': [
"'self'",
"https://fonts.gstatic.com", # Google Fonts
"https://cdnjs.cloudflare.com", # FontAwesome fonts
"https://cdn.jsdelivr.net", # Bootstrap fonts
"data:" # Data URIs for fonts
],
'img-src': [
"'self'",
"data:", # Required for inline images and icons
"https:", # Allow all HTTPS images
"blob:" # For dynamic images
],
'connect-src': [
"'self'",
"ws:", # WebSocket connections (dev)
"wss:", # Secure WebSocket connections (prod)
"https://cdn.socket.io", # Socket.IO connections
"https://cdn.jsdelivr.net", # Bootstrap source maps
"https://cdnjs.cloudflare.com" # Socket.IO and other CDN source maps
]
}
Talisman(
app,
force_https=True, # Set to True in production
strict_transport_security=True,
strict_transport_security_max_age=31536000, # 1 year
content_security_policy=csp,
referrer_policy='strict-origin-when-cross-origin',
feature_policy={
'camera': "'none'",
'microphone': "'none'",
'geolocation': "'none'"
}
)
# User loader for Flask-Login. Called on every authenticated request, so
# keep it cheap: a simple synchronous primary-key lookup inside the
# existing app context. The previous implementation spun up a fresh
# ThreadPoolExecutor AND a fresh asyncio event loop per call just to
# await an async method that ultimately wraps ``User.query.get(user_id)``.
@login_manager.user_loader
def load_user(user_id):
from app.models.user import User
return User.query.get(user_id)
# Initialize OAuth
from app.routes.auth import oauth
oauth.init_app(app)
oauth.register(
name='google',
client_id=app.config.get('GOOGLE_CLIENT_ID'),
client_secret=app.config.get('GOOGLE_CLIENT_SECRET'),
server_metadata_url=app.config.get('GOOGLE_DISCOVERY_URL'),
client_kwargs={
'scope': 'openid email profile'
}
)
@app.route('/')
def index():
from flask import redirect, url_for
return redirect(url_for('auth.login'))
# Register blueprints
from app.routes.admin import admin_bp
from app.routes.api import api_bp
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
from app.routes.manual_review import manual_review_bp
from app.routes.monitoring import monitoring_bp
from app.routes.websocket import websocket_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(dashboard_bp, url_prefix='/dashboard')
app.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(websocket_bp)
app.register_blueprint(admin_bp, url_prefix='/admin')
app.register_blueprint(manual_review_bp)
app.register_blueprint(monitoring_bp)
# Exempt API endpoints from CSRF protection (they use API key authentication)
csrf.exempt(api_bp)
# Database initialization with retry logic (only in main process, not reloader)
if not os.environ.get('WERKZEUG_RUN_MAIN'):
with app.app_context():
_initialize_database_with_retry(app)
# Add custom Jinja2 filters
@app.template_filter('to_dict_list')
def to_dict_list_filter(items: List[Any]) -> List[Union[dict, Any]]:
"""Convert a list of objects with to_dict() method to a list of dictionaries"""
if not items:
return []
return [item.to_dict() if hasattr(item, 'to_dict') else item for item in items]
@app.template_filter('format_number')
def format_number_filter(value):
"""Format numbers with comma separators for readability"""
if value is None:
return '0'
try:
return '{:,}'.format(int(value))
except (ValueError, TypeError):
return str(value)
return app
def _initialize_database_with_retry(app: Flask, max_retries: int = 3, delay: int = 5) -> None:
"""Initialize database with retry logic for connection pool issues"""
logger = logging.getLogger(__name__)
for attempt in range(max_retries):
try:
logger.info(f"Attempting database initialization (attempt {attempt + 1}/{max_retries})")
db.create_all()
_create_default_admin(app)
logger.info("Database initialization successful")
return
except Exception as e:
error_msg = str(e).lower()
if "max clients" in error_msg or "pool" in error_msg:
logger.warning(f"Database pool issue on attempt {attempt + 1}: {e}")
if attempt < max_retries - 1:
logger.info(f"Waiting {delay} seconds before retry...")
time.sleep(delay)
delay *= 2 # Exponential backoff
else:
logger.error("Max retries reached. Database initialization failed.")
logger.error("Solutions:")
logger.error(" 1. Check your database connection pool settings")
logger.error(" 2. Reduce SQLALCHEMY_ENGINE_OPTIONS pool_size in config")
logger.error(" 3. Contact your database administrator")
# Don't exit completely, allow app to start without DB init
break
else:
logger.error(f"Database error: {e}")
if attempt < max_retries - 1:
logger.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
else:
logger.error("Database initialization failed after all retries")
break
def _create_default_admin(app: Flask) -> None:
"""Create default admin user"""
logger = logging.getLogger(__name__)
try:
import asyncio
from app.services.database_service import db_service
# Use the same pattern as user_loader for async handling
try:
loop = asyncio.get_event_loop()
admin = loop.run_until_complete(
db_service.get_user_by_email(app.config['ADMIN_EMAIL']))
except RuntimeError:
# If no event loop is running, create a new one
admin = asyncio.run(db_service.get_user_by_email(
app.config['ADMIN_EMAIL']))
if not admin:
try:
loop = asyncio.get_event_loop()
admin = loop.run_until_complete(db_service.create_user(
username='admin',
email=app.config['ADMIN_EMAIL'],
password=app.config['ADMIN_PASSWORD'],
is_admin=True
))
except RuntimeError:
admin = asyncio.run(db_service.create_user(
username='admin',
email=app.config['ADMIN_EMAIL'],
password=app.config['ADMIN_PASSWORD'],
is_admin=True
))
if admin:
logger.info(f"Created default admin user: {app.config['ADMIN_EMAIL']}")
else:
logger.error("Failed to create default admin user")
except Exception as e:
logger.error(f"Error creating default admin user: {e}")