Active la regle isort (I) de ruff. 45 fichiers reordonnes, aucun changement de comportement : la suite passe avant comme apres. app/models/__init__.py en est exclu. Ses imports sont ranges en onze couches commentees qui decrivent le graphe de dependances ; trier par ordre alphabetique laisse chaque titre au-dessus d un import qu il ne decrit pas, et ce fichier n a qu un role, etre lu. Commit isole, comme le formatage : un diff de brassage ne doit pas servir de couverture a un changement de comportement.
562 lines
21 KiB
Python
562 lines
21 KiB
Python
"""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
|
|
import secrets
|
|
|
|
import markupsafe
|
|
from dotenv import load_dotenv
|
|
from flask import Flask, g, jsonify, redirect, render_template, request, url_for
|
|
from flask_cors import CORS
|
|
from sqlalchemy import text
|
|
from werkzeug.exceptions import HTTPException
|
|
|
|
from app import i18n
|
|
from app.extensions import babel, csrf, db, limiter, login_manager
|
|
|
|
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:
|
|
# Markup('<br>').join() escapes each segment before joining.
|
|
# Markup('<br>'.join(...)) would mark attacker-controlled text as safe.
|
|
return markupsafe.Markup('<br>').join(str(value).splitlines())
|
|
return ''
|
|
|
|
|
|
def normalise_database_url(url):
|
|
"""Name the PostgreSQL driver explicitly in a connection URL.
|
|
|
|
`postgresql://…` does not mean "whichever driver is installed": it means
|
|
psycopg2, which SQLAlchemy imports at create_engine() time. requirements
|
|
.txt pins psycopg 3 (`psycopg[binary]`) and no psycopg2, so a clean
|
|
install starting against the URL Render hands out — and the one this
|
|
project's own documentation shows — raises
|
|
|
|
ModuleNotFoundError: No module named 'psycopg2'
|
|
|
|
before the first request. Anything with a driver already spelled out
|
|
(`postgresql+psycopg://`, `postgresql+psycopg2://`) is left alone, so
|
|
naming psycopg2 stays possible for an environment that has it.
|
|
|
|
`postgres://` is the legacy alias several hosts still emit; SQLAlchemy
|
|
dropped it in 1.4.
|
|
|
|
Args:
|
|
url: Value of DATABASE_URL, or None.
|
|
|
|
Returns:
|
|
str | None: The URL, with a driver named when it was PostgreSQL.
|
|
"""
|
|
if not url:
|
|
return url
|
|
scheme, separator, rest = url.partition('://')
|
|
if not separator or '+' in scheme:
|
|
return url
|
|
if scheme in ('postgres', 'postgresql'):
|
|
return f'postgresql+psycopg://{rest}'
|
|
return url
|
|
|
|
|
|
def build_csp(*, allow_inline_script, nonce=None):
|
|
"""Assemble the Content-Security-Policy header.
|
|
|
|
Two mutually exclusive modes, and they really are exclusive.
|
|
|
|
Under CSP level 3, a browser that understands nonces **ignores
|
|
'unsafe-inline' entirely as soon as a nonce is present**. Emitting both
|
|
would therefore not be a gentle transition: it would drop every inline
|
|
script and every onclick attribute at once, in modern browsers only.
|
|
The switch has to be atomic, which is why one flag drives it.
|
|
|
|
While allow_inline_script is true no nonce is emitted at all, so adding
|
|
nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless.
|
|
|
|
Flipping the flag requires every inline event handler to be gone first.
|
|
A nonce cannot authorise an onclick attribute — nonces apply to script
|
|
elements, never to handler attributes. See tests/test_csp.py, which
|
|
tracks how many are left.
|
|
|
|
Args:
|
|
allow_inline_script: Keep 'unsafe-inline' in script-src.
|
|
nonce: Per-request nonce, used only when inline script is not allowed.
|
|
|
|
Returns:
|
|
str: The header value.
|
|
"""
|
|
if allow_inline_script:
|
|
script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
|
else:
|
|
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
|
|
|
|
return '; '.join(
|
|
[
|
|
"default-src 'self'",
|
|
f'script-src {script_src}',
|
|
# style-src is a separate migration: inline style="" attributes are
|
|
# spread across the templates and are not an XSS vector on their own.
|
|
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
|
|
"font-src 'self' https://cdnjs.cloudflare.com",
|
|
"img-src 'self' data: https://cdn.discordapp.com",
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
]
|
|
)
|
|
|
|
|
|
def create_app(config=None):
|
|
"""Create and configure the Flask application.
|
|
|
|
Args:
|
|
config: Optional mapping of configuration overrides, applied after the
|
|
environment defaults and before validation. This is what makes the
|
|
factory usable from tests: pass a throwaway database URI, a dummy
|
|
secret, and turn off the Discord bot, without touching os.environ.
|
|
|
|
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__)
|
|
|
|
# --- defaults from the environment ------------------------------------
|
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
app.config['WTF_CSRF_ENABLED'] = True
|
|
app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '')
|
|
app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true'
|
|
|
|
# Now false: every inline event handler has been replaced by a
|
|
# data-action attribute dispatched from main.js, so script-src no longer
|
|
# needs 'unsafe-inline'. Inline <script> blocks carry a per-request
|
|
# nonce. The escape hatch remains for a deployment that hits an
|
|
# overlooked handler — but leaving it on gives up the protection that
|
|
# would have blocked SEC-XSS-001.
|
|
app.config['CSP_ALLOW_INLINE_SCRIPT'] = (
|
|
os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'false').lower() == 'true'
|
|
)
|
|
|
|
# Internationalisation. French is the site's primary language.
|
|
app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE
|
|
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), 'translations'
|
|
)
|
|
|
|
# Side effects of create_app(), both on by default so that production and
|
|
# development behave exactly as before. Tests turn them off.
|
|
app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
|
|
app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
|
|
|
|
# --- caller overrides win ---------------------------------------------
|
|
if config:
|
|
app.config.update(config)
|
|
|
|
# --- validation, after overrides so tests can supply their own ---------
|
|
if not app.config['SECRET_KEY']:
|
|
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
|
if not app.config['SQLALCHEMY_DATABASE_URI']:
|
|
raise RuntimeError(
|
|
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
|
|
)
|
|
|
|
# After the overrides, so a caller-supplied URL is normalised too.
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = normalise_database_url(
|
|
app.config['SQLALCHEMY_DATABASE_URI']
|
|
)
|
|
|
|
# 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 = str(app.config.get('CORS_ALLOWED_ORIGINS') or '').split(',')
|
|
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
|
|
|
|
# CORS is only configured when origins are named explicitly.
|
|
#
|
|
# The previous else-branch called CORS(app, supports_credentials=True)
|
|
# with no origins argument. flask-cors then defaults to '*' and, because
|
|
# credentials are allowed, echoes back whatever Origin the caller sent
|
|
# together with Access-Control-Allow-Credentials: true — the opposite of
|
|
# the "allow all (development) or none (production)" the comment claimed.
|
|
#
|
|
# Exploitation was blocked by SESSION_COOKIE_SAMESITE = 'Lax', which stops
|
|
# the browser attaching the session cookie to a cross-site fetch. That is
|
|
# a single setting standing between a misconfiguration and a cross-origin
|
|
# data leak. This application renders server-side HTML on one origin and
|
|
# needs no CORS policy at all.
|
|
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
|
|
)
|
|
|
|
db.init_app(app)
|
|
login_manager.init_app(app)
|
|
csrf.init_app(app)
|
|
limiter.init_app(app)
|
|
babel.init_app(app, locale_selector=i18n.select_locale)
|
|
|
|
# Exposed to every template so the language switcher can render itself
|
|
# without each view having to pass the list along.
|
|
@app.before_request
|
|
def generate_csp_nonce():
|
|
# Only meaningful once inline script is disallowed; generated
|
|
# unconditionally so templates can carry nonce="" beforehand.
|
|
g.csp_nonce = secrets.token_urlsafe(16)
|
|
|
|
@app.context_processor
|
|
def inject_csp_nonce():
|
|
return {
|
|
'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
|
|
}
|
|
|
|
@app.context_processor
|
|
def inject_locales():
|
|
from flask_babel import get_locale
|
|
|
|
return {
|
|
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
|
|
'supported_locales': i18n.SUPPORTED_LOCALES,
|
|
'locale_names': i18n.LOCALE_NAMES,
|
|
}
|
|
|
|
# Configure structured logging
|
|
from app.logging_config import configure_logging
|
|
|
|
configure_logging(app)
|
|
|
|
from app.routes.auth import auth_bp
|
|
from app.routes.evaluations import evaluations_bp
|
|
from app.routes.main import main_bp
|
|
from app.routes.matches import matches_bp
|
|
from app.routes.team_matches import team_matches_bp
|
|
from app.routes.teams import teams_bp
|
|
from app.routes.tryouts import tryouts_bp
|
|
from app.routes.users import users_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.
|
|
"""
|
|
# X-XSS-Protection is deliberately not set: the auditor it addressed
|
|
# has been removed from every current browser, and its last versions
|
|
# introduced vulnerabilities of their own. CSP frame-ancestors and
|
|
# X-Frame-Options cover the remaining ground.
|
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
response.headers['X-Frame-Options'] = 'DENY'
|
|
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'] = build_csp(
|
|
allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'],
|
|
nonce=g.get('csp_nonce'),
|
|
)
|
|
|
|
# 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.
|
|
|
|
Returns:
|
|
Response | None: A redirect, or None to let the request through.
|
|
"""
|
|
if not app.debug and app.config['FORCE_HTTPS']:
|
|
already_secure = (
|
|
request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
|
|
)
|
|
if not already_secure:
|
|
return redirect(request.url.replace('http://', 'https://'), code=301)
|
|
return None
|
|
|
|
# =========================================================================
|
|
# 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',
|
|
}
|
|
|
|
# The bot runs in a daemon thread inside this process. When it dies
|
|
# the site keeps serving pages and every notification stops, with
|
|
# nothing to see from outside — which is how it stayed unnoticed.
|
|
# Reported, not fatal: a club without Discord reminders is degraded,
|
|
# not down, and a 503 here would take the site out of the load
|
|
# balancer for it (OPS-012).
|
|
if app.config['ENABLE_DISCORD_BOT']:
|
|
from app.discord_bot import bot_status
|
|
|
|
health_data['discord_bot'] = bot_status()
|
|
|
|
# Check database connectivity
|
|
try:
|
|
db.session.execute(text('SELECT 1'))
|
|
health_data['database'] = 'connected'
|
|
except Exception:
|
|
# Never echo the driver error: it routinely carries the host,
|
|
# database name and user of the connection string, and /health
|
|
# is unauthenticated.
|
|
app.logger.error('Health check: database unreachable', exc_info=True)
|
|
health_data['status'] = 'unhealthy'
|
|
health_data['database'] = 'error'
|
|
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
|
|
|
|
# NOTE: create_all() only ever creates missing tables. It never adds a
|
|
# column to an existing one, so a model change is silently absent from
|
|
# any database that already has the table. Replacing this with Alembic
|
|
# is tracked as DB-002/DB-004; until then the behaviour is preserved.
|
|
if app.config['AUTO_CREATE_TABLES']:
|
|
db.create_all()
|
|
|
|
# Start the Discord bot for notifications
|
|
if app.config['ENABLE_DISCORD_BOT']:
|
|
try:
|
|
from app.discord_bot import start_bot
|
|
|
|
start_bot(flask_app=app)
|
|
except Exception as e:
|
|
app.logger.warning('Could not start Discord bot: %s', e)
|
|
|
|
return app
|
|
|
|
|
|
# No __main__ block here on purpose. There used to be one, and with run.py
|
|
# and wsgi.py that made three ways to start the application, each with its
|
|
# own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000
|
|
# while `python run.py` bound 127.0.0.2:5000 with the debugger on. This
|
|
# module defines the factory; run.py starts it for development, wsgi.py for
|
|
# production (ARCH-007).
|