diff --git a/.gitignore b/.gitignore index c3d83e3..0db6a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,28 @@ -venv -instance +# Virtual Environment +venv/ +.venv/ + +# Flask instance folder +instance/ + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Database files +*.db +*.db-shm +*.db-wal + +# Logs +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..3ed15e1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,130 @@ +# ircquotes Production Deployment + +## Configuration Management + +### Configuration File: `config.json` +All application settings are now centralized in `config.json`. You can easily modify: + +- **App settings** (host, port, debug mode) +- **Database configuration** (URI, connection pool settings) +- **Security settings** (CSRF, session cookies, security headers) +- **Rate limiting** (per-endpoint limits) +- **Quote settings** (length limits, pagination) +- **Admin credentials** +- **Feature toggles** + +### Viewing Current Configuration +```bash +python config_manager.py +``` + +### Updating Configuration +```bash +# Change port +python config_manager.py app.port 8080 + +# Change quotes per page +python config_manager.py quotes.per_page 50 + +# Disable CSRF (not recommended) +python config_manager.py security.csrf_enabled false + +# Change rate limits +python config_manager.py rate_limiting.endpoints.login "10 per minute" +``` + +## Running with Gunicorn (Production) + +### Quick Start +```bash +# Activate virtual environment +source .venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Run with Gunicorn (recommended for production) +gunicorn --config gunicorn.conf.py app:app +``` + +### Alternative Gunicorn Commands + +**Basic production run:** +```bash +gunicorn -w 4 -b 0.0.0.0:5050 app:app +``` + +**With more workers (for higher traffic):** +```bash +gunicorn -w 8 -b 0.0.0.0:5050 --timeout 30 app:app +``` + +**Behind a reverse proxy (nginx/apache):** +```bash +gunicorn -w 4 -b 127.0.0.1:5050 app:app +``` + +### Environment Variables for Production +```bash +export FLASK_ENV=production +``` + +## Security Notes + +- All major security vulnerabilities have been fixed +- CSRF protection enabled +- XSS protection with output escaping +- SQL injection prevention +- Rate limiting on all endpoints +- Secure session configuration +- Security headers added + +## Admin Access +- Username: Configurable in `config.json` (default: admin) +- Password: Use the Argon2 hashed password in `config.json` + +## Configuration Examples + +### High-Traffic Setup +```json +{ + "quotes": { + "per_page": 50 + }, + "rate_limiting": { + "endpoints": { + "vote": "120 per minute", + "search": "60 per minute" + } + } +} +``` + +### Development Setup +```json +{ + "app": { + "debug": true, + "port": 5000 + }, + "security": { + "session_cookie_secure": false + }, + "logging": { + "level": "DEBUG" + } +} +``` + +### Production Security Setup +```json +{ + "security": { + "session_cookie_secure": true, + "csrf_enabled": true + }, + "logging": { + "level": "WARNING" + } +} +``` \ No newline at end of file diff --git a/app.py b/app.py index e92e536..12c74e9 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_cors import CORS +from flask_wtf.csrf import CSRFProtect import datetime import json import random @@ -10,10 +11,58 @@ from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from werkzeug.middleware.proxy_fix import ProxyFix # Import ProxyFix import logging +from sqlalchemy import event +from sqlalchemy.engine import Engine +import sqlite3 +from config_loader import config # Import configuration system + +# Configure SQLite for better concurrency +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + # Set WAL mode for better concurrency + cursor.execute("PRAGMA journal_mode=WAL") + # Set timeout for locked database + cursor.execute("PRAGMA busy_timeout=30000") # 30 seconds + # Optimize for performance + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA cache_size=1000") + cursor.execute("PRAGMA temp_store=memory") + cursor.close() app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///quotes.db' +app.config['SQLALCHEMY_DATABASE_URI'] = config.database_uri app.config['SECRET_KEY'] = open("instance/flask_secret_key", "r").read().strip() + +# Configure secure session settings from config +app.config['SESSION_COOKIE_SECURE'] = config.get('security.session_cookie_secure', False) +app.config['SESSION_COOKIE_HTTPONLY'] = config.get('security.session_cookie_httponly', True) +app.config['SESSION_COOKIE_SAMESITE'] = config.get('security.session_cookie_samesite', 'Lax') + +# Configure CSRF protection from config +app.config['WTF_CSRF_ENABLED'] = config.csrf_enabled +app.config['WTF_CSRF_TIME_LIMIT'] = config.get('security.csrf_time_limit') +app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'pool_timeout': config.get('database.pool_timeout', 20), + 'pool_recycle': config.get('database.pool_recycle', -1), + 'pool_pre_ping': config.get('database.pool_pre_ping', True) +} + +# Initialize CSRF protection +csrf = CSRFProtect(app) + +# Exempt API endpoints from CSRF protection +csrf.exempt('get_all_quotes') +csrf.exempt('get_quote') +csrf.exempt('get_random_quote') +csrf.exempt('get_top_quotes') +csrf.exempt('search_quotes') +csrf.exempt('get_stats') + +# Initialize rate limiter +limiter = Limiter(app, key_func=get_remote_address) + db = SQLAlchemy(app) # Apply ProxyFix middleware @@ -22,14 +71,32 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_ # Initialize Argon2 password hasher ph = PasswordHasher() -# Initialize logging for debugging -logging.basicConfig(level=logging.DEBUG) +# Configure logging from config +logging.basicConfig( + level=getattr(logging, config.logging_level), + format=config.get('logging.format', '%(asctime)s [%(levelname)s] %(message)s') +) -# Hardcoded admin credentials (hashed password using Argon2) +# Add security headers from config +@app.after_request +def add_security_headers(response): + headers = config.get('security.security_headers', {}) + if headers.get('x_content_type_options'): + response.headers['X-Content-Type-Options'] = headers['x_content_type_options'] + if headers.get('x_frame_options'): + response.headers['X-Frame-Options'] = headers['x_frame_options'] + if headers.get('x_xss_protection'): + response.headers['X-XSS-Protection'] = headers['x_xss_protection'] + if headers.get('strict_transport_security'): + response.headers['Strict-Transport-Security'] = headers['strict_transport_security'] + if headers.get('content_security_policy'): + response.headers['Content-Security-Policy'] = headers['content_security_policy'] + return response + +# Admin credentials from config ADMIN_CREDENTIALS = { - 'username': 'admin', - # Replace this with the hashed password generated by Argon2 - 'password': '$argon2i$v=19$m=65536,t=4,p=1$cWZDc1pQaUJLTUJoaVI4cw$kn8XKz6AEZi8ebXfyyZuzommSypliVFrsGqzOyUEIHA' # Example hash + 'username': config.admin_username, + 'password': config.admin_password_hash } # Define the Quote model @@ -42,6 +109,7 @@ class Quote(db.Model): ip_address = db.Column(db.String(45)) # Store IPv4 and IPv6 addresses user_agent = db.Column(db.String(255)) # Store user-agent strings submitted_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + flag_count = db.Column(db.Integer, default=0) # Track how many times quote has been flagged # Home route to display quotes @app.route('/') @@ -57,12 +125,32 @@ def index(): # Separate route for submitting quotes @app.route('/submit', methods=['GET', 'POST']) +@limiter.limit(config.get('rate_limiting.endpoints.submit', '5 per minute')) def submit(): if request.method == 'POST': quote_text = request.form.get('quote') if not quote_text: - flash("Quote cannot be empty.", 'error') + flash("Oops! Your quote seems to be empty. Please enter some text before submitting.", 'error') return redirect(url_for('submit')) + + # Input validation and length limits from config + quote_text = quote_text.strip() + min_length = config.min_quote_length + max_length = config.max_quote_length + + if len(quote_text) < min_length: + flash(f"Your quote is too short. Please enter at least {min_length} characters.", 'error') + return redirect(url_for('submit')) + + if len(quote_text) > max_length: + flash(f"Your quote is too long. Please keep it under {max_length} characters.", 'error') + return redirect(url_for('submit')) + + # Basic content validation (no scripts or dangerous content) + if not config.get('quotes.allow_html', False): + if ' + +
@@ -33,8 +45,9 @@ Submit / Browse / ModApp / - Search + Search / FAQ + @@ -48,12 +61,16 @@ {% for quote in quotes.items %}#{{ quote.id }} - + - ({{ quote.votes }}) - - - + + + + {{ quote.votes }} + - + + X + + C
-{{ quote.text }}
+{{ quote.text|e }}
#{{ quote.id }} - + - ({{ quote.votes }}) - - - [X] + + + + {{ quote.votes }} + - + + X + + C
-{{ quote.text }}
+{{ quote.text|e }}
#{{ quote.id }} - + - ({{ quote.votes }}) - - - [X] + + + + {{ quote.votes }} + - + + X + + C
-{{ quote.text }}
+{{ quote.text|e }}