demander IA de faire tous les modifications pour que le webapp soit pret au déploiement.
Force connection HTTPS, proxy-inversé, WSGI de production, reset cookie de conncection à chaque reconnection, limite sur les mdp, fichiers et One on One par minute, verification d'injection de SQL dans les champs d'entrées. renommage des fichiers lors du téléchargement, fichier de backup quotidien pour la bd et j'ai oublié quelque chose :(
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Dependabot configuration for automated dependency updates
|
||||
#
|
||||
# Dependabot creates PRs when dependencies have new versions,
|
||||
# helping keep the application secure and up-to-date.
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies (pip)
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "America/Toronto"
|
||||
# Group all updates into a single PR
|
||||
groups:
|
||||
python-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
# Limit open PRs to avoid overwhelm
|
||||
open-pull-requests-limit: 5
|
||||
# Labels for PRs
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "security"
|
||||
# Assign reviewer
|
||||
reviewers:
|
||||
- "cedrick2711"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
time: "09:00"
|
||||
timezone: "America/Toronto"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
@@ -0,0 +1,94 @@
|
||||
name: CI - Security & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch: # Allow manual triggers
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pip-audit
|
||||
|
||||
- name: Scan for vulnerable dependencies
|
||||
run: pip-audit --require-hashes --no-deps || pip-audit
|
||||
|
||||
lint:
|
||||
name: Lint with Ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff linter
|
||||
run: ruff check . --output-format=github
|
||||
|
||||
- name: Run ruff formatter check
|
||||
run: ruff format --check .
|
||||
|
||||
security-scan:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install app dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run security scan
|
||||
env:
|
||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||
FLASK_DEBUG: 'false'
|
||||
run: python security_scan.py --skip-http
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [security-audit, lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "No tests configured yet. Add tests to the project."
|
||||
# python -m pytest tests/ --cov=. --cov-report=xml
|
||||
continue-on-error: true
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Team Tryouts Application - Flask Application Factory.
|
||||
|
||||
This module provides the application factory for creating and configuring
|
||||
the Flask application instance.
|
||||
the Flask application instance with comprehensive security hardening.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, request, redirect
|
||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||
from flask_cors import CORS
|
||||
from 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
|
||||
|
||||
@@ -16,10 +18,10 @@ 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.
|
||||
"""
|
||||
@@ -30,17 +32,22 @@ def nl2br(value):
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -52,17 +59,46 @@ def create_app():
|
||||
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 logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
|
||||
from routes.auth import auth_bp
|
||||
from routes.tryouts import tryouts_bp
|
||||
from routes.evaluations import evaluations_bp
|
||||
@@ -82,30 +118,232 @@ def create_app():
|
||||
# Register custom Jinja filters
|
||||
app.jinja_env.filters['nl2br'] = nl2br
|
||||
|
||||
# Add security headers to all responses
|
||||
# =========================================================================
|
||||
# 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['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';"
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
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
|
||||
|
||||
# Force HTTPS in production (when not in debug mode)
|
||||
# =========================================================================
|
||||
# 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 models
|
||||
from models import User, MatchParticipant
|
||||
from models import User
|
||||
try:
|
||||
# Check if the database schema is up to date by testing a query
|
||||
# that uses all model columns including team_side for match participants
|
||||
db.session.execute(text('SELECT games, team_side FROM match_participants LIMIT 1'))
|
||||
db.create_all()
|
||||
except Exception:
|
||||
@@ -113,7 +351,7 @@ def create_app():
|
||||
db.session.rollback()
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
|
||||
|
||||
# Seed database if empty
|
||||
if User.query.count() == 0:
|
||||
from seed import seed_database
|
||||
@@ -124,14 +362,18 @@ def create_app():
|
||||
from discord_bot import start_bot
|
||||
start_bot()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Could not start Discord bot: {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 should only be enabled via environment variable for security
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||
app.run(debug=debug_mode, host='0.0.0.0', port=5000)
|
||||
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.1', port=5000)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Database backup script for the Team Tryouts application.
|
||||
|
||||
This module provides a simple backup mechanism for the SQLite database
|
||||
and uploaded contract documents. Designed to be run as a scheduled task
|
||||
(Windows Task Scheduler) or cron job.
|
||||
|
||||
Usage:
|
||||
python backup.py
|
||||
|
||||
Configuration via environment variables:
|
||||
BACKUP_DIR: Directory to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
||||
DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db'))
|
||||
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
|
||||
|
||||
|
||||
def create_backup_dir():
|
||||
"""Create the backup directory if it doesn't exist."""
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def backup_database():
|
||||
"""Backup the SQLite database using sqlite3's built-in backup API.
|
||||
|
||||
Returns:
|
||||
str: Path to the created backup file, or None if failed.
|
||||
"""
|
||||
if not os.path.exists(DATABASE_PATH):
|
||||
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'db_backup_{timestamp}.db'
|
||||
backup_path = os.path.join(BACKUP_DIR, backup_filename)
|
||||
|
||||
try:
|
||||
source = sqlite3.connect(DATABASE_PATH)
|
||||
destination = sqlite3.connect(backup_path)
|
||||
source.backup(destination)
|
||||
source.close()
|
||||
destination.close()
|
||||
print(f'[OK] Database backed up to: {backup_path}')
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Database backup failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def backup_documents():
|
||||
"""Backup the uploaded contract documents directory.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if no documents exist.
|
||||
"""
|
||||
if not os.path.exists(DOCUMENTS_DIR):
|
||||
print('[INFO] No documents directory found. Skipping document backup.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = f'documents_backup_{timestamp}'
|
||||
archive_path = os.path.join(BACKUP_DIR, archive_basename)
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR)
|
||||
zip_path = f'{archive_path}.zip'
|
||||
print(f'[OK] Documents backed up to: {zip_path}')
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Document backup failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||
if not os.path.exists(BACKUP_DIR):
|
||||
return
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
|
||||
removed_count = 0
|
||||
|
||||
for filename in os.listdir(BACKUP_DIR):
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||
except OSError as e:
|
||||
print(f'[WARNING] Could not remove {filename}: {e}')
|
||||
|
||||
if removed_count > 0:
|
||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||
else:
|
||||
print('[CLEANUP] No old backups to remove.')
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Verify a database backup by running a quick integrity check.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the backup file to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if backup is valid, False otherwise.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(backup_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('PRAGMA integrity_check')
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
is_valid = result[0] == 'ok'
|
||||
if is_valid:
|
||||
print(f'[OK] Backup integrity verified: {backup_path}')
|
||||
else:
|
||||
print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}')
|
||||
return is_valid
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Backup verification failed: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the full backup process.
|
||||
|
||||
Steps:
|
||||
1. Create backup directory
|
||||
2. Backup database
|
||||
3. Backup documents (if any)
|
||||
4. Verify database backup
|
||||
5. Clean up old backups
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on failure.
|
||||
"""
|
||||
print(f'=== Team Tryouts Backup ===')
|
||||
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print(f'Backup directory: {BACKUP_DIR}')
|
||||
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
||||
print()
|
||||
|
||||
create_backup_dir()
|
||||
|
||||
# 1. Backup database
|
||||
db_backup_path = backup_database()
|
||||
success = True
|
||||
|
||||
# 2. Verify database backup
|
||||
if db_backup_path:
|
||||
if not verify_backup(db_backup_path):
|
||||
success = False
|
||||
|
||||
# 3. Backup documents
|
||||
backup_documents()
|
||||
|
||||
# 4. Cleanup old backups
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if success:
|
||||
print('=== Backup completed successfully ===')
|
||||
else:
|
||||
print('=== Backup completed with warnings ===')
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
# Team Tryouts - Production Deployment Guide (Windows)
|
||||
|
||||
This document outlines the secure production deployment for the Team Tryouts
|
||||
Flask application on Windows with Nginx reverse proxy.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Internet → Nginx (HTTPS:443) → Waitress (127.0.0.1:5000) → Flask App
|
||||
```
|
||||
|
||||
- **Nginx**: TLS termination, static file serving, rate limiting, security headers
|
||||
- **Waitress**: Production WSGI server with multiple worker threads
|
||||
- **Flask**: Application logic (never exposed directly to the internet)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Python 3.12+** installed
|
||||
2. **Nginx for Windows** downloaded from [nginx.org](https://nginx.org/en/download.html)
|
||||
3. **SSL Certificate** (Let's Encrypt via certbot or commercial provider)
|
||||
4. **Windows Firewall** configured properly
|
||||
|
||||
## Step 1: Install Dependencies
|
||||
|
||||
```powershell
|
||||
# Install Python packages
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create required directories
|
||||
mkdir logs
|
||||
mkdir backups
|
||||
```
|
||||
|
||||
## Step 2: Configure Environment Variables
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```env
|
||||
# Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||
SECRET_KEY=<your-generated-64-char-hex-key>
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///team_tryouts.db
|
||||
|
||||
# Security settings
|
||||
SESSION_COOKIE_SECURE=true
|
||||
FORCE_HTTPS=true
|
||||
FLASK_DEBUG=false
|
||||
|
||||
# CORS (set to your actual domain in production)
|
||||
CORS_ALLOWED_ORIGINS=https://yourdomain.com
|
||||
|
||||
# Discord bot (optional)
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_WEBHOOK_URL=
|
||||
|
||||
# Backup settings
|
||||
BACKUP_DIR=./backups
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
```
|
||||
|
||||
**Important**: Never commit `.env` to version control.
|
||||
|
||||
## Step 3: Configure Nginx
|
||||
|
||||
1. Copy `nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`)
|
||||
2. Place SSL certificate files:
|
||||
- `C:\nginx\certs\fullchain.pem`
|
||||
- `C:\nginx\certs\privkey.pem`
|
||||
3. Start Nginx: `C:\nginx\nginx.exe`
|
||||
|
||||
### Obtaining SSL Certificates
|
||||
|
||||
Using Let's Encrypt with certbot (recommended):
|
||||
|
||||
```powershell
|
||||
# Using certbot on Windows
|
||||
certbot certonly --standalone -d yourdomain.com
|
||||
```
|
||||
|
||||
Or use your hosting provider's SSL certificate.
|
||||
|
||||
## Step 4: Start the Application
|
||||
|
||||
```powershell
|
||||
# Production start
|
||||
python wsgi.py
|
||||
|
||||
# Or with custom port/threads
|
||||
$env:PORT=5000
|
||||
$env:WAITRESS_THREADS=5
|
||||
python wsgi.py
|
||||
```
|
||||
|
||||
## Step 5: Configure Windows Firewall
|
||||
|
||||
```powershell
|
||||
# Allow only necessary ports
|
||||
New-NetFirewallRule -DisplayName "Nginx HTTPS" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow
|
||||
New-NetFirewallRule -DisplayName "Nginx HTTP Redirect" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
|
||||
|
||||
# Block direct access to Waitress port (5000) from outside
|
||||
New-NetFirewallRule -DisplayName "Block Waitress External" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Block
|
||||
```
|
||||
|
||||
## Step 6: Set Up Automated Backups
|
||||
|
||||
Create a scheduled task for daily backups:
|
||||
|
||||
```powershell
|
||||
# Run backup script daily at 2:00 AM
|
||||
$Action = New-ScheduledTaskAction -Execute "python" -Argument "backup.py" -WorkingDirectory "C:\path\to\team-tryouts"
|
||||
$Trigger = New-ScheduledTaskTrigger -Daily -At 2:00AM
|
||||
Register-ScheduledTask -TaskName "TeamTryoutsBackup" -Action $Action -Trigger $Trigger
|
||||
```
|
||||
|
||||
## Step 7: Run Security Validation
|
||||
|
||||
Before going live:
|
||||
|
||||
```powershell
|
||||
# Run the security scanner
|
||||
python security_scan.py --url https://yourdomain.com
|
||||
```
|
||||
|
||||
All checks must pass before deployment.
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] `.env` is not committed to repository
|
||||
- [ ] `SECRET_KEY` is strong (64+ hex characters, randomly generated)
|
||||
- [ ] `FLASK_DEBUG` is set to `false`
|
||||
- [ ] Nginx is running with HTTPS (port 443)
|
||||
- [ ] HTTP (port 80) redirects to HTTPS
|
||||
- [ ] TLS 1.2+ configured (TLS 1.0/1.1 disabled)
|
||||
- [ ] HSTS header is present (`max-age=31536000; includeSubDomains; preload`)
|
||||
- [ ] CSP header is configured with no `unsafe-eval`
|
||||
- [ ] `server_tokens` is `off` in Nginx
|
||||
- [ ] File uploads restricted to PDF only
|
||||
- [ ] File uploads limited to 16MB
|
||||
- [ ] Database backups scheduled daily
|
||||
- [ ] Firewall rules applied (only 80/443 open)
|
||||
- [ ] Application is not running as administrator
|
||||
- [ ] Logs directory exists and is writable
|
||||
- [ ] Health check endpoint returns 200
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Application logs**: `./logs/app.log`
|
||||
- **Error logs**: `./logs/errors.log`
|
||||
- **Auth logs**: `./logs/auth.log`
|
||||
- **Nginx access logs**: `C:\nginx\logs\access.log`
|
||||
- **Nginx error logs**: `C:\nginx\logs\error.log`
|
||||
|
||||
Monitor these logs regularly for suspicious activity.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Run `pip-audit` weekly to check for vulnerabilities
|
||||
- Run `python security_scan.py` after any configuration changes
|
||||
- Test backup restoration quarterly
|
||||
- Review and rotate `SECRET_KEY` if compromised
|
||||
- Keep Python and system packages updated
|
||||
@@ -0,0 +1,169 @@
|
||||
# Pre-Deployment Security Checklist
|
||||
|
||||
Run through this checklist before deploying to production.
|
||||
|
||||
## Phase 1 — Transport Security
|
||||
|
||||
- [ ] SSL certificate obtained and installed
|
||||
- [ ] All HTTP requests redirect to HTTPS (301)
|
||||
- [ ] HSTS header present: `max-age=31536000; includeSubDomains; preload`
|
||||
- [ ] TLS 1.2 minimum, TLS 1.3 preferred
|
||||
- [ ] Strong ciphers configured (no RC4, 3DES, or export-grade)
|
||||
- [ ] OCSP Stapling configured (optional but recommended)
|
||||
|
||||
## Phase 2 — Production WSGI Server
|
||||
|
||||
- [ ] Application running via Waitress (not Flask dev server)
|
||||
- [ ] Worker threads: `CPU * 2 + 1`
|
||||
- [ ] Application bound to `127.0.0.1` (not `0.0.0.0`)
|
||||
- [ ] Graceful shutdown configured
|
||||
|
||||
## Phase 3 — Secure Cookies & Sessions
|
||||
|
||||
- [ ] `SESSION_COOKIE_SECURE = True`
|
||||
- [ ] `SESSION_COOKIE_HTTPONLY = True`
|
||||
- [ ] `SESSION_COOKIE_SAMESITE = "Lax"`
|
||||
- [ ] Session timeout ≤ 1 hour
|
||||
- [ ] Session regenerated after login
|
||||
- [ ] Session cleared after logout
|
||||
|
||||
## Phase 5 — Authentication Security
|
||||
|
||||
- [ ] Passwords hashed with werkzeug/bcrypt (never plain text)
|
||||
- [ ] Password policy enforced (min 8 chars, uppercase, lowercase, digit)
|
||||
- [ ] Account lockout after 5 failed attempts (15 min)
|
||||
- [ ] CAPTCHA on registration form
|
||||
- [ ] Rate limiting on login (10/min) and registration (3/hr)
|
||||
|
||||
## Phase 6 — CSRF Protection
|
||||
|
||||
- [ ] CSRF protection enabled (Flask-WTF)
|
||||
- [ ] All POST/PUT/PATCH/DELETE requests protected
|
||||
- [ ] CSRF-exempt routes reviewed and justified
|
||||
|
||||
## Phase 7 — Input Validation
|
||||
|
||||
- [ ] All form inputs validated with Marshmallow schemas
|
||||
- [ ] Input whitespace stripped automatically
|
||||
- [ ] Email format validated
|
||||
- [ ] Phone format validated (if provided)
|
||||
- [ ] Discord username format validated (if provided)
|
||||
|
||||
## Phase 8 — SQL Injection Protection
|
||||
|
||||
- [ ] All database queries use SQLAlchemy ORM
|
||||
- [ ] No raw SQL with string interpolation
|
||||
- [ ] Parameterized queries used if raw SQL is necessary
|
||||
|
||||
## Phase 9 — HTTP Security Headers
|
||||
|
||||
- [ ] `X-Content-Type-Options: nosniff`
|
||||
- [ ] `X-Frame-Options: DENY`
|
||||
- [ ] `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- [ ] `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||
- [ ] `Cross-Origin-Opener-Policy: same-origin`
|
||||
- [ ] `Content-Security-Policy` configured
|
||||
- [ ] CSP does not contain `unsafe-eval`
|
||||
|
||||
## Phase 10 — CORS
|
||||
|
||||
- [ ] CORS configured with explicit allowed origins
|
||||
- [ ] No wildcard (`*`) origin in production
|
||||
- [ ] Only necessary methods exposed
|
||||
- [ ] Credentials support configured properly
|
||||
|
||||
## Phase 11 — API Security
|
||||
|
||||
- [ ] All API endpoints require authentication
|
||||
- [ ] Authorization verified per-endpoint (not just authentication)
|
||||
- [ ] Cross-user data access prevented (e.g., Player A cannot view Player B's data)
|
||||
|
||||
## Phase 12 — File Upload Security
|
||||
|
||||
- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`)
|
||||
- [ ] File extensions restricted to PDF only
|
||||
- [ ] Files stored with UUID filenames (not original names)
|
||||
- [ ] Upload directory outside web root
|
||||
|
||||
## Phase 13 — Logging & Monitoring
|
||||
|
||||
- [ ] Structured logging configured
|
||||
- [ ] Separate logs for errors and auth events
|
||||
- [ ] Sensitive data filtered from logs
|
||||
- [ ] Log rotation configured
|
||||
- [ ] Logs directory secured
|
||||
|
||||
## Phase 14 — Rate Limiting & Abuse Protection
|
||||
|
||||
- [ ] Global rate limits configured
|
||||
- [ ] Login: 10 requests/minute
|
||||
- [ ] Registration: 3 requests/hour
|
||||
- [ ] CAPTCHA on registration
|
||||
|
||||
## Phase 15 — Reverse Proxy Security
|
||||
|
||||
- [ ] `server_tokens off` in Nginx
|
||||
- [ ] Request size limits configured
|
||||
- [ ] Buffer size limits configured
|
||||
- [ ] Timeout settings configured
|
||||
- [ ] Real IP forwarding headers set
|
||||
|
||||
## Phase 16 — Database Security
|
||||
|
||||
- [ ] Database not accessible from outside
|
||||
- [ ] Least-privilege database user (when using PostgreSQL/MySQL)
|
||||
- [ ] Daily automated backups configured
|
||||
- [ ] Backup restoration tested
|
||||
|
||||
## Phase 17 — Dependency Security
|
||||
|
||||
- [ ] All packages pinned to specific versions
|
||||
- [ ] Dependabot configured for automated updates
|
||||
- [ ] pip-audit run with no critical vulnerabilities
|
||||
- [ ] Regular dependency review schedule
|
||||
|
||||
## Phase 18 — Infrastructure
|
||||
|
||||
- [ ] Firewall: only ports 80 and 443 open
|
||||
- [ ] Block direct access to application port (5000)
|
||||
- [ ] SSH key authentication only (no password)
|
||||
- [ ] Automatic security updates enabled
|
||||
- [ ] Application running with least privilege
|
||||
|
||||
## Phase 19 — Flask Best Practices
|
||||
|
||||
- [ ] DEBUG mode disabled
|
||||
- [ ] Custom error handlers for 400/401/403/404/429/500
|
||||
- [ ] No stack traces exposed in error pages
|
||||
- [ ] Environment variables for all configuration
|
||||
- [ ] Health check endpoint (`GET /health`) working
|
||||
- [ ] Blueprints used for modular organization
|
||||
|
||||
## Phase 20 — Deployment & CI/CD
|
||||
|
||||
- [ ] Code in version control (Git)
|
||||
- [ ] Main branch protected
|
||||
- [ ] CI pipeline running (lint, security scan, tests)
|
||||
- [ ] No secrets committed to repository
|
||||
- [ ] Backup before each deployment
|
||||
|
||||
## Phase 21 — Final Validation
|
||||
|
||||
- [ ] Run `python security_scan.py --url https://yourdomain.com`
|
||||
- [ ] Scan with Mozilla Observatory: https://observatory.mozilla.org/
|
||||
- [ ] Scan with SecurityHeaders.com: https://securityheaders.com/
|
||||
- [ ] Verify all pages redirect correctly to HTTPS
|
||||
- [ ] Check cookies: Secure, HttpOnly, SameSite
|
||||
- [ ] Confirm debug mode is off
|
||||
- [ ] Review initial logs for sensitive data leaks
|
||||
- [ ] Test rate limiting on login and registration
|
||||
- [ ] Test account lockout mechanism
|
||||
- [ ] Test file upload restrictions (non-PDF should fail)
|
||||
|
||||
---
|
||||
|
||||
**Date Validated**: _______________
|
||||
|
||||
**Validated By**: _______________
|
||||
|
||||
**Notes**: _______________
|
||||
Binary file not shown.
@@ -0,0 +1,154 @@
|
||||
"""Structured logging configuration for the Team Tryouts application.
|
||||
|
||||
This module configures rotating file handlers for application logs,
|
||||
with separate files for errors, authentication events, and general logs.
|
||||
Sensitive data (passwords, tokens) is automatically filtered out.
|
||||
|
||||
Usage:
|
||||
from logging_config import configure_logging
|
||||
configure_logging(app)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import re
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""Logging filter that redacts sensitive information from log messages.
|
||||
|
||||
Filters out: passwords, API keys, session tokens, and other secrets
|
||||
that might accidentally be logged.
|
||||
"""
|
||||
|
||||
# Patterns to redact
|
||||
SENSITIVE_PATTERNS = [
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'),
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'),
|
||||
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
||||
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
||||
]
|
||||
|
||||
def filter(self, record):
|
||||
"""Apply redaction to the log record's message.
|
||||
|
||||
Args:
|
||||
record: The log record to filter.
|
||||
|
||||
Returns:
|
||||
bool: Always True (never drops records, only redacts).
|
||||
"""
|
||||
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
||||
msg = record.msg
|
||||
for pattern, replacement in self.SENSITIVE_PATTERNS:
|
||||
if callable(replacement):
|
||||
msg = pattern.sub(replacement, msg)
|
||||
else:
|
||||
msg = pattern.sub(replacement, msg)
|
||||
record.msg = msg
|
||||
return True
|
||||
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure structured logging for the Flask application.
|
||||
|
||||
Sets up three rotating file handlers:
|
||||
- errors.log: ERROR and CRITICAL level messages
|
||||
- auth.log: Authentication-related events (INFO and above)
|
||||
- app.log: All application logs (DEBUG and above, configurable)
|
||||
|
||||
Also configures console output for development.
|
||||
|
||||
Args:
|
||||
app: The Flask application instance to configure logging for.
|
||||
"""
|
||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Remove default Flask handlers to avoid duplicate logging
|
||||
app.logger.handlers.clear()
|
||||
|
||||
# Set base log level from environment (default: INFO)
|
||||
log_level_name = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
app.logger.setLevel(log_level)
|
||||
|
||||
# Create the sensitive data filter
|
||||
sensitive_filter = SensitiveDataFilter()
|
||||
|
||||
# Formatter with timestamp, level, module, and message
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. Error Log Handler
|
||||
# -------------------------------------------------------------------------
|
||||
error_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
error_handler.addFilter(sensitive_filter)
|
||||
app.logger.addHandler(error_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. Authentication Log Handler
|
||||
# -------------------------------------------------------------------------
|
||||
auth_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'auth.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5
|
||||
)
|
||||
auth_handler.setLevel(logging.INFO)
|
||||
auth_handler.setFormatter(formatter)
|
||||
auth_handler.addFilter(sensitive_filter)
|
||||
|
||||
# Create a named logger specifically for auth events
|
||||
auth_logger = logging.getLogger('team_tryouts.auth')
|
||||
auth_logger.setLevel(logging.INFO)
|
||||
auth_logger.addHandler(auth_handler)
|
||||
auth_logger.propagate = False # Don't double-log to root
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. Application Log Handler (general)
|
||||
# -------------------------------------------------------------------------
|
||||
app_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
)
|
||||
app_handler.setLevel(log_level)
|
||||
app_handler.setFormatter(formatter)
|
||||
app_handler.addFilter(sensitive_filter)
|
||||
app.logger.addHandler(app_handler)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 4. Console Handler (for development)
|
||||
# -------------------------------------------------------------------------
|
||||
if os.getenv('FLASK_DEBUG', 'false').lower() == 'true':
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(sensitive_filter)
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
# Log startup information
|
||||
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
||||
app.logger.info('Application startup')
|
||||
|
||||
return app.logger
|
||||
|
||||
|
||||
# Module-level auth logger factory
|
||||
def get_auth_logger():
|
||||
"""Get the authentication event logger.
|
||||
|
||||
Returns:
|
||||
logging.Logger: Logger for authentication events.
|
||||
"""
|
||||
return logging.getLogger('team_tryouts.auth')
|
||||
@@ -81,6 +81,10 @@ class User(UserMixin, db.Model):
|
||||
is_active_account = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Account lockout fields for brute-force protection
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# E-Sports specific fields
|
||||
games = db.Column(db.Text, nullable=True) # Comma-separated list of games
|
||||
discord_username = db.Column(db.String(128), nullable=True) # Discord handle (e.g., Username#1234)
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# Team Tryouts - Production Nginx Configuration (Windows)
|
||||
#
|
||||
# This configuration provides:
|
||||
# - HTTP to HTTPS redirect
|
||||
# - TLS 1.2/1.3 with strong ciphers
|
||||
# - HSTS enforcement
|
||||
# - Request size limits
|
||||
# - gzip compression
|
||||
# - Proxy to Waitress (Flask)
|
||||
# - Security headers (reinforced at reverse proxy level)
|
||||
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
# =========================================================================
|
||||
# Basic Settings
|
||||
# =========================================================================
|
||||
server_tokens off; # Hide Nginx version
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 16M; # Max upload size (matches Flask MAX_CONTENT_LENGTH)
|
||||
client_body_buffer_size 128k;
|
||||
client_header_buffer_size 1k;
|
||||
large_client_header_buffers 4 8k;
|
||||
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# =========================================================================
|
||||
# Logging
|
||||
# =========================================================================
|
||||
access_log logs/access.log;
|
||||
error_log logs/error.log warn;
|
||||
|
||||
# =========================================================================
|
||||
# Gzip Compression
|
||||
# =========================================================================
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml
|
||||
font/ttf
|
||||
font/otf;
|
||||
|
||||
# =========================================================================
|
||||
# HTTP → HTTPS Redirect
|
||||
# =========================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# HTTPS Server
|
||||
# =========================================================================
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# SSL/TLS Configuration
|
||||
# ---------------------------------------------------------------------
|
||||
# Paths to SSL certificate and key (update these for your deployment)
|
||||
ssl_certificate C:/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key C:/nginx/certs/privkey.pem;
|
||||
|
||||
# Strong TLS configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
|
||||
# SSL session settings
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# OCSP Stapling (uncomment when running on a proper domain)
|
||||
# ssl_stapling on;
|
||||
# ssl_stapling_verify on;
|
||||
# ssl_trusted_certificate C:/nginx/certs/chain.pem;
|
||||
|
||||
# Diffie-Hellman parameters (generate with: openssl dhparam -out dhparam.pem 2048)
|
||||
# ssl_dhparam C:/nginx/certs/dhparam.pem;
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Security Headers (defense-in-depth with Flask's own headers)
|
||||
# ---------------------------------------------------------------------
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Proxy to Waitress (Flask)
|
||||
# ---------------------------------------------------------------------
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Static Files (served directly by Nginx for performance)
|
||||
# Uncomment and adjust path if you want Nginx to serve static files
|
||||
# ---------------------------------------------------------------------
|
||||
# location /static/ {
|
||||
# alias C:/path/to/team-tryouts/static/;
|
||||
# expires 30d;
|
||||
# add_header Cache-Control "public, immutable";
|
||||
# access_log off;
|
||||
# }
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
# ---------------------------------------------------------------------
|
||||
# Define rate limit zones (uncomment when rate limiting at Nginx level)
|
||||
# limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
# limit_req_zone $binary_remote_addr zone=global:10m rate=100r/m;
|
||||
|
||||
# location /auth/login {
|
||||
# limit_req zone=login burst=5 nodelay;
|
||||
# proxy_pass http://127.0.0.1:5000;
|
||||
# }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+178
-43
@@ -1,21 +1,31 @@
|
||||
"""Authentication routes for user login, logout, and registration.
|
||||
|
||||
This module handles user authentication including login, logout, and new user registration.
|
||||
This module handles user authentication including login with account lockout
|
||||
protection, logout with session clearing, and new user registration with
|
||||
password policy enforcement and CAPTCHA verification.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from extensions import db, hash_password, check_password, limiter
|
||||
from models import User, ESPORT_GAMES
|
||||
from validators import RegisterSchema, LoginSchema
|
||||
from marshmallow import ValidationError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Account lockout settings
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
def is_safe_url(url):
|
||||
"""Validate that a URL is safe for redirection (same origin).
|
||||
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the URL is safe (relative or same origin).
|
||||
"""
|
||||
@@ -26,20 +36,59 @@ def is_safe_url(url):
|
||||
return not parsed.netloc or parsed.netloc == request.host
|
||||
|
||||
|
||||
def generate_captcha():
|
||||
"""Generate a simple math CAPTCHA challenge.
|
||||
|
||||
Creates a random addition problem and stores the answer in the session.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
||||
"""
|
||||
import random
|
||||
a = random.randint(1, 10)
|
||||
b = random.randint(1, 10)
|
||||
captcha_id = str(uuid.uuid4())
|
||||
session['captcha_id'] = captcha_id
|
||||
session['captcha_answer'] = a + b
|
||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
||||
|
||||
|
||||
def verify_captcha(user_answer):
|
||||
"""Verify the CAPTCHA answer from the session.
|
||||
|
||||
Args:
|
||||
user_answer: The user's submitted answer (string or int).
|
||||
|
||||
Returns:
|
||||
bool: True if the answer matches the stored CAPTCHA, False otherwise.
|
||||
"""
|
||||
try:
|
||||
expected = session.pop('captcha_answer', None)
|
||||
session.pop('captcha_id', None)
|
||||
if expected is None:
|
||||
return False
|
||||
return int(user_answer) == expected
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
"""Handle user login authentication.
|
||||
|
||||
"""Handle user login authentication with account lockout protection.
|
||||
|
||||
GET: Render the login form.
|
||||
POST: Authenticate user credentials and log them in.
|
||||
|
||||
POST: Authenticate user credentials with lockout check and audit logging.
|
||||
|
||||
Account lockout: After 5 consecutive failed attempts, the account is
|
||||
locked for 15 minutes. Successful login resets the counter.
|
||||
|
||||
Redirects authenticated users to dashboard. Validates credentials and checks
|
||||
account status before login.
|
||||
|
||||
|
||||
Returns:
|
||||
Response: Login form or redirect to dashboard/next page.
|
||||
"""
|
||||
@@ -47,16 +96,49 @@ def login():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
# Validate input with marshmallow schema
|
||||
login_schema = LoginSchema()
|
||||
try:
|
||||
validated = login_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
username = validated['username']
|
||||
password = validated['password']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Check if account is locked
|
||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
||||
flash(
|
||||
f'Account is locked due to too many failed attempts. '
|
||||
f'Please try again in {remaining} minute(s).',
|
||||
'danger'
|
||||
)
|
||||
return render_template('pages/login.html')
|
||||
|
||||
if user and check_password(user.password_hash, password):
|
||||
if not user.is_active_account:
|
||||
flash('This account has been deactivated.', 'danger')
|
||||
return render_template('pages/login.html')
|
||||
# Regenerate session to prevent session fixation attacks
|
||||
|
||||
# Reset failed login attempts on successful login
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.session.commit()
|
||||
|
||||
# Clear old session data and preserve CSRF token to prevent
|
||||
# session fixation attacks (Flask-Login rotates the session ID)
|
||||
_csrf_token = session.get('csrf_token')
|
||||
session.clear()
|
||||
if _csrf_token:
|
||||
session['csrf_token'] = _csrf_token
|
||||
|
||||
login_user(user)
|
||||
|
||||
# Validate redirect URL to prevent open redirect vulnerability
|
||||
next_page = request.args.get('next')
|
||||
if next_page and not is_safe_url(next_page):
|
||||
@@ -64,52 +146,104 @@ def login():
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
# Track failed login attempt
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
flash(
|
||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
||||
'danger'
|
||||
)
|
||||
else:
|
||||
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
|
||||
flash(
|
||||
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
|
||||
'danger'
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
|
||||
return render_template('pages/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per hour")
|
||||
def register():
|
||||
"""Handle new player registration.
|
||||
|
||||
GET: Render the registration form with E-Sports games list.
|
||||
POST: Create a new player account with provided details.
|
||||
|
||||
"""Handle new player registration with CAPTCHA and password policy.
|
||||
|
||||
GET: Render the registration form with E-Sports games list and CAPTCHA.
|
||||
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
|
||||
and create a new player account.
|
||||
|
||||
Only players can register through this form. Validates username/email
|
||||
uniqueness and password confirmation.
|
||||
|
||||
|
||||
Returns:
|
||||
Response: Registration form or redirect to login.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# Generate CAPTCHA for GET requests
|
||||
captcha = generate_captcha()
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
full_name = request.form.get('full_name')
|
||||
phone = request.form.get('phone')
|
||||
# Validate CAPTCHA first
|
||||
captcha_answer = request.form.get('captcha_answer', '')
|
||||
if not verify_captcha(captcha_answer):
|
||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
||||
captcha = generate_captcha() # Generate new captcha
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
# E-Sports fields
|
||||
selected_games = request.form.getlist('games')
|
||||
trn_username = request.form.get('trn_username', '').strip()
|
||||
discord_username = request.form.get('discord_username', '').strip()
|
||||
league_os_profile = request.form.get('league_os_profile', '').strip()
|
||||
# Validate input with marshmallow schema
|
||||
register_schema = RegisterSchema()
|
||||
try:
|
||||
validated = register_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Passwords do not match.', 'danger')
|
||||
return render_template('pages/register.html', esport_games=ESPORT_GAMES)
|
||||
username = validated['username']
|
||||
email = validated['email']
|
||||
password = validated['password']
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
trn_username = request.form.get('trn_username', '').strip() or None
|
||||
discord_username = validated.get('discord_username')
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
return render_template('pages/register.html', esport_games=ESPORT_GAMES)
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
return render_template('pages/register.html', esport_games=ESPORT_GAMES)
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha
|
||||
)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user = User(
|
||||
@@ -120,9 +254,8 @@ def register():
|
||||
email=email,
|
||||
phone=phone,
|
||||
games=','.join(selected_games) if selected_games else None,
|
||||
trn_username=trn_username or None,
|
||||
discord_username=discord_username or None,
|
||||
league_os_profile=league_os_profile or None
|
||||
discord_username=discord_username,
|
||||
league_os_profile=league_os_profile
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
@@ -130,19 +263,21 @@ def register():
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('pages/register.html', esport_games=ESPORT_GAMES)
|
||||
return render_template('pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha)
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""Log out the current user.
|
||||
|
||||
Clears the user session and redirects to the login page.
|
||||
|
||||
"""Log out the current user and clear the session.
|
||||
|
||||
Clears the user session and regenerates session ID to prevent
|
||||
session fixation/replay after logout.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to login page with logout message.
|
||||
"""
|
||||
logout_user()
|
||||
session.clear()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
+50
-29
@@ -1,18 +1,25 @@
|
||||
"""User management routes for profiles, disponibilities, and contracts.
|
||||
|
||||
This module handles user CRUD operations, profile editing, player availability,
|
||||
and contract management.
|
||||
and contract management with secure file upload handling.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from extensions import db, hash_password
|
||||
from extensions import db, hash_password, csrf
|
||||
from models import User, ROLES, ESPORT_GAMES, PlayerDisponibility, UserGamertag, GAME_PLATFORMS, Contract, OrgTeam, CoachAvailability, TeamNote, PersonalNote, OneOnOneRequest, Evaluation, Match, Team, TeamMember, MatchParticipant, Tryout, TryoutRegistration, TeamPlayer
|
||||
from werkzeug.utils import secure_filename
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
from marshmallow import ValidationError
|
||||
from validators import CreateUserSchema, EditUserSchema, EditProfileSchema, UploadContractSchema, OneOnOneRequestSchema
|
||||
import requests
|
||||
|
||||
# Allowed file extensions for uploads
|
||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
||||
|
||||
|
||||
users_bp = Blueprint('users', __name__, url_prefix='/users')
|
||||
|
||||
@@ -641,8 +648,18 @@ def upload_contract():
|
||||
players = User.query.filter_by(role='player').all()
|
||||
|
||||
if request.method == 'POST':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
notes = request.form.get('notes', '').strip()
|
||||
# Validate form input
|
||||
contract_schema = UploadContractSchema()
|
||||
try:
|
||||
validated = contract_schema.load(request.form)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(f'{field}: {msg}', 'danger')
|
||||
return render_template('pages/upload_contract.html', players=players)
|
||||
|
||||
player_id = validated['player_id']
|
||||
notes = validated.get('notes')
|
||||
|
||||
if not can_manage_player_contract(current_user, player_id):
|
||||
flash('You do not have permission to upload a contract for this player.', 'danger')
|
||||
@@ -657,6 +674,11 @@ def upload_contract():
|
||||
flash('No file selected.', 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
# Validate file extension (PDF only)
|
||||
if not file.filename.lower().endswith('.pdf'):
|
||||
flash('Only PDF files are allowed for contracts.', 'danger')
|
||||
return redirect(url_for('users.upload_contract'))
|
||||
|
||||
# Create upload directory
|
||||
upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
@@ -674,11 +696,10 @@ def upload_contract():
|
||||
else:
|
||||
final_dir = upload_dir
|
||||
|
||||
# Generate unique filename
|
||||
# Generate UUID-based filename for security (prevents filename guessing)
|
||||
original_filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
stored_filename = f"{secure_filename(player.username)}_{timestamp}_{original_filename}"
|
||||
stored_filename = stored_filename.replace(' ', '_')
|
||||
file_uuid = str(uuid.uuid4())
|
||||
stored_filename = f"{file_uuid}.pdf"
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(final_dir, stored_filename)
|
||||
@@ -1008,6 +1029,7 @@ def one_on_one():
|
||||
|
||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def manage_coach_availability():
|
||||
"""Manage coach availability (for coaches).
|
||||
|
||||
@@ -1025,6 +1047,11 @@ def manage_coach_availability():
|
||||
data = request.get_json()
|
||||
slots = data.get('slots', [])
|
||||
|
||||
# Clear all existing availability slots first, then re-insert from client state.
|
||||
# This fixes the bug where un-toggling a slot on the client did not delete it
|
||||
# from the database (the old code only added, never removed).
|
||||
CoachAvailability.query.filter_by(coach_id=current_user.id).delete()
|
||||
|
||||
created = []
|
||||
for slot in slots:
|
||||
day_of_week = slot.get('day_of_week')
|
||||
@@ -1040,29 +1067,21 @@ def manage_coach_availability():
|
||||
|
||||
end_time = add_30_minutes(start_time)
|
||||
|
||||
# Check if this slot already exists for this coach
|
||||
existing = CoachAvailability.query.filter_by(
|
||||
availability = CoachAvailability(
|
||||
coach_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
availability = CoachAvailability(
|
||||
coach_id=current_user.id,
|
||||
day_of_week=day_of_week,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(availability)
|
||||
db.session.flush()
|
||||
created.append({
|
||||
'id': availability.id,
|
||||
'day_of_week': availability.day_of_week,
|
||||
'day_name': DAY_NAMES[availability.day_of_week],
|
||||
'start_time': availability.start_time.strftime('%H:%M'),
|
||||
'end_time': availability.end_time.strftime('%H:%M')
|
||||
})
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(availability)
|
||||
db.session.flush()
|
||||
created.append({
|
||||
'id': availability.id,
|
||||
'day_of_week': availability.day_of_week,
|
||||
'day_name': DAY_NAMES[availability.day_of_week],
|
||||
'start_time': availability.start_time.strftime('%H:%M'),
|
||||
'end_time': availability.end_time.strftime('%H:%M')
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'created': created})
|
||||
@@ -1076,6 +1095,7 @@ def manage_coach_availability():
|
||||
|
||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def clear_coach_availability():
|
||||
"""Clear all coach availability slots.
|
||||
|
||||
@@ -1092,6 +1112,7 @@ def clear_coach_availability():
|
||||
|
||||
@users_bp.route('/coach-availability/<int:availability_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def delete_coach_availability(availability_id):
|
||||
"""Delete a coach availability slot.
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Security validation script for the Team Tryouts application.
|
||||
|
||||
This script performs pre-deployment security checks to validate:
|
||||
- HTTP security headers
|
||||
- Cookie security attributes
|
||||
- Debug mode status
|
||||
- HTTPS configuration
|
||||
- Dependency vulnerabilities
|
||||
- Database connectivity
|
||||
|
||||
Usage:
|
||||
python security_scan.py [--url http://localhost:5000]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def check_environment():
|
||||
"""Check required environment variables are set.
|
||||
|
||||
Returns:
|
||||
bool: True if all critical variables are set.
|
||||
"""
|
||||
print('=' * 60)
|
||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
critical_vars = ['SECRET_KEY']
|
||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
||||
all_ok = True
|
||||
|
||||
for var in critical_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
# Check SECRET_KEY is not a default/weak value
|
||||
if var == 'SECRET_KEY' and len(value) < 32:
|
||||
print(f'[WARN] {var} is set but too short (less than 32 chars)')
|
||||
all_ok = False
|
||||
else:
|
||||
print(f'[OK] {var} is set')
|
||||
else:
|
||||
print(f'[FAIL] {var} is not set!')
|
||||
all_ok = False
|
||||
|
||||
for var in recommended_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
print(f'[OK] {var} is set')
|
||||
else:
|
||||
print(f'[INFO] {var} is not set (using default)')
|
||||
|
||||
# Check FLASK_DEBUG
|
||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||
if debug == 'true':
|
||||
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
|
||||
else:
|
||||
print('[OK] FLASK_DEBUG is disabled')
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_https_headers(url):
|
||||
"""Check HTTP security headers from a running application.
|
||||
|
||||
Args:
|
||||
url: The base URL of the application to check.
|
||||
|
||||
Returns:
|
||||
bool: True if all critical headers are present.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('2. HTTP SECURITY HEADERS CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
required_headers = {
|
||||
'Strict-Transport-Security': 'HSTS enabled',
|
||||
'X-Content-Type-Options': 'Prevents MIME sniffing',
|
||||
'X-Frame-Options': 'Prevents clickjacking',
|
||||
'Content-Security-Policy': 'CSP configured',
|
||||
'Referrer-Policy': 'Referrer control',
|
||||
'Permissions-Policy': 'Permissions control',
|
||||
'Cross-Origin-Opener-Policy': 'Cross-origin isolation',
|
||||
}
|
||||
|
||||
all_ok = True
|
||||
|
||||
try:
|
||||
# Create a context that doesn't verify SSL (for local testing)
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(url, method='HEAD')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
|
||||
headers = response.headers
|
||||
status = response.status
|
||||
|
||||
print(f'[INFO] Response status: {status}')
|
||||
|
||||
for header, description in required_headers.items():
|
||||
if header in headers:
|
||||
print(f'[OK] {header}: {description}')
|
||||
else:
|
||||
print(f'[FAIL] {header} is missing: {description}')
|
||||
all_ok = False
|
||||
|
||||
# Check cookie attributes if any set-cookie headers exist
|
||||
if 'Set-Cookie' in headers:
|
||||
cookie = headers['Set-Cookie']
|
||||
if 'Secure' in cookie:
|
||||
print('[OK] Cookies have Secure flag')
|
||||
else:
|
||||
print('[WARN] Cookies missing Secure flag')
|
||||
all_ok = False
|
||||
|
||||
if 'HttpOnly' in cookie:
|
||||
print('[OK] Cookies have HttpOnly flag')
|
||||
else:
|
||||
print('[WARN] Cookies missing HttpOnly flag')
|
||||
all_ok = False
|
||||
|
||||
if 'SameSite' in cookie:
|
||||
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}')
|
||||
else:
|
||||
print('[WARN] Cookies missing SameSite attribute')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[INFO] No Set-Cookie headers in response')
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f'[INFO] Got HTTP {e.code} (may need authentication)')
|
||||
# Still check headers even on error responses
|
||||
for header, description in required_headers.items():
|
||||
if header in e.headers:
|
||||
print(f'[OK] {header}: {description}')
|
||||
else:
|
||||
print(f'[FAIL] {header} is missing: {description}')
|
||||
all_ok = False
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
||||
print('[SKIP] Run with --url <application_url> to check headers')
|
||||
return True # Not a failure, just can't check
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Run pip-audit to check for known vulnerabilities.
|
||||
|
||||
Returns:
|
||||
bool: True if no critical vulnerabilities found.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('3. DEPENDENCY VULNERABILITY SCAN')
|
||||
print('=' * 60)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print('[OK] No known vulnerabilities found')
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
vulns = data.get('dependencies', [])
|
||||
if vulns:
|
||||
for vuln in vulns:
|
||||
print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}')
|
||||
return False
|
||||
else:
|
||||
print('[OK] No vulnerabilities found')
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
if result.stdout:
|
||||
print(f'[INFO] {result.stdout.strip()}')
|
||||
if result.stderr:
|
||||
print(f'[WARN] {result.stderr.strip()}')
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
print('[WARN] pip-audit timed out')
|
||||
return True
|
||||
|
||||
|
||||
def check_file_permissions():
|
||||
"""Check for common security issues in the project structure.
|
||||
|
||||
Returns:
|
||||
bool: True if no critical issues found.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('4. PROJECT FILES CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
all_ok = True
|
||||
|
||||
# Check .gitignore exists and contains important patterns
|
||||
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
|
||||
if os.path.exists(gitignore_path):
|
||||
required_patterns = ['.env', 'instance/', '*.db', '*.log']
|
||||
with open(gitignore_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
for pattern in required_patterns:
|
||||
if pattern in content:
|
||||
print(f'[OK] .gitignore contains: {pattern}')
|
||||
else:
|
||||
print(f'[WARN] .gitignore missing: {pattern}')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[FAIL] .gitignore file not found!')
|
||||
all_ok = False
|
||||
|
||||
# Check for .env in working directory (should NOT be committed)
|
||||
env_path = os.path.join(os.getcwd(), '.env')
|
||||
if os.path.exists(env_path):
|
||||
print('[INFO] .env file exists (ensure it is NOT committed)')
|
||||
else:
|
||||
print('[WARN] No .env file found')
|
||||
|
||||
# Check for leftover .pyc or __pycache__
|
||||
pycache_count = 0
|
||||
for root, dirs, files in os.walk(os.getcwd()):
|
||||
if '__pycache__' in dirs:
|
||||
pycache_count += 1
|
||||
for f in files:
|
||||
if f.endswith('.pyc'):
|
||||
pycache_count += 1
|
||||
if pycache_count == 0:
|
||||
print('[OK] No __pycache__ or .pyc files found')
|
||||
else:
|
||||
print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)')
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def check_flask_config():
|
||||
"""Check Flask application configuration for security.
|
||||
|
||||
Returns:
|
||||
bool: True if configuration looks secure.
|
||||
"""
|
||||
print('\n' + '=' * 60)
|
||||
print('5. FLASK CONFIGURATION CHECK')
|
||||
print('=' * 60)
|
||||
|
||||
all_ok = True
|
||||
|
||||
try:
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
|
||||
# Check session cookie settings
|
||||
cookie_checks = [
|
||||
('SESSION_COOKIE_SECURE', True, 'Secure cookies'),
|
||||
('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'),
|
||||
('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'),
|
||||
]
|
||||
|
||||
for config_key, expected, description in cookie_checks:
|
||||
value = app.config.get(config_key)
|
||||
if config_key == 'PERMANENT_SESSION_LIFETIME':
|
||||
if value and value <= 3600:
|
||||
print(f'[OK] {description}: {value}s')
|
||||
else:
|
||||
print(f'[WARN] {description}: {value}s (should be <= 1 hour)')
|
||||
all_ok = False
|
||||
elif value == expected:
|
||||
print(f'[OK] {description}: enabled')
|
||||
else:
|
||||
print(f'[FAIL] {description}: {value}')
|
||||
all_ok = False
|
||||
|
||||
# Check MAX_CONTENT_LENGTH
|
||||
max_content = app.config.get('MAX_CONTENT_LENGTH')
|
||||
if max_content:
|
||||
mb = max_content / (1024 * 1024)
|
||||
print(f'[OK] MAX_CONTENT_LENGTH: {mb}MB')
|
||||
else:
|
||||
print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)')
|
||||
all_ok = False
|
||||
|
||||
# Check CSRF
|
||||
csrf_enabled = app.config.get('WTF_CSRF_ENABLED')
|
||||
if csrf_enabled:
|
||||
print('[OK] CSRF protection: enabled')
|
||||
else:
|
||||
print('[FAIL] CSRF protection: disabled')
|
||||
all_ok = False
|
||||
|
||||
# Check if app is in DEBUG mode
|
||||
if app.debug:
|
||||
print('[FAIL] DEBUG mode is enabled!')
|
||||
all_ok = False
|
||||
else:
|
||||
print('[OK] DEBUG mode: disabled')
|
||||
|
||||
except Exception as e:
|
||||
print(f'[SKIP] Cannot check Flask config: {e}')
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all security checks and produce a summary report.
|
||||
|
||||
Returns:
|
||||
int: 0 if all checks pass, 1 if any fail.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Security validation scanner')
|
||||
parser.add_argument('--url', default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)')
|
||||
args = parser.parse_args()
|
||||
|
||||
print('╔══════════════════════════════════════════════════════════╗')
|
||||
print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║')
|
||||
print('╠══════════════════════════════════════════════════════════╣')
|
||||
print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print('╚══════════════════════════════════════════════════════════╝')
|
||||
|
||||
checks = [
|
||||
check_environment,
|
||||
lambda: check_https_headers(args.url),
|
||||
check_dependencies,
|
||||
check_file_permissions,
|
||||
check_flask_config,
|
||||
]
|
||||
|
||||
results = []
|
||||
for check in checks:
|
||||
results.append(check())
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('SUMMARY')
|
||||
print('=' * 60)
|
||||
|
||||
passed = sum(1 for r in results if r)
|
||||
failed = sum(1 for r in results if not r)
|
||||
total = len(results)
|
||||
|
||||
print(f'Passed: {passed}/{total}')
|
||||
print(f'Failed: {failed}/{total}')
|
||||
|
||||
if failed == 0:
|
||||
print('\n[OK] All security checks passed!')
|
||||
return 0
|
||||
else:
|
||||
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1893,3 +1893,58 @@ a:hover { color: var(--primary-dark); }
|
||||
[data-theme="dark"] .sort-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Error Pages
|
||||
========================================================================= */
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 60px 30px;
|
||||
max-width: 500px;
|
||||
margin: 40px auto;
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.error-container h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-800);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-container p {
|
||||
color: var(--gray-500);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-container .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .error-container {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .error-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .error-container h2 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .error-container p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}400 Bad Request - TryoutPro{% endblock %}
|
||||
{% block page_title %}Bad Request{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h2>400 — Bad Request</h2>
|
||||
<p>The request could not be understood by the server. Please check your input and try again.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}403 Forbidden - TryoutPro{% endblock %}
|
||||
{% block page_title %}Access Denied{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h2>403 — Forbidden</h2>
|
||||
<p>You do not have permission to access this resource. If you believe this is an error, please contact an administrator.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}404 Not Found - TryoutPro{% endblock %}
|
||||
{% block page_title %}Page Not Found{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h2>404 — Not Found</h2>
|
||||
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i> Return Home
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}429 Too Many Requests - TryoutPro{% endblock %}
|
||||
{% block page_title %}Rate Limit Exceeded{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<h2>429 — Too Many Requests</h2>
|
||||
<p>You have sent too many requests in a short period. Please wait a moment and try again.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}500 Server Error - TryoutPro{% endblock %}
|
||||
{% block page_title %}Internal Server Error{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-cogs"></i>
|
||||
</div>
|
||||
<h2>500 — Internal Server Error</h2>
|
||||
<p>Something went wrong on our end. The error has been logged and will be investigated. Please try again later.</p>
|
||||
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
|
||||
<i class="fas fa-redo-alt"></i> Try Again
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -22,39 +22,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-list"></i> Current Availability</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if existing_availability %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th>Time</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for av in existing_availability %}
|
||||
<tr>
|
||||
<td>{{ ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][av.day_of_week] }}</td>
|
||||
<td>{{ av.start_time.strftime('%I:%M %p') }} - {{ av.end_time.strftime('%I:%M %p') }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteAvailability({{ av.id }})">
|
||||
<i class="fas fa-trash"></i> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No availability slots set. Use the grid above to add your available times.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -207,9 +174,12 @@ function saveAvailability() {
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
flash('Availability saved successfully!', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
flash('Availability saved!', 'success');
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Save error:', error);
|
||||
flash('Error saving availability.', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,17 +199,6 @@ function clearAllAvailability() {
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAvailability(availabilityId) {
|
||||
fetch('{{ url_for("users.delete_coach_availability", availability_id=0) }}'.replace('/0', '/' + availabilityId), { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
flash('Availability slot removed!', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flash(message, type) {
|
||||
const flashContainer = document.querySelector('.flash-messages');
|
||||
const alert = document.createElement('div');
|
||||
|
||||
@@ -70,12 +70,6 @@
|
||||
<h4 class="section-title"><i class="fas fa-clock"></i> Select Match Time</h4>
|
||||
<p class="text-muted small">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p>
|
||||
|
||||
<div id="merged-disponibility-selected" class="merged-disponibility-selected-display hidden">
|
||||
<span>Selected time:</span>
|
||||
<span class="selected-time-badge" id="selected-time-badge"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="clearTimeSelection()">Change</button>
|
||||
</div>
|
||||
|
||||
<div id="merged-disponibility-grid" class="merged-disponibility-grid">
|
||||
<p class="text-muted">Loading...</p>
|
||||
</div>
|
||||
@@ -539,9 +533,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('merged-disponibility-selected').classList.remove('hidden');
|
||||
document.getElementById('selected-time-badge').textContent =
|
||||
formatTimeDisplay(selectedStartTime) + ' - ' + formatTimeDisplay(selectedEndTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,26 +612,38 @@ function renderMergedDisponibilityGrid() {
|
||||
dayRow += '<div class="merged-disponibility-day-label">' + day.name + '</div>';
|
||||
dayRow += '<div class="merged-disponibility-time-blocks">';
|
||||
|
||||
// First pass: collect all counts to determine max
|
||||
var slotCounts = [];
|
||||
TIME_SLOTS.forEach(function(slot) {
|
||||
var count = getAvailabilityCount(day.value, slot.time);
|
||||
var percentage = totalPlayers > 0 ? count / totalPlayers : 0;
|
||||
slotCounts.push({ time: slot.time, display: slot.display, count: count });
|
||||
});
|
||||
|
||||
// Find max count to use as reference
|
||||
var maxCount = 0;
|
||||
slotCounts.forEach(function(s) { if (s.count > maxCount) maxCount = s.count; });
|
||||
|
||||
slotCounts.forEach(function(slotData) {
|
||||
var count = slotData.count;
|
||||
var cssClass = 'merged-disponibility-time-block';
|
||||
|
||||
if (percentage >= 0.75) {
|
||||
cssClass += ' high-availability';
|
||||
} else if (percentage >= 0.5) {
|
||||
cssClass += ' medium-availability';
|
||||
} else {
|
||||
cssClass += ' low-availability';
|
||||
// Only color slots with 2+ available players. Green = top count, yellow = medium, no class for low
|
||||
if (count >= 2) {
|
||||
if (maxCount > 0 && count === maxCount) {
|
||||
cssClass += ' high-availability';
|
||||
} else if (count >= Math.ceil(maxCount * 0.5)) {
|
||||
cssClass += ' medium-availability';
|
||||
}
|
||||
}
|
||||
// Slots with 0-1 players get no special color class (neutral)
|
||||
|
||||
if (selectedSlots.includes(slot.time)) {
|
||||
if (selectedSlots.includes(slotData.time)) {
|
||||
cssClass += ' selected';
|
||||
}
|
||||
|
||||
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slot.time + '" ' +
|
||||
'onclick="toggleTimeSlot(' + day.value + ', \'' + slot.time + '\', this)">' +
|
||||
slot.display +
|
||||
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
|
||||
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
|
||||
slotData.display +
|
||||
'<span class="merged-disponibility-count">' + count + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
@@ -658,6 +661,9 @@ function getAvailabilityCount(dayOfWeek, timeStr) {
|
||||
var minutes = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1]);
|
||||
|
||||
for (var playerId in allDisponibilities) {
|
||||
// Only count players registered for THIS tryout
|
||||
if (!allRegisteredPlayers.includes(parseInt(playerId))) continue;
|
||||
|
||||
var playerData = allDisponibilities[playerId];
|
||||
if (playerData && playerData.disponibilities) {
|
||||
var isAvailable = playerData.disponibilities.some(function(d) {
|
||||
@@ -746,14 +752,6 @@ function toggleTimeSlot(dayOfWeek, timeStr, element) {
|
||||
document.getElementById('start_time').value = startTime;
|
||||
document.getElementById('end_time').value = endTime;
|
||||
|
||||
if (selectedSlots.length > 0) {
|
||||
document.getElementById('merged-disponibility-selected').classList.remove('hidden');
|
||||
document.getElementById('selected-time-badge').textContent =
|
||||
formatTimeDisplay(selectedSlots[0]) + ' - ' + formatTimeDisplay(endTime);
|
||||
} else {
|
||||
document.getElementById('merged-disponibility-selected').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.merged-disponibility-time-block').forEach(function(el) {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
@@ -868,12 +866,11 @@ function updatePlayerPool() {
|
||||
var team2Ids = getSelectedTeamIds(2);
|
||||
var assignedIds = [...team1Ids, ...team2Ids];
|
||||
|
||||
// When time slots are selected, show only players available in all slots who aren't assigned yet
|
||||
// When no time slots selected, show all registered players
|
||||
var playersToShow = selectedSlots.length > 0 ? availablePlayersForSlots : allRegisteredPlayers;
|
||||
// Always show ALL registered players, not just available ones.
|
||||
// Availability indicators are shown per-player, but coaches can still select any player.
|
||||
|
||||
if (playersToShow.length === 0) {
|
||||
pool.innerHTML = '<p class="text-muted small">No players available</p>';
|
||||
if (allRegisteredPlayers.length === 0) {
|
||||
pool.innerHTML = '<p class="text-muted small">No players registered</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -883,7 +880,7 @@ function updatePlayerPool() {
|
||||
var dayOfWeek = jsDay === 0 ? 6 : jsDay - 1;
|
||||
|
||||
var html = '';
|
||||
playersToShow.forEach(function(pid) {
|
||||
allRegisteredPlayers.forEach(function(pid) {
|
||||
if (assignedIds.includes(pid)) return;
|
||||
|
||||
var playerName = playerDataById.player_data[pid];
|
||||
@@ -896,6 +893,9 @@ function updatePlayerPool() {
|
||||
isAvailable = selectedSlots.every(function(slot) {
|
||||
return playerSlots.includes(slot);
|
||||
});
|
||||
} else if (selectedSlots.length === 0) {
|
||||
// No time slots selected yet: all players count as "available" (no filter active)
|
||||
isAvailable = true;
|
||||
}
|
||||
|
||||
var availabilityClass = isAvailable ? 'available' : 'unavailable';
|
||||
@@ -961,7 +961,6 @@ function clearTimeSelection() {
|
||||
selectedSlots = [];
|
||||
document.getElementById('start_time').value = '';
|
||||
document.getElementById('end_time').value = '';
|
||||
document.getElementById('merged-disponibility-selected').classList.add('hidden');
|
||||
document.querySelectorAll('.merged-disponibility-time-block').forEach(function(el) {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
"""Input validation schemas for the Team Tryouts application.
|
||||
|
||||
This module provides Marshmallow schemas for validating and sanitizing
|
||||
all user inputs including forms, JSON requests, and file uploads.
|
||||
All validation is centralized here for consistency and maintainability.
|
||||
|
||||
Usage:
|
||||
from validators import LoginSchema
|
||||
schema = LoginSchema()
|
||||
errors = schema.validate(request.form)
|
||||
"""
|
||||
|
||||
import re
|
||||
from marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Custom Validators
|
||||
# =============================================================================
|
||||
|
||||
PASSWORD_POLICY = re.compile(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
|
||||
)
|
||||
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
|
||||
|
||||
|
||||
def validate_password(value):
|
||||
"""Validate password meets strength requirements.
|
||||
|
||||
Requires: minimum 8 characters, at least one uppercase letter,
|
||||
one lowercase letter, and one digit.
|
||||
|
||||
Args:
|
||||
value: The password string to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If password does not meet requirements.
|
||||
"""
|
||||
if not PASSWORD_POLICY.match(value):
|
||||
raise ValidationError(
|
||||
'Password must be at least 8 characters with uppercase, '
|
||||
'lowercase, and a number.'
|
||||
)
|
||||
|
||||
|
||||
def validate_username(value):
|
||||
"""Validate username format.
|
||||
|
||||
Usernames must be 3-30 characters and contain only alphanumeric
|
||||
characters, underscores, and hyphens.
|
||||
|
||||
Args:
|
||||
value: The username string to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If username does not meet requirements.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value):
|
||||
raise ValidationError(
|
||||
'Username must be 3-30 characters (letters, numbers, underscore, hyphen).'
|
||||
)
|
||||
|
||||
|
||||
def validate_discord_username(value):
|
||||
"""Validate Discord username format if provided.
|
||||
|
||||
Accepts empty strings (optional field). Validates that the username
|
||||
matches common Discord username patterns.
|
||||
|
||||
Args:
|
||||
value: The Discord username to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the format is invalid.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if not re.match(r'^[a-zA-Z0-9_.]{2,32}$', value):
|
||||
raise ValidationError('Invalid Discord username format.')
|
||||
|
||||
|
||||
def validate_discord_user_id(value):
|
||||
"""Validate Discord user ID (snowflake) if provided.
|
||||
|
||||
Discord user IDs are 17-20 digit numbers.
|
||||
|
||||
Args:
|
||||
value: The Discord user ID to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the format is invalid.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
if not re.match(r'^\d{17,20}$', value):
|
||||
raise ValidationError('Discord User ID must be a 17-20 digit number.')
|
||||
|
||||
|
||||
def validate_phone(value):
|
||||
"""Validate optional phone number format.
|
||||
|
||||
Accepts empty strings. Validates common phone formats.
|
||||
|
||||
Args:
|
||||
value: The phone number to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the format is invalid.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
cleaned = re.sub(r'[\s\-\(\)\.]', '', value)
|
||||
if not re.match(r'^\+?\d{7,15}$', cleaned):
|
||||
raise ValidationError('Invalid phone number format.')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Validation Schemas
|
||||
# =============================================================================
|
||||
|
||||
class StripMixin(Schema):
|
||||
"""Mixin that automatically strips whitespace from all string fields
|
||||
and ignores unknown fields (e.g., csrf_token from Flask-WTF)."""
|
||||
|
||||
class Meta:
|
||||
"""Marshmallow Meta options."""
|
||||
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
|
||||
|
||||
@pre_load
|
||||
def strip_strings(self, data, **kwargs):
|
||||
"""Strip whitespace from all string values in the input data.
|
||||
|
||||
Args:
|
||||
data: The input dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Data with stripped strings.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
|
||||
return data
|
||||
|
||||
|
||||
class LoginSchema(StripMixin):
|
||||
"""Validate login form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars, required.
|
||||
password: Non-empty, required.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=80, error='Username is required.'),
|
||||
)
|
||||
password = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, error='Password is required.'),
|
||||
)
|
||||
|
||||
|
||||
class RegisterSchema(StripMixin):
|
||||
"""Validate player registration form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars alphanumeric, required.
|
||||
email: Valid email, required.
|
||||
password: Meets password policy, required.
|
||||
confirm_password: Must match password, required.
|
||||
full_name: 1-100 chars, required.
|
||||
phone: Optional, valid phone format.
|
||||
games: Optional list.
|
||||
discord_username: Optional, valid format.
|
||||
league_os_profile: Optional URL.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=3, max=80, error='Username must be 3-80 characters.'),
|
||||
validate_username,
|
||||
],
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120, error='Email must be 120 characters or less.'),
|
||||
)
|
||||
password = fields.String(
|
||||
required=True,
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
)
|
||||
confirm_password = fields.String(
|
||||
required=True,
|
||||
load_only=True,
|
||||
)
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
games = fields.List(fields.String(), missing=[])
|
||||
discord_username = fields.String(
|
||||
validate=validate_discord_username,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
trn_username = fields.String(
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
league_os_profile = fields.String(
|
||||
validate=validate.Length(max=256),
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
|
||||
@validates_schema
|
||||
def validate_password_match(self, data, **kwargs):
|
||||
"""Ensure confirm_password matches password.
|
||||
|
||||
Args:
|
||||
data: The validated data dictionary.
|
||||
|
||||
Raises:
|
||||
ValidationError: If passwords do not match.
|
||||
"""
|
||||
if data.get('password') != data.get('confirm_password'):
|
||||
raise ValidationError('Passwords do not match.', field_name='confirm_password')
|
||||
|
||||
|
||||
class CreateUserSchema(StripMixin):
|
||||
"""Validate president-created user form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars alphanumeric, required.
|
||||
email: Valid email, required.
|
||||
password: Meets password policy, required.
|
||||
full_name: 1-100 chars, required.
|
||||
role: Must be valid role, required.
|
||||
phone: Optional, valid phone format.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=3, max=80, error='Username must be 3-80 characters.'),
|
||||
validate_username,
|
||||
],
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120),
|
||||
)
|
||||
password = fields.String(
|
||||
required=True,
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
)
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
role = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
['president', 'manager', 'coach', 'player', 'scout'],
|
||||
error='Invalid role selected.'
|
||||
),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
|
||||
|
||||
class EditUserSchema(StripMixin):
|
||||
"""Validate president-edited user form input.
|
||||
|
||||
Fields:
|
||||
full_name: 1-100 chars, required.
|
||||
email: Valid email, required.
|
||||
role: Must be valid role, required.
|
||||
is_active_account: Boolean.
|
||||
phone: Optional.
|
||||
password: Optional (only if changing).
|
||||
discord_username: Optional.
|
||||
discord_user_id: Optional.
|
||||
league_os_profile: Optional.
|
||||
games: Optional list.
|
||||
"""
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120),
|
||||
)
|
||||
role = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
['president', 'manager', 'coach', 'player', 'scout'],
|
||||
error='Invalid role selected.'
|
||||
),
|
||||
)
|
||||
is_active_account = fields.Boolean(missing=True)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
password = fields.String(
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
missing='',
|
||||
)
|
||||
discord_username = fields.String(
|
||||
validate=validate_discord_username,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
discord_user_id = fields.String(
|
||||
validate=validate_discord_user_id,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
league_os_profile = fields.String(
|
||||
validate=validate.Length(max=256),
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
games = fields.List(fields.String(), missing=[])
|
||||
|
||||
|
||||
class EditProfileSchema(StripMixin):
|
||||
"""Validate self-edit profile form input.
|
||||
|
||||
Fields:
|
||||
username: 3-30 chars, required.
|
||||
full_name: 1-100 chars, required.
|
||||
email: Valid email, required.
|
||||
phone: Optional.
|
||||
password: Optional (only if changing).
|
||||
discord_username: Optional.
|
||||
discord_user_id: Optional.
|
||||
league_os_profile: Optional.
|
||||
games: Optional list.
|
||||
"""
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=3, max=80),
|
||||
validate_username,
|
||||
],
|
||||
)
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error='Full name is required.'),
|
||||
)
|
||||
email = fields.Email(
|
||||
required=True,
|
||||
validate=validate.Length(max=120),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
password = fields.String(
|
||||
validate=validate_password,
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
missing='',
|
||||
)
|
||||
discord_username = fields.String(
|
||||
validate=validate_discord_username,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
discord_user_id = fields.String(
|
||||
validate=validate_discord_user_id,
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
league_os_profile = fields.String(
|
||||
validate=validate.Length(max=256),
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
games = fields.List(fields.String(), missing=[])
|
||||
|
||||
|
||||
class UploadContractSchema(StripMixin):
|
||||
"""Validate contract upload form input.
|
||||
|
||||
Fields:
|
||||
player_id: Integer, required.
|
||||
notes: Optional text.
|
||||
"""
|
||||
player_id = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(min=1, error='Player must be selected.'),
|
||||
)
|
||||
notes = fields.String(
|
||||
validate=validate.Length(max=2000, error='Notes must be 2000 characters or less.'),
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
|
||||
|
||||
class OneOnOneRequestSchema(StripMixin):
|
||||
"""Validate One on One session request form input.
|
||||
|
||||
Fields:
|
||||
date: Date string (YYYY-MM-DD), required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
end_time: Time string (HH:MM), required.
|
||||
points: Optional text.
|
||||
"""
|
||||
date = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{4}-\d{2}-\d{2}$',
|
||||
error='Date must be in YYYY-MM-DD format.'
|
||||
),
|
||||
)
|
||||
start_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error='Start time must be in HH:MM format.'
|
||||
),
|
||||
)
|
||||
end_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error='End time must be in HH:MM format.'
|
||||
),
|
||||
)
|
||||
points = fields.String(
|
||||
validate=validate.Length(max=2000, error='Points must be 2000 characters or less.'),
|
||||
allow_none=True,
|
||||
missing=None,
|
||||
)
|
||||
|
||||
|
||||
class DisponibilityAddSchema(StripMixin):
|
||||
"""Validate disponibility block addition.
|
||||
|
||||
Fields:
|
||||
day_of_week: Integer 0-6, required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
"""
|
||||
day_of_week = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(
|
||||
min=0, max=6,
|
||||
error='Day must be 0 (Monday) to 6 (Sunday).'
|
||||
),
|
||||
)
|
||||
start_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error='Start time must be in HH:MM format.'
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""WSGI entry point for the Team Tryouts application.
|
||||
|
||||
This module provides the production WSGI server using Waitress (Windows).
|
||||
Use this file to start the application in production instead of Flask's
|
||||
built-in development server.
|
||||
|
||||
Usage:
|
||||
python wsgi.py
|
||||
|
||||
Configuration via environment variables:
|
||||
PORT: Port to listen on (default: 5000)
|
||||
WAITRESS_THREADS: Number of worker threads (default: CPU*2+1)
|
||||
"""
|
||||
|
||||
import os
|
||||
import multiprocessing
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
from waitress import serve
|
||||
|
||||
port = int(os.getenv('PORT', 5000))
|
||||
threads = int(os.getenv('WAITRESS_THREADS', multiprocessing.cpu_count() * 2 + 1))
|
||||
host = os.getenv('HOST', '127.0.0.1') # Bind to localhost by default (Nginx reverse proxy)
|
||||
|
||||
print(f'Starting Waitress server on {host}:{port} with {threads} threads')
|
||||
serve(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
threads=threads,
|
||||
# Graceful shutdown settings
|
||||
channel_timeout=30, # Seconds to wait for in-flight requests
|
||||
cleanup_interval=30,
|
||||
)
|
||||
Reference in New Issue
Block a user