From cd27cc8ad98f7e741fe9b9ac56112574e7c758bb Mon Sep 17 00:00:00 2001 From: ComputerTech312 Date: Sun, 21 Sep 2025 19:45:08 +0100 Subject: [PATCH] Major refactor: Fix SQLite concurrency, remove rate limiting, simplify architecture - Switch to single Gunicorn worker to eliminate SQLite database locking issues - Remove Flask-Limiter and all rate limiting complexity - Remove Cloudflare proxy setup and dependencies - Simplify configuration and remove unnecessary features - Update all templates and static files for streamlined operation - Clean up old files and documentation - Restore stable database from backup - System now runs fast and reliably without database locks --- CLOUDFLARE_SETUP.md | 146 ------- CONFIG_GUIDE.md | 148 ++++++++ DEPLOYMENT.md | 36 +- app.py | 860 +++++++++++++++++++++++++++++++----------- config.json | 30 +- config_manager.py | 84 ----- create_fresh_db.py | 77 ++++ generate_password.py | 8 +- gunicorn.conf.py | 59 --- instance/quotes.db | Bin 6447104 -> 6447104 bytes nginx-ircquotes.conf | 5 +- production.py | 83 ++++ requirements.txt | 1 - setup.sh | 12 +- start_gunicorn.py | 49 --- static/modapp.js | 189 ++++++++++ static/styles.css | 101 +++-- static/voting.js | 29 +- templates/browse.html | 10 +- templates/faq.html | 19 +- templates/index.html | 4 +- templates/modapp.html | 11 +- templates/quote.html | 8 + templates/random.html | 8 + templates/search.html | 8 + templates/submit.html | 18 +- 26 files changed, 1326 insertions(+), 677 deletions(-) delete mode 100644 CLOUDFLARE_SETUP.md create mode 100644 CONFIG_GUIDE.md delete mode 100644 config_manager.py create mode 100644 create_fresh_db.py delete mode 100644 gunicorn.conf.py create mode 100755 production.py delete mode 100644 start_gunicorn.py create mode 100644 static/modapp.js diff --git a/CLOUDFLARE_SETUP.md b/CLOUDFLARE_SETUP.md deleted file mode 100644 index 9b78dd9..0000000 --- a/CLOUDFLARE_SETUP.md +++ /dev/null @@ -1,146 +0,0 @@ -# Cloudflare + nginx Setup Guide for ircquotes - -## Overview -This setup ensures that your ircquotes application can see real client IP addresses even when behind: -1. **Cloudflare** (CDN/Proxy) -2. **nginx** (Reverse Proxy) -3. **Gunicorn** (WSGI Server) - -## Architecture -``` -Client → Cloudflare → nginx → Gunicorn → ircquotes -``` - -## Setup Steps - -### 1. Cloudflare Configuration - -#### Enable Proxy (Orange Cloud) -- Set your DNS record to "Proxied" (orange cloud icon) -- This routes traffic through Cloudflare's edge servers - -#### Recommended Cloudflare Settings: -- **SSL/TLS**: Full (Strict) if you have SSL on origin -- **Security Level**: Medium -- **Bot Fight Mode**: Enabled -- **Rate Limiting**: Configure as needed -- **Page Rules**: Optional caching rules - -#### Important Headers: -Cloudflare automatically adds these headers: -- `CF-Connecting-IP`: Real client IP address -- `CF-Ray`: Request identifier -- `CF-Visitor`: Visitor information - -### 2. nginx Configuration - -Copy the provided `nginx-ircquotes.conf` to your nginx sites: - -```bash -sudo cp nginx-ircquotes.conf /etc/nginx/sites-available/ircquotes -sudo ln -s /etc/nginx/sites-available/ircquotes /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl reload nginx -``` - -**Key nginx features:** -- ✅ Cloudflare IP range restoration -- ✅ Real IP detection via CF-Connecting-IP -- ✅ Additional rate limiting layer -- ✅ Security headers -- ✅ Gzip compression -- ✅ Static file optimization - -### 3. Application Configuration - -The ircquotes app is already configured to: -- ✅ Use `CF-Connecting-IP` header (Cloudflare's real IP) -- ✅ Fall back to `X-Forwarded-For` and `X-Real-IP` -- ✅ Handle 2-proxy setup (Cloudflare + nginx) -- ✅ Rate limit by real client IP - -### 4. Verification - -To verify real IPs are being detected: - -1. **Check application logs**: - ```bash - tail -f /var/log/ircquotes/access.log - ``` - -2. **Test from different locations**: - - Visit your site from different networks - - Check admin panel for real IPs in quote submissions - - Verify rate limiting works per real IP - -3. **Debug headers** (temporary debug route): - ```python - @app.route('/debug-headers') - def debug_headers(): - return jsonify({ - 'real_ip': get_real_ip(), - 'cf_connecting_ip': request.headers.get('CF-Connecting-IP'), - 'x_forwarded_for': request.headers.get('X-Forwarded-For'), - 'x_real_ip': request.headers.get('X-Real-IP'), - 'remote_addr': request.remote_addr - }) - ``` - -### 5. Security Considerations - -#### Cloudflare Settings: -- Enable **DDoS Protection** -- Configure **WAF Rules** for your application -- Set up **Rate Limiting** at Cloudflare level -- Enable **Bot Management** if available - -#### nginx Security: -- Keep Cloudflare IP ranges updated -- Monitor for suspicious patterns -- Implement additional rate limiting -- Regular security updates - -#### Application Security: -- All security features already implemented -- Rate limiting per real IP -- CSRF protection enabled -- Input validation active - -## Troubleshooting - -### IPs showing as 127.0.0.1: -1. Check nginx is passing headers correctly -2. Verify Cloudflare IP ranges in nginx config -3. Ensure ProxyFix is configured for 2 proxies -4. Check `CF-Connecting-IP` header presence - -### Rate limiting not working: -1. Verify real IP detection is working -2. Check rate limiting configuration -3. Monitor nginx and application logs -4. Test with different source IPs - -### Performance issues: -1. Enable nginx caching for static files -2. Configure Cloudflare caching rules -3. Monitor Gunicorn worker count -4. Check database connection pooling - -## Monitoring - -Recommended monitoring: -- **Application logs**: Real IP addresses in logs -- **nginx access logs**: Request patterns -- **Cloudflare Analytics**: Traffic patterns -- **Rate limiting metrics**: Blocked vs allowed requests - -## Production Checklist - -- [ ] Cloudflare proxy enabled (orange cloud) -- [ ] nginx configuration deployed -- [ ] Real IP detection working -- [ ] Rate limiting functional -- [ ] Security headers present -- [ ] SSL/TLS configured -- [ ] Monitoring in place -- [ ] Backup and recovery tested \ No newline at end of file diff --git a/CONFIG_GUIDE.md b/CONFIG_GUIDE.md new file mode 100644 index 0000000..0aa1cb1 --- /dev/null +++ b/CONFIG_GUIDE.md @@ -0,0 +1,148 @@ +# Configuration Guide + +This guide explains how to configure the ircquotes application by editing `config.json` manually. + +## Configuration File Structure + +The `config.json` file is organized into sections: + +### App Section +```json +"app": { + "name": "ircquotes", // Application name + "host": "127.0.0.1", // Host to bind to (use 0.0.0.0 for all interfaces) + "port": 6969, // Port number to run on + "debug": false // Enable debug mode (set to true for development) +} +``` + +### Gunicorn Section (Production Settings) +```json +"gunicorn": { + "workers": 4, // Number of worker processes + "timeout": 30, // Request timeout in seconds + "keepalive": 5, // Keep-alive timeout + "max_requests": 1000, // Max requests per worker before restart + "preload": true // Preload application code +} +``` + +### Database Section +```json +"database": { + "uri": "sqlite:///quotes.db?timeout=20", // Database connection string + "pool_timeout": 20, // Connection pool timeout + "pool_recycle": -1, // Connection recycle time (-1 = disabled) + "pool_pre_ping": true // Test connections before use +} +``` + +### Security Section +```json +"security": { + "csrf_enabled": true, // Enable CSRF protection + "csrf_time_limit": null, // CSRF token time limit (null = no limit) + "session_cookie_secure": false, // Require HTTPS for session cookies + "session_cookie_httponly": true, // Prevent JavaScript access to session cookies + "session_cookie_samesite": "Lax", // SameSite policy for session cookies + "security_headers": { + "x_content_type_options": "nosniff", + "x_frame_options": "DENY", + "x_xss_protection": "1; mode=block", + "strict_transport_security": "max-age=31536000; includeSubDomains", + "content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" + } +} +``` + +### Admin Section +```json +"admin": { + "username": "admin", // Admin username + "password_hash": "..." // Argon2 password hash (use generate_password.py) +} +``` + +### Quotes Section +```json +"quotes": { + "min_length": 1, // Minimum quote length in characters + "max_length": 5000, // Maximum quote length in characters + "per_page": 25, // Quotes displayed per page + "auto_approve": false, // Automatically approve new quotes + "allow_html": false // Allow HTML in quotes (not recommended) +} +``` + +### Features Section +```json +"features": { + "voting_enabled": true, // Enable voting on quotes + "flagging_enabled": true, // Enable flagging inappropriate quotes + "copy_quotes_enabled": true, // Enable copy-to-clipboard feature + "dark_mode_enabled": true, // Enable dark mode toggle + "api_enabled": true, // Enable JSON API endpoints + "bulk_moderation_enabled": true // Enable bulk moderation actions +} +``` + +### Logging Section +```json +"logging": { + "level": "DEBUG", // Logging level (DEBUG, INFO, WARNING, ERROR) + "format": "%(asctime)s [%(levelname)s] %(message)s" // Log message format +} +``` + +## Common Configuration Tasks + +### Change Admin Password +1. Run: `python generate_password.py` +2. Edit `config.json` and update `admin.password_hash` with the generated hash +3. Restart the application + +### Change Port +Edit the `app.port` value in `config.json`: +```json +"app": { + "port": 8080 +} +``` + +### Adjust Quote Limits +Edit the `quotes` section: +```json +"quotes": { + "min_length": 10, + "max_length": 2000, + "per_page": 50 +} +``` + +### Disable Features +Set feature flags to `false`: +```json +"features": { + "voting_enabled": false, + "flagging_enabled": false +} +``` + +### Adjust Rate Limits +Modify the `rate_limiting.endpoints` section: +```json +"rate_limiting": { + "endpoints": { + "submit": "10 per minute", + "vote": "120 per minute" + } +} +``` + +## Important Notes + +- Always restart the application after making configuration changes +- Use valid JSON syntax (no trailing commas, proper quotes) +- Test configuration changes in a development environment first +- Keep backups of your working configuration +- Use `python generate_password.py` to create secure password hashes \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3fab514..217c0da 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -13,24 +13,30 @@ All application settings are now centralized in `config.json`. You can easily mo - **Admin credentials** - **Feature toggles** -### Viewing Current Configuration +### Configuration Management +All configuration is done by editing `config.json` directly. This file contains all application settings organized in sections: + +- **app**: Basic application settings (name, host, port, debug) +- **database**: Database connection settings +- **security**: Security headers, CSRF, proxy settings +- **rate_limiting**: Rate limiting configuration for different endpoints +- **admin**: Admin username and password hash +- **quotes**: Quote submission settings (length limits, pagination) +- **features**: Feature toggles (voting, flagging, dark mode, etc.) +- **logging**: Logging configuration + +### Example Configuration Changes ```bash -python config_manager.py -``` +# Edit config.json in any text editor +nano config.json -### Updating Configuration -```bash -# Change port -python config_manager.py app.port 8080 +# Example changes: +# - Change port: "port": 8080 in the "app" section +# - Change quotes per page: "per_page": 50 in the "quotes" section +# - Disable CSRF: "csrf_enabled": false in the "security" section +# - Change rate limits: "login": "10 per minute" in rate_limiting.endpoints -# 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" +# After making changes, restart the application ``` ## Running with Gunicorn (Production) diff --git a/app.py b/app.py index 779c7ea..ef7f464 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, flash, abort, make_response, session, jsonify 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 @@ -9,59 +7,109 @@ import json import random 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 +import time +import ipaddress from config_loader import config # Import configuration system -def get_real_ip(): +def db_retry_operation(operation, max_retries=2, delay=0.01): """ - Get the real client IP address considering Cloudflare and nginx reverse proxy. - Checks headers in order of priority: - 1. CF-Connecting-IP (Cloudflare's real IP header) - 2. X-Forwarded-For (standard proxy header) - 3. X-Real-IP (nginx real IP header) - 4. request.remote_addr (fallback) + Retry database operations that might fail due to database locks. + Includes session cleanup for better reliability. + + Args: + operation: A callable that performs the database operation + max_retries: Maximum number of retry attempts + delay: Initial delay between retries + + Returns: + The result of the operation if successful + + Raises: + The last exception if all retries fail """ - # Cloudflare provides the real IP in CF-Connecting-IP header - cf_ip = request.headers.get('CF-Connecting-IP') - if cf_ip: - return cf_ip + last_exception = None - # Check X-Forwarded-For (may contain multiple IPs, first is original client) - forwarded_for = request.headers.get('X-Forwarded-For') - if forwarded_for: - # Take the first IP in the chain (original client) - return forwarded_for.split(',')[0].strip() + for attempt in range(max_retries + 1): + try: + return operation() + except Exception as e: + last_exception = e + error_msg = str(e).lower() + + # Handle specific database errors that benefit from retry + if ('database is locked' in error_msg or + 'sqlite3.operationalerror' in error_msg or + 'transaction has been rolled back' in error_msg): + + try: + # Only rollback, don't close session to avoid unbound objects + db.session.rollback() + except: + pass # Ignore cleanup errors + + if attempt < max_retries: + logging.warning(f"Database error detected, rollback and retry (attempt {attempt + 1}/{max_retries + 1})") + time.sleep(delay) + continue + + # For non-database errors or final attempt, re-raise immediately + raise - # Check X-Real-IP (nginx header) - real_ip = request.headers.get('X-Real-IP') - if real_ip: - return real_ip - - # Fallback to request.remote_addr or default - return request.remote_addr or '127.0.0.1' + # This should never be reached due to the logic above, but just in case + if last_exception: + raise last_exception + else: + raise RuntimeError("Database operation failed for unknown reasons") -# Configure SQLite for better concurrency +def validate_ip_address(ip_str): + """ + Validate that an IP address string is a valid IPv4 or IPv6 address. + Returns a sanitized IP address string or '127.0.0.1' if invalid. + """ + try: + # This will raise ValueError if the IP is invalid + ip_obj = ipaddress.ip_address(ip_str) + return str(ip_obj) + except (ValueError, TypeError): + # If IP is invalid, return localhost as fallback + app.logger.warning(f"Invalid IP address detected: {ip_str}") + return '127.0.0.1' + +# Configure SQLite for better concurrency and performance @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 + # Reduce timeout for faster failures instead of long waits + cursor.execute("PRAGMA busy_timeout=1000") # 1 second - faster failure # Optimize for performance cursor.execute("PRAGMA synchronous=NORMAL") - cursor.execute("PRAGMA cache_size=1000") + cursor.execute("PRAGMA cache_size=20000") # Larger cache cursor.execute("PRAGMA temp_store=memory") + cursor.execute("PRAGMA mmap_size=268435456") # 256MB memory mapped + cursor.execute("PRAGMA wal_autocheckpoint=500") # More frequent checkpoints + cursor.execute("PRAGMA optimize") # Enable automatic index optimization cursor.close() app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = config.database_uri app.config['SECRET_KEY'] = open("instance/flask_secret_key", "r").read().strip() +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# Enhanced connection pool configuration for better concurrency +app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'pool_size': 10, # Maintain 10 connections in pool + 'pool_recycle': 3600, # Recycle connections every hour + 'pool_pre_ping': True, # Test connections before use + 'pool_timeout': 5, # Wait up to 5 seconds for connection + 'max_overflow': 20 # Allow up to 20 additional connections +} # Configure secure session settings from config app.config['SESSION_COOKIE_SECURE'] = config.get('security.session_cookie_secure', False) @@ -88,18 +136,10 @@ csrf.exempt('get_top_quotes') csrf.exempt('search_quotes') csrf.exempt('get_stats') -# Initialize rate limiter with custom IP detection -limiter = Limiter( - key_func=get_real_ip, - app=app -) +# Remove rate limiting - immediate response for all requests db = SQLAlchemy(app) -# Apply ProxyFix middleware for Cloudflare + nginx setup -# x_for=2: nginx (1) + Cloudflare (1) = 2 proxies in X-Forwarded-For chain -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=1, x_host=1, x_port=1, x_prefix=1) - # Initialize Argon2 password hasher ph = PasswordHasher() @@ -136,12 +176,18 @@ class Quote(db.Model): id = db.Column(db.Integer, primary_key=True) text = db.Column(db.Text, nullable=False) votes = db.Column(db.Integer, default=0) - date = db.Column(db.DateTime, default=datetime.datetime.utcnow) - status = db.Column(db.Integer, default=0) # 0 = pending, 1 = approved, 2 = rejected + date = db.Column(db.DateTime, nullable=True) # Legacy field for old quotes + status = db.Column(db.Integer, default=0, index=True) # 0 = pending, 1 = approved, 2 = rejected 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 + submitted_at = db.Column(db.DateTime, nullable=True) # New timestamp field for new quotes + flag_count = db.Column(db.Integer, default=0, index=True) # Track how many times quote has been flagged + + # Add composite indexes for common queries + __table_args__ = ( + db.Index('idx_status_id', 'status', 'id'), + db.Index('idx_flag_count_id', 'flag_count', 'id'), + ) # Home route to display quotes @app.route('/') @@ -157,10 +203,11 @@ 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') + is_preview = 'submit2' in request.form # Preview button is named submit2 + if not quote_text: flash("Oops! Your quote seems to be empty. Please enter some text before submitting.", 'error') return redirect(url_for('submit')) @@ -183,8 +230,18 @@ def submit(): if '/') -@limiter.limit("20 per minute") def vote(id, action): - quote = Quote.query.get_or_404(id) + # Only allow voting on approved quotes (status = 1) + quote = Quote.query.filter_by(id=id, status=1).first() + if not quote: + error_msg = "Quote not found or not available for voting." + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': False, + 'message': error_msg + }), 404 + else: + flash(error_msg, 'error') + return redirect(url_for('browse')) # Retrieve vote history from the cookie vote_cookie = request.cookies.get('votes') @@ -249,7 +318,7 @@ def vote(id, action): quote.votes -= 1 vote_data[str(id)] = 'downvote' message = "Thank you for voting!" - + else: previous_action = vote_data[str(id)] @@ -271,9 +340,10 @@ def vote(id, action): vote_data[str(id)] = 'upvote' message = "Your vote has been changed." - # Save the updated vote data to the cookie + # Save the updated vote data with retry for database locks try: - db.session.commit() + # Simple retry mechanism for database locks + db_retry_operation(lambda: db.session.commit()) # Check if it's an AJAX request if request.headers.get('X-Requested-With') == 'XMLHttpRequest': @@ -295,13 +365,16 @@ def vote(id, action): return resp except Exception as e: db.session.rollback() + logging.error(f"Vote error for quote {id}, action {action}: {str(e)}") + user_error_msg = "Sorry, there was an error processing your vote. Please try again." + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({ 'success': False, - 'message': f"Error while voting: {e}" + 'message': user_error_msg }), 500 else: - flash(f"Error while voting: {e}", 'error') + flash(user_error_msg, 'error') page = request.args.get('page', 1) return redirect(url_for('browse', page=page)) @@ -325,17 +398,25 @@ def random_quote(): @app.route('/') def quote_homepathid(id): - quote = Quote.query.get_or_404(id) + # Only show approved quotes (status = 1) + quote = Quote.query.filter_by(id=id, status=1).first() + if not quote: + abort(404) return render_template('quote.html', quote=quote) @app.route('/quote') def quote(): - quote_id = request.args.get('id') + quote_id = request.args.get('id', type=int) # Convert to int directly if not quote_id: - flash("Please enter a quote number to view that specific quote.", 'error') + flash("Please enter a valid quote number to view that specific quote.", 'error') return redirect(url_for('browse')) - quote = Quote.query.get_or_404(quote_id) + # Only show approved quotes (status = 1) + quote = Quote.query.filter_by(id=quote_id, status=1).first() + if not quote: + flash(f"No approved quote found with ID {quote_id}", 'error') + return redirect(url_for('search')) + return render_template('quote.html', quote=quote) @app.route('/faq') @@ -344,15 +425,36 @@ def faq(): # Flag/Report a quote route @app.route('/flag/') -@limiter.limit("10 per minute") def flag_quote(id): - quote = Quote.query.get_or_404(id) + # Only allow flagging of approved quotes (status = 1) + quote = Quote.query.filter_by(id=id, status=1).first() + if not quote: + message = 'Quote not found or not available for flagging.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': False, + 'message': message + }), 404 + else: + flash(message, 'error') + referer = request.headers.get('Referer') + if referer and any(path in referer for path in ['/browse', '/quote', '/random', '/search']): + return redirect(referer) + else: + return redirect(url_for('browse')) # Increment flag count quote.flag_count += 1 - try: + def commit_flag_changes(): + """Helper function to commit flag changes with proper error handling""" db.session.commit() + return True + + try: + # Use retry mechanism for database commit + db_retry_operation(commit_flag_changes) message = 'Quote has been flagged for review. Thank you for helping keep the site clean!' # Check if it's an AJAX request @@ -366,15 +468,17 @@ def flag_quote(id): flash(message, 'success') except Exception as e: db.session.rollback() - error_message = 'Error flagging quote. Please try again.' + # Log detailed error but only show generic message to user + logging.error(f"Flag error for quote {id}: {str(e)}") + user_error_msg = 'Sorry, there was an error flagging this quote. Please try again.' if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({ 'success': False, - 'message': error_message + 'message': user_error_msg }), 500 else: - flash(error_message, 'error') + flash(user_error_msg, 'error') # For non-AJAX requests, redirect back to the same page referer = request.headers.get('Referer') @@ -385,29 +489,45 @@ def flag_quote(id): # Admin login route @app.route('/login', methods=['GET', 'POST']) -@limiter.limit(config.get('rate_limiting.endpoints.login', '5 per minute')) def login(): if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] - - # Check if the username is correct and verify the password using Argon2 - if username == ADMIN_CREDENTIALS['username']: - try: - ph.verify(ADMIN_CREDENTIALS['password'], password) # Verify password using Argon2 - session['admin'] = True - flash('Welcome back! You are now logged in as administrator.', 'success') - return redirect(url_for('modapp')) - except VerifyMismatchError: - flash('The password you entered is incorrect. Please check your password and try again.', 'danger') - else: - flash('The username you entered is not recognized. Please check your username and try again.', 'danger') + try: + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + + if not username or not password: + flash('Please enter both username and password.', 'danger') + return render_template('login.html') + + # Check if the username is correct and verify the password using Argon2 + if username == ADMIN_CREDENTIALS['username']: + try: + ph.verify(ADMIN_CREDENTIALS['password'], password) # Verify password using Argon2 + + # Regenerate session ID to prevent session fixation attacks + # Clear the old session and create a new one + session.clear() + session.permanent = True + session['admin'] = True + + flash('Welcome back! You are now logged in as administrator.', 'success') + return redirect(url_for('modapp')) + except VerifyMismatchError: + flash('The password you entered is incorrect. Please check your password and try again.', 'danger') + except Exception as e: + logging.error(f"Password verification error: {e}") + flash('An error occurred during login. Please try again.', 'danger') + else: + flash('The username you entered is not recognized. Please check your username and try again.', 'danger') + + except Exception as e: + logging.error(f"Login error: {e}") + flash('An error occurred during login. Please try again.', 'danger') return render_template('login.html') # Admin panel route (accessible only to logged-in admins) @app.route('/modapp') -@limiter.limit("20 per minute") def modapp(): if not session.get('admin'): flash('Access denied. Please log in with administrator credentials to access the moderation panel.', 'danger') @@ -417,21 +537,30 @@ def modapp(): filter_status = request.args.get('filter', 'pending') page = request.args.get('page', 1, type=int) + # Get quotes based on filter with optimized queries if filter_status == 'approved': - quotes = Quote.query.filter_by(status=1).order_by(Quote.date.desc()).paginate(page=page, per_page=10) + quotes = Quote.query.filter_by(status=1).order_by(Quote.id.desc()).paginate(page=page, per_page=10, error_out=False) elif filter_status == 'rejected': - quotes = Quote.query.filter_by(status=2).order_by(Quote.date.desc()).paginate(page=page, per_page=10) + quotes = Quote.query.filter_by(status=2).order_by(Quote.id.desc()).paginate(page=page, per_page=10, error_out=False) elif filter_status == 'flagged': # Show quotes with flag_count > 0, ordered by flag count (highest first) - quotes = Quote.query.filter(Quote.flag_count > 0).order_by(Quote.flag_count.desc(), Quote.date.desc()).paginate(page=page, per_page=10) + quotes = Quote.query.filter(Quote.flag_count > 0).order_by(Quote.flag_count.desc(), Quote.id.desc()).paginate(page=page, per_page=10, error_out=False) else: # Default to pending - quotes = Quote.query.filter_by(status=0).order_by(Quote.date.desc()).paginate(page=page, per_page=10) + quotes = Quote.query.filter_by(status=0).order_by(Quote.id.desc()).paginate(page=page, per_page=10, error_out=False) - # Get counts for each status - approved_count = Quote.query.filter_by(status=1).count() - pending_count = Quote.query.filter_by(status=0).count() - rejected_count = Quote.query.filter_by(status=2).count() - flagged_count = Quote.query.filter(Quote.flag_count > 0).count() + # Get counts for each status in a single query to avoid multiple hits + from sqlalchemy import func, case + count_results = db.session.query( + func.count(case((Quote.status == 1, 1))).label('approved_count'), + func.count(case((Quote.status == 0, 1))).label('pending_count'), + func.count(case((Quote.status == 2, 1))).label('rejected_count'), + func.count(case((Quote.flag_count > 0, 1))).label('flagged_count') + ).first() + + approved_count = count_results.approved_count + pending_count = count_results.pending_count + rejected_count = count_results.rejected_count + flagged_count = count_results.flagged_count return render_template('modapp.html', quotes=quotes, filter_status=filter_status, approved_count=approved_count, pending_count=pending_count, @@ -440,7 +569,6 @@ def modapp(): # Bulk actions route for modapp @app.route('/modapp/bulk', methods=['POST']) -@limiter.limit("10 per minute") def modapp_bulk(): if not session.get('admin'): flash('Access denied. Administrator login required for bulk actions.', 'danger') @@ -458,58 +586,198 @@ def modapp_bulk(): return redirect(url_for('modapp')) success_count = 0 + error_count = 0 + + # Validate all quote IDs first + valid_quote_ids = [] + for quote_id_str in quote_ids: + try: + quote_id = int(quote_id_str) + quote = Quote.query.get(quote_id) + if quote: + valid_quote_ids.append(quote_id) + else: + error_count += 1 + logging.warning(f"Quote ID {quote_id} not found during bulk {action}") + except (ValueError, TypeError): + error_count += 1 + logging.warning(f"Invalid quote ID '{quote_id_str}' during bulk {action}") + + if not valid_quote_ids: + flash('No valid quotes selected. Please try again.', 'error') + return redirect(url_for('modapp')) + + # Perform bulk operation with transaction safety + def bulk_operation(): + nonlocal success_count + + try: + for quote_id in valid_quote_ids: + try: + # Use fresh query to avoid stale session issues + quote = db.session.query(Quote).filter(Quote.id == quote_id).first() + if quote: + if action == 'approve': + if quote.status != 1: # Only approve if not already approved + quote.status = 1 + success_count += 1 + elif action == 'reject': + if quote.status != 2: # Only reject if not already rejected + quote.status = 2 + success_count += 1 + elif action == 'delete': + db.session.delete(quote) + success_count += 1 + elif action == 'clear_flags': + if quote.flag_count > 0: # Only clear if there are flags + quote.flag_count = 0 + success_count += 1 + except Exception as e: + logging.error(f"Error processing quote {quote_id} during bulk {action}: {str(e)}") + raise # Re-raise to trigger rollback + + # Commit all changes at once + db.session.commit() + return True + + except Exception as e: + db.session.rollback() + raise e try: - for quote_id in quote_ids: - quote = Quote.query.get(int(quote_id)) - if quote: - if action == 'approve': - quote.status = 1 - success_count += 1 - elif action == 'reject': - quote.status = 2 - success_count += 1 - elif action == 'delete': - db.session.delete(quote) - success_count += 1 - elif action == 'clear_flags': - quote.flag_count = 0 - success_count += 1 + db_retry_operation(bulk_operation) - db.session.commit() - - if action == 'clear_flags': - flash(f'Successfully cleared flags on {success_count} quote(s).', 'success') + # Generate success message + if success_count > 0: + if action == 'clear_flags': + message = f'Successfully cleared flags on {success_count} quote(s).' + else: + action_past_tense = { + 'approve': 'approved', + 'reject': 'rejected', + 'delete': 'deleted' + }.get(action, f'{action}d') + message = f'Successfully {action_past_tense} {success_count} quote(s).' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'message': message}) + else: + flash(message, 'success') else: - flash(f'Successfully {action}d {success_count} quote(s).', 'success') + message = 'No changes were made. The selected quotes may already be in the requested state.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'info') + + if error_count > 0: + warning_message = f'{error_count} quote(s) had invalid IDs and were skipped.' + if not request.headers.get('X-Requested-With') == 'XMLHttpRequest': + flash(warning_message, 'warning') except Exception as e: db.session.rollback() - flash(f'Error performing bulk action: {str(e)}', 'error') + logging.error(f'Bulk {action} operation failed: {str(e)}') + error_message = f'Error performing bulk {action}. Please check the logs for details.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': error_message}) + else: + flash(error_message, 'error') + # For non-AJAX requests, redirect back to modapp return redirect(url_for('modapp')) # Helper function to approve a quote def approve_quote(quote_id): - quote = Quote.query.get(quote_id) - if quote and quote.status != 1: # Only approve if not already approved - quote.status = 1 # Approved - db.session.commit() + """Helper function to approve a quote with proper session management""" + try: + # Use a fresh query in case of stale session + quote = db.session.query(Quote).filter(Quote.id == quote_id).first() + if quote and quote.status != 1: # Only approve if not already approved + quote.status = 1 # Approved + + def commit_operation(): + try: + db.session.commit() + return True + except Exception as e: + db.session.rollback() + raise e + + return db_retry_operation(commit_operation) + return False + except Exception as e: + db.session.rollback() + logging.error(f"Error in approve_quote({quote_id}): {str(e)}") + raise # Helper function to reject a quote def reject_quote(quote_id): - quote = Quote.query.get(quote_id) - if quote and quote.status != 2: # Only reject if not already rejected - quote.status = 'rejected' - db.session.commit() + """Helper function to reject a quote with proper session management""" + try: + # Use a fresh query in case of stale session + quote = db.session.query(Quote).filter(Quote.id == quote_id).first() + if quote and quote.status != 2: # Only reject if not already rejected + quote.status = 2 # Rejected + + def commit_operation(): + try: + db.session.commit() + return True + except Exception as e: + db.session.rollback() + raise e + + return db_retry_operation(commit_operation) + return False + except Exception as e: + db.session.rollback() + logging.error(f"Error in reject_quote({quote_id}): {str(e)}") + raise + logging.error(f"Error in reject_quote({quote_id}): {str(e)}") + raise # Helper function to delete a quote def delete_quote(quote_id): - quote = Quote.query.get(quote_id) - if quote: - db.session.delete(quote) - db.session.commit() + """Helper function to delete a quote with proper error handling""" + try: + quote = Quote.query.get(quote_id) + if quote: + def commit_operation(): + db.session.delete(quote) + db.session.commit() + return True + + return db_retry_operation(commit_operation) + return False + except Exception as e: + db.session.rollback() + logging.error(f"Error in delete_quote({quote_id}): {str(e)}") + raise + +# Helper function to clear flags from a quote +def clear_flags_quote(quote_id): + """Helper function to clear flags from a quote with proper error handling""" + try: + quote = Quote.query.get(quote_id) + if quote and quote.flag_count > 0: + original_flag_count = quote.flag_count + quote.flag_count = 0 + + def commit_operation(): + db.session.commit() + return True + + db_retry_operation(commit_operation) + return original_flag_count # Return number of flags cleared + return 0 # No flags to clear + except Exception as e: + db.session.rollback() + logging.error(f"Error in clear_flags_quote({quote_id}): {str(e)}") + raise @app.route('/search', methods=['GET']) def search(): @@ -559,68 +827,227 @@ def browse(): return render_template('browse.html', quotes=quotes, approved_count=approved_count, pending_count=pending_count) +@app.route('/top') +def top_quotes(): + """Display the top quotes sorted by votes""" + # Query the counts of approved and pending quotes + approved_count = Quote.query.filter_by(status=1).count() + pending_count = Quote.query.filter_by(status=0).count() + + # Get top 100 quotes or paginate + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 100, type=int) + per_page = min(per_page, 100) # Cap at 100 per page + + # Get approved quotes sorted by votes (descending), then by date + quotes = Quote.query.filter_by(status=1).order_by(Quote.votes.desc(), Quote.date.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + # Use the browse template but with top quotes + return render_template('browse.html', quotes=quotes, approved_count=approved_count, + pending_count=pending_count, is_top=True) + + # Approve a quote (admin only) @app.route('/approve/') -@limiter.limit("30 per minute") def approve(id): if not session.get('admin'): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': 'Access denied. Administrator login required.'}) + flash('Access denied. Administrator login required.', 'danger') return redirect(url_for('login')) - quote = Quote.query.get_or_404(id) - quote.status = 1 - db.session.commit() + try: + success = approve_quote(id) + if success: + message = f'Quote #{id} has been approved.' + logging.info(f"Admin approved quote {id}") + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'message': message}) + else: + flash(message, 'success') + else: + message = f'Quote #{id} could not be approved (may not exist or already approved).' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'warning') + + except Exception as e: + logging.error(f"Error approving quote {id}: {str(e)}") + message = 'Error approving quote. Please try again or check the logs.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'error') - # Redirect back to the same page - page = request.args.get('page', 1) - return redirect(url_for('modapp', page=page)) + # For non-AJAX requests, redirect back to modapp + filter_status = request.args.get('filter', 'pending') + return redirect(url_for('modapp', filter=filter_status)) # Reject a quote (admin only) @app.route('/reject/') -@limiter.limit("30 per minute") def reject(id): if not session.get('admin'): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': 'Access denied. Administrator login required.'}) + flash('Access denied. Administrator login required.', 'danger') return redirect(url_for('login')) - quote = Quote.query.get_or_404(id) - quote.status = 2 # 2 = rejected - db.session.commit() - return redirect(url_for('modapp')) + try: + success = reject_quote(id) + if success: + message = f'Quote #{id} has been rejected.' + logging.info(f"Admin rejected quote {id}") + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'message': message}) + else: + flash(message, 'success') + else: + message = f'Quote #{id} could not be rejected (may not exist or already rejected).' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'warning') + + except Exception as e: + logging.error(f"Error rejecting quote {id}: {str(e)}") + message = 'Error rejecting quote. Please try again or check the logs.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'error') -# Delete a quote (admin only) + # For non-AJAX requests, redirect back to modapp + filter_status = request.args.get('filter', 'pending') + return redirect(url_for('modapp', filter=filter_status))# Delete a quote (admin only) @app.route('/delete/') -@limiter.limit("20 per minute") def delete(id): if not session.get('admin'): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': 'Access denied. Administrator login required.'}) + flash('Access denied. Administrator login required.', 'danger') return redirect(url_for('login')) - quote = Quote.query.get_or_404(id) - db.session.delete(quote) - db.session.commit() - return redirect(url_for('modapp')) + try: + # Get quote text for preview before deletion + quote = Quote.query.get(id) + if quote: + quote_text_preview = quote.text[:50] + "..." if len(quote.text) > 50 else quote.text + else: + quote_text_preview = "unknown quote" + + success = delete_quote(id) + if success: + message = f'Quote #{id} ("{quote_text_preview}") has been permanently deleted.' + logging.info(f"Admin deleted quote {id}") + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'message': message}) + else: + flash(message, 'success') + else: + message = f'Quote #{id} could not be deleted (may not exist).' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'warning') + + except Exception as e: + logging.error(f"Error deleting quote {id}: {str(e)}") + message = 'Error deleting quote. Please try again or check the logs.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'error') + + # For non-AJAX requests, redirect back to modapp + filter_status = request.args.get('filter', 'pending') + return redirect(url_for('modapp', filter=filter_status)) # Clear flags from a quote (admin only) @app.route('/clear_flags/') def clear_flags(id): if not session.get('admin'): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': 'Access denied. Administrator login required.'}) + flash('Access denied. Administrator login required.', 'danger') return redirect(url_for('login')) - quote = Quote.query.get_or_404(id) - quote.flag_count = 0 - db.session.commit() - flash(f'Flags cleared for quote #{id}. Quote remains {["pending", "approved", "rejected"][quote.status]}.', 'success') + try: + # Get quote status for feedback message + quote = Quote.query.get(id) + if not quote: + message = f'Quote #{id} not found.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'error') + else: + flags_cleared = clear_flags_quote(id) + if flags_cleared > 0: + status_names = {0: "pending", 1: "approved", 2: "rejected"} + message = f'Cleared {flags_cleared} flag(s) from quote #{id}. Quote remains {status_names.get(quote.status, "unknown")}.' + logging.info(f"Admin cleared {flags_cleared} flags from quote {id}") + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'message': message}) + else: + flash(message, 'success') + else: + message = f'Quote #{id} has no flags to clear.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'info') + + except Exception as e: + logging.error(f"Error clearing flags for quote {id}: {str(e)}") + message = 'Error clearing flags. Please try again or check the logs.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': message}) + else: + flash(message, 'error') - # Redirect back to the same page with filter preserved - page = request.args.get('page', 1) + # For non-AJAX requests, redirect back to modapp filter_status = request.args.get('filter', 'flagged') - return redirect(url_for('modapp', page=page, filter=filter_status)) + return redirect(url_for('modapp', filter=filter_status)) # Admin logout route @app.route('/logout') def logout(): - session.pop('admin', None) + # Clear the entire session for security + session.clear() flash('Logged out successfully.', 'success') return redirect(url_for('login')) +# Debug route for IP detection (admin only) +@app.route('/debug/ip') +def debug_ip(): + if not session.get('admin'): + abort(403) + + ip_info = { + 'detected_ip': request.remote_addr, + 'headers': { + 'User-Agent': request.headers.get('User-Agent'), + 'Remote-Addr': request.remote_addr, + } + } + return jsonify(ip_info) + # Automatically create the database tables using app context with app.app_context(): db.create_all() @@ -635,116 +1062,90 @@ with app.app_context(): db.session.execute(db.text("ALTER TABLE quote ADD COLUMN flag_count INTEGER DEFAULT 0")) db.session.commit() print("Added flag_count column to existing database") + + # Add submitted_at column if it doesn't exist (for existing databases) + try: + # Try to access submitted_at on a quote to test if column exists + test_query = db.session.execute(db.text("SELECT submitted_at FROM quote LIMIT 1")) + except Exception as e: + if "no such column" in str(e).lower(): + # Add the missing column using raw SQL + db.session.execute(db.text("ALTER TABLE quote ADD COLUMN submitted_at DATETIME")) + db.session.commit() + print("Added submitted_at column to existing database") # Initialize CORS for cross-origin API access CORS(app) -# API to get all approved quotes with pagination +# API to get all approved quotes with pagination (DISABLED) @app.route('/api/quotes', methods=['GET']) -@limiter.limit("60 per minute") def get_all_quotes(): - page = request.args.get('page', 1, type=int) - per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 per page - sort_by = request.args.get('sort', 'date') # date, votes, id - order = request.args.get('order', 'desc') # asc, desc - - # Build query - query = Quote.query.filter_by(status=1) - - # Apply sorting - if sort_by == 'votes': - if order == 'asc': - query = query.order_by(Quote.votes.asc()) - else: - query = query.order_by(Quote.votes.desc()) - elif sort_by == 'id': - if order == 'asc': - query = query.order_by(Quote.id.asc()) - else: - query = query.order_by(Quote.id.desc()) - else: # Default to date - if order == 'asc': - query = query.order_by(Quote.date.asc()) - else: - query = query.order_by(Quote.date.desc()) - - # Paginate - quotes = query.paginate(page=page, per_page=per_page, error_out=False) - - quote_list = [{ - 'id': quote.id, - 'text': quote.text, - 'votes': quote.votes - } for quote in quotes.items] - return jsonify({ - 'quotes': quote_list, - 'pagination': { - 'page': quotes.page, - 'pages': quotes.pages, - 'per_page': quotes.per_page, - 'total': quotes.total, - 'has_next': quotes.has_next, - 'has_prev': quotes.has_prev + "error": "Bulk quote access via API is disabled to prevent abuse.", + "message": "Use /api/quotes/ for specific quotes, /api/random for random quotes, or /api/search for searching.", + "alternatives": { + "specific_quote": request.url_root + "api/quotes/1", + "random_quote": request.url_root + "api/random", + "search_quotes": request.url_root + "api/search?q=example" } - }), 200 + }), 403 # API to get a specific quote by ID @app.route('/api/quotes/', methods=['GET']) -@limiter.limit("120 per minute") def get_quote(id): quote = Quote.query.filter_by(id=id, status=1).first_or_404() # Only approved quotes + + # Use submitted_at for new quotes, fall back to date for legacy quotes + timestamp = quote.submitted_at if quote.submitted_at else quote.date + quote_data = { 'id': quote.id, 'text': quote.text, - 'votes': quote.votes + 'votes': quote.votes, + 'submitted_at': timestamp.isoformat() if timestamp else None } return jsonify(quote_data), 200 # API to get a random approved quote @app.route('/api/random', methods=['GET']) -@limiter.limit("30 per minute") def get_random_quote(): count = Quote.query.filter_by(status=1).count() if count == 0: return jsonify({"error": "No approved quotes available"}), 404 + # Use a safer approach to get random quote random_offset = random.randint(0, count - 1) random_quote = Quote.query.filter_by(status=1).offset(random_offset).first() + # Handle potential race condition where quote could be None + if not random_quote: + # Fallback: get the first available quote + random_quote = Quote.query.filter_by(status=1).first() + if not random_quote: + return jsonify({"error": "No approved quotes available"}), 404 + quote_data = { - 'id': random_quote.id, + 'id': random_quote.id, 'text': random_quote.text, - 'votes': random_quote.votes + 'votes': random_quote.votes, + 'date': random_quote.submitted_at.strftime('%d/%m/%y') if random_quote.submitted_at else random_quote.date.strftime('%d/%m/%y') if random_quote.date else None } return jsonify(quote_data), 200 -# API to get the top quotes by vote count +# API to get the top quotes by vote count (DISABLED) @app.route('/api/top', methods=['GET']) -@limiter.limit("30 per minute") def get_top_quotes(): - limit = min(request.args.get('limit', 10, type=int), 100) # Default 10, max 100 - min_votes = request.args.get('min_votes', 0, type=int) # Minimum vote threshold - - top_quotes = Quote.query.filter(Quote.status == 1, Quote.votes >= min_votes).order_by(Quote.votes.desc()).limit(limit).all() - quote_list = [{ - 'id': quote.id, - 'text': quote.text, - 'votes': quote.votes - } for quote in top_quotes] - return jsonify({ - 'quotes': quote_list, - 'meta': { - 'limit': limit, - 'min_votes': min_votes, - 'count': len(quote_list) + "error": "Top quotes bulk access via API is disabled to prevent abuse.", + "message": "Use /api/search to find highly-voted quotes or /api/random for random quotes.", + "alternatives": { + "search_high_voted": request.url_root + "api/search?q=", + "random_quote": request.url_root + "api/random" } - }), 200 + }), 403 # API to search for quotes with pagination @app.route('/api/search', methods=['GET']) -@limiter.limit("40 per minute") def search_quotes(): query = request.args.get('q', '').strip() if not query: @@ -771,7 +1172,8 @@ def search_quotes(): quote_list = [{ 'id': quote.id, 'text': quote.text, - 'votes': quote.votes + 'votes': quote.votes, + 'submitted_at': (quote.submitted_at if quote.submitted_at else quote.date).isoformat() if (quote.submitted_at or quote.date) else None } for quote in quotes.items] return jsonify({ @@ -789,7 +1191,6 @@ def search_quotes(): # API to get quote statistics @app.route('/api/stats', methods=['GET']) -@limiter.limit("20 per minute") def get_stats(): total_quotes = Quote.query.count() approved_quotes = Quote.query.filter_by(status=1).count() @@ -821,7 +1222,6 @@ def get_stats(): # API documentation endpoint @app.route('/api/docs', methods=['GET']) -@limiter.limit("10 per minute") def api_docs(): docs = { "ircquotes.org API Documentation": { @@ -887,7 +1287,7 @@ def api_docs(): "text": "Quote text content", "votes": "Current vote count", "date": "Creation date (YYYY-MM-DD)", - "datetime": "Full timestamp (YYYY-MM-DD HH:MM:SS)" + "datetime": "Full timestamp (DD/MM/YY HH:MM:SS format for display)" }, "pagination": { "page": "Current page number", @@ -910,7 +1310,6 @@ def api_docs(): # API to submit a new quote (DISABLED for abuse prevention) @app.route('/api/submit', methods=['POST']) -@limiter.limit("5 per minute") def submit_quote(): return jsonify({ "error": "Quote submission via API is currently disabled to prevent abuse.", @@ -922,9 +1321,18 @@ def submit_quote(): with app.app_context(): db.create_all() -# For Gunicorn deployment +# For development server (app.py) if __name__ == '__main__': # This is only used for local development testing - # In production, use: gunicorn -w 4 -b 0.0.0.0:5050 app:app - print("Warning: Using Flask development server. Use Gunicorn for production!") - app.run(host='127.0.0.1', port=5050, debug=False) + # In production, use: python production.py + print("Starting Flask development server...") + print(f"Debug mode: {config.get('app.debug', False)}") + print(f"Host: {config.app_host}") + print(f"Port: {config.app_port}") + print("Warning: This is a development server. Use 'python production.py' for production!") + + app.run( + host=config.app_host, + port=config.app_port, + debug=config.get('app.debug', False) + ) diff --git a/config.json b/config.json index 84a8ce9..ca3ec12 100644 --- a/config.json +++ b/config.json @@ -3,10 +3,10 @@ "name": "ircquotes", "host": "127.0.0.1", "port": 6969, - "debug": false + "debug": true }, "gunicorn": { - "workers": 4, + "workers": 1, "timeout": 30, "keepalive": 5, "max_requests": 1000, @@ -24,12 +24,6 @@ "session_cookie_secure": false, "session_cookie_httponly": true, "session_cookie_samesite": "Lax", - "proxy_setup": { - "behind_cloudflare": true, - "behind_nginx": true, - "trusted_proxies": 2, - "cloudflare_ip_header": "CF-Connecting-IP" - }, "security_headers": { "x_content_type_options": "nosniff", "x_frame_options": "DENY", @@ -38,25 +32,9 @@ "content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" } }, - "rate_limiting": { - "enabled": true, - "global_limit": "1000 per hour", - "endpoints": { - "login": "5 per minute", - "submit": "5 per minute", - "modapp": "20 per minute", - "bulk_actions": "10 per minute", - "approve": "30 per minute", - "reject": "30 per minute", - "delete": "20 per minute", - "vote": "60 per minute", - "flag": "10 per minute", - "search": "30 per minute" - } - }, "admin": { - "username": "admin", - "password_hash": "$argon2i$v=19$m=65536,t=4,p=1$cWZDc1pQaUJLTUJoaVI4cw$kn8XKz6AEZi8ebXfyyZuzommSypliVFrsGqzOyUEIHA" + "username": "ComputerTech", + "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$cIPRCJrjS1DwjaFov5G+BQ$yundbpf2i1jBrKsj96ra7wTNmVZ56SJR25XX4jp2yR8" }, "quotes": { "min_length": 1, diff --git a/config_manager.py b/config_manager.py deleted file mode 100644 index 9213b4d..0000000 --- a/config_manager.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -Configuration management utility for ircquotes. -Allows you to view and update configuration values easily. -""" - -import json -import sys -from config_loader import config - -def show_config(): - """Display current configuration.""" - print("Current Configuration:") - print("=" * 50) - print(f"App Name: {config.app_name}") - print(f"Host: {config.app_host}") - print(f"Port: {config.app_port}") - print(f"Debug Mode: {config.debug_mode}") - print(f"Database URI: {config.database_uri}") - print(f"CSRF Enabled: {config.csrf_enabled}") - print(f"Rate Limiting: {config.rate_limiting_enabled}") - print(f"Quotes per Page: {config.quotes_per_page}") - print(f"Min Quote Length: {config.min_quote_length}") - print(f"Max Quote Length: {config.max_quote_length}") - print(f"Admin Username: {config.admin_username}") - print(f"Logging Level: {config.logging_level}") - print("=" * 50) - -def update_config(key, value): - """Update a configuration value.""" - try: - # Load current config - with open('config.json', 'r') as f: - data = json.load(f) - - # Navigate to the key using dot notation - keys = key.split('.') - current = data - for k in keys[:-1]: - if k not in current: - current[k] = {} - current = current[k] - - # Convert value to appropriate type - if value.lower() == 'true': - value = True - elif value.lower() == 'false': - value = False - elif value.isdigit(): - value = int(value) - elif value.replace('.', '').isdigit(): - value = float(value) - - # Set the value - current[keys[-1]] = value - - # Save back to file - with open('config.json', 'w') as f: - json.dump(data, f, indent=2) - - print(f"Updated {key} = {value}") - print("Restart the application for changes to take effect.") - - except Exception as e: - print(f"Error updating configuration: {e}") - -def main(): - if len(sys.argv) == 1: - show_config() - elif len(sys.argv) == 3: - key, value = sys.argv[1], sys.argv[2] - update_config(key, value) - else: - print("Usage:") - print(" python config_manager.py # Show current config") - print(" python config_manager.py # Update config value") - print() - print("Examples:") - print(" python config_manager.py app.port 8080") - print(" python config_manager.py quotes.per_page 50") - print(" python config_manager.py security.csrf_enabled false") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/create_fresh_db.py b/create_fresh_db.py new file mode 100644 index 0000000..e267a52 --- /dev/null +++ b/create_fresh_db.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Create a fresh quotes database with test data""" + +import os +import sqlite3 +from datetime import datetime + +# Remove existing database files +db_files = ['instance/quotes.db', 'instance/quotes.db-shm', 'instance/quotes.db-wal'] +for db_file in db_files: + if os.path.exists(db_file): + os.remove(db_file) + print(f"Removed {db_file}") + +# Create fresh database +conn = sqlite3.connect('instance/quotes.db') +cursor = conn.cursor() + +# Create the quote table with proper schema +cursor.execute(""" +CREATE TABLE quote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + votes INTEGER DEFAULT 0, + date DATETIME, + status INTEGER DEFAULT 0, + ip_address TEXT, + user_agent TEXT, + submitted_at DATETIME, + flag_count INTEGER DEFAULT 0 +) +""") + +# Create indexes for performance +cursor.execute("CREATE INDEX idx_status_id ON quote(status, id)") +cursor.execute("CREATE INDEX idx_flag_count_id ON quote(flag_count, id)") + +# Insert test data +test_quotes = [ + ("This is a pending quote for testing moderation", 0, 0), # pending + ("This is an approved quote that should appear in browse", 5, 1), # approved + ("Another approved quote with positive votes", 12, 1), # approved + ("A rejected quote that was not good enough", -2, 2), # rejected + ("Another pending quote to test approve/reject", 0, 0), # pending + ("Third pending quote for comprehensive testing", 0, 0), # pending +] + +current_time = datetime.now() + +for i, (text, votes, status) in enumerate(test_quotes, 1): + cursor.execute(""" + INSERT INTO quote (text, votes, status, submitted_at, ip_address, user_agent, flag_count) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (text, votes, status, current_time, '127.0.0.1', 'Test Script', 0)) + +# Set WAL mode for better concurrency +cursor.execute("PRAGMA journal_mode=WAL") +cursor.execute("PRAGMA busy_timeout=1000") + +# Commit and close +conn.commit() + +# Verify the data +cursor.execute("SELECT id, text, status FROM quote ORDER BY id") +results = cursor.fetchall() + +print("\nCreated fresh database with test quotes:") +print("ID | Status | Text") +print("-" * 50) +for quote_id, text, status in results: + status_name = {0: "PENDING", 1: "APPROVED", 2: "REJECTED"}[status] + print(f"{quote_id:2d} | {status_name:8s} | {text[:40]}...") + +conn.close() +print(f"\nFresh database created successfully!") +print(f"Total quotes: {len(test_quotes)}") +print("3 pending, 2 approved, 1 rejected") \ No newline at end of file diff --git a/generate_password.py b/generate_password.py index ab165fa..d6f6f3d 100644 --- a/generate_password.py +++ b/generate_password.py @@ -29,8 +29,12 @@ def generate_password_hash(): print("\nGenerated password hash:") print(hash_value) - print("\nTo set this as admin password, run:") - print(f'python config_manager.py admin.password_hash "{hash_value}"') + print("\nTo set this as admin password:") + print("1. Open config.json in a text editor") + print("2. Find the 'admin' section") + print("3. Replace the 'password_hash' value with:") + print(f' "{hash_value}"') + print("4. Save the file and restart the application") if __name__ == "__main__": generate_password_hash() \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py deleted file mode 100644 index 8b460fa..0000000 --- a/gunicorn.conf.py +++ /dev/null @@ -1,59 +0,0 @@ -# Gunicorn configuration file for ircquotes -import multiprocessing -import json -import os - -# Load configuration from config.json -def load_app_config(): - config_file = os.path.join(os.path.dirname(__file__), 'config.json') - try: - with open(config_file, 'r') as f: - return json.load(f) - except: - # Fallback to defaults if config.json not found - return { - "app": {"host": "0.0.0.0", "port": 5050} - } - -app_config = load_app_config() - -# Server socket - use config.json values -host = app_config.get('app', {}).get('host', '0.0.0.0') -port = app_config.get('app', {}).get('port', 5050) -bind = f"{host}:{port}" -backlog = 2048 - -# Worker processes - use config.json values -workers = app_config.get('gunicorn', {}).get('workers', multiprocessing.cpu_count() * 2 + 1) -worker_class = "sync" -worker_connections = 1000 -timeout = app_config.get('gunicorn', {}).get('timeout', 30) -keepalive = app_config.get('gunicorn', {}).get('keepalive', 5) - -# Restart workers after this many requests, to help prevent memory leaks -max_requests = app_config.get('gunicorn', {}).get('max_requests', 1000) -max_requests_jitter = 100 - -# Preload app for better performance -preload_app = app_config.get('gunicorn', {}).get('preload', True) - -# Logging -accesslog = "-" # Log to stdout -errorlog = "-" # Log to stderr -loglevel = "info" -access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' - -# Process naming -proc_name = 'ircquotes' - -# Preload app for better performance -preload_app = True - -# Security -limit_request_line = 4096 -limit_request_fields = 100 -limit_request_field_size = 8190 - -# SSL (uncomment and configure for HTTPS) -# keyfile = '/path/to/keyfile' -# certfile = '/path/to/certfile' \ No newline at end of file diff --git a/instance/quotes.db b/instance/quotes.db index 971da4df43d4f2a57ff319e9ecd2e8fbc89353ee..9c5414ba2b2d46d4901eb93e81c950b8f2e3ae4b 100644 GIT binary patch delta 575 zcmb7Yy6xb&X`z)iblT}4fkBcKCY^NA zO%J{F(a#A^GQcSYIn5AfILkTCGt3AUX+{}ioCzk$aDj_Vaf!=h$uW&UqOfsr$>VW_ z0zNZb$y!A*+XVwT(7VUBqgSY(N1R#;_?5@pu8%Lbd=qe7J}?(=|$JmN7=c*-`l zcXhoyWa{6v?iXDjI^UR|QeRV>9VNp`l-u9xQF?p$VNDr^R(He+bXk+51c zBI#uYlTvv(5l2SvEA>*Qq{H6AP`3g%Ge7oSw_zUGZj`}~NKh9lsXl>O$C%yyii>s0V delta 260 zcmWl|Ia0y^06q7j7gx$n>D##fP5&L3-(mv%n%tEE6Yz&I(CZS!10IHrZmE6g%v)$3AHeIOK>7S#lh6!YO$QoN>+t zmlzbe;+h*u+*0O_3imuvrN$$58kjU+EmtulONJxx { + quoteRow.remove(); + updateCounters(); + }, 300); + } + } else { + // Show error message + showMessage(result.message || 'Action failed', 'error'); + // Restore original state + element.textContent = originalText; + element.style.pointerEvents = 'auto'; + } + + } catch (error) { + console.error('Moderation action failed:', error); + showMessage('Network error. Please try again.', 'error'); + // Restore original state + element.textContent = originalText; + element.style.pointerEvents = 'auto'; + } + + return false; // Prevent default link behavior +} + +// Show temporary message to user +function showMessage(message, type = 'info') { + // Remove any existing messages + const existingMsg = document.getElementById('temp-message'); + if (existingMsg) { + existingMsg.remove(); + } + + // Create new message element + const msgDiv = document.createElement('div'); + msgDiv.id = 'temp-message'; + msgDiv.textContent = message; + msgDiv.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + padding: 10px 20px; + border-radius: 5px; + font-weight: bold; + color: white; + max-width: 300px; + word-wrap: break-word; + transition: opacity 0.3s ease; + ${type === 'success' ? 'background-color: #28a745;' : ''} + ${type === 'error' ? 'background-color: #dc3545;' : ''} + ${type === 'info' ? 'background-color: #17a2b8;' : ''} + `; + + document.body.appendChild(msgDiv); + + // Auto-remove after 3 seconds + setTimeout(() => { + msgDiv.style.opacity = '0'; + setTimeout(() => msgDiv.remove(), 300); + }, 3000); +} + +// Update quote counters (simplified - could be enhanced with actual counts) +function updateCounters() { + // This could be enhanced to fetch actual counts from server + // For now, just indicate that counts may have changed + const statusElements = document.querySelectorAll('.quote-status'); + statusElements.forEach(el => { + el.style.opacity = '0.8'; + setTimeout(() => el.style.opacity = '1', 100); + }); +} + +// Handle bulk actions form +async function handleBulkAction(form, event) { + event.preventDefault(); + + const formData = new FormData(form); + const action = formData.get('action'); + const quoteIds = formData.getAll('quote_ids'); + + if (quoteIds.length === 0) { + showMessage('Please select at least one quote', 'error'); + return false; + } + + if (!confirm(`Are you sure you want to ${action} ${quoteIds.length} quote(s)?`)) { + return false; + } + + try { + const response = await fetch('/modapp/bulk', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData + }); + + const result = await response.json(); + + if (result.success) { + showMessage(result.message, 'success'); + + // Remove processed quotes from view + quoteIds.forEach(quoteId => { + const checkbox = document.querySelector(`input[value="${quoteId}"]`); + if (checkbox) { + const quoteRow = checkbox.closest('tr'); + if (quoteRow) { + quoteRow.style.transition = 'opacity 0.3s ease'; + quoteRow.style.opacity = '0'; + setTimeout(() => quoteRow.remove(), 300); + } + } + }); + + // Reset form + form.reset(); + updateCounters(); + + } else { + showMessage(result.message || 'Bulk action failed', 'error'); + } + + } catch (error) { + console.error('Bulk action failed:', error); + showMessage('Network error. Please try again.', 'error'); + } + + return false; +} + +// Initialize when page loads +document.addEventListener('DOMContentLoaded', function() { + // Convert all moderation links to AJAX + document.querySelectorAll('a[href^="/approve/"], a[href^="/reject/"], a[href^="/delete/"], a[href^="/clear_flags/"]').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + + const href = this.getAttribute('href'); + const parts = href.split('/'); + const action = parts[1]; // approve, reject, delete, clear_flags + const quoteId = parts[2]; + + moderationAction(action, quoteId, this); + }); + }); + + // Convert bulk form to AJAX + const bulkForm = document.querySelector('form[action="/modapp/bulk"]'); + if (bulkForm) { + bulkForm.addEventListener('submit', function(e) { + handleBulkAction(this, e); + }); + } +}); \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 425111c..1e340eb 100644 --- a/static/styles.css +++ b/static/styles.css @@ -116,6 +116,9 @@ input.button:hover { display: inline-block; min-width: 12px; text-align: center; + border-radius: 2px; + user-select: none; + -webkit-user-select: none; } .qa:hover { @@ -129,6 +132,13 @@ input.button:hover { border: 1px inset #808080; } +/* Quote controls wrapper for better mobile layout */ +.quote-controls { + display: inline-block; + white-space: nowrap; + margin: 0 2px; +} + .quote { font-family: 'Courier New', 'Lucida Console', monospace; font-size: smaller; @@ -187,10 +197,14 @@ footer { color: #c08000; text-decoration: none; font-weight: bold; + padding: 2px 4px; + margin-right: 4px; } .quote-id:hover { text-decoration: underline; + background-color: rgba(192, 128, 0, 0.1); + border-radius: 2px; } /* Dark Mode Toggle Button */ @@ -415,14 +429,17 @@ html.dark-theme font { padding: 5px; } - /* Quote buttons - make them touch-friendly */ + /* Quote buttons - compact but touch-friendly */ .qa { - padding: 8px 12px; - margin: 2px; - min-width: 35px; - font-size: 16px; + padding: 6px 8px; + margin: 1px 2px; + min-width: 28px; + min-height: 32px; + font-size: 14px; display: inline-block; text-align: center; + border-radius: 3px; + line-height: 1.2; } /* Quote text - ensure readability */ @@ -430,40 +447,43 @@ html.dark-theme font { font-size: 14px; line-height: 1.4; word-wrap: break-word; + padding: 8px; } - /* Quote header - adjust spacing */ + /* Quote header - adjust spacing and make more compact */ .quote { - font-size: 14px; - margin-bottom: 8px; + font-size: 13px; + margin-bottom: 6px; + line-height: 1.3; } /* Form elements - make touch-friendly */ input[type="text"], input[type="number"], textarea, select { width: 90%; - padding: 10px; + padding: 8px; font-size: 16px; /* Prevents zoom on iOS */ - margin: 5px 0; + margin: 3px 0; } input[type="submit"], button { - padding: 10px 15px; - font-size: 16px; - margin: 5px; - min-height: 44px; /* iOS touch target */ + padding: 8px 12px; + font-size: 14px; + margin: 3px; + min-height: 40px; /* Reasonable touch target */ } /* Pagination - mobile friendly */ #pagination { - font-size: 14px; + font-size: 13px; text-align: center; - padding: 10px; + padding: 8px; } #pagination a { - padding: 8px 12px; + padding: 6px 10px; margin: 2px; display: inline-block; + border-radius: 3px; } /* ModApp table - horizontal scroll for wide tables */ @@ -477,24 +497,35 @@ html.dark-theme font { width: auto; } - /* Hide less important columns on mobile */ + /* Compact view for smaller screens */ @media screen and (max-width: 480px) { .mobile-hide { display: none; } .qa { - padding: 6px 10px; - font-size: 14px; - min-width: 30px; + padding: 5px 7px; + font-size: 13px; + min-width: 26px; + min-height: 30px; + margin: 1px; } .quote { font-size: 12px; + margin-bottom: 5px; } .qt { font-size: 13px; + padding: 6px; + } + + /* Stack quote controls for very small screens */ + .quote-controls { + white-space: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } } } @@ -503,22 +534,30 @@ html.dark-theme font { @media (hover: none) and (pointer: coarse) { /* This targets touch devices */ .qa { - padding: 10px 15px; - margin: 3px; - min-height: 44px; - min-width: 44px; + padding: 7px 10px; + margin: 2px; + min-height: 36px; + min-width: 32px; + border-radius: 4px; } a { - padding: 5px; - margin: 2px; + padding: 3px; + margin: 1px; } - /* Ensure all interactive elements are large enough */ + /* Ensure all interactive elements are large enough but not oversized */ button, input[type="submit"], input[type="button"] { - min-height: 44px; - min-width: 44px; - padding: 10px; + min-height: 40px; + min-width: 40px; + padding: 8px 12px; + } + + /* Theme toggle button - keep compact */ + #theme-toggle { + min-height: 36px; + min-width: 36px; + padding: 6px 10px; } } diff --git a/static/voting.js b/static/voting.js index 3852f33..7140055 100644 --- a/static/voting.js +++ b/static/voting.js @@ -1,11 +1,11 @@ // AJAX voting functionality function vote(quoteId, action, buttonElement) { - // Prevent multiple clicks + // Prevent multiple clicks on the same button if (buttonElement.disabled) { return false; } - // Disable button temporarily + // Disable button temporarily to prevent double-clicks buttonElement.disabled = true; // Make AJAX request @@ -15,9 +15,16 @@ function vote(quoteId, action, buttonElement) { 'X-Requested-With': 'XMLHttpRequest' } }) - .then(response => response.json()) - .then(data => { - if (data.success) { + .then(response => { + // Handle both successful and error responses with JSON + return response.json().then(data => { + return { data, status: response.status, ok: response.ok }; + }); + }) + .then(result => { + const { data, status, ok } = result; + + if (ok && data.success) { // Update vote count display const voteElement = document.getElementById(`votes-${quoteId}`); if (voteElement) { @@ -27,7 +34,15 @@ function vote(quoteId, action, buttonElement) { // Update button states based on user's voting history updateButtonStates(quoteId, data.user_vote); } else { - alert(data.message || 'Sorry, your vote could not be recorded. Please try again.'); + // Show the server's error message, with special handling for rate limiting + let errorMessage = data.message || 'Sorry, your vote could not be recorded. Please try again.'; + + if (status === 429) { + // Rate limiting or flood control + errorMessage = data.message || 'Please slow down! You\'re voting too quickly. Wait a moment and try again.'; + } + + alert(errorMessage); } }) .catch(error => { @@ -35,7 +50,7 @@ function vote(quoteId, action, buttonElement) { alert('Connection error while voting. Please check your internet connection and try again.'); }) .finally(() => { - // Re-enable button + // Re-enable the button immediately buttonElement.disabled = false; }); diff --git a/templates/browse.html b/templates/browse.html index 4898630..14711e3 100644 --- a/templates/browse.html +++ b/templates/browse.html @@ -30,7 +30,7 @@ ircquotes - Browse Quotes + {% if is_top %}Top Quotes{% else %}Browse Quotes{% endif %} @@ -69,6 +69,14 @@ X   C +   + + {% if quote.submitted_at %} + {{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }} + {% elif quote.date %} + {{ quote.date.strftime('%d/%m/%y') }} + {% endif %} +

