Fixes duplicate placeholder cover image on the 3 newest blog posts, broken aspect-ratio classes on the albums listing page, and a non-responsive fixed sidebar on the chat page.
479 lines
18 KiB
Python
479 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Membership API Server
|
|
|
|
Handles user authentication, membership management, download tracking,
|
|
testimonials, and secure contact form submissions.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import sqlite3
|
|
import hashlib
|
|
import secrets
|
|
import re
|
|
import smtplib
|
|
import time
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from functools import wraps
|
|
|
|
from flask import Flask, request, jsonify, send_from_directory
|
|
from flask_cors import CORS
|
|
import bcrypt
|
|
import jwt
|
|
|
|
# Configuration
|
|
PORT = int(os.getenv("MEMBERSHIP_PORT", 80))
|
|
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
|
|
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "indiana.holmes8@gmail.com")
|
|
JWT_SECRET = os.getenv("JWT_SECRET", secrets.token_urlsafe(32))
|
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
|
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
|
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
|
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
|
|
|
# Static folder for Hugo build
|
|
STATIC_FOLDER = "/root/hydro-sterile/public"
|
|
|
|
# Database path
|
|
DB_PATH = Path("/root/hydro-sterile/db/members.db")
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
app = Flask(__name__)
|
|
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
|
|
|
# Static folder for Hugo build
|
|
STATIC_FOLDER = "/root/hydro-sterile/public"
|
|
|
|
# Rate limiting storage
|
|
rate_limits = {}
|
|
|
|
def get_client_ip():
|
|
"""Get client IP address."""
|
|
if request.headers.get('X-Forwarded-For'):
|
|
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
|
return request.remote_addr or 'unknown'
|
|
|
|
def init_db():
|
|
"""Initialize database with required tables."""
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
|
|
# Users table
|
|
c.execute('''CREATE TABLE IF NOT EXISTS users
|
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
status TEXT DEFAULT 'pending',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
approved_at TIMESTAMP,
|
|
approved_by TEXT)''')
|
|
|
|
# Downloads table
|
|
c.execute('''CREATE TABLE IF NOT EXISTS downloads
|
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ip_address TEXT NOT NULL,
|
|
user_id INTEGER,
|
|
file_path TEXT NOT NULL,
|
|
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id))''')
|
|
|
|
# Testimonials table
|
|
c.execute('''CREATE TABLE IF NOT EXISTS testimonials
|
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
content TEXT NOT NULL,
|
|
author_name TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
approved BOOLEAN DEFAULT 0,
|
|
FOREIGN KEY (user_id) REFERENCES users(id))''')
|
|
|
|
# Sessions table
|
|
c.execute('''CREATE TABLE IF NOT EXISTS sessions
|
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
session_token TEXT UNIQUE NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id))''')
|
|
|
|
# Admin sessions table
|
|
c.execute('''CREATE TABLE IF NOT EXISTS admin_sessions
|
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_token TEXT UNIQUE NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL)''')
|
|
|
|
# Contact submissions table
|
|
c.execute('''CREATE TABLE IF NOT EXISTS contact_submissions
|
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id))''')
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def rate_limit(max_per_minute=10, window_minutes=1):
|
|
"""Rate limiting decorator."""
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
client_ip = get_client_ip()
|
|
now = time.time()
|
|
window_seconds = window_minutes * 60
|
|
|
|
if client_ip not in rate_limits:
|
|
rate_limits[client_ip] = []
|
|
|
|
rate_limits[client_ip] = [
|
|
timestamp for timestamp in rate_limits[client_ip]
|
|
if now - timestamp < window_seconds
|
|
]
|
|
|
|
if len(rate_limits[client_ip]) >= max_per_minute:
|
|
return jsonify({"error": "Rate limit exceeded"}), 429
|
|
|
|
rate_limits[client_ip].append(now)
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
return decorator
|
|
|
|
def sanitize_input(text, max_length=1000):
|
|
"""Sanitize user input."""
|
|
if not text or not isinstance(text, str):
|
|
return ""
|
|
text = re.sub(r'[<>]', '', text)
|
|
return text[:max_length].strip()
|
|
|
|
def validate_email(email):
|
|
"""Validate email format."""
|
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
return re.match(pattern, email) is not None
|
|
|
|
def send_email(to_email, subject, body_html, body_text=None):
|
|
"""Send email via SMTP."""
|
|
if not SMTP_HOST or not SMTP_USER or not SMTP_PASS:
|
|
print(f"EMAIL NOT CONFIGURED: Would send to {to_email}: {subject}", file=sys.stderr)
|
|
return False
|
|
|
|
try:
|
|
msg = MIMEMultipart('alternative')
|
|
msg['Subject'] = subject
|
|
msg['From'] = SMTP_USER
|
|
msg['To'] = to_email
|
|
|
|
if body_text:
|
|
msg.attach(MIMEText(body_text, 'plain'))
|
|
msg.attach(MIMEText(body_html, 'html'))
|
|
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
server.starttls()
|
|
server.login(SMTP_USER, SMTP_PASS)
|
|
server.send_message(msg)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Email send error: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
def require_auth(f):
|
|
"""Require user authentication."""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
if not token:
|
|
return jsonify({"error": "Authentication required"}), 401
|
|
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
|
user_id = payload.get('user_id')
|
|
|
|
# Verify session exists
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT user_id FROM sessions WHERE session_token = ? AND expires_at > ?',
|
|
(token, datetime.utcnow()))
|
|
session = c.fetchone()
|
|
conn.close()
|
|
|
|
if not session:
|
|
return jsonify({"error": "Invalid session"}), 401
|
|
|
|
request.user_id = user_id
|
|
return f(*args, **kwargs)
|
|
except jwt.ExpiredSignatureError:
|
|
return jsonify({"error": "Session expired"}), 401
|
|
except Exception as e:
|
|
return jsonify({"error": "Invalid token"}), 401
|
|
return decorated_function
|
|
|
|
def require_admin(f):
|
|
"""Require admin authentication."""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
if not token:
|
|
return jsonify({"error": "Admin authentication required"}), 401
|
|
|
|
try:
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT id FROM admin_sessions WHERE session_token = ? AND expires_at > ?',
|
|
(token, datetime.utcnow()))
|
|
session = c.fetchone()
|
|
conn.close()
|
|
|
|
if not session:
|
|
return jsonify({"error": "Invalid admin session"}), 401
|
|
|
|
return f(*args, **kwargs)
|
|
except Exception as e:
|
|
return jsonify({"error": "Invalid admin token"}), 401
|
|
return decorated_function
|
|
|
|
# Initialize database on startup
|
|
init_db()
|
|
|
|
# ============================================================================
|
|
# API Endpoints
|
|
# ============================================================================
|
|
|
|
@app.route('/api/auth/register', methods=['POST'])
|
|
@rate_limit(max_per_minute=5, window_minutes=1)
|
|
def register():
|
|
"""Register a new user."""
|
|
try:
|
|
data = request.get_json()
|
|
email = sanitize_input(data.get('email', ''), max_length=255)
|
|
password = data.get('password', '')
|
|
name = sanitize_input(data.get('name', ''), max_length=255)
|
|
|
|
if not email or not password or not name:
|
|
return jsonify({"error": "Email, password, and name are required"}), 400
|
|
|
|
if not validate_email(email):
|
|
return jsonify({"error": "Invalid email format"}), 400
|
|
|
|
if len(password) < 8:
|
|
return jsonify({"error": "Password must be at least 8 characters"}), 400
|
|
|
|
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
try:
|
|
c.execute('INSERT INTO users (email, password_hash, name, status) VALUES (?, ?, ?, ?)',
|
|
(email, password_hash, name, 'pending'))
|
|
user_id = c.lastrowid
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
conn.close()
|
|
return jsonify({"error": "Email already registered"}), 400
|
|
conn.close()
|
|
|
|
email_body = f"<h2>New Membership Signup</h2><p>Name: {name}</p><p>Email: {email}</p>"
|
|
send_email(ADMIN_EMAIL, f"New Membership Signup: {name}", email_body)
|
|
|
|
return jsonify({"message": "Registration successful. Pending approval.", "user_id": user_id}), 201
|
|
except Exception as e:
|
|
return jsonify({"error": "Registration failed"}), 500
|
|
|
|
@app.route('/api/auth/login', methods=['POST'])
|
|
@rate_limit(max_per_minute=5, window_minutes=1)
|
|
def login():
|
|
"""User login."""
|
|
try:
|
|
data = request.get_json()
|
|
email = sanitize_input(data.get('email', ''), max_length=255)
|
|
password = data.get('password', '')
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT id, password_hash, status FROM users WHERE email = ?', (email,))
|
|
user = c.fetchone()
|
|
conn.close()
|
|
|
|
if not user or not bcrypt.checkpw(password.encode('utf-8'), user[1].encode('utf-8')):
|
|
return jsonify({"error": "Invalid email or password"}), 401
|
|
|
|
if user[2] != 'approved':
|
|
return jsonify({"error": f"Membership status: {user[2]}"}), 403
|
|
|
|
token = jwt.encode({'user_id': user[0], 'exp': datetime.utcnow() + timedelta(days=30)}, JWT_SECRET, algorithm='HS256')
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('INSERT INTO sessions (user_id, session_token, expires_at) VALUES (?, ?, ?)',
|
|
(user[0], token, datetime.utcnow() + timedelta(days=30)))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({"token": token, "user_id": user[0], "message": "Login successful"}), 200
|
|
except Exception as e:
|
|
return jsonify({"error": "Login failed"}), 500
|
|
|
|
@app.route('/api/auth/logout', methods=['POST'])
|
|
@require_auth
|
|
def logout():
|
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('DELETE FROM sessions WHERE session_token = ?', (token,))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({"message": "Logged out"}), 200
|
|
|
|
@app.route('/api/auth/check', methods=['GET'])
|
|
def check_auth():
|
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
if not token: return jsonify({"authenticated": False}), 200
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT u.id, u.email, u.name, u.status FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.session_token = ? AND s.expires_at > ?', (token, datetime.utcnow()))
|
|
user = c.fetchone()
|
|
conn.close()
|
|
if user: return jsonify({"authenticated": True, "user_id": user[0], "email": user[1], "name": user[2], "status": user[3]}), 200
|
|
except: pass
|
|
return jsonify({"authenticated": False}), 200
|
|
|
|
@app.route('/api/download/count', methods=['GET'])
|
|
def get_download_count():
|
|
ip_address = get_client_ip()
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT COUNT(*) FROM downloads WHERE ip_address = ?', (ip_address,))
|
|
count = c.fetchone()[0]
|
|
conn.close()
|
|
return jsonify({"count": count, "limit": 1, "unlimited": False}), 200
|
|
|
|
@app.route('/api/download/track', methods=['POST'])
|
|
@rate_limit(max_per_minute=10, window_minutes=1)
|
|
def track_download():
|
|
data = request.get_json()
|
|
file_path = sanitize_input(data.get('file_path', ''), max_length=500)
|
|
ip_address = get_client_ip()
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('INSERT INTO downloads (ip_address, file_path) VALUES (?, ?)', (ip_address, file_path))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({"message": "Tracked"}), 200
|
|
|
|
@app.route('/api/testimonials', methods=['GET', 'POST'])
|
|
def handle_testimonials():
|
|
if request.method == 'GET':
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT id, content, author_name, created_at FROM testimonials WHERE approved = 1 ORDER BY created_at DESC')
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
return jsonify({"testimonials": [{"id": r[0], "content": r[1], "author_name": r[2], "created_at": r[3]} for r in rows]}), 200
|
|
else:
|
|
@require_auth
|
|
def submit():
|
|
data = request.get_json()
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('INSERT INTO testimonials (user_id, content, author_name, approved) VALUES (?, ?, ?, 0)', (request.user_id, data.get('content'), data.get('author_name')))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({"message": "Submitted"}), 201
|
|
return submit()
|
|
|
|
@app.route('/api/contact', methods=['POST'])
|
|
@require_auth
|
|
def contact():
|
|
data = request.get_json()
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('INSERT INTO contact_submissions (user_id, name, email, message) VALUES (?, ?, ?, ?)', (request.user_id, data.get('name'), data.get('email'), data.get('message')))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({"message": "Sent"}), 200
|
|
|
|
@app.route('/api/admin/login', methods=['POST'])
|
|
def admin_login():
|
|
data = request.get_json()
|
|
if data.get('password') == ADMIN_PASSWORD:
|
|
token = jwt.encode({'admin': True, 'exp': datetime.utcnow() + timedelta(hours=24)}, JWT_SECRET, algorithm='HS256')
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('INSERT INTO admin_sessions (session_token, expires_at) VALUES (?, ?)', (token, datetime.utcnow() + timedelta(hours=24)))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({"token": token}), 200
|
|
return jsonify({"error": "Unauthorized"}), 401
|
|
|
|
@app.route('/api/admin/pending', methods=['GET'])
|
|
@require_admin
|
|
def admin_pending():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('SELECT id, email, name, created_at FROM users WHERE status = "pending"')
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
return jsonify({"members": [{"id": r[0], "email": r[1], "name": r[2], "created_at": r[3]} for r in rows]}), 200
|
|
|
|
@app.route('/api/admin/approve', methods=['POST'])
|
|
@require_admin
|
|
def admin_approve():
|
|
user_id = request.get_json().get('user_id')
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('UPDATE users SET status = "approved" WHERE id = ?', (user_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({"message": "Approved"}), 200
|
|
|
|
@app.route('/api/health', methods=['GET'])
|
|
def health():
|
|
return jsonify({"status":"healthy"}), 200
|
|
|
|
# ============================================================================
|
|
# Static File Serving (CATCH-ALL)
|
|
# ============================================================================
|
|
|
|
@app.route('/', defaults={'path': ''})
|
|
@app.route('/<path:path>')
|
|
def serve(path):
|
|
# Ignore API routes
|
|
if path.startswith('api/'):
|
|
return "Not Found", 404
|
|
|
|
full_path = os.path.join(STATIC_FOLDER, path)
|
|
|
|
# If path is a directory or empty, serve index.html
|
|
if path == "" or os.path.isdir(full_path):
|
|
# Check if the directory has an index.html
|
|
if os.path.exists(os.path.join(full_path, 'index.html')):
|
|
return send_from_directory(full_path, 'index.html')
|
|
# Otherwise, check if it's a pretty URL like /about
|
|
elif os.path.exists(os.path.join(STATIC_FOLDER, path, 'index.html')):
|
|
return send_from_directory(os.path.join(STATIC_FOLDER, path), 'index.html')
|
|
return send_from_directory(STATIC_FOLDER, '404.html'), 404
|
|
|
|
# If file exists, serve it
|
|
if os.path.exists(full_path):
|
|
return send_from_directory(STATIC_FOLDER, path)
|
|
|
|
# Handle Hugo's pretty URLs: /about -> /about/index.html
|
|
if os.path.exists(os.path.join(STATIC_FOLDER, path, 'index.html')):
|
|
return send_from_directory(os.path.join(STATIC_FOLDER, path), 'index.html')
|
|
|
|
# Default to 404
|
|
if os.path.exists(os.path.join(STATIC_FOLDER, '404.html')):
|
|
return send_from_directory(STATIC_FOLDER, '404.html'), 404
|
|
return "404 Not Found", 404
|
|
|
|
if __name__ == '__main__':
|
|
print(f"Starting Membership API + Static Server on port {PORT}")
|
|
app.run(host='0.0.0.0', port=PORT, debug=False)
|