remodulation du projet et des classes
This commit is contained in:
+373
@@ -0,0 +1,373 @@
|
||||
"""Team Tryouts Application - Flask Application Factory.
|
||||
|
||||
This module provides the application factory for creating and configuring
|
||||
the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||
from flask_cors import CORS
|
||||
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
||||
from sqlalchemy import text
|
||||
from werkzeug.exceptions import HTTPException
|
||||
import markupsafe
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def nl2br(value):
|
||||
"""Convert newlines to HTML line breaks.
|
||||
|
||||
Args:
|
||||
value: String value to convert.
|
||||
|
||||
Returns:
|
||||
Markup: HTML-safe string with line breaks.
|
||||
"""
|
||||
if value:
|
||||
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
|
||||
return ''
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application.
|
||||
|
||||
Initializes Flask with:
|
||||
- Secret key for session security
|
||||
- Database configuration
|
||||
- CSRF protection
|
||||
- CORS with restricted origins
|
||||
- Login manager
|
||||
- Rate limiting
|
||||
- All route blueprints
|
||||
- Security headers and HTTPS redirects
|
||||
- Custom error handlers
|
||||
- Health check endpoint
|
||||
- Structured logging
|
||||
|
||||
Handles database initialization and seeding with sample data if empty.
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||
if not app.config['SECRET_KEY']:
|
||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['WTF_CSRF_ENABLED'] = True
|
||||
|
||||
# File upload size limit (16 MB)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||
|
||||
# Secure session cookie settings
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||
|
||||
# Configure CORS - restrict to specific origins in production
|
||||
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
||||
|
||||
if allowed_origins:
|
||||
CORS(
|
||||
app,
|
||||
origins=allowed_origins,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600, # Cache preflight for 1 hour
|
||||
)
|
||||
else:
|
||||
# When no origins specified, allow all (development) or none (production)
|
||||
# In production with a reverse proxy, CORS is handled at the Nginx level
|
||||
CORS(
|
||||
app,
|
||||
supports_credentials=True,
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
max_age=3600,
|
||||
)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
# Configure structured logging
|
||||
from app.logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.tryouts import tryouts_bp
|
||||
from app.routes.evaluations import evaluations_bp
|
||||
from app.routes.users import users_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.teams import teams_bp
|
||||
from app.routes.matches import matches_bp
|
||||
from app.routes.team_matches import team_matches_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tryouts_bp)
|
||||
app.register_blueprint(evaluations_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(teams_bp)
|
||||
app.register_blueprint(matches_bp)
|
||||
app.register_blueprint(team_matches_bp)
|
||||
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
|
||||
# =========================================================================
|
||||
# Security Headers
|
||||
# =========================================================================
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses.
|
||||
|
||||
Implements defense-in-depth with comprehensive HTTP security headers.
|
||||
These complement the headers set by Nginx in production.
|
||||
|
||||
HSTS is only sent in production (non-debug) to avoid breaking
|
||||
local development over plain HTTP.
|
||||
"""
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), '
|
||||
'interest-cohort=(), payment=(), usb=()'
|
||||
)
|
||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' https://cdnjs.cloudflare.com; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
# Only enable HSTS when HTTPS is actually being used
|
||||
# (either direct TLS or behind a proxy that terminates TLS)
|
||||
is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
if is_https:
|
||||
response.headers['Strict-Transport-Security'] = (
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS Redirect (Production only)
|
||||
# =========================================================================
|
||||
@app.before_request
|
||||
def force_https():
|
||||
"""Redirect all HTTP requests to HTTPS in production.
|
||||
|
||||
Respects the X-Forwarded-Proto header from reverse proxies.
|
||||
Can be disabled via FORCE_HTTPS environment variable.
|
||||
"""
|
||||
if not app.debug:
|
||||
if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
|
||||
if os.getenv('FORCE_HTTPS', 'true').lower() == 'true':
|
||||
return redirect(request.url.replace('http://', 'https://'), code=301)
|
||||
|
||||
# =========================================================================
|
||||
# Health Check Endpoint
|
||||
# =========================================================================
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring and load balancers.
|
||||
|
||||
Verifies database connectivity and application health.
|
||||
Returns 200 with basic status info or 503 if unhealthy.
|
||||
|
||||
Returns:
|
||||
Response: JSON health status.
|
||||
"""
|
||||
health_data = {
|
||||
'status': 'healthy',
|
||||
'app': 'team-tryouts',
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
health_data['database'] = 'connected'
|
||||
except Exception as e:
|
||||
health_data['status'] = 'unhealthy'
|
||||
health_data['database'] = f'error: {str(e)}'
|
||||
return jsonify(health_data), 503
|
||||
|
||||
return jsonify(health_data), 200
|
||||
|
||||
# =========================================================================
|
||||
# Custom Error Handlers
|
||||
# =========================================================================
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
"""Handle 400 Bad Request errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/coach-availability') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
"""Handle 401 Unauthorized errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login for pages, JSON for API.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
from flask import flash as _flash
|
||||
_flash('Please log in to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
"""Handle 403 Forbidden errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 Not Found errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@app.errorhandler(429)
|
||||
def too_many_requests(error):
|
||||
"""Handle 429 Too Many Requests errors.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API or rendered page.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Too many requests',
|
||||
'message': 'Please try again later.'
|
||||
}), 429
|
||||
return render_template('errors/429.html', error=error), 429
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 Internal Server Error.
|
||||
|
||||
Never exposes stack traces to users. Logs the full error internally.
|
||||
|
||||
Args:
|
||||
error: The error object.
|
||||
|
||||
Returns:
|
||||
Response: Generic error page or JSON.
|
||||
"""
|
||||
# Log the full error for debugging
|
||||
app.logger.error('Internal Server Error: %s', str(error), exc_info=True)
|
||||
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.'
|
||||
}), 500
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(error):
|
||||
"""Catch-all handler for any unhandled HTTP exceptions.
|
||||
|
||||
Args:
|
||||
error: The HTTPException object.
|
||||
|
||||
Returns:
|
||||
Response: JSON error for API, re-raises for others.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': error.name,
|
||||
'message': error.description,
|
||||
'code': error.code
|
||||
}), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
# Database Initialization
|
||||
# =========================================================================
|
||||
with app.app_context():
|
||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
||||
from app.models import User
|
||||
db.create_all()
|
||||
|
||||
# Seed database if empty
|
||||
if User.query.count() == 0:
|
||||
from app.supporting_scrits.seed import seed_database
|
||||
seed_database()
|
||||
|
||||
# Start the Discord bot for notifications
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
start_bot()
|
||||
except Exception as e:
|
||||
app.logger.warning('Could not start Discord bot: %s', e)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only used for development - production uses wsgi.py (Waitress)
|
||||
app = create_app()
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
if debug_mode:
|
||||
app.logger.warning(
|
||||
'Running in DEBUG mode with Flask built-in server. '
|
||||
'This is NOT suitable for production. Use wsgi.py instead.'
|
||||
)
|
||||
app.run(debug=debug_mode, host='127.0.0.2', port=5000)
|
||||
Reference in New Issue
Block a user