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 '/') +@limiter.limit("20 per minute") def vote(id, action): quote = Quote.query.get_or_404(id) # Retrieve vote history from the cookie vote_cookie = request.cookies.get('votes') if vote_cookie: - vote_data = json.loads(vote_cookie) + try: + vote_data = json.loads(vote_cookie) + except (json.JSONDecodeError, ValueError): + # If cookie is corrupted, start fresh + vote_data = {} else: vote_data = {} + message = "" # If no prior vote, apply the new vote if str(id) not in vote_data: if action == 'upvote': @@ -104,7 +198,7 @@ def vote(id, action): elif action == 'downvote': quote.votes -= 1 vote_data[str(id)] = 'downvote' - flash("Thank you for voting!", 'success') + message = "Thank you for voting!" else: previous_action = vote_data[str(id)] @@ -116,7 +210,7 @@ def vote(id, action): elif action == 'downvote': quote.votes += 1 del vote_data[str(id)] # Remove the vote record (undo) - flash("Your vote has been undone.", 'success') + message = "Your vote has been undone." else: # If the user switches votes (upvote -> downvote or vice versa) if previous_action == 'upvote' and action == 'downvote': @@ -125,33 +219,56 @@ def vote(id, action): elif previous_action == 'downvote' and action == 'upvote': quote.votes += 2 # Undo downvote (-1) and apply upvote (+1) vote_data[str(id)] = 'upvote' - flash("Your vote has been changed.", 'success') + message = "Your vote has been changed." # Save the updated vote data to the cookie try: db.session.commit() - page = request.args.get('page', 1) - resp = make_response(redirect(url_for('browse', page=page))) - resp.set_cookie('votes', json.dumps(vote_data), max_age=60*60*24*365) # Store vote history in cookies for 1 year - return resp + + # Check if it's an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # Return JSON response for AJAX + resp = make_response(jsonify({ + 'success': True, + 'votes': quote.votes, + 'user_vote': vote_data.get(str(id)), + 'message': message + })) + resp.set_cookie('votes', json.dumps(vote_data), max_age=60*60*24*365) + return resp + else: + # Traditional redirect for non-AJAX requests + flash(message, 'success') + page = request.args.get('page', 1) + resp = make_response(redirect(url_for('browse', page=page))) + resp.set_cookie('votes', json.dumps(vote_data), max_age=60*60*24*365) + return resp except Exception as e: db.session.rollback() - flash(f"Error while voting: {e}", 'error') - return redirect(url_for('browse', page=page)) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': False, + 'message': f"Error while voting: {e}" + }), 500 + else: + flash(f"Error while voting: {e}", 'error') + page = request.args.get('page', 1) + return redirect(url_for('browse', page=page)) # Route for displaying a random quote @app.route('/random') def random_quote(): approved_count = Quote.query.filter_by(status=1).count() pending_count = Quote.query.filter_by(status=0).count() - count = Quote.query.count() + count = Quote.query.filter_by(status=1).count() # Only count approved quotes if count == 0: - flash("No quotes available yet.", 'error') + flash("No quotes have been approved yet. Check back later or submit the first one!", 'error') return redirect(url_for('index')) - random_id = random.randint(1, count) - random_quote = Quote.query.get(random_id) + # Use offset to get a random quote from approved quotes + random_offset = random.randint(0, count - 1) + random_quote = Quote.query.filter_by(status=1).offset(random_offset).first() return render_template('random.html', quote=random_quote, approved_count=approved_count, pending_count=pending_count) @@ -165,7 +282,7 @@ def quote_homepathid(id): def quote(): quote_id = request.args.get('id') if not quote_id: - flash("Quote ID not provided.", 'error') + flash("Please enter a quote number to view that specific quote.", 'error') return redirect(url_for('browse')) quote = Quote.query.get_or_404(quote_id) @@ -175,8 +292,50 @@ def quote(): def faq(): return render_template('faq.html') +# Flag/Report a quote route +@app.route('/flag/') +@limiter.limit("10 per minute") +def flag_quote(id): + quote = Quote.query.get_or_404(id) + + # Increment flag count + quote.flag_count += 1 + + try: + db.session.commit() + message = 'Quote has been flagged for review. Thank you for helping keep the site clean!' + + # Check if it's an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': True, + 'message': message, + 'flag_count': quote.flag_count + }) + else: + flash(message, 'success') + except Exception as e: + db.session.rollback() + error_message = 'Error flagging quote. Please try again.' + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': False, + 'message': error_message + }), 500 + else: + flash(error_message, 'error') + + # For non-AJAX requests, redirect back to the same page + 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')) + # 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'] @@ -187,23 +346,24 @@ def login(): try: ph.verify(ADMIN_CREDENTIALS['password'], password) # Verify password using Argon2 session['admin'] = True - flash('Login successful!', 'success') + flash('Welcome back! You are now logged in as administrator.', 'success') return redirect(url_for('modapp')) except VerifyMismatchError: - flash('Invalid password. Please try again.', 'danger') + flash('The password you entered is incorrect. Please check your password and try again.', 'danger') else: - flash('Invalid username. Please try again.', 'danger') + flash('The username you entered is not recognized. Please check your username and 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('You need to log in first.', 'danger') + flash('Access denied. Please log in with administrator credentials to access the moderation panel.', 'danger') return redirect(url_for('login')) - # Apply filtering (pending, approved, rejected) + # Apply filtering (pending, approved, rejected, flagged) filter_status = request.args.get('filter', 'pending') page = request.args.get('page', 1, type=int) @@ -211,6 +371,9 @@ def modapp(): quotes = Quote.query.filter_by(status=1).order_by(Quote.date.desc()).paginate(page=page, per_page=10) elif filter_status == 'rejected': quotes = Quote.query.filter_by(status=2).order_by(Quote.date.desc()).paginate(page=page, per_page=10) + 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) else: # Default to pending quotes = Quote.query.filter_by(status=0).order_by(Quote.date.desc()).paginate(page=page, per_page=10) @@ -218,10 +381,63 @@ def modapp(): 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() return render_template('modapp.html', quotes=quotes, filter_status=filter_status, approved_count=approved_count, pending_count=pending_count, - rejected_count=rejected_count) + rejected_count=rejected_count, flagged_count=flagged_count) + + +# 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') + return redirect(url_for('login')) + + action = request.form.get('action') + quote_ids = request.form.getlist('quote_ids') + + if not quote_ids: + flash('Please select at least one quote before performing a bulk action.', 'error') + return redirect(url_for('modapp')) + + if not action or action not in ['approve', 'reject', 'delete', 'clear_flags']: + flash('The requested action is not supported. Please try again or contact support.', 'error') + return redirect(url_for('modapp')) + + success_count = 0 + + 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.session.commit() + + if action == 'clear_flags': + flash(f'Successfully cleared flags on {success_count} quote(s).', 'success') + else: + flash(f'Successfully {action}d {success_count} quote(s).', 'success') + + except Exception as e: + db.session.rollback() + flash(f'Error performing bulk action: {str(e)}', 'error') + + return redirect(url_for('modapp')) # Helper function to approve a quote @@ -235,15 +451,13 @@ def approve_quote(quote_id): def reject_quote(quote_id): quote = Quote.query.get(quote_id) if quote and quote.status != 2: # Only reject if not already rejected - logging.debug(f"Rejecting quote ID: {quote.id}") # Add logging for rejection - quote.status = 2 # Rejected + quote.status = 'rejected' db.session.commit() # Helper function to delete a quote def delete_quote(quote_id): quote = Quote.query.get(quote_id) if quote: - logging.debug(f"Deleting quote ID: {quote.id}") # Add logging for deletion db.session.delete(quote) db.session.commit() @@ -257,8 +471,8 @@ def search(): pending_count = Quote.query.filter_by(status=0).count() if query: - # Perform text search in quotes - quotes = Quote.query.filter(Quote.text.like(f'%{query}%'), Quote.status == 1).all() + # Perform text search in quotes using safe parameterized query + quotes = Quote.query.filter(Quote.text.contains(query), Quote.status == 1).all() return render_template('search.html', quotes=quotes, query=query, approved_count=approved_count, pending_count=pending_count) @@ -267,7 +481,7 @@ def read_quote(): quote_id = request.args.get('id', type=int) # Get the quote number if not quote_id: - flash("Quote number is required.", 'error') + flash("Please enter a valid quote number to search for that specific quote.", 'error') return redirect(url_for('search')) # Find the quote by ID (only approved quotes) @@ -286,9 +500,10 @@ def browse(): approved_count = Quote.query.filter_by(status=1).count() pending_count = Quote.query.filter_by(status=0).count() - # Pagination setup + # Pagination setup with config page = request.args.get('page', 1, type=int) - quotes = Quote.query.filter_by(status=1).order_by(Quote.date.desc()).paginate(page=page, per_page=10) + per_page = config.quotes_per_page + quotes = Quote.query.filter_by(status=1).order_by(Quote.date.desc()).paginate(page=page, per_page=per_page) # Pass the counts and the quotes to the template return render_template('browse.html', quotes=quotes, approved_count=approved_count, pending_count=pending_count) @@ -296,6 +511,7 @@ def browse(): # Approve a quote (admin only) @app.route('/approve/') +@limiter.limit("30 per minute") def approve(id): if not session.get('admin'): return redirect(url_for('login')) @@ -310,6 +526,7 @@ def approve(id): # Reject a quote (admin only) @app.route('/reject/') +@limiter.limit("30 per minute") def reject(id): if not session.get('admin'): return redirect(url_for('login')) @@ -321,6 +538,7 @@ def reject(id): # Delete a quote (admin only) @app.route('/delete/') +@limiter.limit("20 per minute") def delete(id): if not session.get('admin'): return redirect(url_for('login')) @@ -330,6 +548,22 @@ def delete(id): db.session.commit() return redirect(url_for('modapp')) +# Clear flags from a quote (admin only) +@app.route('/clear_flags/') +def clear_flags(id): + if not session.get('admin'): + 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') + + # Redirect back to the same page with filter preserved + page = request.args.get('page', 1) + filter_status = request.args.get('filter', 'flagged') + return redirect(url_for('modapp', page=page, filter=filter_status)) + # Admin logout route @app.route('/logout') def logout(): @@ -340,38 +574,86 @@ def logout(): # Automatically create the database tables using app context with app.app_context(): db.create_all() + + # Add flag_count column if it doesn't exist (for existing databases) + try: + # Try to access flag_count on a quote to test if column exists + test_query = db.session.execute(db.text("SELECT flag_count 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 flag_count INTEGER DEFAULT 0")) + db.session.commit() + print("Added flag_count column to existing database") -# Initialize rate limiter and CORS for cross-origin API access -limiter = Limiter(app, key_func=get_remote_address) +# Initialize CORS for cross-origin API access CORS(app) -# API to get all approved quotes +# API to get all approved quotes with pagination @app.route('/api/quotes', methods=['GET']) +@limiter.limit("60 per minute") def get_all_quotes(): - quotes = Quote.query.filter_by(status=1).all() # Only approved 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, - 'date': quote.date.strftime('%Y-%m-%d') - } for quote in quotes] + 'votes': quote.votes + } for quote in quotes.items] - return jsonify(quote_list), 200 + 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 + } + }), 200 # 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 quote_data = { 'id': quote.id, 'text': quote.text, - 'votes': quote.votes, - 'date': quote.date.strftime('%Y-%m-%d') + 'votes': quote.votes } 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: @@ -383,70 +665,216 @@ def get_random_quote(): quote_data = { 'id': random_quote.id, 'text': random_quote.text, - 'votes': random_quote.votes, - 'date': random_quote.date.strftime('%Y-%m-%d') + 'votes': random_quote.votes } return jsonify(quote_data), 200 # API to get the top quotes by vote count @app.route('/api/top', methods=['GET']) +@limiter.limit("30 per minute") def get_top_quotes(): - top_quotes = Quote.query.filter_by(status=1).order_by(Quote.votes.desc()).limit(10).all() # Limit to top 10 + 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, - 'date': quote.date.strftime('%Y-%m-%d') + 'votes': quote.votes } for quote in top_quotes] - return jsonify(quote_list), 200 + return jsonify({ + 'quotes': quote_list, + 'meta': { + 'limit': limit, + 'min_votes': min_votes, + 'count': len(quote_list) + } + }), 200 -# API to search for quotes +# 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: return jsonify({"error": "No search term provided"}), 400 - quotes = Quote.query.filter(Quote.text.ilike(f'%{query}%'), Quote.status == 1).all() # Search in approved quotes - if not quotes: - return jsonify({"error": "No quotes found for search term"}), 404 + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 per page + + # Search in approved quotes with pagination using safe parameterized query + quotes = Quote.query.filter( + Quote.text.contains(query), + Quote.status == 1 + ).order_by(Quote.votes.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + if not quotes.items: + return jsonify({ + "error": "No quotes found for search term", + "search_term": query, + "total_results": 0 + }), 404 quote_list = [{ 'id': quote.id, 'text': quote.text, - 'votes': quote.votes, - 'date': quote.date.strftime('%Y-%m-%d') - } for quote in quotes] + 'votes': quote.votes + } for quote in quotes.items] - return jsonify(quote_list), 200 + return jsonify({ + 'quotes': quote_list, + 'search_term': query, + '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 + } + }), 200 -# API to submit a new quote +# 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() + pending_quotes = Quote.query.filter_by(status=0).count() + rejected_quotes = Quote.query.filter_by(status=2).count() + flagged_quotes = Quote.query.filter(Quote.flag_count > 0).count() + + # Vote statistics + top_voted = Quote.query.filter_by(status=1).order_by(Quote.votes.desc()).first() + total_votes = db.session.query(db.func.sum(Quote.votes)).filter_by(status=1).scalar() or 0 + avg_votes = db.session.query(db.func.avg(Quote.votes)).filter_by(status=1).scalar() or 0 + + return jsonify({ + 'total_quotes': total_quotes, + 'approved_quotes': approved_quotes, + 'pending_quotes': pending_quotes, + 'rejected_quotes': rejected_quotes, + 'flagged_quotes': flagged_quotes, + 'vote_stats': { + 'total_votes': int(total_votes), + 'average_votes': round(float(avg_votes), 2), + 'highest_voted': { + 'id': top_voted.id if top_voted else None, + 'votes': top_voted.votes if top_voted else 0, + 'text_preview': top_voted.text[:100] + '...' if top_voted and len(top_voted.text) > 100 else (top_voted.text if top_voted else None) + } + } + }), 200 + +# API documentation endpoint +@app.route('/api/docs', methods=['GET']) +@limiter.limit("10 per minute") +def api_docs(): + docs = { + "ircquotes.org API Documentation": { + "version": "1.0", + "description": "Read-only API for accessing IRC quotes", + "base_url": request.url_root + "api/", + "endpoints": { + "/api/quotes": { + "method": "GET", + "description": "Get paginated list of approved quotes", + "parameters": { + "page": "Page number (default: 1)", + "per_page": "Results per page (default: 20, max: 100)", + "sort": "Sort by 'date', 'votes', or 'id' (default: 'date')", + "order": "Sort order 'asc' or 'desc' (default: 'desc')" + }, + "example": "/api/quotes?page=1&per_page=10&sort=votes&order=desc" + }, + "/api/quotes/": { + "method": "GET", + "description": "Get a specific quote by ID", + "parameters": { + "id": "Quote ID (required)" + }, + "example": "/api/quotes/12345" + }, + "/api/random": { + "method": "GET", + "description": "Get a random approved quote", + "parameters": "None", + "example": "/api/random" + }, + "/api/top": { + "method": "GET", + "description": "Get top-voted quotes", + "parameters": { + "limit": "Number of quotes to return (default: 10, max: 100)", + "min_votes": "Minimum vote threshold (default: 0)" + }, + "example": "/api/top?limit=20&min_votes=5" + }, + "/api/search": { + "method": "GET", + "description": "Search quotes by text content", + "parameters": { + "q": "Search query (required)", + "page": "Page number (default: 1)", + "per_page": "Results per page (default: 20, max: 100)" + }, + "example": "/api/search?q=linux&page=1&per_page=10" + }, + "/api/stats": { + "method": "GET", + "description": "Get quote database statistics", + "parameters": "None", + "example": "/api/stats" + } + }, + "response_format": { + "quotes": "Array of quote objects", + "quote_object": { + "id": "Quote ID", + "text": "Quote text content", + "votes": "Current vote count", + "date": "Creation date (YYYY-MM-DD)", + "datetime": "Full timestamp (YYYY-MM-DD HH:MM:SS)" + }, + "pagination": { + "page": "Current page number", + "pages": "Total pages", + "per_page": "Results per page", + "total": "Total results", + "has_next": "Boolean - has next page", + "has_prev": "Boolean - has previous page" + } + }, + "notes": [ + "All endpoints return only approved quotes", + "Rate limiting may apply to prevent abuse", + "All responses are in JSON format", + "CORS is enabled for cross-origin requests" + ] + } + } + return jsonify(docs), 200 + +# API to submit a new quote (DISABLED for abuse prevention) @app.route('/api/submit', methods=['POST']) -@limiter.limit("5 per minute") # Rate limiting to prevent abuse +@limiter.limit("5 per minute") def submit_quote(): - data = request.get_json() + return jsonify({ + "error": "Quote submission via API is currently disabled to prevent abuse.", + "message": "Please use the web interface at /submit to submit quotes.", + "web_submit_url": request.url_root + "submit" + }), 403 - # Validate the input - if not data or not data.get('text'): - return jsonify({"error": "Quote text is required"}), 400 - - quote_text = data.get('text').strip() +# Create tables if they don't exist +with app.app_context(): + db.create_all() - # Basic validation to prevent spam - if len(quote_text) < 5 or len(quote_text) > 1000: - return jsonify({"error": "Quote must be between 5 and 1000 characters"}), 400 - - new_quote = Quote(text=quote_text) - - try: - db.session.add(new_quote) - db.session.commit() - return jsonify({"success": "Quote submitted successfully!", "id": new_quote.id}), 201 - except Exception as e: - db.session.rollback() - return jsonify({"error": f"Error submitting quote: {str(e)}"}), 500 - -# Run the Flask app +# For Gunicorn deployment if __name__ == '__main__': - app.run(host='127.0.0.1', port=5050, debug=True) + # 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) diff --git a/config.json b/config.json new file mode 100644 index 0000000..4851bc5 --- /dev/null +++ b/config.json @@ -0,0 +1,67 @@ +{ + "app": { + "name": "ircquotes", + "host": "0.0.0.0", + "port": 5050, + "debug": false + }, + "database": { + "uri": "sqlite:///quotes.db?timeout=20", + "pool_timeout": 20, + "pool_recycle": -1, + "pool_pre_ping": true + }, + "security": { + "csrf_enabled": true, + "csrf_time_limit": null, + "session_cookie_secure": false, + "session_cookie_httponly": true, + "session_cookie_samesite": "Lax", + "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'" + } + }, + "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" + }, + "quotes": { + "min_length": 10, + "max_length": 5000, + "per_page": 25, + "auto_approve": false, + "allow_html": false + }, + "features": { + "voting_enabled": true, + "flagging_enabled": true, + "copy_quotes_enabled": true, + "dark_mode_enabled": true, + "api_enabled": true, + "bulk_moderation_enabled": true + }, + "logging": { + "level": "WARNING", + "format": "%(asctime)s [%(levelname)s] %(message)s" + } +} \ No newline at end of file diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..00473d6 --- /dev/null +++ b/config_loader.py @@ -0,0 +1,104 @@ +""" +Configuration loader for ircquotes application. +Loads settings from config.json and provides easy access to configuration values. +""" + +import json +import os +from typing import Any, Dict + +class Config: + """Configuration manager for ircquotes application.""" + + def __init__(self, config_file: str = "config.json"): + """Initialize configuration from JSON file.""" + self.config_file = config_file + self._config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from JSON file.""" + if not os.path.exists(self.config_file): + raise FileNotFoundError(f"Configuration file {self.config_file} not found") + + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in configuration file: {e}") + + def get(self, key: str, default: Any = None) -> Any: + """Get configuration value using dot notation (e.g., 'app.host').""" + keys = key.split('.') + value = self._config + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def get_section(self, section: str) -> Dict[str, Any]: + """Get entire configuration section.""" + return self._config.get(section, {}) + + def reload(self): + """Reload configuration from file.""" + self._config = self._load_config() + + # Convenience properties for commonly used settings + @property + def app_name(self) -> str: + return self.get('app.name', 'ircquotes') + + @property + def app_host(self) -> str: + return self.get('app.host', '0.0.0.0') + + @property + def app_port(self) -> int: + return self.get('app.port', 5050) + + @property + def debug_mode(self) -> bool: + return self.get('app.debug', False) + + @property + def database_uri(self) -> str: + return self.get('database.uri', 'sqlite:///quotes.db') + + @property + def csrf_enabled(self) -> bool: + return self.get('security.csrf_enabled', True) + + @property + def rate_limiting_enabled(self) -> bool: + return self.get('rate_limiting.enabled', True) + + @property + def quotes_per_page(self) -> int: + return self.get('quotes.per_page', 25) + + @property + def min_quote_length(self) -> int: + return self.get('quotes.min_length', 10) + + @property + def max_quote_length(self) -> int: + return self.get('quotes.max_length', 5000) + + @property + def admin_username(self) -> str: + return self.get('admin.username', 'admin') + + @property + def admin_password_hash(self) -> str: + return self.get('admin.password_hash', '') + + @property + def logging_level(self) -> str: + return self.get('logging.level', 'WARNING') + +# Global configuration instance +config = Config() \ No newline at end of file diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..9213b4d --- /dev/null +++ b/config_manager.py @@ -0,0 +1,84 @@ +#!/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/generate_password.py b/generate_password.py new file mode 100644 index 0000000..ab165fa --- /dev/null +++ b/generate_password.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +""" +Password hash generator for ircquotes admin. +Generates Argon2 password hashes for secure storage. +""" + +from argon2 import PasswordHasher +import getpass +import sys + +def generate_password_hash(): + """Generate an Argon2 password hash.""" + ph = PasswordHasher() + + if len(sys.argv) > 1: + # Password provided as argument + password = sys.argv[1] + else: + # Prompt for password securely + password = getpass.getpass("Enter admin password: ") + confirm = getpass.getpass("Confirm password: ") + + if password != confirm: + print("Passwords don't match!") + return + + # Generate hash + hash_value = ph.hash(password) + + 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}"') + +if __name__ == "__main__": + generate_password_hash() \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..6295abf --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,38 @@ +# Gunicorn configuration file for ircquotes +import multiprocessing + +# Server socket +bind = "0.0.0.0:5050" +backlog = 2048 + +# Worker processes +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +worker_connections = 1000 +timeout = 30 +keepalive = 5 + +# Restart workers after this many requests, to help prevent memory leaks +max_requests = 1000 +max_requests_jitter = 100 + +# 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/requirements.txt b/requirements.txt index 98a6834..c62890c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ Flask==2.3.2 Flask-SQLAlchemy==3.0.5 Flask-Limiter==2.4 Flask-CORS==3.0.10 +Flask-WTF==1.2.1 argon2-cffi==21.3.0 +gunicorn==21.2.0 diff --git a/static/styles.css b/static/styles.css index 4981564..425111c 100644 --- a/static/styles.css +++ b/static/styles.css @@ -105,8 +105,28 @@ input.button:hover { .qa { font-family: Arial, Helvetica, sans-serif; - font-size: 8pt; + font-size: 10pt; text-decoration: none; + font-weight: bold; + background-color: #f0f0f0; + border: 1px solid #808080; + padding: 1px 4px; + margin: 0 1px; + color: #000000; + display: inline-block; + min-width: 12px; + text-align: center; +} + +.qa:hover { + background-color: #e0e0e0; + border-color: #606060; + text-decoration: none; +} + +.qa:active { + background-color: #d0d0d0; + border: 1px inset #808080; } .quote { @@ -173,127 +193,332 @@ footer { text-decoration: underline; } -/* Dark Mode */ -@media (prefers-color-scheme: dark) { - /* Body Styles */ - body { - background-color: #121212; /* Deep dark background */ - color: #d1d1d1; /* Softer off-white for improved contrast */ +/* Dark Mode Toggle Button */ +#theme-toggle { + background-color: #f0f0f0; + border: 2px outset #c0c0c0; + color: #000000; + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + font-family: Arial, Helvetica, sans-serif; + margin-left: 10px; + vertical-align: middle; +} + +#theme-toggle:hover { + background-color: #e0e0e0; +} + +#theme-toggle:active { + border: 2px inset #c0c0c0; +} + +/* Dark Mode Styles */ +body.dark-theme { + background-color: #121212; + color: #d1d1d1; +} + +body.dark-theme a { + color: #ffa500; +} + +body.dark-theme a:hover { + color: #ffcc80; +} + +body.dark-theme .bodytext { + color: #e0e0e0; +} + +body.dark-theme .qt { + color: #ffffff; + background-color: #1e1e1e; + padding: 10px; + border-radius: 4px; + border-left: 3px solid #ffa500; +} + +body.dark-theme .qa { + background-color: #2e2e2e; + color: #ffffff; + border: 1px solid #ffa500; +} + +body.dark-theme .qa:hover { + background-color: #3e3e3e; + color: #ffcc80; +} + +body.dark-theme table { + background-color: #1e1e1e; + border: 1px solid #ffa500; +} + +body.dark-theme td[bgcolor="#c08000"] { + background-color: #c08000 !important; +} + +body.dark-theme td[bgcolor="#f0f0f0"] { + background-color: #2e2e2e !important; + color: #d1d1d1; +} + +body.dark-theme #theme-toggle { + background-color: #2e2e2e; + border: 2px outset #555555; + color: #ffffff; +} + +body.dark-theme #theme-toggle:hover { + background-color: #3e3e3e; +} + +body.dark-theme #theme-toggle:active { + border: 2px inset #555555; +} + +body.dark-theme input[type="text"], +body.dark-theme input[type="number"], +body.dark-theme textarea, +body.dark-theme select { + background-color: #2e2e2e; + color: #ffffff; + border: 1px solid #555555; +} + +body.dark-theme input[type="submit"], +body.dark-theme button { + background-color: #2e2e2e; + color: #ffffff; + border: 2px outset #555555; +} + +body.dark-theme input[type="submit"]:hover, +body.dark-theme button:hover { + background-color: #3e3e3e; +} + +/* Override inline font colors in dark mode */ +body.dark-theme font[color="green"] { + color: #90ee90 !important; +} + +body.dark-theme font { + color: inherit !important; +} + +/* Apply dark theme when class is on html element (prevents flash) */ +html.dark-theme body { + background-color: #121212; + color: #d1d1d1; +} + +html.dark-theme a { + color: #ffa500; +} + +html.dark-theme a:hover { + color: #ffcc80; +} + +html.dark-theme .bodytext { + color: #d1d1d1; +} + +html.dark-theme .qt { + background-color: #2e2e2e; + color: #ffffff; + border-left: 3px solid #ffa500; +} + +html.dark-theme .qa { + background-color: #333; + color: #d1d1d1; + border-color: #555; +} + +html.dark-theme .qa:hover { + background-color: #444; + color: #fff; +} + +html.dark-theme table { + background-color: #2e2e2e; +} + +html.dark-theme td[bgcolor="#c08000"] { + background-color: #8b4513 !important; +} + +html.dark-theme td[bgcolor="#f0f0f0"] { + background-color: #333 !important; +} + +html.dark-theme #theme-toggle { + background-color: #333; + color: #d1d1d1; + border-color: #555; +} + +html.dark-theme #theme-toggle:hover { + background-color: #444; +} + +html.dark-theme #theme-toggle:active { + background-color: #3e3e3e; +} + +html.dark-theme input[type="text"], +html.dark-theme input[type="number"], +html.dark-theme textarea, +html.dark-theme select { + background-color: #333; + color: #d1d1d1; + border-color: #555; +} + +html.dark-theme input[type="submit"], +html.dark-theme button { + background-color: #444; + color: #d1d1d1; + border-color: #666; +} + +html.dark-theme input[type="submit"]:hover, +html.dark-theme button:hover { + background-color: #3e3e3e; +} + +/* Override inline font colors in dark mode (for html element) */ +html.dark-theme font[color="green"] { + color: #90ee90 !important; +} + +html.dark-theme font { + color: inherit !important; +} + +/* Mobile Responsive Design */ +@media screen and (max-width: 768px) { + /* Make tables and content mobile-friendly while keeping bash.org aesthetic */ + table { + width: 95%; + font-size: 14px; } - /* Link Styles */ - a { - color: #ffa500; - text-decoration: none; - transition: color 0.3s ease; + /* Navigation links - stack on small screens */ + .toplinks { + font-size: 12px; + text-align: center; + padding: 5px; } - - a:hover { - color: #ffcc80; /* Subtle, warmer hover color */ - text-decoration: underline; /* Underline on hover for better accessibility */ + + /* Quote buttons - make them touch-friendly */ + .qa { + padding: 8px 12px; + margin: 2px; + min-width: 35px; + font-size: 16px; + display: inline-block; + text-align: center; } - - /* Body Text */ - .bodytext { - line-height: 1.7; /* Increased line spacing for readability */ - font-size: 1rem; /* Consistent font size */ - color: #e0e0e0; /* Softer white for text */ + + /* Quote text - ensure readability */ + .qt { + font-size: 14px; + line-height: 1.4; + word-wrap: break-word; } - - /* Table, Input Fields, Textarea, Select Elements */ - table, td, input.text, textarea, select { - background-color: #1a1a1a; /* Darker backgrounds for contrast */ - color: #d1d1d1; /* Softer white text */ - border-color: #ffa500; /* Keeping borders on accents */ + + /* Quote header - adjust spacing */ + .quote { + font-size: 14px; + margin-bottom: 8px; } - - /* Submit Button */ - input.button { - background-color: #ffa500; - color: #121212; /* Darker button text for readability */ - border-radius: 6px; /* Slight rounding for modern design */ - padding: 8px 12px; /* Increased padding for better button size */ - font-weight: bold; /* More defined button text */ - transition: background-color 0.3s ease, color 0.3s ease; + + /* Form elements - make touch-friendly */ + input[type="text"], input[type="number"], textarea, select { + width: 90%; + padding: 10px; + font-size: 16px; /* Prevents zoom on iOS */ + margin: 5px 0; } - - input.button:hover { - background-color: #ff8c00; /* Brighter hover color */ - color: #ffffff; /* White text on hover for better contrast */ + + input[type="submit"], button { + padding: 10px 15px; + font-size: 16px; + margin: 5px; + min-height: 44px; /* iOS touch target */ } - - /* Headers, Footer, and Other Text Elements */ - .footertext, .toplinks, h2 { - color: #ffa500; /* Accent color for headers */ + + /* Pagination - mobile friendly */ + #pagination { + font-size: 14px; + text-align: center; + padding: 10px; } - - h2 { - font-size: 1.5rem; /* Larger headers for better hierarchy */ - border-bottom: 2px solid #ffa500; /* Visual distinction for headers */ - padding-bottom: 10px; - } - - /* Quote Box Borders */ - td.quote-box { - border-color: #ffa500; - background-color: #1e1e1e; /* Subtle background for quotes */ - padding: 15px; /* Improved padding for readability */ - } - - /* Footer */ - footer { - background-color: #181818; /* Consistent footer background */ - color: #d1d1d1; /* Softer text in footer */ - border-top: 2px solid #ffa500; /* Accent line to separate footer */ - padding: 15px; - } - - /* Pagination Links */ + #pagination a { - border-color: #ffa500; - color: #ffa500; - background-color: transparent; - padding: 8px 12px; /* Larger clickable area */ - border-radius: 4px; /* Rounded edges for pagination */ - transition: background-color 0.3s ease, color 0.3s ease; + padding: 8px 12px; + margin: 2px; + display: inline-block; } - - #pagination a:hover { - background-color: #2e2e2e; /* Slightly brighter hover */ - color: #ffffff; /* White text for hover state */ + + /* ModApp table - horizontal scroll for wide tables */ + .modapp-table-container { + overflow-x: auto; + -webkit-overflow-scrolling: touch; } - - /* Quote ID Links */ - .quote-id { - color: #ffa500; - font-weight: bold; /* Bold for emphasis */ - transition: color 0.3s ease; + + .modapp-table-container table { + min-width: 600px; + width: auto; } - - .quote-id:hover { - color: #ffcc80; /* Brighter hover for clarity */ - } - - /* Other Element Styles */ - input.text, textarea, select { - border-radius: 4px; - padding: 8px; - } - - input.button:disabled { - background-color: #444444; /* Disabled button appearance */ - color: #aaaaaa; /* Disabled text color */ - cursor: not-allowed; - } - - /* Table and Layout Enhancements */ - table { - background-color: #1e1e1e; - border: 1px solid #ffa500; - } - - td { - padding: 15px; /* More padding for larger table cells */ + + /* Hide less important columns on mobile */ + @media screen and (max-width: 480px) { + .mobile-hide { + display: none; + } + + .qa { + padding: 6px 10px; + font-size: 14px; + min-width: 30px; + } + + .quote { + font-size: 12px; + } + + .qt { + font-size: 13px; + } + } +} + +/* Touch-friendly improvements for all screen sizes */ +@media (hover: none) and (pointer: coarse) { + /* This targets touch devices */ + .qa { + padding: 10px 15px; + margin: 3px; + min-height: 44px; + min-width: 44px; + } + + a { + padding: 5px; + margin: 2px; + } + + /* Ensure all interactive elements are large enough */ + button, input[type="submit"], input[type="button"] { + min-height: 44px; + min-width: 44px; + padding: 10px; } } diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..11dca82 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,44 @@ +// Dark mode toggle functionality for ircquotes +// Maintains bash.org aesthetic while providing dark theme option + +function toggleDarkMode() { + const body = document.body; + const html = document.documentElement; + const isDark = body.classList.contains('dark-theme') || html.classList.contains('dark-theme'); + + if (isDark) { + body.classList.remove('dark-theme'); + html.classList.remove('dark-theme'); + localStorage.setItem('theme', 'light'); + updateToggleButton(false); + } else { + body.classList.add('dark-theme'); + html.classList.add('dark-theme'); + localStorage.setItem('theme', 'dark'); + updateToggleButton(true); + } +} + +function updateToggleButton(isDark) { + const toggleBtn = document.getElementById('theme-toggle'); + if (toggleBtn) { + toggleBtn.textContent = isDark ? '☀' : '🌙'; + toggleBtn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode'; + } +} + +// Initialize theme on page load +document.addEventListener('DOMContentLoaded', function() { + const savedTheme = localStorage.getItem('theme'); + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + if (savedTheme === 'dark' || (!savedTheme && prefersDark)) { + document.body.classList.add('dark-theme'); + document.documentElement.classList.add('dark-theme'); + updateToggleButton(true); + } else { + document.body.classList.remove('dark-theme'); + document.documentElement.classList.remove('dark-theme'); + updateToggleButton(false); + } +}); \ No newline at end of file diff --git a/static/voting.js b/static/voting.js new file mode 100644 index 0000000..3852f33 --- /dev/null +++ b/static/voting.js @@ -0,0 +1,217 @@ +// AJAX voting functionality +function vote(quoteId, action, buttonElement) { + // Prevent multiple clicks + if (buttonElement.disabled) { + return false; + } + + // Disable button temporarily + buttonElement.disabled = true; + + // Make AJAX request + fetch(`/vote/${quoteId}/${action}`, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Update vote count display + const voteElement = document.getElementById(`votes-${quoteId}`); + if (voteElement) { + voteElement.textContent = data.votes; + } + + // 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.'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Connection error while voting. Please check your internet connection and try again.'); + }) + .finally(() => { + // Re-enable button + buttonElement.disabled = false; + }); + + return false; // Prevent default link behavior +} + +function updateButtonStates(quoteId, userVote) { + const upButton = document.getElementById(`up-${quoteId}`); + const downButton = document.getElementById(`down-${quoteId}`); + + if (upButton && downButton) { + // Reset button styles + upButton.style.backgroundColor = ''; + downButton.style.backgroundColor = ''; + + // Highlight the voted button + if (userVote === 'upvote') { + upButton.style.backgroundColor = '#90EE90'; // Light green + } else if (userVote === 'downvote') { + downButton.style.backgroundColor = '#FFB6C1'; // Light pink + } + } +} + +// Flag quote functionality +function flag(quoteId, buttonElement) { + if (buttonElement.disabled) { + return false; + } + + if (!confirm('Are you sure you want to flag this quote as inappropriate?')) { + return false; + } + + buttonElement.disabled = true; + + fetch(`/flag/${quoteId}`, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(data.message || 'Thank you! This quote has been flagged for review by moderators.'); + buttonElement.style.backgroundColor = '#FFB6C1'; // Light pink + buttonElement.textContent = '✓'; + } else { + alert(data.message || 'Sorry, we could not flag this quote. Please try again.'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Connection error while flagging. Please check your internet connection and try again.'); + }) + .finally(() => { + buttonElement.disabled = false; + }); + + return false; +} + +// Copy quote functionality +function copyQuote(quoteId, buttonElement) { + if (buttonElement.disabled) { + return false; + } + + // Get the quote text + const quoteElement = document.querySelector(`#quote-${quoteId} .qt, [data-quote-id="${quoteId}"] .qt`); + let quoteText = ''; + + if (quoteElement) { + quoteText = quoteElement.textContent || quoteElement.innerText; + } else { + // Fallback: look for quote text in any element after the quote header + const allQuotes = document.querySelectorAll('.qt'); + const quoteHeaders = document.querySelectorAll('.quote'); + + for (let i = 0; i < quoteHeaders.length; i++) { + const header = quoteHeaders[i]; + if (header.innerHTML.includes(`#${quoteId}`)) { + if (allQuotes[i]) { + quoteText = allQuotes[i].textContent || allQuotes[i].innerText; + } + break; + } + } + } + + if (!quoteText) { + alert('Sorry, we could not find the quote text to copy. Please try selecting and copying the text manually.'); + return false; + } + + // Format the text with quote number + const formattedText = `#${quoteId}: ${quoteText.trim()}`; + + // Copy to clipboard + if (navigator.clipboard && window.isSecureContext) { + // Modern approach + navigator.clipboard.writeText(formattedText).then(() => { + showCopySuccess(buttonElement); + }).catch(() => { + fallbackCopy(formattedText, buttonElement); + }); + } else { + // Fallback for older browsers + fallbackCopy(formattedText, buttonElement); + } + + return false; +} + +function fallbackCopy(text, buttonElement) { + // Create temporary textarea + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + showCopySuccess(buttonElement); + } catch (err) { + console.error('Could not copy text: ', err); + alert('Copy to clipboard failed. Please manually select and copy the quote text using Ctrl+C (or Cmd+C on Mac).'); + } + + document.body.removeChild(textArea); +} + +function showCopySuccess(buttonElement) { + const originalText = buttonElement.textContent; + buttonElement.textContent = '✓'; + buttonElement.style.backgroundColor = '#90EE90'; // Light green + + setTimeout(() => { + buttonElement.textContent = originalText; + buttonElement.style.backgroundColor = ''; + }, 1500); +} + +// Load user vote states when page loads +document.addEventListener('DOMContentLoaded', function() { + // Get all vote elements and check their states + const voteElements = document.querySelectorAll('[id^="votes-"]'); + + // Get user's voting history from cookies + const votes = getCookie('votes'); + if (votes) { + try { + const voteData = JSON.parse(votes); + + // Update button states for each quote + voteElements.forEach(element => { + const quoteId = element.id.replace('votes-', ''); + const userVote = voteData[quoteId]; + if (userVote) { + updateButtonStates(quoteId, userVote); + } + }); + } catch (e) { + console.log('Could not parse vote cookie'); + } + } +}); + +// Helper function to get cookie value +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} \ No newline at end of file diff --git a/templates/browse.html b/templates/browse.html index c5e5775..705d12d 100644 --- a/templates/browse.html +++ b/templates/browse.html @@ -6,6 +6,18 @@ ircquotes: Browse Quotes + + + @@ -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 }}


