mirror of
https://github.com/Significant-Gravitas/AutoModerate.git
synced 2026-11-08 17:50:32 -10:00
386 lines
16 KiB
Python
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}")
|