{{ quote.text|e }}


diff --git a/templates/faq.html b/templates/faq.html index 636d534..0bffc67 100644 --- a/templates/faq.html +++ b/templates/faq.html @@ -56,37 +56,24 @@

Frequently Asked Questions (FAQ)

What is ircquotes?

-

ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat). You can browse quotes, submit your own, and vote on others.

+

ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat) and other chat platforms. You can browse quotes, submit your own, and vote on others.

How does the API work?

-

The ircquotes API allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications.

+

The ircquotes API is a read-only interface that allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications. Quote submissions must be done through the web interface to prevent abuse.

Available API Endpoints

    -
  • Get All Approved Quotes: GET /api/quotes
  • Get a Specific Quote by ID: GET /api/quotes/<id>
  • Get a Random Quote: GET /api/random
  • -
  • Get Top Quotes: GET /api/top
  • Search Quotes: GET /api/search?q=<search_term>
-

Submitting Quotes via the API

-

The API also allows you to submit quotes, but this feature is rate-limited to prevent abuse. Each user is allowed 5 submissions per minute.

-
    -
  • Submit a Quote: POST /api/submit
  • -
  • Request Body: The request body should be in JSON format and contain the quote text like this:
  • -
    {
    -    "text": "This is a memorable quote!"
    -}
    -
  • Validation Rules: Quotes must be between 5 and 1000 characters.
  • -