{% endfor %} diff --git a/templates/faq.html b/templates/faq.html index 4dbd8e1..636d534 100644 --- a/templates/faq.html +++ b/templates/faq.html @@ -5,6 +5,17 @@ ircquotes: FAQ + + @@ -29,8 +40,9 @@ Submit / Browse / ModApp / - Search + Search / FAQ + diff --git a/templates/index.html b/templates/index.html index fb28870..9d94adf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,17 @@ ircquotes: Home + + @@ -33,8 +44,9 @@ Submit / Browse / ModApp / - Search + Search / FAQ + diff --git a/templates/login.html b/templates/login.html index 439e1b0..75a0bd4 100644 --- a/templates/login.html +++ b/templates/login.html @@ -5,6 +5,17 @@ ircquotes: Admin Login + + @@ -31,8 +42,9 @@ Submit / Browse / ModApp / - Search + Search / FAQ + @@ -42,6 +54,7 @@

Admin Login

+ diff --git a/templates/modapp.html b/templates/modapp.html index 5f153f1..7cd1fe6 100644 --- a/templates/modapp.html +++ b/templates/modapp.html @@ -5,6 +5,17 @@ ircquotes: Admin Panel + + @@ -29,6 +40,7 @@ Submit / Browse / Modapp +
Username:
@@ -45,48 +57,130 @@ +
{% if quotes.items %} + +
+ +
+ Bulk Actions: + +    + + + + +
+ +
+ - - - + + + + {% for quote in quotes.items %} + {% if quote.flag_count > 5 %} #ffcccc {% elif quote.flag_count > 2 %} #ffe6cc {% elif quote.status == 1 %} #d4edda {% elif quote.status == 2 %} #f8d7da {% else %} #fff {% endif %}"> + - + - - - + + + + {% endfor %}
Select Quote ID Quote StatusSubmitted AtIP AddressUser AgentFlagsSubmitted AtIP AddressUser Agent Actions
#{{ quote.id }}{{ quote.text }}{{ quote.text|e }} - {% if quote.status == 0 %} - Pending - {% elif quote.status == 1 %} - Approved + {% if filter_status == 'flagged' %} + + {% if quote.status == 0 %} + ⚠️ PENDING + FLAGGED + {% elif quote.status == 1 %} + ✅ APPROVED + FLAGGED + {% else %} + ❌ REJECTED + FLAGGED + {% endif %} {% else %} - Rejected + + {% if quote.status == 0 %} + Pending + {% elif quote.status == 1 %} + Approved + {% else %} + Rejected + {% endif %} {% endif %} {{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') if quote.submitted_at else 'N/A' }}{{ quote.ip_address }}{{ quote.user_agent }} - Approve | - Reject | - Delete + {% if quote.flag_count > 0 %} + {{ quote.flag_count }} + {% else %} + 0 + {% endif %} + {{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') if quote.submitted_at else 'N/A' }}{{ quote.ip_address|e }}{{ quote.user_agent|e|truncate(50) }} + {% if filter_status == 'flagged' %} + + {% if quote.status == 1 %} + + Clear Flags | + Reject | + Delete + {% elif quote.status == 0 %} + + Approve | + Clear Flags | + Reject | + Delete + {% else %} + + Clear Flags | + Delete + {% endif %} + {% else %} + + Approve | + Reject | + Delete + {% endif %}
+
+
+ + +
+ diff --git a/templates/search.html b/templates/search.html index 6ef47bc..fe20c78 100644 --- a/templates/search.html +++ b/templates/search.html @@ -5,28 +5,113 @@ ircquotes: Search & Read + + + - + - +
- +
- - + +
- ircquotes + + ircquotes - Search & Read Quotes + + Search & Read Quotes +
+ + + + + +
- +
- + - + +
+ +

