Major refactor: Fix SQLite concurrency, remove rate limiting, simplify architecture
- Switch to single Gunicorn worker to eliminate SQLite database locking issues - Remove Flask-Limiter and all rate limiting complexity - Remove Cloudflare proxy setup and dependencies - Simplify configuration and remove unnecessary features - Update all templates and static files for streamlined operation - Clean up old files and documentation - Restore stable database from backup - System now runs fast and reliably without database locks
This commit is contained in:
@@ -1,146 +0,0 @@
|
||||
# Cloudflare + nginx Setup Guide for ircquotes
|
||||
|
||||
## Overview
|
||||
This setup ensures that your ircquotes application can see real client IP addresses even when behind:
|
||||
1. **Cloudflare** (CDN/Proxy)
|
||||
2. **nginx** (Reverse Proxy)
|
||||
3. **Gunicorn** (WSGI Server)
|
||||
|
||||
## Architecture
|
||||
```
|
||||
Client → Cloudflare → nginx → Gunicorn → ircquotes
|
||||
```
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Cloudflare Configuration
|
||||
|
||||
#### Enable Proxy (Orange Cloud)
|
||||
- Set your DNS record to "Proxied" (orange cloud icon)
|
||||
- This routes traffic through Cloudflare's edge servers
|
||||
|
||||
#### Recommended Cloudflare Settings:
|
||||
- **SSL/TLS**: Full (Strict) if you have SSL on origin
|
||||
- **Security Level**: Medium
|
||||
- **Bot Fight Mode**: Enabled
|
||||
- **Rate Limiting**: Configure as needed
|
||||
- **Page Rules**: Optional caching rules
|
||||
|
||||
#### Important Headers:
|
||||
Cloudflare automatically adds these headers:
|
||||
- `CF-Connecting-IP`: Real client IP address
|
||||
- `CF-Ray`: Request identifier
|
||||
- `CF-Visitor`: Visitor information
|
||||
|
||||
### 2. nginx Configuration
|
||||
|
||||
Copy the provided `nginx-ircquotes.conf` to your nginx sites:
|
||||
|
||||
```bash
|
||||
sudo cp nginx-ircquotes.conf /etc/nginx/sites-available/ircquotes
|
||||
sudo ln -s /etc/nginx/sites-available/ircquotes /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
**Key nginx features:**
|
||||
- ✅ Cloudflare IP range restoration
|
||||
- ✅ Real IP detection via CF-Connecting-IP
|
||||
- ✅ Additional rate limiting layer
|
||||
- ✅ Security headers
|
||||
- ✅ Gzip compression
|
||||
- ✅ Static file optimization
|
||||
|
||||
### 3. Application Configuration
|
||||
|
||||
The ircquotes app is already configured to:
|
||||
- ✅ Use `CF-Connecting-IP` header (Cloudflare's real IP)
|
||||
- ✅ Fall back to `X-Forwarded-For` and `X-Real-IP`
|
||||
- ✅ Handle 2-proxy setup (Cloudflare + nginx)
|
||||
- ✅ Rate limit by real client IP
|
||||
|
||||
### 4. Verification
|
||||
|
||||
To verify real IPs are being detected:
|
||||
|
||||
1. **Check application logs**:
|
||||
```bash
|
||||
tail -f /var/log/ircquotes/access.log
|
||||
```
|
||||
|
||||
2. **Test from different locations**:
|
||||
- Visit your site from different networks
|
||||
- Check admin panel for real IPs in quote submissions
|
||||
- Verify rate limiting works per real IP
|
||||
|
||||
3. **Debug headers** (temporary debug route):
|
||||
```python
|
||||
@app.route('/debug-headers')
|
||||
def debug_headers():
|
||||
return jsonify({
|
||||
'real_ip': get_real_ip(),
|
||||
'cf_connecting_ip': request.headers.get('CF-Connecting-IP'),
|
||||
'x_forwarded_for': request.headers.get('X-Forwarded-For'),
|
||||
'x_real_ip': request.headers.get('X-Real-IP'),
|
||||
'remote_addr': request.remote_addr
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Security Considerations
|
||||
|
||||
#### Cloudflare Settings:
|
||||
- Enable **DDoS Protection**
|
||||
- Configure **WAF Rules** for your application
|
||||
- Set up **Rate Limiting** at Cloudflare level
|
||||
- Enable **Bot Management** if available
|
||||
|
||||
#### nginx Security:
|
||||
- Keep Cloudflare IP ranges updated
|
||||
- Monitor for suspicious patterns
|
||||
- Implement additional rate limiting
|
||||
- Regular security updates
|
||||
|
||||
#### Application Security:
|
||||
- All security features already implemented
|
||||
- Rate limiting per real IP
|
||||
- CSRF protection enabled
|
||||
- Input validation active
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### IPs showing as 127.0.0.1:
|
||||
1. Check nginx is passing headers correctly
|
||||
2. Verify Cloudflare IP ranges in nginx config
|
||||
3. Ensure ProxyFix is configured for 2 proxies
|
||||
4. Check `CF-Connecting-IP` header presence
|
||||
|
||||
### Rate limiting not working:
|
||||
1. Verify real IP detection is working
|
||||
2. Check rate limiting configuration
|
||||
3. Monitor nginx and application logs
|
||||
4. Test with different source IPs
|
||||
|
||||
### Performance issues:
|
||||
1. Enable nginx caching for static files
|
||||
2. Configure Cloudflare caching rules
|
||||
3. Monitor Gunicorn worker count
|
||||
4. Check database connection pooling
|
||||
|
||||
## Monitoring
|
||||
|
||||
Recommended monitoring:
|
||||
- **Application logs**: Real IP addresses in logs
|
||||
- **nginx access logs**: Request patterns
|
||||
- **Cloudflare Analytics**: Traffic patterns
|
||||
- **Rate limiting metrics**: Blocked vs allowed requests
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Cloudflare proxy enabled (orange cloud)
|
||||
- [ ] nginx configuration deployed
|
||||
- [ ] Real IP detection working
|
||||
- [ ] Rate limiting functional
|
||||
- [ ] Security headers present
|
||||
- [ ] SSL/TLS configured
|
||||
- [ ] Monitoring in place
|
||||
- [ ] Backup and recovery tested
|
||||
148
CONFIG_GUIDE.md
Normal file
148
CONFIG_GUIDE.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Configuration Guide
|
||||
|
||||
This guide explains how to configure the ircquotes application by editing `config.json` manually.
|
||||
|
||||
## Configuration File Structure
|
||||
|
||||
The `config.json` file is organized into sections:
|
||||
|
||||
### App Section
|
||||
```json
|
||||
"app": {
|
||||
"name": "ircquotes", // Application name
|
||||
"host": "127.0.0.1", // Host to bind to (use 0.0.0.0 for all interfaces)
|
||||
"port": 6969, // Port number to run on
|
||||
"debug": false // Enable debug mode (set to true for development)
|
||||
}
|
||||
```
|
||||
|
||||
### Gunicorn Section (Production Settings)
|
||||
```json
|
||||
"gunicorn": {
|
||||
"workers": 4, // Number of worker processes
|
||||
"timeout": 30, // Request timeout in seconds
|
||||
"keepalive": 5, // Keep-alive timeout
|
||||
"max_requests": 1000, // Max requests per worker before restart
|
||||
"preload": true // Preload application code
|
||||
}
|
||||
```
|
||||
|
||||
### Database Section
|
||||
```json
|
||||
"database": {
|
||||
"uri": "sqlite:///quotes.db?timeout=20", // Database connection string
|
||||
"pool_timeout": 20, // Connection pool timeout
|
||||
"pool_recycle": -1, // Connection recycle time (-1 = disabled)
|
||||
"pool_pre_ping": true // Test connections before use
|
||||
}
|
||||
```
|
||||
|
||||
### Security Section
|
||||
```json
|
||||
"security": {
|
||||
"csrf_enabled": true, // Enable CSRF protection
|
||||
"csrf_time_limit": null, // CSRF token time limit (null = no limit)
|
||||
"session_cookie_secure": false, // Require HTTPS for session cookies
|
||||
"session_cookie_httponly": true, // Prevent JavaScript access to session cookies
|
||||
"session_cookie_samesite": "Lax", // SameSite policy for session cookies
|
||||
"security_headers": {
|
||||
"x_content_type_options": "nosniff",
|
||||
"x_frame_options": "DENY",
|
||||
"x_xss_protection": "1; mode=block",
|
||||
"strict_transport_security": "max-age=31536000; includeSubDomains",
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Section
|
||||
```json
|
||||
"admin": {
|
||||
"username": "admin", // Admin username
|
||||
"password_hash": "..." // Argon2 password hash (use generate_password.py)
|
||||
}
|
||||
```
|
||||
|
||||
### Quotes Section
|
||||
```json
|
||||
"quotes": {
|
||||
"min_length": 1, // Minimum quote length in characters
|
||||
"max_length": 5000, // Maximum quote length in characters
|
||||
"per_page": 25, // Quotes displayed per page
|
||||
"auto_approve": false, // Automatically approve new quotes
|
||||
"allow_html": false // Allow HTML in quotes (not recommended)
|
||||
}
|
||||
```
|
||||
|
||||
### Features Section
|
||||
```json
|
||||
"features": {
|
||||
"voting_enabled": true, // Enable voting on quotes
|
||||
"flagging_enabled": true, // Enable flagging inappropriate quotes
|
||||
"copy_quotes_enabled": true, // Enable copy-to-clipboard feature
|
||||
"dark_mode_enabled": true, // Enable dark mode toggle
|
||||
"api_enabled": true, // Enable JSON API endpoints
|
||||
"bulk_moderation_enabled": true // Enable bulk moderation actions
|
||||
}
|
||||
```
|
||||
|
||||
### Logging Section
|
||||
```json
|
||||
"logging": {
|
||||
"level": "DEBUG", // Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
"format": "%(asctime)s [%(levelname)s] %(message)s" // Log message format
|
||||
}
|
||||
```
|
||||
|
||||
## Common Configuration Tasks
|
||||
|
||||
### Change Admin Password
|
||||
1. Run: `python generate_password.py`
|
||||
2. Edit `config.json` and update `admin.password_hash` with the generated hash
|
||||
3. Restart the application
|
||||
|
||||
### Change Port
|
||||
Edit the `app.port` value in `config.json`:
|
||||
```json
|
||||
"app": {
|
||||
"port": 8080
|
||||
}
|
||||
```
|
||||
|
||||
### Adjust Quote Limits
|
||||
Edit the `quotes` section:
|
||||
```json
|
||||
"quotes": {
|
||||
"min_length": 10,
|
||||
"max_length": 2000,
|
||||
"per_page": 50
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Features
|
||||
Set feature flags to `false`:
|
||||
```json
|
||||
"features": {
|
||||
"voting_enabled": false,
|
||||
"flagging_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### Adjust Rate Limits
|
||||
Modify the `rate_limiting.endpoints` section:
|
||||
```json
|
||||
"rate_limiting": {
|
||||
"endpoints": {
|
||||
"submit": "10 per minute",
|
||||
"vote": "120 per minute"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always restart the application after making configuration changes
|
||||
- Use valid JSON syntax (no trailing commas, proper quotes)
|
||||
- Test configuration changes in a development environment first
|
||||
- Keep backups of your working configuration
|
||||
- Use `python generate_password.py` to create secure password hashes
|
||||
@@ -13,24 +13,30 @@ All application settings are now centralized in `config.json`. You can easily mo
|
||||
- **Admin credentials**
|
||||
- **Feature toggles**
|
||||
|
||||
### Viewing Current Configuration
|
||||
### Configuration Management
|
||||
All configuration is done by editing `config.json` directly. This file contains all application settings organized in sections:
|
||||
|
||||
- **app**: Basic application settings (name, host, port, debug)
|
||||
- **database**: Database connection settings
|
||||
- **security**: Security headers, CSRF, proxy settings
|
||||
- **rate_limiting**: Rate limiting configuration for different endpoints
|
||||
- **admin**: Admin username and password hash
|
||||
- **quotes**: Quote submission settings (length limits, pagination)
|
||||
- **features**: Feature toggles (voting, flagging, dark mode, etc.)
|
||||
- **logging**: Logging configuration
|
||||
|
||||
### Example Configuration Changes
|
||||
```bash
|
||||
python config_manager.py
|
||||
```
|
||||
# Edit config.json in any text editor
|
||||
nano config.json
|
||||
|
||||
### Updating Configuration
|
||||
```bash
|
||||
# Change port
|
||||
python config_manager.py app.port 8080
|
||||
# Example changes:
|
||||
# - Change port: "port": 8080 in the "app" section
|
||||
# - Change quotes per page: "per_page": 50 in the "quotes" section
|
||||
# - Disable CSRF: "csrf_enabled": false in the "security" section
|
||||
# - Change rate limits: "login": "10 per minute" in rate_limiting.endpoints
|
||||
|
||||
# Change quotes per page
|
||||
python config_manager.py quotes.per_page 50
|
||||
|
||||
# Disable CSRF (not recommended)
|
||||
python config_manager.py security.csrf_enabled false
|
||||
|
||||
# Change rate limits
|
||||
python config_manager.py rate_limiting.endpoints.login "10 per minute"
|
||||
# After making changes, restart the application
|
||||
```
|
||||
|
||||
## Running with Gunicorn (Production)
|
||||
|
||||
30
config.json
30
config.json
@@ -3,10 +3,10 @@
|
||||
"name": "ircquotes",
|
||||
"host": "127.0.0.1",
|
||||
"port": 6969,
|
||||
"debug": false
|
||||
"debug": true
|
||||
},
|
||||
"gunicorn": {
|
||||
"workers": 4,
|
||||
"workers": 1,
|
||||
"timeout": 30,
|
||||
"keepalive": 5,
|
||||
"max_requests": 1000,
|
||||
@@ -24,12 +24,6 @@
|
||||
"session_cookie_secure": false,
|
||||
"session_cookie_httponly": true,
|
||||
"session_cookie_samesite": "Lax",
|
||||
"proxy_setup": {
|
||||
"behind_cloudflare": true,
|
||||
"behind_nginx": true,
|
||||
"trusted_proxies": 2,
|
||||
"cloudflare_ip_header": "CF-Connecting-IP"
|
||||
},
|
||||
"security_headers": {
|
||||
"x_content_type_options": "nosniff",
|
||||
"x_frame_options": "DENY",
|
||||
@@ -38,25 +32,9 @@
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
}
|
||||
},
|
||||
"rate_limiting": {
|
||||
"enabled": true,
|
||||
"global_limit": "1000 per hour",
|
||||
"endpoints": {
|
||||
"login": "5 per minute",
|
||||
"submit": "5 per minute",
|
||||
"modapp": "20 per minute",
|
||||
"bulk_actions": "10 per minute",
|
||||
"approve": "30 per minute",
|
||||
"reject": "30 per minute",
|
||||
"delete": "20 per minute",
|
||||
"vote": "60 per minute",
|
||||
"flag": "10 per minute",
|
||||
"search": "30 per minute"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"username": "admin",
|
||||
"password_hash": "$argon2i$v=19$m=65536,t=4,p=1$cWZDc1pQaUJLTUJoaVI4cw$kn8XKz6AEZi8ebXfyyZuzommSypliVFrsGqzOyUEIHA"
|
||||
"username": "ComputerTech",
|
||||
"password_hash": "$argon2id$v=19$m=65536,t=3,p=4$cIPRCJrjS1DwjaFov5G+BQ$yundbpf2i1jBrKsj96ra7wTNmVZ56SJR25XX4jp2yR8"
|
||||
},
|
||||
"quotes": {
|
||||
"min_length": 1,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration management utility for ircquotes.
|
||||
Allows you to view and update configuration values easily.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from config_loader import config
|
||||
|
||||
def show_config():
|
||||
"""Display current configuration."""
|
||||
print("Current Configuration:")
|
||||
print("=" * 50)
|
||||
print(f"App Name: {config.app_name}")
|
||||
print(f"Host: {config.app_host}")
|
||||
print(f"Port: {config.app_port}")
|
||||
print(f"Debug Mode: {config.debug_mode}")
|
||||
print(f"Database URI: {config.database_uri}")
|
||||
print(f"CSRF Enabled: {config.csrf_enabled}")
|
||||
print(f"Rate Limiting: {config.rate_limiting_enabled}")
|
||||
print(f"Quotes per Page: {config.quotes_per_page}")
|
||||
print(f"Min Quote Length: {config.min_quote_length}")
|
||||
print(f"Max Quote Length: {config.max_quote_length}")
|
||||
print(f"Admin Username: {config.admin_username}")
|
||||
print(f"Logging Level: {config.logging_level}")
|
||||
print("=" * 50)
|
||||
|
||||
def update_config(key, value):
|
||||
"""Update a configuration value."""
|
||||
try:
|
||||
# Load current config
|
||||
with open('config.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Navigate to the key using dot notation
|
||||
keys = key.split('.')
|
||||
current = data
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
|
||||
# Convert value to appropriate type
|
||||
if value.lower() == 'true':
|
||||
value = True
|
||||
elif value.lower() == 'false':
|
||||
value = False
|
||||
elif value.isdigit():
|
||||
value = int(value)
|
||||
elif value.replace('.', '').isdigit():
|
||||
value = float(value)
|
||||
|
||||
# Set the value
|
||||
current[keys[-1]] = value
|
||||
|
||||
# Save back to file
|
||||
with open('config.json', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
print(f"Updated {key} = {value}")
|
||||
print("Restart the application for changes to take effect.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating configuration: {e}")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) == 1:
|
||||
show_config()
|
||||
elif len(sys.argv) == 3:
|
||||
key, value = sys.argv[1], sys.argv[2]
|
||||
update_config(key, value)
|
||||
else:
|
||||
print("Usage:")
|
||||
print(" python config_manager.py # Show current config")
|
||||
print(" python config_manager.py <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()
|
||||
77
create_fresh_db.py
Normal file
77
create_fresh_db.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a fresh quotes database with test data"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
# Remove existing database files
|
||||
db_files = ['instance/quotes.db', 'instance/quotes.db-shm', 'instance/quotes.db-wal']
|
||||
for db_file in db_files:
|
||||
if os.path.exists(db_file):
|
||||
os.remove(db_file)
|
||||
print(f"Removed {db_file}")
|
||||
|
||||
# Create fresh database
|
||||
conn = sqlite3.connect('instance/quotes.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create the quote table with proper schema
|
||||
cursor.execute("""
|
||||
CREATE TABLE quote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
votes INTEGER DEFAULT 0,
|
||||
date DATETIME,
|
||||
status INTEGER DEFAULT 0,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
submitted_at DATETIME,
|
||||
flag_count INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for performance
|
||||
cursor.execute("CREATE INDEX idx_status_id ON quote(status, id)")
|
||||
cursor.execute("CREATE INDEX idx_flag_count_id ON quote(flag_count, id)")
|
||||
|
||||
# Insert test data
|
||||
test_quotes = [
|
||||
("This is a pending quote for testing moderation", 0, 0), # pending
|
||||
("This is an approved quote that should appear in browse", 5, 1), # approved
|
||||
("Another approved quote with positive votes", 12, 1), # approved
|
||||
("A rejected quote that was not good enough", -2, 2), # rejected
|
||||
("Another pending quote to test approve/reject", 0, 0), # pending
|
||||
("Third pending quote for comprehensive testing", 0, 0), # pending
|
||||
]
|
||||
|
||||
current_time = datetime.now()
|
||||
|
||||
for i, (text, votes, status) in enumerate(test_quotes, 1):
|
||||
cursor.execute("""
|
||||
INSERT INTO quote (text, votes, status, submitted_at, ip_address, user_agent, flag_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (text, votes, status, current_time, '127.0.0.1', 'Test Script', 0))
|
||||
|
||||
# Set WAL mode for better concurrency
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=1000")
|
||||
|
||||
# Commit and close
|
||||
conn.commit()
|
||||
|
||||
# Verify the data
|
||||
cursor.execute("SELECT id, text, status FROM quote ORDER BY id")
|
||||
results = cursor.fetchall()
|
||||
|
||||
print("\nCreated fresh database with test quotes:")
|
||||
print("ID | Status | Text")
|
||||
print("-" * 50)
|
||||
for quote_id, text, status in results:
|
||||
status_name = {0: "PENDING", 1: "APPROVED", 2: "REJECTED"}[status]
|
||||
print(f"{quote_id:2d} | {status_name:8s} | {text[:40]}...")
|
||||
|
||||
conn.close()
|
||||
print(f"\nFresh database created successfully!")
|
||||
print(f"Total quotes: {len(test_quotes)}")
|
||||
print("3 pending, 2 approved, 1 rejected")
|
||||
@@ -29,8 +29,12 @@ def generate_password_hash():
|
||||
|
||||
print("\nGenerated password hash:")
|
||||
print(hash_value)
|
||||
print("\nTo set this as admin password, run:")
|
||||
print(f'python config_manager.py admin.password_hash "{hash_value}"')
|
||||
print("\nTo set this as admin password:")
|
||||
print("1. Open config.json in a text editor")
|
||||
print("2. Find the 'admin' section")
|
||||
print("3. Replace the 'password_hash' value with:")
|
||||
print(f' "{hash_value}"')
|
||||
print("4. Save the file and restart the application")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_password_hash()
|
||||
@@ -1,59 +0,0 @@
|
||||
# Gunicorn configuration file for ircquotes
|
||||
import multiprocessing
|
||||
import json
|
||||
import os
|
||||
|
||||
# Load configuration from config.json
|
||||
def load_app_config():
|
||||
config_file = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
# Fallback to defaults if config.json not found
|
||||
return {
|
||||
"app": {"host": "0.0.0.0", "port": 5050}
|
||||
}
|
||||
|
||||
app_config = load_app_config()
|
||||
|
||||
# Server socket - use config.json values
|
||||
host = app_config.get('app', {}).get('host', '0.0.0.0')
|
||||
port = app_config.get('app', {}).get('port', 5050)
|
||||
bind = f"{host}:{port}"
|
||||
backlog = 2048
|
||||
|
||||
# Worker processes - use config.json values
|
||||
workers = app_config.get('gunicorn', {}).get('workers', multiprocessing.cpu_count() * 2 + 1)
|
||||
worker_class = "sync"
|
||||
worker_connections = 1000
|
||||
timeout = app_config.get('gunicorn', {}).get('timeout', 30)
|
||||
keepalive = app_config.get('gunicorn', {}).get('keepalive', 5)
|
||||
|
||||
# Restart workers after this many requests, to help prevent memory leaks
|
||||
max_requests = app_config.get('gunicorn', {}).get('max_requests', 1000)
|
||||
max_requests_jitter = 100
|
||||
|
||||
# Preload app for better performance
|
||||
preload_app = app_config.get('gunicorn', {}).get('preload', True)
|
||||
|
||||
# Logging
|
||||
accesslog = "-" # Log to stdout
|
||||
errorlog = "-" # Log to stderr
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||
|
||||
# Process naming
|
||||
proc_name = 'ircquotes'
|
||||
|
||||
# Preload app for better performance
|
||||
preload_app = True
|
||||
|
||||
# Security
|
||||
limit_request_line = 4096
|
||||
limit_request_fields = 100
|
||||
limit_request_field_size = 8190
|
||||
|
||||
# SSL (uncomment and configure for HTTPS)
|
||||
# keyfile = '/path/to/keyfile'
|
||||
# certfile = '/path/to/certfile'
|
||||
Binary file not shown.
@@ -6,7 +6,8 @@ server {
|
||||
server_name your-domain.com; # Replace with your actual domain
|
||||
|
||||
# Cloudflare real IP restoration
|
||||
# Get latest Cloudflare IP ranges from: https://www.cloudflare.com/ips/
|
||||
# Updated Cloudflare IP ranges (as of September 2025)
|
||||
# IPv4 ranges
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
set_real_ip_from 103.21.244.0/22;
|
||||
set_real_ip_from 103.22.200.0/22;
|
||||
@@ -23,7 +24,7 @@ server {
|
||||
set_real_ip_from 172.64.0.0/13;
|
||||
set_real_ip_from 131.0.72.0/22;
|
||||
|
||||
# IPv6 ranges (optional)
|
||||
# IPv6 ranges
|
||||
set_real_ip_from 2400:cb00::/32;
|
||||
set_real_ip_from 2606:4700::/32;
|
||||
set_real_ip_from 2803:f800::/32;
|
||||
|
||||
83
production.py
Executable file
83
production.py
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Production launcher for ircquotes using Gunicorn
|
||||
Reads all configuration from config.json
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from config_loader import config
|
||||
|
||||
def main():
|
||||
"""Launch Gunicorn with settings from config.json"""
|
||||
|
||||
print("Starting ircquotes in production mode with Gunicorn...")
|
||||
|
||||
# Get configuration values from config.json
|
||||
host = config.app_host
|
||||
port = config.app_port
|
||||
workers = config.get('gunicorn.workers', 1) # Default to 1 to avoid SQLite locking
|
||||
timeout = config.get('gunicorn.timeout', 30)
|
||||
keepalive = config.get('gunicorn.keepalive', 5)
|
||||
max_requests = config.get('gunicorn.max_requests', 1000)
|
||||
preload = config.get('gunicorn.preload', True)
|
||||
|
||||
# Use virtual environment's gunicorn if available
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
venv_gunicorn = os.path.join(script_dir, '.venv', 'bin', 'gunicorn')
|
||||
|
||||
if os.path.exists(venv_gunicorn):
|
||||
gunicorn_cmd = venv_gunicorn
|
||||
print(f"Using virtual environment Gunicorn: {venv_gunicorn}")
|
||||
else:
|
||||
gunicorn_cmd = 'gunicorn'
|
||||
print("Using system Gunicorn")
|
||||
|
||||
# Build Gunicorn command with all config.json settings
|
||||
cmd = [
|
||||
gunicorn_cmd,
|
||||
'--bind', f'{host}:{port}',
|
||||
'--workers', str(workers),
|
||||
'--timeout', str(timeout),
|
||||
'--keep-alive', str(keepalive), # Fixed: --keep-alive not --keepalive
|
||||
'--max-requests', str(max_requests),
|
||||
'--max-requests-jitter', '100',
|
||||
'--access-logfile', '-', # Log to stdout
|
||||
'--error-logfile', '-', # Log to stderr
|
||||
'--log-level', 'info'
|
||||
]
|
||||
|
||||
# Add preload option if enabled
|
||||
if preload:
|
||||
cmd.append('--preload')
|
||||
|
||||
# Add the app module at the end
|
||||
cmd.append('app:app')
|
||||
|
||||
print(f"Configuration:")
|
||||
print(f" Host: {host}")
|
||||
print(f" Port: {port}")
|
||||
print(f" Workers: {workers}")
|
||||
print(f" Timeout: {timeout}s")
|
||||
print(f" Max Requests: {max_requests}")
|
||||
print(f" Preload: {preload}")
|
||||
print()
|
||||
print(f"Gunicorn command: {' '.join(cmd)}")
|
||||
print()
|
||||
|
||||
# Execute Gunicorn
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error starting Gunicorn: {e}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping Gunicorn...")
|
||||
sys.exit(0)
|
||||
except FileNotFoundError:
|
||||
print("Error: Gunicorn not found. Please install it with: pip install gunicorn")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,5 @@
|
||||
Flask==2.3.2
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
Flask-Limiter==2.4
|
||||
Flask-CORS==3.0.10
|
||||
Flask-WTF==1.2.1
|
||||
argon2-cffi==21.3.0
|
||||
|
||||
12
setup.sh
12
setup.sh
@@ -41,13 +41,13 @@ echo ""
|
||||
echo "Setup complete! You can now:"
|
||||
echo "1. Configure admin credentials:"
|
||||
echo " python generate_password.py"
|
||||
echo " python config_manager.py admin.username 'yourusername'"
|
||||
echo " python config_manager.py admin.password_hash 'generated_hash'"
|
||||
echo " # Then edit config.json and update admin.username and admin.password_hash"
|
||||
echo ""
|
||||
echo "2. Configure other settings:"
|
||||
echo " python config_manager.py app.port 6969"
|
||||
echo " python config_manager.py quotes.min_length 1"
|
||||
echo " python config_manager.py quotes.max_length 10000"
|
||||
echo "2. Configure other settings by editing config.json:"
|
||||
echo " # app.port - Change server port"
|
||||
echo " # quotes.min_length - Minimum quote length"
|
||||
echo " # quotes.max_length - Maximum quote length"
|
||||
echo " # security.csrf_enabled - Enable/disable CSRF protection"
|
||||
echo ""
|
||||
echo "3. Start the application:"
|
||||
echo " source .venv/bin/activate"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gunicorn launcher that reads configuration from config.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from config_loader import config
|
||||
|
||||
def main():
|
||||
"""Launch Gunicorn with settings from config.json"""
|
||||
|
||||
# Get configuration values
|
||||
host = config.app_host
|
||||
port = config.app_port
|
||||
workers = config.get('gunicorn.workers', 4)
|
||||
|
||||
# Build Gunicorn command
|
||||
cmd = [
|
||||
'gunicorn',
|
||||
'--bind', f'{host}:{port}',
|
||||
'--workers', str(workers),
|
||||
'--timeout', '30',
|
||||
'--keepalive', '5',
|
||||
'--max-requests', '1000',
|
||||
'--max-requests-jitter', '100',
|
||||
'--access-logfile', '-',
|
||||
'--error-logfile', '-',
|
||||
'--log-level', 'info',
|
||||
'--preload',
|
||||
'app:app'
|
||||
]
|
||||
|
||||
print(f"Starting Gunicorn on {host}:{port} with {workers} workers...")
|
||||
print(f"Command: {' '.join(cmd)}")
|
||||
|
||||
# Execute Gunicorn
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error starting Gunicorn: {e}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping Gunicorn...")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
189
static/modapp.js
Normal file
189
static/modapp.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* ModApp JavaScript - AJAX moderation actions without page refresh
|
||||
*/
|
||||
|
||||
// Handle individual moderation actions (approve, reject, delete, clear_flags)
|
||||
async function moderationAction(action, quoteId, element) {
|
||||
try {
|
||||
// Show loading state
|
||||
const originalText = element.textContent;
|
||||
element.textContent = 'Loading...';
|
||||
element.style.pointerEvents = 'none';
|
||||
|
||||
const response = await fetch(`/${action}/${quoteId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest' // Tell server this is AJAX
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Show success message briefly
|
||||
showMessage(result.message, 'success');
|
||||
|
||||
// Remove the quote from the current view or update its display
|
||||
const quoteRow = element.closest('tr');
|
||||
if (quoteRow) {
|
||||
// Fade out the quote
|
||||
quoteRow.style.transition = 'opacity 0.3s ease';
|
||||
quoteRow.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
quoteRow.remove();
|
||||
updateCounters();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
// Show error message
|
||||
showMessage(result.message || 'Action failed', 'error');
|
||||
// Restore original state
|
||||
element.textContent = originalText;
|
||||
element.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Moderation action failed:', error);
|
||||
showMessage('Network error. Please try again.', 'error');
|
||||
// Restore original state
|
||||
element.textContent = originalText;
|
||||
element.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
return false; // Prevent default link behavior
|
||||
}
|
||||
|
||||
// Show temporary message to user
|
||||
function showMessage(message, type = 'info') {
|
||||
// Remove any existing messages
|
||||
const existingMsg = document.getElementById('temp-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
|
||||
// Create new message element
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.id = 'temp-message';
|
||||
msgDiv.textContent = message;
|
||||
msgDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
transition: opacity 0.3s ease;
|
||||
${type === 'success' ? 'background-color: #28a745;' : ''}
|
||||
${type === 'error' ? 'background-color: #dc3545;' : ''}
|
||||
${type === 'info' ? 'background-color: #17a2b8;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(msgDiv);
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
msgDiv.style.opacity = '0';
|
||||
setTimeout(() => msgDiv.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Update quote counters (simplified - could be enhanced with actual counts)
|
||||
function updateCounters() {
|
||||
// This could be enhanced to fetch actual counts from server
|
||||
// For now, just indicate that counts may have changed
|
||||
const statusElements = document.querySelectorAll('.quote-status');
|
||||
statusElements.forEach(el => {
|
||||
el.style.opacity = '0.8';
|
||||
setTimeout(() => el.style.opacity = '1', 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle bulk actions form
|
||||
async function handleBulkAction(form, event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const action = formData.get('action');
|
||||
const quoteIds = formData.getAll('quote_ids');
|
||||
|
||||
if (quoteIds.length === 0) {
|
||||
showMessage('Please select at least one quote', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} ${quoteIds.length} quote(s)?`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/modapp/bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showMessage(result.message, 'success');
|
||||
|
||||
// Remove processed quotes from view
|
||||
quoteIds.forEach(quoteId => {
|
||||
const checkbox = document.querySelector(`input[value="${quoteId}"]`);
|
||||
if (checkbox) {
|
||||
const quoteRow = checkbox.closest('tr');
|
||||
if (quoteRow) {
|
||||
quoteRow.style.transition = 'opacity 0.3s ease';
|
||||
quoteRow.style.opacity = '0';
|
||||
setTimeout(() => quoteRow.remove(), 300);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
updateCounters();
|
||||
|
||||
} else {
|
||||
showMessage(result.message || 'Bulk action failed', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Bulk action failed:', error);
|
||||
showMessage('Network error. Please try again.', 'error');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Convert all moderation links to AJAX
|
||||
document.querySelectorAll('a[href^="/approve/"], a[href^="/reject/"], a[href^="/delete/"], a[href^="/clear_flags/"]').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const href = this.getAttribute('href');
|
||||
const parts = href.split('/');
|
||||
const action = parts[1]; // approve, reject, delete, clear_flags
|
||||
const quoteId = parts[2];
|
||||
|
||||
moderationAction(action, quoteId, this);
|
||||
});
|
||||
});
|
||||
|
||||
// Convert bulk form to AJAX
|
||||
const bulkForm = document.querySelector('form[action="/modapp/bulk"]');
|
||||
if (bulkForm) {
|
||||
bulkForm.addEventListener('submit', function(e) {
|
||||
handleBulkAction(this, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -116,6 +116,9 @@ input.button:hover {
|
||||
display: inline-block;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.qa:hover {
|
||||
@@ -129,6 +132,13 @@ input.button:hover {
|
||||
border: 1px inset #808080;
|
||||
}
|
||||
|
||||
/* Quote controls wrapper for better mobile layout */
|
||||
.quote-controls {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
font-size: smaller;
|
||||
@@ -187,10 +197,14 @@ footer {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.quote-id:hover {
|
||||
text-decoration: underline;
|
||||
background-color: rgba(192, 128, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Dark Mode Toggle Button */
|
||||
@@ -415,14 +429,17 @@ html.dark-theme font {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* Quote buttons - make them touch-friendly */
|
||||
/* Quote buttons - compact but touch-friendly */
|
||||
.qa {
|
||||
padding: 8px 12px;
|
||||
margin: 2px;
|
||||
min-width: 35px;
|
||||
font-size: 16px;
|
||||
padding: 6px 8px;
|
||||
margin: 1px 2px;
|
||||
min-width: 28px;
|
||||
min-height: 32px;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Quote text - ensure readability */
|
||||
@@ -430,40 +447,43 @@ html.dark-theme font {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Quote header - adjust spacing */
|
||||
/* Quote header - adjust spacing and make more compact */
|
||||
.quote {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Form elements - make touch-friendly */
|
||||
input[type="text"], input[type="number"], textarea, select {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
margin: 5px 0;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
input[type="submit"], button {
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
margin: 5px;
|
||||
min-height: 44px; /* iOS touch target */
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
margin: 3px;
|
||||
min-height: 40px; /* Reasonable touch target */
|
||||
}
|
||||
|
||||
/* Pagination - mobile friendly */
|
||||
#pagination {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#pagination a {
|
||||
padding: 8px 12px;
|
||||
padding: 6px 10px;
|
||||
margin: 2px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ModApp table - horizontal scroll for wide tables */
|
||||
@@ -477,24 +497,35 @@ html.dark-theme font {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Hide less important columns on mobile */
|
||||
/* Compact view for smaller screens */
|
||||
@media screen and (max-width: 480px) {
|
||||
.mobile-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qa {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
min-width: 30px;
|
||||
padding: 5px 7px;
|
||||
font-size: 13px;
|
||||
min-width: 26px;
|
||||
min-height: 30px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.qt {
|
||||
font-size: 13px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
/* Stack quote controls for very small screens */
|
||||
.quote-controls {
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,22 +534,30 @@ html.dark-theme font {
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* This targets touch devices */
|
||||
.qa {
|
||||
padding: 10px 15px;
|
||||
margin: 3px;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 7px 10px;
|
||||
margin: 2px;
|
||||
min-height: 36px;
|
||||
min-width: 32px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 5px;
|
||||
margin: 2px;
|
||||
padding: 3px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
/* Ensure all interactive elements are large enough */
|
||||
/* Ensure all interactive elements are large enough but not oversized */
|
||||
button, input[type="submit"], input[type="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 10px;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Theme toggle button - keep compact */
|
||||
#theme-toggle {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// AJAX voting functionality
|
||||
function vote(quoteId, action, buttonElement) {
|
||||
// Prevent multiple clicks
|
||||
// Prevent multiple clicks on the same button
|
||||
if (buttonElement.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disable button temporarily
|
||||
// Disable button temporarily to prevent double-clicks
|
||||
buttonElement.disabled = true;
|
||||
|
||||
// Make AJAX request
|
||||
@@ -15,9 +15,16 @@ function vote(quoteId, action, buttonElement) {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
.then(response => {
|
||||
// Handle both successful and error responses with JSON
|
||||
return response.json().then(data => {
|
||||
return { data, status: response.status, ok: response.ok };
|
||||
});
|
||||
})
|
||||
.then(result => {
|
||||
const { data, status, ok } = result;
|
||||
|
||||
if (ok && data.success) {
|
||||
// Update vote count display
|
||||
const voteElement = document.getElementById(`votes-${quoteId}`);
|
||||
if (voteElement) {
|
||||
@@ -27,7 +34,15 @@ function vote(quoteId, action, buttonElement) {
|
||||
// Update button states based on user's voting history
|
||||
updateButtonStates(quoteId, data.user_vote);
|
||||
} else {
|
||||
alert(data.message || 'Sorry, your vote could not be recorded. Please try again.');
|
||||
// Show the server's error message, with special handling for rate limiting
|
||||
let errorMessage = data.message || 'Sorry, your vote could not be recorded. Please try again.';
|
||||
|
||||
if (status === 429) {
|
||||
// Rate limiting or flood control
|
||||
errorMessage = data.message || 'Please slow down! You\'re voting too quickly. Wait a moment and try again.';
|
||||
}
|
||||
|
||||
alert(errorMessage);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -35,7 +50,7 @@ function vote(quoteId, action, buttonElement) {
|
||||
alert('Connection error while voting. Please check your internet connection and try again.');
|
||||
})
|
||||
.finally(() => {
|
||||
// Re-enable button
|
||||
// Re-enable the button immediately
|
||||
buttonElement.disabled = false;
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<font size="+1"><b><i>ircquotes</i></b></font>
|
||||
</td>
|
||||
<td bgcolor="#c08000" align="right">
|
||||
<font face="arial" size="+1"><b>Browse Quotes</b></font>
|
||||
<font face="arial" size="+1"><b>{% if is_top %}Top Quotes{% else %}Browse Quotes{% endif %}</b></font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -69,6 +69,14 @@
|
||||
<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>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
<hr>
|
||||
|
||||
@@ -56,37 +56,24 @@
|
||||
<h2>Frequently Asked Questions (FAQ)</h2>
|
||||
|
||||
<h3>What is ircquotes?</h3>
|
||||
<p>ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat). You can browse quotes, submit your own, and vote on others.</p>
|
||||
<p>ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat) and other chat platforms. You can browse quotes, submit your own, and vote on others.</p>
|
||||
|
||||
<h3>How does the API work?</h3>
|
||||
<p>The ircquotes API allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications.</p>
|
||||
<p>The ircquotes API is a read-only interface that allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications. Quote submissions must be done through the web interface to prevent abuse.</p>
|
||||
|
||||
<h4>Available API Endpoints</h4>
|
||||
<ul>
|
||||
<li><strong>Get All Approved Quotes</strong>: <code>GET /api/quotes</code></li>
|
||||
<li><strong>Get a Specific Quote by ID</strong>: <code>GET /api/quotes/<id></code></li>
|
||||
<li><strong>Get a Random Quote</strong>: <code>GET /api/random</code></li>
|
||||
<li><strong>Get Top Quotes</strong>: <code>GET /api/top</code></li>
|
||||
<li><strong>Search Quotes</strong>: <code>GET /api/search?q=<search_term></code></li>
|
||||
</ul>
|
||||
|
||||
<h4>Submitting Quotes via the API</h4>
|
||||
<p>The API also allows you to submit quotes, but this feature is rate-limited to prevent abuse. Each user is allowed 5 submissions per minute.</p>
|
||||
<ul>
|
||||
<li><strong>Submit a Quote</strong>: <code>POST /api/submit</code></li>
|
||||
<li><strong>Request Body</strong>: The request body should be in JSON format and contain the quote text like this:</li>
|
||||
<pre><code>{
|
||||
"text": "This is a memorable quote!"
|
||||
}</code></pre>
|
||||
<li><strong>Validation Rules</strong>: Quotes must be between 5 and 1000 characters.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Rules for Submitting Quotes</h3>
|
||||
<p>To ensure that ircquotes remains a fun and enjoyable platform for everyone, we ask that you follow a few simple rules when submitting quotes:</p>
|
||||
<ul>
|
||||
<li><strong>No Offensive Content</strong>: Do not submit quotes that contain offensive language, hate speech, or other harmful content.</li>
|
||||
<li><strong>No Spam</strong>: Please avoid submitting irrelevant or repetitive content. The site is for memorable IRC quotes, not spam.</li>
|
||||
<li><strong>Stay On-Topic</strong>: Ensure that your submissions are actual quotes from IRC, not made-up content.</li>
|
||||
<li><strong>Stay On-Topic</strong>: Ensure that your submissions are actual quotes from chat platforms, not made-up content.</li>
|
||||
<li><strong>Rate Limiting</strong>: The submission rate is limited to 5 quotes per minute to prevent spam.</li>
|
||||
<li><strong>Moderation</strong>: All quotes are subject to approval by site moderators. Rejected quotes will not be publicly visible.</li>
|
||||
</ul>
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
|
||||
<td class="bodytext" width="50%" valign="top">
|
||||
<b>Latest Updates</b>
|
||||
<p><strong>20/09/25</strong><br>Major security and feature update! Added copy quotes, bulk moderation, mobile support, rate limiting, and enhanced security.</p>
|
||||
<p><strong>13/10/24</strong><br>Added dark theme and re added all of bash.org's old quotes.</p>
|
||||
<p><strong>20/09/25</strong><br>Improved some things in the backend and added Dark theme</p>
|
||||
<p><strong>13/10/24</strong><br>Re added all of bash.org's old quotes.</p>
|
||||
<p><strong>09/10/24</strong><br>We are now live! Start submitting your favourite IRC quotes.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modapp.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -125,7 +126,15 @@
|
||||
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">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M:%S') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }} (legacy)
|
||||
{% else %}
|
||||
No date
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mobile-hide">{{ quote.ip_address|e }}</td>
|
||||
<td class="mobile-hide">{{ quote.user_agent|e|truncate(50) }}</td>
|
||||
<td>
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
<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>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
|
||||
@@ -64,6 +64,14 @@
|
||||
<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>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
</td>
|
||||
|
||||
@@ -94,6 +94,14 @@
|
||||
<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>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
<hr>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.newquote.focus();">
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.quote.focus();">
|
||||
<center>
|
||||
<!-- Header -->
|
||||
<table cellpadding="2" cellspacing="0" width="80%" border="0">
|
||||
@@ -54,8 +54,20 @@
|
||||
<form action="/submit" name="add" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<table cellpadding="2" cellspacing="0" width="60%">
|
||||
{% if preview_text %}
|
||||
<!-- Preview Section -->
|
||||
<tr>
|
||||
<td><textarea cols="100%" rows="10" name="quote" class="text"></textarea></td>
|
||||
<td>
|
||||
<h3>Quote Preview:</h3>
|
||||
<div style="border: 1px solid #ccc; padding: 10px; margin: 10px 0; background-color: #f9f9f9;">
|
||||
<pre style="white-space: pre-wrap; font-family: monospace;">{{ preview_text }}</pre>
|
||||
</div>
|
||||
<p><strong>If this looks correct, click "Submit Quote" below. Otherwise, edit your quote and preview again.</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><textarea cols="100%" rows="10" name="quote" class="text">{{ original_text or '' }}</textarea></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" name="strip" checked> Automatically attempt to fix timestamps and common mistakes.</td>
|
||||
@@ -71,7 +83,7 @@
|
||||
<td>
|
||||
<br><br>
|
||||
You may submit quotes from any chat medium, so long as they have reasonable
|
||||
formatting (IRC, AIM, ICQ, Yahoo, NOT MSN or MS Chat).<br><br>
|
||||
formatting (IRC, etc.).<br><br>
|
||||
Tips for approval: no timestamps, keep it short (trim useless parts), remove trailing laughs, and avoid inside jokes.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user