Compare commits
10 Commits
fb6ce5a103
...
f409977257
| Author | SHA1 | Date | |
|---|---|---|---|
| f409977257 | |||
| 0b1241714d | |||
|
|
58884e119d | ||
|
|
875fd9a6b2 | ||
|
|
63a9144c7f | ||
| 915febb352 | |||
| 8c036a5a43 | |||
| 9f4d380950 | |||
| 449c3a2dc2 | |||
| 4a6bcd8390 |
30
.gitignore
vendored
30
.gitignore
vendored
@@ -1,2 +1,28 @@
|
|||||||
venv
|
# Virtual Environment
|
||||||
instance
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
650
app.py
650
app.py
@@ -3,6 +3,7 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
@@ -10,10 +11,58 @@ from argon2 import PasswordHasher
|
|||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix # Import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix # Import ProxyFix
|
||||||
import logging
|
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 = Flask(__name__)
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///quotes.db'
|
app.config['SQLALCHEMY_DATABASE_URI'] = config.database_uri
|
||||||
app.config['SECRET_KEY'] = 'your_secret_key' # Use environment variable in production
|
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)
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
# Apply ProxyFix middleware
|
# 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
|
# Initialize Argon2 password hasher
|
||||||
ph = PasswordHasher()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
# Initialize logging for debugging
|
# Configure logging from config
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
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 = {
|
ADMIN_CREDENTIALS = {
|
||||||
'username': 'admin',
|
'username': config.admin_username,
|
||||||
# Replace this with the hashed password generated by Argon2
|
'password': config.admin_password_hash
|
||||||
'password': '$argon2i$v=19$m=65536,t=4,p=1$cWZDc1pQaUJLTUJoaVI4cw$kn8XKz6AEZi8ebXfyyZuzommSypliVFrsGqzOyUEIHA' # Example hash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Define the Quote model
|
# Define the Quote model
|
||||||
@@ -42,6 +109,7 @@ class Quote(db.Model):
|
|||||||
ip_address = db.Column(db.String(45)) # Store IPv4 and IPv6 addresses
|
ip_address = db.Column(db.String(45)) # Store IPv4 and IPv6 addresses
|
||||||
user_agent = db.Column(db.String(255)) # Store user-agent strings
|
user_agent = db.Column(db.String(255)) # Store user-agent strings
|
||||||
submitted_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
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
|
# Home route to display quotes
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -57,11 +125,31 @@ def index():
|
|||||||
|
|
||||||
# Separate route for submitting quotes
|
# Separate route for submitting quotes
|
||||||
@app.route('/submit', methods=['GET', 'POST'])
|
@app.route('/submit', methods=['GET', 'POST'])
|
||||||
|
@limiter.limit(config.get('rate_limiting.endpoints.submit', '5 per minute'))
|
||||||
def submit():
|
def submit():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
quote_text = request.form.get('quote')
|
quote_text = request.form.get('quote')
|
||||||
if not quote_text:
|
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'))
|
return redirect(url_for('submit'))
|
||||||
|
|
||||||
ip_address = request.headers.get('CF-Connecting-IP', request.remote_addr) # Get the user's IP address
|
ip_address = request.headers.get('CF-Connecting-IP', request.remote_addr) # Get the user's IP address
|
||||||
@@ -72,10 +160,10 @@ def submit():
|
|||||||
try:
|
try:
|
||||||
db.session.add(new_quote)
|
db.session.add(new_quote)
|
||||||
db.session.commit()
|
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:
|
except Exception as e:
|
||||||
db.session.rollback()
|
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'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
@@ -86,55 +174,85 @@ def submit():
|
|||||||
return render_template('submit.html', approved_count=approved_count, pending_count=pending_count)
|
return render_template('submit.html', approved_count=approved_count, pending_count=pending_count)
|
||||||
|
|
||||||
@app.route('/vote/<int:id>/<action>')
|
@app.route('/vote/<int:id>/<action>')
|
||||||
|
@limiter.limit("20 per minute")
|
||||||
def vote(id, action):
|
def vote(id, action):
|
||||||
quote = Quote.query.get_or_404(id)
|
quote = Quote.query.get_or_404(id)
|
||||||
|
|
||||||
# Retrieve vote history from the cookie
|
# Retrieve vote history from the cookie
|
||||||
vote_cookie = request.cookies.get('votes')
|
vote_cookie = request.cookies.get('votes')
|
||||||
if vote_cookie:
|
if vote_cookie:
|
||||||
|
try:
|
||||||
vote_data = json.loads(vote_cookie)
|
vote_data = json.loads(vote_cookie)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# If cookie is corrupted, start fresh
|
||||||
|
vote_data = {}
|
||||||
else:
|
else:
|
||||||
vote_data = {}
|
vote_data = {}
|
||||||
|
|
||||||
# If the user has already voted, check for undoing or switching vote
|
message = ""
|
||||||
if str(id) in vote_data:
|
# If no prior vote, apply the new vote
|
||||||
|
if str(id) not in vote_data:
|
||||||
|
if action == 'upvote':
|
||||||
|
quote.votes += 1
|
||||||
|
vote_data[str(id)] = 'upvote'
|
||||||
|
elif action == 'downvote':
|
||||||
|
quote.votes -= 1
|
||||||
|
vote_data[str(id)] = 'downvote'
|
||||||
|
message = "Thank you for voting!"
|
||||||
|
|
||||||
|
else:
|
||||||
previous_action = vote_data[str(id)]
|
previous_action = vote_data[str(id)]
|
||||||
|
|
||||||
if previous_action == action:
|
if previous_action == action:
|
||||||
# Undo the vote
|
# If the user clicks the same action again, undo the vote
|
||||||
if action == 'upvote':
|
if action == 'upvote':
|
||||||
quote.votes -= 1
|
quote.votes -= 1
|
||||||
elif action == 'downvote':
|
elif action == 'downvote':
|
||||||
quote.votes += 1
|
quote.votes += 1
|
||||||
del vote_data[str(id)] # Remove vote from record
|
del vote_data[str(id)] # Remove the vote record (undo)
|
||||||
flash("Your vote has been undone.", 'success')
|
message = "Your vote has been undone."
|
||||||
else:
|
else:
|
||||||
# Switching from upvote to downvote or vice versa
|
# If the user switches votes (upvote -> downvote or vice versa)
|
||||||
if action == 'upvote':
|
if previous_action == 'upvote' and action == 'downvote':
|
||||||
quote.votes += 2 # From downvote to upvote, effectively +2
|
quote.votes -= 2 # Undo upvote (+1) and apply downvote (-1)
|
||||||
elif action == 'downvote':
|
vote_data[str(id)] = 'downvote'
|
||||||
quote.votes -= 2 # From upvote to downvote, effectively -2
|
elif previous_action == 'downvote' and action == 'upvote':
|
||||||
vote_data[str(id)] = action # Update vote action
|
quote.votes += 2 # Undo downvote (-1) and apply upvote (+1)
|
||||||
flash("Your vote has been changed.", 'success')
|
vote_data[str(id)] = 'upvote'
|
||||||
else:
|
message = "Your vote has been changed."
|
||||||
# First-time voting on this quote (starting from neutral)
|
|
||||||
if action == 'upvote':
|
|
||||||
quote.votes += 1 # Add +1 vote
|
|
||||||
elif action == 'downvote':
|
|
||||||
quote.votes -= 1 # Subtract -1 vote
|
|
||||||
vote_data[str(id)] = action # Store vote action
|
|
||||||
flash("Thank you for voting!", 'success')
|
|
||||||
|
|
||||||
# Save the updated vote data to the cookie
|
# Save the updated vote data to the cookie
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# 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)
|
page = request.args.get('page', 1)
|
||||||
resp = make_response(redirect(url_for('browse', page=page)))
|
resp = make_response(redirect(url_for('browse', page=page)))
|
||||||
resp.set_cookie('votes', json.dumps(vote_data), max_age=60*60*24*365) # Cookie for 1 year
|
resp.set_cookie('votes', json.dumps(vote_data), max_age=60*60*24*365)
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
flash(f"Error voting on quote: {e}", 'error')
|
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))
|
return redirect(url_for('browse', page=page))
|
||||||
|
|
||||||
# Route for displaying a random quote
|
# Route for displaying a random quote
|
||||||
@@ -142,14 +260,15 @@ def vote(id, action):
|
|||||||
def random_quote():
|
def random_quote():
|
||||||
approved_count = Quote.query.filter_by(status=1).count()
|
approved_count = Quote.query.filter_by(status=1).count()
|
||||||
pending_count = Quote.query.filter_by(status=0).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:
|
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'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
random_id = random.randint(1, count)
|
# Use offset to get a random quote from approved quotes
|
||||||
random_quote = Quote.query.get(random_id)
|
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)
|
return render_template('random.html', quote=random_quote, approved_count=approved_count, pending_count=pending_count)
|
||||||
|
|
||||||
@@ -163,7 +282,7 @@ def quote_homepathid(id):
|
|||||||
def quote():
|
def quote():
|
||||||
quote_id = request.args.get('id')
|
quote_id = request.args.get('id')
|
||||||
if not quote_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'))
|
return redirect(url_for('browse'))
|
||||||
|
|
||||||
quote = Quote.query.get_or_404(quote_id)
|
quote = Quote.query.get_or_404(quote_id)
|
||||||
@@ -173,8 +292,50 @@ def quote():
|
|||||||
def faq():
|
def faq():
|
||||||
return render_template('faq.html')
|
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
|
# Admin login route
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
@limiter.limit(config.get('rate_limiting.endpoints.login', '5 per minute'))
|
||||||
def login():
|
def login():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
username = request.form['username']
|
username = request.form['username']
|
||||||
@@ -185,23 +346,24 @@ def login():
|
|||||||
try:
|
try:
|
||||||
ph.verify(ADMIN_CREDENTIALS['password'], password) # Verify password using Argon2
|
ph.verify(ADMIN_CREDENTIALS['password'], password) # Verify password using Argon2
|
||||||
session['admin'] = True
|
session['admin'] = True
|
||||||
flash('Login successful!', 'success')
|
flash('Welcome back! You are now logged in as administrator.', 'success')
|
||||||
return redirect(url_for('modapp'))
|
return redirect(url_for('modapp'))
|
||||||
except VerifyMismatchError:
|
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:
|
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')
|
return render_template('login.html')
|
||||||
|
|
||||||
# Admin panel route (accessible only to logged-in admins)
|
# Admin panel route (accessible only to logged-in admins)
|
||||||
@app.route('/modapp')
|
@app.route('/modapp')
|
||||||
|
@limiter.limit("20 per minute")
|
||||||
def modapp():
|
def modapp():
|
||||||
if not session.get('admin'):
|
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'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
# Apply filtering (pending, approved, rejected)
|
# Apply filtering (pending, approved, rejected, flagged)
|
||||||
filter_status = request.args.get('filter', 'pending')
|
filter_status = request.args.get('filter', 'pending')
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
|
|
||||||
@@ -209,6 +371,9 @@ def modapp():
|
|||||||
quotes = Quote.query.filter_by(status=1).order_by(Quote.date.desc()).paginate(page=page, per_page=10)
|
quotes = Quote.query.filter_by(status=1).order_by(Quote.date.desc()).paginate(page=page, per_page=10)
|
||||||
elif filter_status == 'rejected':
|
elif filter_status == 'rejected':
|
||||||
quotes = Quote.query.filter_by(status=2).order_by(Quote.date.desc()).paginate(page=page, per_page=10)
|
quotes = Quote.query.filter_by(status=2).order_by(Quote.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
|
else: # Default to pending
|
||||||
quotes = Quote.query.filter_by(status=0).order_by(Quote.date.desc()).paginate(page=page, per_page=10)
|
quotes = Quote.query.filter_by(status=0).order_by(Quote.date.desc()).paginate(page=page, per_page=10)
|
||||||
|
|
||||||
@@ -216,10 +381,63 @@ def modapp():
|
|||||||
approved_count = Quote.query.filter_by(status=1).count()
|
approved_count = Quote.query.filter_by(status=1).count()
|
||||||
pending_count = Quote.query.filter_by(status=0).count()
|
pending_count = Quote.query.filter_by(status=0).count()
|
||||||
rejected_count = Quote.query.filter_by(status=2).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,
|
return render_template('modapp.html', quotes=quotes, filter_status=filter_status,
|
||||||
approved_count=approved_count, pending_count=pending_count,
|
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
|
# Helper function to approve a quote
|
||||||
@@ -233,34 +451,48 @@ def approve_quote(quote_id):
|
|||||||
def reject_quote(quote_id):
|
def reject_quote(quote_id):
|
||||||
quote = Quote.query.get(quote_id)
|
quote = Quote.query.get(quote_id)
|
||||||
if quote and quote.status != 2: # Only reject if not already rejected
|
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 = 'rejected'
|
||||||
quote.status = 2 # Rejected
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Helper function to delete a quote
|
# Helper function to delete a quote
|
||||||
def delete_quote(quote_id):
|
def delete_quote(quote_id):
|
||||||
quote = Quote.query.get(quote_id)
|
quote = Quote.query.get(quote_id)
|
||||||
if quote:
|
if quote:
|
||||||
logging.debug(f"Deleting quote ID: {quote.id}") # Add logging for deletion
|
|
||||||
db.session.delete(quote)
|
db.session.delete(quote)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@app.route('/search', methods=['GET'])
|
@app.route('/search', methods=['GET'])
|
||||||
def search():
|
def search():
|
||||||
query = request.args.get('q', '').strip() # Get the search query and trim whitespace
|
query = request.args.get('q', '').strip() # Get the search query
|
||||||
quotes = []
|
quotes = []
|
||||||
|
|
||||||
# Query the counts of approved and pending quotes
|
# Query counts of approved and pending quotes
|
||||||
approved_count = Quote.query.filter_by(status=1).count()
|
approved_count = Quote.query.filter_by(status=1).count()
|
||||||
pending_count = Quote.query.filter_by(status=0).count()
|
pending_count = Quote.query.filter_by(status=0).count()
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
# Perform the search only if the query is provided
|
# Perform text search in quotes using safe parameterized query
|
||||||
quotes = Quote.query.filter(Quote.text.like(f'%{query}%'), Quote.status == 1).all()
|
quotes = Quote.query.filter(Quote.text.contains(query), Quote.status == 1).all()
|
||||||
|
|
||||||
# Render the search page with the results, counts, and search query
|
|
||||||
return render_template('search.html', quotes=quotes, query=query, approved_count=approved_count, pending_count=pending_count)
|
return render_template('search.html', quotes=quotes, query=query, approved_count=approved_count, pending_count=pending_count)
|
||||||
|
|
||||||
|
@app.route('/read', methods=['GET'])
|
||||||
|
def read_quote():
|
||||||
|
quote_id = request.args.get('id', type=int) # Get the quote number
|
||||||
|
|
||||||
|
if not quote_id:
|
||||||
|
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)
|
||||||
|
quote = Quote.query.filter_by(id=quote_id, status=1).first()
|
||||||
|
|
||||||
|
if quote:
|
||||||
|
return render_template('quote.html', quote=quote)
|
||||||
|
else:
|
||||||
|
flash(f"No quote found with ID {quote_id}", 'error')
|
||||||
|
return redirect(url_for('search'))
|
||||||
|
|
||||||
# Route for browsing approved quotes
|
# Route for browsing approved quotes
|
||||||
@app.route('/browse', methods=['GET'])
|
@app.route('/browse', methods=['GET'])
|
||||||
def browse():
|
def browse():
|
||||||
@@ -268,9 +500,10 @@ def browse():
|
|||||||
approved_count = Quote.query.filter_by(status=1).count()
|
approved_count = Quote.query.filter_by(status=1).count()
|
||||||
pending_count = Quote.query.filter_by(status=0).count()
|
pending_count = Quote.query.filter_by(status=0).count()
|
||||||
|
|
||||||
# Pagination setup
|
# Pagination setup with config
|
||||||
page = request.args.get('page', 1, type=int)
|
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
|
# Pass the counts and the quotes to the template
|
||||||
return render_template('browse.html', quotes=quotes, approved_count=approved_count, pending_count=pending_count)
|
return render_template('browse.html', quotes=quotes, approved_count=approved_count, pending_count=pending_count)
|
||||||
@@ -278,6 +511,7 @@ def browse():
|
|||||||
|
|
||||||
# Approve a quote (admin only)
|
# Approve a quote (admin only)
|
||||||
@app.route('/approve/<int:id>')
|
@app.route('/approve/<int:id>')
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
def approve(id):
|
def approve(id):
|
||||||
if not session.get('admin'):
|
if not session.get('admin'):
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
@@ -292,6 +526,7 @@ def approve(id):
|
|||||||
|
|
||||||
# Reject a quote (admin only)
|
# Reject a quote (admin only)
|
||||||
@app.route('/reject/<int:id>')
|
@app.route('/reject/<int:id>')
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
def reject(id):
|
def reject(id):
|
||||||
if not session.get('admin'):
|
if not session.get('admin'):
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
@@ -303,6 +538,7 @@ def reject(id):
|
|||||||
|
|
||||||
# Delete a quote (admin only)
|
# Delete a quote (admin only)
|
||||||
@app.route('/delete/<int:id>')
|
@app.route('/delete/<int:id>')
|
||||||
|
@limiter.limit("20 per minute")
|
||||||
def delete(id):
|
def delete(id):
|
||||||
if not session.get('admin'):
|
if not session.get('admin'):
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
@@ -312,6 +548,22 @@ def delete(id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for('modapp'))
|
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
|
# Admin logout route
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
@@ -323,37 +575,85 @@ def logout():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Initialize rate limiter and CORS for cross-origin API access
|
# Add flag_count column if it doesn't exist (for existing databases)
|
||||||
limiter = Limiter(app, key_func=get_remote_address)
|
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 CORS for cross-origin API access
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
# API to get all approved quotes
|
# API to get all approved quotes with pagination
|
||||||
@app.route('/api/quotes', methods=['GET'])
|
@app.route('/api/quotes', methods=['GET'])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
def get_all_quotes():
|
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 = [{
|
quote_list = [{
|
||||||
'id': quote.id,
|
'id': quote.id,
|
||||||
'text': quote.text,
|
'text': quote.text,
|
||||||
'votes': quote.votes,
|
'votes': quote.votes
|
||||||
'date': quote.date.strftime('%Y-%m-%d')
|
} for quote in quotes.items]
|
||||||
} for quote in quotes]
|
|
||||||
|
|
||||||
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
|
# API to get a specific quote by ID
|
||||||
@app.route('/api/quotes/<int:id>', methods=['GET'])
|
@app.route('/api/quotes/<int:id>', methods=['GET'])
|
||||||
|
@limiter.limit("120 per minute")
|
||||||
def get_quote(id):
|
def get_quote(id):
|
||||||
quote = Quote.query.filter_by(id=id, status=1).first_or_404() # Only approved quotes
|
quote = Quote.query.filter_by(id=id, status=1).first_or_404() # Only approved quotes
|
||||||
quote_data = {
|
quote_data = {
|
||||||
'id': quote.id,
|
'id': quote.id,
|
||||||
'text': quote.text,
|
'text': quote.text,
|
||||||
'votes': quote.votes,
|
'votes': quote.votes
|
||||||
'date': quote.date.strftime('%Y-%m-%d')
|
|
||||||
}
|
}
|
||||||
return jsonify(quote_data), 200
|
return jsonify(quote_data), 200
|
||||||
|
|
||||||
# API to get a random approved quote
|
# API to get a random approved quote
|
||||||
@app.route('/api/random', methods=['GET'])
|
@app.route('/api/random', methods=['GET'])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
def get_random_quote():
|
def get_random_quote():
|
||||||
count = Quote.query.filter_by(status=1).count()
|
count = Quote.query.filter_by(status=1).count()
|
||||||
if count == 0:
|
if count == 0:
|
||||||
@@ -365,70 +665,216 @@ def get_random_quote():
|
|||||||
quote_data = {
|
quote_data = {
|
||||||
'id': random_quote.id,
|
'id': random_quote.id,
|
||||||
'text': random_quote.text,
|
'text': random_quote.text,
|
||||||
'votes': random_quote.votes,
|
'votes': random_quote.votes
|
||||||
'date': random_quote.date.strftime('%Y-%m-%d')
|
|
||||||
}
|
}
|
||||||
return jsonify(quote_data), 200
|
return jsonify(quote_data), 200
|
||||||
|
|
||||||
# API to get the top quotes by vote count
|
# API to get the top quotes by vote count
|
||||||
@app.route('/api/top', methods=['GET'])
|
@app.route('/api/top', methods=['GET'])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
def get_top_quotes():
|
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 = [{
|
quote_list = [{
|
||||||
'id': quote.id,
|
'id': quote.id,
|
||||||
'text': quote.text,
|
'text': quote.text,
|
||||||
'votes': quote.votes,
|
'votes': quote.votes
|
||||||
'date': quote.date.strftime('%Y-%m-%d')
|
|
||||||
} for quote in top_quotes]
|
} 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'])
|
@app.route('/api/search', methods=['GET'])
|
||||||
|
@limiter.limit("40 per minute")
|
||||||
def search_quotes():
|
def search_quotes():
|
||||||
query = request.args.get('q', '').strip()
|
query = request.args.get('q', '').strip()
|
||||||
if not query:
|
if not query:
|
||||||
return jsonify({"error": "No search term provided"}), 400
|
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
|
page = request.args.get('page', 1, type=int)
|
||||||
if not quotes:
|
per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 per page
|
||||||
return jsonify({"error": "No quotes found for search term"}), 404
|
|
||||||
|
# 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 = [{
|
quote_list = [{
|
||||||
'id': quote.id,
|
'id': quote.id,
|
||||||
'text': quote.text,
|
'text': quote.text,
|
||||||
'votes': quote.votes,
|
'votes': quote.votes
|
||||||
'date': quote.date.strftime('%Y-%m-%d')
|
} for quote in quotes.items]
|
||||||
} for quote in quotes]
|
|
||||||
|
|
||||||
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'])
|
@app.route('/api/submit', methods=['POST'])
|
||||||
@limiter.limit("5 per minute") # Rate limiting to prevent abuse
|
@limiter.limit("5 per minute")
|
||||||
def submit_quote():
|
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
|
# Create tables if they don't exist
|
||||||
if not data or not data.get('text'):
|
with app.app_context():
|
||||||
return jsonify({"error": "Quote text is required"}), 400
|
db.create_all()
|
||||||
|
|
||||||
quote_text = data.get('text').strip()
|
# For Gunicorn deployment
|
||||||
|
|
||||||
# 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
|
|
||||||
if __name__ == '__main__':
|
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-SQLAlchemy==3.0.5
|
||||||
Flask-Limiter==2.4
|
Flask-Limiter==2.4
|
||||||
Flask-CORS==3.0.10
|
Flask-CORS==3.0.10
|
||||||
|
Flask-WTF==1.2.1
|
||||||
argon2-cffi==21.3.0
|
argon2-cffi==21.3.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
/* Global Styles */
|
|
||||||
body {
|
|
||||||
background-color: #ffffff;
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
transition: background-color 0.3s, color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Links */
|
|
||||||
a {
|
|
||||||
color: #c08000;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited, a:link, a:hover {
|
|
||||||
color: #c08000;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table and Layout */
|
|
||||||
table {
|
|
||||||
width: 80%;
|
|
||||||
margin: 20px auto;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Headers */
|
|
||||||
h2 {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #c08000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text Classes */
|
|
||||||
.smalltext {
|
|
||||||
font-size: 10px;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bodytext {
|
|
||||||
line-height: 21px;
|
|
||||||
font-size: smaller;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footertext {
|
|
||||||
font-size: smaller;
|
|
||||||
color: #000000;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toplinks {
|
|
||||||
font-size: smaller;
|
|
||||||
color: #000000;
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topnum {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input Fields */
|
|
||||||
input.text, textarea, select {
|
|
||||||
color: #000000;
|
|
||||||
background-color: #ffffff;
|
|
||||||
padding: 5px;
|
|
||||||
border: 1px solid #c08000;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.3s, color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.button {
|
|
||||||
background-color: #c08000;
|
|
||||||
color: #000000;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.button:hover {
|
|
||||||
background-color: #a06500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quote Styling */
|
|
||||||
.qt {
|
|
||||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-top: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 8pt;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote {
|
|
||||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
|
||||||
font-size: smaller;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Borders for Quotes and Other Content */
|
|
||||||
td.quote-box {
|
|
||||||
border: 1px solid #c08000;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: smaller;
|
|
||||||
background-color: #c08000;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
#pagination {
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pagination a {
|
|
||||||
color: #c08000;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border: 1px solid #c08000;
|
|
||||||
margin: 0 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.3s, color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pagination a:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quote List */
|
|
||||||
.quote-list {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-item {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-id {
|
|
||||||
color: #c08000;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-id:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark Mode */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body {
|
|
||||||
background-color: #121212;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bodytext {
|
|
||||||
line-height: 21px;
|
|
||||||
font-size: smaller;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
table, td, input.text, textarea, select {
|
|
||||||
background-color: #01002c11;
|
|
||||||
color: #ffffff;
|
|
||||||
border-color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.button {
|
|
||||||
background-color: #ffa500;
|
|
||||||
color: #00000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.button:hover {
|
|
||||||
background-color: #ff8c00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footertext, .toplinks, h2 {
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.quote-box {
|
|
||||||
border-color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
background-color: #ffa500;
|
|
||||||
color: #121212;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pagination a {
|
|
||||||
border-color: #ffa500;
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pagination a:hover {
|
|
||||||
background-color: #333333;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-id {
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
524
static/styles.css
Normal file
524
static/styles.css
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
/* Global Styles */
|
||||||
|
body {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: #c08000;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited, a:link, a:hover {
|
||||||
|
color: #c08000;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table and Layout */
|
||||||
|
table {
|
||||||
|
width: 80%;
|
||||||
|
margin: 20px auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers */
|
||||||
|
h2 {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #c08000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Classes */
|
||||||
|
.smalltext {
|
||||||
|
font-size: 10px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodytext {
|
||||||
|
line-height: 21px;
|
||||||
|
font-size: smaller;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footertext {
|
||||||
|
font-size: smaller;
|
||||||
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toplinks {
|
||||||
|
font-size: smaller;
|
||||||
|
color: #000000;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnum {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Fields */
|
||||||
|
input.text, textarea, select {
|
||||||
|
color: #000000;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #c08000;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.button {
|
||||||
|
background-color: #c08000;
|
||||||
|
color: #000000;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.button:hover {
|
||||||
|
background-color: #a06500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote Styling */
|
||||||
|
.qt {
|
||||||
|
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||||
|
font-size: 10pt;
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
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 {
|
||||||
|
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||||
|
font-size: smaller;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Borders for Quotes and Other Content */
|
||||||
|
td.quote-box {
|
||||||
|
border: 1px solid #c08000;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: smaller;
|
||||||
|
background-color: #c08000;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
#pagination {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pagination a {
|
||||||
|
color: #c08000;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #c08000;
|
||||||
|
margin: 0 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pagination a:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote List */
|
||||||
|
.quote-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-id {
|
||||||
|
color: #c08000;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-id:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation links - stack on small screens */
|
||||||
|
.toplinks {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote buttons - make them touch-friendly */
|
||||||
|
.qa {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 2px;
|
||||||
|
min-width: 35px;
|
||||||
|
font-size: 16px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote text - ensure readability */
|
||||||
|
.qt {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote header - adjust spacing */
|
||||||
|
.quote {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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[type="submit"], button {
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 5px;
|
||||||
|
min-height: 44px; /* iOS touch target */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination - mobile friendly */
|
||||||
|
#pagination {
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pagination a {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ModApp table - horizontal scroll for wide tables */
|
||||||
|
.modapp-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modapp-table-container table {
|
||||||
|
min-width: 600px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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();
|
||||||
|
}
|
||||||
@@ -5,7 +5,19 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Browse Quotes</title>
|
<title>ircquotes: Browse Quotes</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
@@ -33,8 +45,9 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -48,12 +61,16 @@
|
|||||||
{% for quote in quotes.items %}
|
{% for quote in quotes.items %}
|
||||||
<p class="quote">
|
<p class="quote">
|
||||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
<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="#" onclick="return vote({{ quote.id }}, "upvote", this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||||
<a href="/vote/{{ quote.id }}/downvote?page={{ quotes.page }}" class="qa">-</a>
|
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||||
<a href="/flag/{{ quote.id }}" class="qa"></a>
|
<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>
|
||||||
<p class="qt">{{ quote.text }}</p>
|
<p class="qt">{{ quote.text|e }}</p>
|
||||||
<hr>
|
<hr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: FAQ</title>
|
<title>ircquotes: FAQ</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
|
|
||||||
@@ -29,8 +40,9 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -5,7 +5,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Home</title>
|
<title>ircquotes: Home</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
@@ -33,8 +44,9 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Admin Login</title>
|
<title>ircquotes: Admin Login</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
|
|
||||||
@@ -31,8 +42,9 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -42,6 +54,7 @@
|
|||||||
<center>
|
<center>
|
||||||
<h2>Admin Login</h2>
|
<h2>Admin Login</h2>
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Username:</td>
|
<td>Username:</td>
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Admin Panel</title>
|
<title>ircquotes: Admin Panel</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
|
|
||||||
@@ -29,6 +40,7 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">Modapp</a>
|
<a href="/modapp">Modapp</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -45,29 +57,58 @@
|
|||||||
<option value="pending" {% if filter_status == 'pending' %}selected{% endif %}>Pending</option>
|
<option value="pending" {% if filter_status == 'pending' %}selected{% endif %}>Pending</option>
|
||||||
<option value="approved" {% if filter_status == 'approved' %}selected{% endif %}>Approved</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="rejected" {% if filter_status == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||||
|
<option value="flagged" {% if filter_status == 'flagged' %}selected{% endif %}>Flagged</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="submit" value="Apply Filter">
|
<input type="submit" value="Apply Filter">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if quotes.items %}
|
{% 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 -->
|
<!-- Table for Quotes -->
|
||||||
|
<div class="modapp-table-container">
|
||||||
<table border="1" cellpadding="5" cellspacing="0" width="100%">
|
<table border="1" cellpadding="5" cellspacing="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>Select</th>
|
||||||
<th>Quote ID</th>
|
<th>Quote ID</th>
|
||||||
<th>Quote</th>
|
<th>Quote</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Submitted At</th>
|
<th>Flags</th>
|
||||||
<th>IP Address</th>
|
<th class="mobile-hide">Submitted At</th>
|
||||||
<th>User Agent</th>
|
<th class="mobile-hide">IP Address</th>
|
||||||
|
<th class="mobile-hide">User Agent</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Loop through quotes -->
|
<!-- Loop through quotes -->
|
||||||
{% for quote in quotes.items %}
|
{% for quote in quotes.items %}
|
||||||
<tr style="background-color:
|
<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.id }}</td>
|
||||||
<td>{{ quote.text }}</td>
|
<td>{{ quote.text|e }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
<!-- Normal status display -->
|
||||||
{% if quote.status == 0 %}
|
{% if quote.status == 0 %}
|
||||||
Pending
|
Pending
|
||||||
{% elif quote.status == 1 %}
|
{% elif quote.status == 1 %}
|
||||||
@@ -75,18 +116,71 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
Rejected
|
Rejected
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
|
{% 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="/approve/{{ quote.id }}">Approve</a> |
|
||||||
<a href="/reject/{{ quote.id }}">Reject</a> |
|
<a href="/reject/{{ quote.id }}">Reject</a> |
|
||||||
<a href="/delete/{{ quote.id }}">Delete</a>
|
<a href="/delete/{{ quote.id }}">Delete</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</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 -->
|
<!-- Pagination Links -->
|
||||||
<div id="pagination">
|
<div id="pagination">
|
||||||
@@ -118,7 +212,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="footertext" align="left"> </td>
|
<td class="footertext" align="left"> </td>
|
||||||
<td class="footertext" align="right">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -5,7 +5,19 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Quote #{{ quote.id }}</title>
|
<title>ircquotes: Quote #{{ quote.id }}</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
@@ -33,8 +45,9 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -47,13 +60,17 @@
|
|||||||
<td valign="top">
|
<td valign="top">
|
||||||
<p class="quote">
|
<p class="quote">
|
||||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
<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="#" onclick="return vote({{ quote.id }}, 'upvote', this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||||
<a href="/flag/{{ quote.id }}" class="qa">[X]</a>
|
<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>
|
||||||
|
|
||||||
<p class="qt">{{ quote.text }}</p>
|
<p class="qt">{{ quote.text|e }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td valign="top"></td>
|
<td valign="top"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -78,7 +95,6 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<font size="-1">
|
<font size="-1">
|
||||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
|
||||||
© ircquotes 2024, All Rights Reserved.
|
© ircquotes 2024, All Rights Reserved.
|
||||||
</font>
|
</font>
|
||||||
</center>
|
</center>
|
||||||
|
|||||||
@@ -5,7 +5,19 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Random Quote</title>
|
<title>ircquotes: Random Quote</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
@@ -30,8 +42,9 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -43,12 +56,16 @@
|
|||||||
<td valign="top">
|
<td valign="top">
|
||||||
<p class="quote">
|
<p class="quote">
|
||||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
<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="#" onclick="return vote({{ quote.id }}, 'upvote', this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||||
<a href="/flag/{{ quote.id }}" class="qa">[X]</a>
|
<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>
|
||||||
<p class="qt">{{ quote.text }}</p>
|
<p class="qt">{{ quote.text|e }}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -77,6 +94,7 @@
|
|||||||
© ircquotes 2024, All Rights Reserved.
|
© ircquotes 2024, All Rights Reserved.
|
||||||
</font>
|
</font>
|
||||||
</center>
|
</center>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,8 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Search</title>
|
<title>ircquotes: Search & Read</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||||
|
|
||||||
@@ -16,7 +28,7 @@
|
|||||||
<font size="+1"><b><i>ircquotes</i></b></font>
|
<font size="+1"><b><i>ircquotes</i></b></font>
|
||||||
</td>
|
</td>
|
||||||
<td bgcolor="#c08000" align="right">
|
<td bgcolor="#c08000" align="right">
|
||||||
<font face="arial" size="+1"><b>Search Quotes</b></font>
|
<font face="arial" size="+1"><b>Search & Read Quotes</b></font>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -31,49 +43,69 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</center>
|
</center>
|
||||||
|
|
||||||
<!-- Search Form -->
|
<!-- Search Forms -->
|
||||||
<center>
|
<center>
|
||||||
<h2>Search for Quotes</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>
|
|
||||||
|
|
||||||
<!-- Show Search Results only if there is a search query -->
|
|
||||||
{% if query %}
|
|
||||||
<h3>Search Results for "{{ query }}"</h3>
|
|
||||||
|
|
||||||
{% if quotes %}
|
|
||||||
<table cellpadding="0" cellspacing="3" width="80%">
|
<table cellpadding="0" cellspacing="3" width="80%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bodytext" width="100%" valign="top">
|
<td class="bodytext" width="100%" valign="top">
|
||||||
{% for quote in quotes %}
|
<!-- Search for Quotes -->
|
||||||
<p class="quote">
|
<p><b>Search for Quotes by Keyword</b></p>
|
||||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote.">
|
<form action="/search" method="GET">
|
||||||
<b>#{{ quote.id }}</b>
|
<input type="text" name="q" value="{{ query or '' }}" placeholder="Enter search term" required>
|
||||||
</a>
|
<input type="submit" value="Search">
|
||||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
</form>
|
||||||
(<font color="green">{{ quote.votes }}</font>)
|
<br>
|
||||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
|
||||||
</p>
|
<!-- Read Quote by Number -->
|
||||||
<p class="qt">{{ quote.text }}</p>
|
<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>
|
<hr>
|
||||||
{% endfor %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
|
||||||
<h4>No quotes found for "{{ query }}".</h4>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</center>
|
</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 -->
|
<!-- Footer -->
|
||||||
<center>
|
<center>
|
||||||
@@ -85,17 +117,20 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="footertext" align="left"> </td>
|
<td class="footertext" align="left"> </td>
|
||||||
<td class="footertext" align="right">{{ approved_count }} quotes approved; {{ pending_count }} quotes pending</td>
|
<td class="footertext" align="right">{{ approved_count }} quotes approved</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<font size="-1">© ircquotes 2024, All Rights Reserved.</font>
|
<font size="-1">
|
||||||
|
<a href="#">Hosted by YourHostingProvider</a><br>
|
||||||
|
© ircquotes 2024, All Rights Reserved.
|
||||||
|
</font>
|
||||||
</center>
|
</center>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ircquotes: Add a Quote</title>
|
<title>ircquotes: Add a Quote</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<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>
|
</head>
|
||||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.newquote.focus();">
|
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.newquote.focus();">
|
||||||
<center>
|
<center>
|
||||||
@@ -32,14 +43,16 @@
|
|||||||
<a href="/submit">Submit</a> /
|
<a href="/submit">Submit</a> /
|
||||||
<a href="/browse">Browse</a> /
|
<a href="/browse">Browse</a> /
|
||||||
<a href="/modapp">ModApp</a> /
|
<a href="/modapp">ModApp</a> /
|
||||||
<a href="/search">Search</a>
|
<a href="/search">Search</a> /
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
|
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Add a Quote Form -->
|
<!-- Add a Quote Form -->
|
||||||
<form action="/submit" name="add" method="POST">
|
<form action="/submit" name="add" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<table cellpadding="2" cellspacing="0" width="60%">
|
<table cellpadding="2" cellspacing="0" width="60%">
|
||||||
<tr>
|
<tr>
|
||||||
<td><textarea cols="100%" rows="10" name="quote" class="text"></textarea></td>
|
<td><textarea cols="100%" rows="10" name="quote" class="text"></textarea></td>
|
||||||
@@ -78,7 +91,6 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<font size="-1">
|
<font size="-1">
|
||||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
|
||||||
© ircquotes 2024, All Rights Reserved.
|
© ircquotes 2024, All Rights Reserved.
|
||||||
</font>
|
</font>
|
||||||
</center>
|
</center>
|
||||||
|
|||||||
Reference in New Issue
Block a user