Search for Quotes by Keyword

+
+ + +
+
+ + +

Read a Quote by Number

+
+ + +
+
+
+
+ + {% if query %} +
+ + + + +
+

Search Results for "{{ query }}"

+ {% if quotes %} + {% for quote in quotes %} +

+ #{{ quote.id }} +   + + + {{ quote.votes }} + - +   + X +   + C +

+

{{ quote.text|e }}

+
+ {% endfor %} + {% else %} +

No quotes found for "{{ query }}".

+ {% endif %} +
+
+ {% endif %} + + +
+ + + -
-
- - -
-
- -

Search for Quotes by Keyword

-
- - -
- - -

Read a Quote by Number

-
- - -
-
- - -
- {% if query %} -

Search Results for "{{ query }}"

- {% if quotes %} - - - - -
- {% for quote in quotes %} -

- - #{{ quote.id }} - - + - ({{ quote.votes }}) - - -

-

{{ quote.text }}

-
- {% endfor %} -
- {% else %} -

No quotes found for "{{ query }}".

- {% endif %} - {% endif %} -
-
- - -
- - + + - +
diff --git a/templates/submit.html b/templates/submit.html index d18b508..d5dc669 100644 --- a/templates/submit.html +++ b/templates/submit.html @@ -5,6 +5,17 @@ ircquotes: Add a Quote + +
@@ -32,14 +43,16 @@ Submit / Browse / ModApp / - Search + Search / FAQ +
+