Files
hydro-site-final/scripts/membership_api.py
Dr. Jones d9961a76ad 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.
2026-07-08 06:18:17 +00:00

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)