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:
2025-09-20 19:41:23 +01:00
parent 0b1241714d
commit f409977257
21 changed files with 1936 additions and 304 deletions

30
.gitignore vendored
View File

@@ -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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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'

View File

@@ -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

View File

@@ -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
View 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
View 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();
}

View File

@@ -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>
&nbsp;
<a href="#" onclick="return vote({{ quote.id }}, &quot;upvote&quot;, 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 }}, &quot;downvote&quot;, this)" class="qa" id="down-{{ quote.id }}">-</a>
&nbsp;
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
&nbsp;
<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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
&nbsp;&nbsp;
<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">&nbsp;</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>

View File

@@ -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>
&nbsp;
<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>
&nbsp;
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
&nbsp;
<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>

View File

@@ -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>
&nbsp;
<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>
&nbsp;
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
&nbsp;
<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 @@
&#169; ircquotes 2024, All Rights Reserved.
</font>
</center>
</body>
</html>

View File

@@ -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>
&nbsp;
<a href="#" onclick="return vote({{ quote.id }}, &quot;upvote&quot;, 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 }}, &quot;downvote&quot;, this)" class="qa" id="down-{{ quote.id }}">-</a>
&nbsp;
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
&nbsp;
<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">&nbsp;</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>
&#169; ircquotes 2024, All Rights Reserved.
</div>
</font>
</center>
</body>

View File

@@ -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>