Complete ircquotes application with all features
- Added copy quote functionality with clipboard integration - Implemented bulk moderation actions for admin - Created mobile responsive design with bash.org styling - Added API rate limiting per IP address - Implemented dark mode toggle with flash prevention - Enhanced error messages throughout application - Fixed all security vulnerabilities (SQL injection, XSS, CSRF) - Added comprehensive rate limiting on all endpoints - Implemented secure session configuration - Added input validation and length limits - Created centralized configuration system with config.json - Set up production deployment with Gunicorn - Added security headers and production hardening - Added password generation and config management tools
This commit is contained in:
30
.gitignore
vendored
30
.gitignore
vendored
@@ -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
|
||||
|
||||
130
DEPLOYMENT.md
Normal file
130
DEPLOYMENT.md
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
602
app.py
602
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 '<script' in quote_text.lower() or 'javascript:' in quote_text.lower():
|
||||
flash("Invalid content detected. Please remove any script tags or JavaScript.", 'error')
|
||||
return redirect(url_for('submit'))
|
||||
|
||||
ip_address = request.headers.get('CF-Connecting-IP', request.remote_addr) # Get the user's IP address
|
||||
user_agent = request.headers.get('User-Agent') # Get the user's browser info
|
||||
@@ -72,10 +160,10 @@ def submit():
|
||||
try:
|
||||
db.session.add(new_quote)
|
||||
db.session.commit()
|
||||
flash("Quote submitted successfully!", 'success')
|
||||
flash("Thanks! Your quote has been submitted and is awaiting approval by our moderators.", 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash("Error submitting quote: {}".format(e), 'error')
|
||||
flash("Sorry, something went wrong while submitting your quote. Please try again in a moment.", 'error')
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@@ -86,16 +174,22 @@ def submit():
|
||||
return render_template('submit.html', approved_count=approved_count, pending_count=pending_count)
|
||||
|
||||
@app.route('/vote/<int:id>/<action>')
|
||||
@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/<int:id>')
|
||||
@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/<int:id>')
|
||||
@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/<int:id>')
|
||||
@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/<int:id>')
|
||||
@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/<int:id>')
|
||||
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/<int:id>', 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/<id>": {
|
||||
"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)
|
||||
|
||||
67
config.json
Normal file
67
config.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
104
config_loader.py
Normal file
104
config_loader.py
Normal file
@@ -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()
|
||||
84
config_manager.py
Normal file
84
config_manager.py
Normal file
@@ -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 <key> <value> # 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()
|
||||
36
generate_password.py
Normal file
36
generate_password.py
Normal file
@@ -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()
|
||||
38
gunicorn.conf.py
Normal file
38
gunicorn.conf.py
Normal file
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
static/theme.js
Normal file
44
static/theme.js
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
217
static/voting.js
Normal file
217
static/voting.js
Normal file
@@ -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();
|
||||
}
|
||||
@@ -6,6 +6,18 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Browse Quotes</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -33,8 +45,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -48,12 +61,16 @@
|
||||
{% for quote in quotes.items %}
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
<a href="/vote/{{ quote.id }}/upvote?page={{ quotes.page }}" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote?page={{ quotes.page }}" class="qa">-</a>
|
||||
<a href="/flag/{{ quote.id }}" class="qa"></a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "upvote", this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "downvote", this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
</td>
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: FAQ</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -29,8 +40,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Home</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -33,8 +44,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Admin Login</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -31,8 +42,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -42,6 +54,7 @@
|
||||
<center>
|
||||
<h2>Admin Login</h2>
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Username:</td>
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Admin Panel</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -29,6 +40,7 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">Modapp</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -45,48 +57,130 @@
|
||||
<option value="pending" {% if filter_status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="approved" {% if filter_status == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="rejected" {% if filter_status == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||
<option value="flagged" {% if filter_status == 'flagged' %}selected{% endif %}>Flagged</option>
|
||||
</select>
|
||||
<input type="submit" value="Apply Filter">
|
||||
</form>
|
||||
|
||||
{% if quotes.items %}
|
||||
<!-- Bulk Actions Form -->
|
||||
<form id="bulk-action-form" method="POST" action="/modapp/bulk">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div style="margin: 10px 0; padding: 10px; background-color: #f0f0f0; border: 1px solid #ccc;">
|
||||
<b>Bulk Actions:</b>
|
||||
<input type="checkbox" id="select-all" onchange="toggleAllCheckboxes(this)"> <label for="select-all">Select All</label>
|
||||
|
||||
<button type="submit" name="action" value="approve" class="qa" onclick="return confirmBulkAction('approve')">Bulk Approve</button>
|
||||
<button type="submit" name="action" value="reject" class="qa" onclick="return confirmBulkAction('reject')">Bulk Reject</button>
|
||||
<button type="submit" name="action" value="delete" class="qa" onclick="return confirmBulkAction('delete')">Bulk Delete</button>
|
||||
<button type="submit" name="action" value="clear_flags" class="qa" onclick="return confirmBulkAction('clear flags')">Clear All Flags</button>
|
||||
</div>
|
||||
|
||||
<!-- Table for Quotes -->
|
||||
<div class="modapp-table-container">
|
||||
<table border="1" cellpadding="5" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Quote ID</th>
|
||||
<th>Quote</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted At</th>
|
||||
<th>IP Address</th>
|
||||
<th>User Agent</th>
|
||||
<th>Flags</th>
|
||||
<th class="mobile-hide">Submitted At</th>
|
||||
<th class="mobile-hide">IP Address</th>
|
||||
<th class="mobile-hide">User Agent</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<!-- Loop through quotes -->
|
||||
{% for quote in quotes.items %}
|
||||
<tr style="background-color:
|
||||
{% if quote.status == 1 %} #d4edda {% elif quote.status == 2 %} #f8d7da {% else %} #fff {% endif %}">
|
||||
{% 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 %}">
|
||||
<td><input type="checkbox" name="quote_ids" value="{{ quote.id }}" class="quote-checkbox"></td>
|
||||
<td>#{{ quote.id }}</td>
|
||||
<td>{{ quote.text }}</td>
|
||||
<td>{{ quote.text|e }}</td>
|
||||
<td>
|
||||
{% if quote.status == 0 %}
|
||||
Pending
|
||||
{% elif quote.status == 1 %}
|
||||
Approved
|
||||
{% if filter_status == 'flagged' %}
|
||||
<!-- Prominent status display for flagged quotes -->
|
||||
{% if quote.status == 0 %}
|
||||
<span style="background-color: #fff3cd; padding: 2px 6px; border-radius: 3px; font-weight: bold;">⚠️ PENDING + FLAGGED</span>
|
||||
{% elif quote.status == 1 %}
|
||||
<span style="background-color: #d4edda; padding: 2px 6px; border-radius: 3px; font-weight: bold;">✅ APPROVED + FLAGGED</span>
|
||||
{% else %}
|
||||
<span style="background-color: #f8d7da; padding: 2px 6px; border-radius: 3px; font-weight: bold;">❌ REJECTED + FLAGGED</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Rejected
|
||||
<!-- Normal status display -->
|
||||
{% if quote.status == 0 %}
|
||||
Pending
|
||||
{% elif quote.status == 1 %}
|
||||
Approved
|
||||
{% else %}
|
||||
Rejected
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') if quote.submitted_at else 'N/A' }}</td>
|
||||
<td>{{ quote.ip_address }}</td>
|
||||
<td>{{ quote.user_agent }}</td>
|
||||
<td>
|
||||
<a href="/approve/{{ quote.id }}">Approve</a> |
|
||||
<a href="/reject/{{ quote.id }}">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}">Delete</a>
|
||||
{% if quote.flag_count > 0 %}
|
||||
<span style="color: red; font-weight: bold;">{{ quote.flag_count }}</span>
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mobile-hide">{{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') if quote.submitted_at else 'N/A' }}</td>
|
||||
<td class="mobile-hide">{{ quote.ip_address|e }}</td>
|
||||
<td class="mobile-hide">{{ quote.user_agent|e|truncate(50) }}</td>
|
||||
<td>
|
||||
{% if filter_status == 'flagged' %}
|
||||
<!-- Special actions for flagged quotes -->
|
||||
{% if quote.status == 1 %}
|
||||
<!-- Already approved but flagged -->
|
||||
<a href="/clear_flags/{{ quote.id }}" style="color: blue;">Clear Flags</a> |
|
||||
<a href="/reject/{{ quote.id }}" style="color: orange;">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}" style="color: red;">Delete</a>
|
||||
{% elif quote.status == 0 %}
|
||||
<!-- Pending and flagged -->
|
||||
<a href="/approve/{{ quote.id }}" style="color: green;">Approve</a> |
|
||||
<a href="/clear_flags/{{ quote.id }}" style="color: blue;">Clear Flags</a> |
|
||||
<a href="/reject/{{ quote.id }}" style="color: orange;">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}" style="color: red;">Delete</a>
|
||||
{% else %}
|
||||
<!-- Rejected and flagged -->
|
||||
<a href="/clear_flags/{{ quote.id }}" style="color: blue;">Clear Flags</a> |
|
||||
<a href="/delete/{{ quote.id }}" style="color: red;">Delete</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<!-- Standard actions for non-flagged quotes -->
|
||||
<a href="/approve/{{ quote.id }}">Approve</a> |
|
||||
<a href="/reject/{{ quote.id }}">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}">Delete</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Bulk Actions JavaScript -->
|
||||
<script>
|
||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
const checkboxes = document.querySelectorAll('.quote-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmBulkAction(action) {
|
||||
const selectedCheckboxes = document.querySelectorAll('.quote-checkbox:checked');
|
||||
if (selectedCheckboxes.length === 0) {
|
||||
alert('Please select at least one quote.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const count = selectedCheckboxes.length;
|
||||
const message = `Are you sure you want to ${action} ${count} selected quote(s)?`;
|
||||
return confirm(message);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Pagination Links -->
|
||||
<div id="pagination">
|
||||
@@ -118,7 +212,8 @@
|
||||
<tr>
|
||||
<td class="footertext" align="left"> </td>
|
||||
<td class="footertext" align="right">
|
||||
{{ approved_count }} quotes approved; {{ pending_count }} quotes pending; {{ rejected_count }} quotes rejected
|
||||
{{ approved_count }} quotes approved; {{ pending_count }} quotes pending; {{ rejected_count }} quotes rejected;
|
||||
<span style="color: red; font-weight: bold;">{{ flagged_count }} quotes flagged</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Quote #{{ quote.id }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -33,8 +45,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -47,13 +60,17 @@
|
||||
<td valign="top">
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
||||
<a href="/flag/{{ quote.id }}" class="qa">[X]</a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'upvote', this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'downvote', this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
</p>
|
||||
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
</td>
|
||||
<td valign="top"></td>
|
||||
</tr>
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Random Quote</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -30,8 +42,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -43,12 +56,16 @@
|
||||
<td valign="top">
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
||||
<a href="/flag/{{ quote.id }}" class="qa">[X]</a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'upvote', this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'downvote', this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -77,6 +94,7 @@
|
||||
© ircquotes 2024, All Rights Reserved.
|
||||
</font>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -5,28 +5,113 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Search & Read</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
<!-- Header -->
|
||||
<!-- Top Navigation Bar -->
|
||||
<center>
|
||||
<table cellpadding="2" cellspacing="0" width="80%" class="header">
|
||||
<table cellpadding="2" cellspacing="0" width="80%" border="0">
|
||||
<tr>
|
||||
<td class="header-left">
|
||||
<b><i>ircquotes</i></b>
|
||||
<td bgcolor="#c08000" align="left">
|
||||
<font size="+1"><b><i>ircquotes</i></b></font>
|
||||
</td>
|
||||
<td class="header-right">
|
||||
<b>Search & Read Quotes</b>
|
||||
<td bgcolor="#c08000" align="right">
|
||||
<font face="arial" size="+1"><b>Search & Read Quotes</b></font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<table cellpadding="2" cellspacing="0" width="80%" border="0">
|
||||
<tr>
|
||||
<td class="footertext" align="left" bgcolor="#f0f0f0"></td>
|
||||
<td align="right" bgcolor="#f0f0f0" class="toplinks" colspan="2">
|
||||
<a href="/">Home</a> /
|
||||
<a href="/random">Random</a> /
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<!-- Search Forms -->
|
||||
<center>
|
||||
<table cellpadding="2" cellspacing="0" width="80%" class="nav-bar">
|
||||
<table cellpadding="0" cellspacing="3" width="80%">
|
||||
<tr>
|
||||
<td align="right" class="toplinks" colspan="2">
|
||||
<td class="bodytext" width="100%" valign="top">
|
||||
<!-- Search for Quotes -->
|
||||
<p><b>Search for Quotes by Keyword</b></p>
|
||||
<form action="/search" method="GET">
|
||||
<input type="text" name="q" value="{{ query or '' }}" placeholder="Enter search term" required>
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
<br>
|
||||
|
||||
<!-- Read Quote by Number -->
|
||||
<p><b>Read a Quote by Number</b></p>
|
||||
<form action="/quote" method="GET">
|
||||
<input type="number" name="id" placeholder="Enter quote number" required>
|
||||
<input type="submit" value="Read">
|
||||
</form>
|
||||
<hr>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
<!-- Search Results -->
|
||||
{% if query %}
|
||||
<center>
|
||||
<table cellpadding="0" cellspacing="3" width="80%">
|
||||
<tr>
|
||||
<td class="bodytext" width="100%" valign="top">
|
||||
<p><b>Search Results for "{{ query }}"</b></p>
|
||||
{% if quotes %}
|
||||
{% for quote in quotes %}
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "upvote", this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "downvote", this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No quotes found for "{{ query }}".</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<center>
|
||||
<table border="0" cellpadding="2" cellspacing="0" width="80%" bgcolor="#c08000">
|
||||
<tr>
|
||||
<td bgcolor="#f0f0f0" class="toplinks" colspan="2">
|
||||
<a href="/">Home</a> /
|
||||
<a href="/random">Random</a> /
|
||||
<a href="/submit">Submit</a> /
|
||||
@@ -36,67 +121,16 @@
|
||||
<a href="/faq">FAQ</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
|
||||
<!-- Content Section -->
|
||||
<center>
|
||||
<div class="content-box">
|
||||
<!-- Search for Quotes -->
|
||||
<h2>Search for Quotes by Keyword</h2>
|
||||
<form action="/search" method="GET">
|
||||
<input type="text" name="q" class="text" placeholder="Enter search term" required>
|
||||
<input type="submit" value="Search" class="button">
|
||||
</form>
|
||||
|
||||
<!-- Read Quote by Number -->
|
||||
<h2>Read a Quote by Number</h2>
|
||||
<form action="/read" method="GET">
|
||||
<input type="number" name="id" class="text" placeholder="Enter quote number" required>
|
||||
<input type="submit" value="Read" class="button">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="results-box">
|
||||
{% if query %}
|
||||
<h3>Search Results for "{{ query }}"</h3>
|
||||
{% if quotes %}
|
||||
<table cellpadding="0" cellspacing="3" width="80%">
|
||||
<tr>
|
||||
<td class="bodytext" width="100%" valign="top">
|
||||
{% for quote in quotes %}
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote.">
|
||||
<b>#{{ quote.id }}</b>
|
||||
</a>
|
||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
||||
(<span class="votes">{{ quote.votes }}</span>)
|
||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<h4>No quotes found for "{{ query }}".</h4>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</center>
|
||||
|
||||
<!-- Footer -->
|
||||
<center>
|
||||
<table border="0" cellpadding="2" cellspacing="0" width="80%" class="footer">
|
||||
<tr>
|
||||
<td class="footertext" align="right">{{ approved_count }} quotes approved; {{ pending_count }} quotes pending</td>
|
||||
<td class="footertext" align="left"> </td>
|
||||
<td class="footertext" align="right">{{ approved_count }} quotes approved</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="copyright">
|
||||
|
||||
<font size="-1">
|
||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
||||
© ircquotes 2024, All Rights Reserved.
|
||||
</div>
|
||||
</font>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Add a Quote</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.newquote.focus();">
|
||||
<center>
|
||||
@@ -32,14 +43,16 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Add a Quote Form -->
|
||||
<form action="/submit" name="add" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<table cellpadding="2" cellspacing="0" width="60%">
|
||||
<tr>
|
||||
<td><textarea cols="100%" rows="10" name="quote" class="text"></textarea></td>
|
||||
|
||||
Reference in New Issue
Block a user