-

Rules for Submitting Quotes

To ensure that ircquotes remains a fun and enjoyable platform for everyone, we ask that you follow a few simple rules when submitting quotes:

  • No Offensive Content: Do not submit quotes that contain offensive language, hate speech, or other harmful content.
  • No Spam: Please avoid submitting irrelevant or repetitive content. The site is for memorable IRC quotes, not spam.
  • -
  • Stay On-Topic: Ensure that your submissions are actual quotes from IRC, not made-up content.
  • +
  • Stay On-Topic: Ensure that your submissions are actual quotes from chat platforms, not made-up content.
  • Rate Limiting: The submission rate is limited to 5 quotes per minute to prevent spam.
  • Moderation: All quotes are subject to approval by site moderators. Rejected quotes will not be publicly visible.
diff --git a/templates/index.html b/templates/index.html index ba6ca31..6b06777 100644 --- a/templates/index.html +++ b/templates/index.html @@ -68,8 +68,8 @@ Latest Updates -

20/09/25
Major security and feature update! Added copy quotes, bulk moderation, mobile support, rate limiting, and enhanced security.

-

13/10/24
Added dark theme and re added all of bash.org's old quotes.

+

20/09/25
Improved some things in the backend and added Dark theme

+

13/10/24
Re added all of bash.org's old quotes.

