#!/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"

New Membership Signup

Name: {name}

Email: {email}

" 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('/') 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)