Initial commit: site code, layouts, content (images added separately)
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.
This commit is contained in:
478
scripts/membership_api.py
Normal file
478
scripts/membership_api.py
Normal file
@@ -0,0 +1,478 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user