09/10/24
We are now live! Start submitting your favourite IRC quotes.

diff --git a/templates/modapp.html b/templates/modapp.html index 7cd1fe6..83880d0 100644 --- a/templates/modapp.html +++ b/templates/modapp.html @@ -16,6 +16,7 @@ })(); + @@ -125,7 +126,15 @@ 0 {% endif %} - {{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') if quote.submitted_at else 'N/A' }} + + {% if quote.submitted_at %} + {{ quote.submitted_at.strftime('%d/%m/%y %H:%M:%S') }} + {% elif quote.date %} + {{ quote.date.strftime('%d/%m/%y') }} (legacy) + {% else %} + No date + {% endif %} + {{ quote.ip_address|e }} {{ quote.user_agent|e|truncate(50) }} diff --git a/templates/quote.html b/templates/quote.html index 2c9b089..2f044c7 100644 --- a/templates/quote.html +++ b/templates/quote.html @@ -68,6 +68,14 @@ X   C +   + + {% if quote.submitted_at %} + {{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }} + {% elif quote.date %} + {{ quote.date.strftime('%d/%m/%y') }} + {% endif %} +

{{ quote.text|e }}

diff --git a/templates/random.html b/templates/random.html index 4607ad8..e73a7e2 100644 --- a/templates/random.html +++ b/templates/random.html @@ -64,6 +64,14 @@ X   C +   + + {% if quote.submitted_at %} + {{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }} + {% elif quote.date %} + {{ quote.date.strftime('%d/%m/%y') }} + {% endif %} +

{{ quote.text|e }}

diff --git a/templates/search.html b/templates/search.html index 4860a21..a90e1df 100644 --- a/templates/search.html +++ b/templates/search.html @@ -94,6 +94,14 @@ X   C +   + + {% if quote.submitted_at %} + {{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }} + {% elif quote.date %} + {{ quote.date.strftime('%d/%m/%y') }} + {% endif %} +

{{ quote.text|e }}


diff --git a/templates/submit.html b/templates/submit.html index 9d6ed31..6e008fb 100644 --- a/templates/submit.html +++ b/templates/submit.html @@ -17,7 +17,7 @@ - +
@@ -54,8 +54,20 @@
+ {% if preview_text %} + - + + + {% endif %} + + @@ -71,7 +83,7 @@
+

Quote Preview:

+
+
{{ preview_text }}
+
+

If this looks correct, click "Submit Quote" below. Otherwise, edit your quote and preview again.

+
Automatically attempt to fix timestamps and common mistakes.

You may submit quotes from any chat medium, so long as they have reasonable - formatting (IRC, AIM, ICQ, Yahoo, NOT MSN or MS Chat).

+ formatting (IRC, etc.).

Tips for approval: no timestamps, keep it short (trim useless parts), remove trailing laughs, and avoid inside jokes.