from flask import Flask, jsonify, request, make_response, session from flask_cors import CORS from flask_caching import Cache from flask_compress import Compress from datetime import datetime, timedelta from postgresql_models import init_db, SessionLocal, Profile, Song, Plan, PlanSong, ProfileSong, ProfileSongKey, BiometricCredential, User from functools import lru_cache import hashlib import hmac import base64 import uuid try: from dotenv import load_dotenv load_dotenv() # Loads variables from a .env file if present except ImportError: # dotenv not installed yet; proceed without loading .env pass import os import json import secrets import logging import re # Import bleach if available, but not required for basic operation try: import bleach BLEACH_AVAILABLE = True except ImportError: BLEACH_AVAILABLE = False logger.warning('bleach not installed - using basic XSS protection') from rate_limiter import rate_limit, get_rate_limit_headers from validators import validate_request_data, PROFILE_SCHEMA, SONG_SCHEMA, PLAN_SCHEMA, sanitize_filename from biometric_auth import ( register_biometric_credential, authenticate_biometric_by_device, get_user_biometric_status, delete_all_user_biometrics ) from helpers import ( success_response, error_response, not_found_response, validation_error, sanitize_text, validate_id, serialize_profile, serialize_song, serialize_plan, extract_profile_data, extract_song_data, extract_plan_data, search_songs, update_model_fields, safe_db_operation, NotFoundError, get_or_404 ) # Configure logging log_dir = os.path.join(os.path.dirname(__file__), 'logs') os.makedirs(log_dir, exist_ok=True) logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(os.path.join(log_dir, 'app.log')), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) app = Flask(__name__) # Generate strong secret key for sessions app.secret_key = os.environ.get('SECRET_KEY') or secrets.token_hex(32) # API Key Authentication (for backend-to-backend calls) API_KEY = os.environ.get('API_KEY') or secrets.token_hex(32) if os.environ.get('API_KEY'): logger.info('API_KEY loaded from environment') else: logger.warning('API_KEY generated at runtime - set in .env for production') # CSRF Token storage (in-memory for simplicity, use Redis in production) csrf_tokens = {} # Performance Optimization - In-Memory Caching with TTL cache = Cache(app, config={ 'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 300, # 5 minutes default 'CACHE_THRESHOLD': 500 # Max cached items }) # Performance Optimization - Gzip Compression Compress(app) app.config['COMPRESS_MIMETYPES'] = ['text/html', 'text/css', 'text/xml', 'application/json', 'application/javascript'] app.config['COMPRESS_LEVEL'] = 6 # Balance between speed and compression app.config['COMPRESS_MIN_SIZE'] = 500 # Only compress responses > 500 bytes # Session Security Settings (applies to all environments) app.config['SESSION_COOKIE_SECURE'] = not app.debug # HTTPS only in production app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent JavaScript access app.config['SESSION_COOKIE_SAMESITE'] = 'Strict' # CSRF protection app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour app.config['SESSION_COOKIE_NAME'] = '__Host-session' if not app.debug else 'session' # Secure prefix # Request size limits app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max request size # Security Headers Middleware @app.after_request def set_security_headers(response): """Add security headers to all responses""" response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' # Add rate limit headers client_id = request.remote_addr or 'unknown' rate_headers = get_rate_limit_headers(client_id) for key, value in rate_headers.items(): response.headers[key] = value # Remove server header to prevent information disclosure response.headers.pop('Server', None) # Add CSP header response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" # Add caching headers based on endpoint type if request.path.startswith('/static/') or request.path.endswith(('.js', '.css', '.png', '.jpg', '.svg')): response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' # 1 year for static assets elif request.path in ['/api/health', '/api/providers']: response.headers['Cache-Control'] = 'public, max-age=60' # 1 minute for health/status elif request.method == 'GET' and request.path.startswith('/api/'): response.headers['Cache-Control'] = 'private, max-age=30, must-revalidate' # 30 seconds for API reads elif request.method in ['POST', 'PUT', 'DELETE']: response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' # No cache for mutations # Add ETag for GET requests to enable conditional requests if request.method == 'GET' and response.status_code == 200: if not response.headers.get('ETag'): import hashlib etag = hashlib.md5(response.get_data()).hexdigest() response.headers['ETag'] = f'"{etag}"' # Check If-None-Match header if request.headers.get('If-None-Match') == response.headers['ETag']: response.status_code = 304 response.set_data(b'') return response # CORS configuration - restricted to known origins ALLOWED_ORIGINS = [ "http://localhost:5100", "https://houseofprayer.ddns.net" ] # Add development IP if in dev mode if os.environ.get('FLASK_ENV') != 'production': ALLOWED_ORIGINS.extend([ "http://192.168.10.130:5100", "http://localhost:8080" ]) CORS(app, resources={ r"/api/*": { "origins": ALLOWED_ORIGINS, "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], "allow_headers": ["Content-Type", "Authorization"], "supports_credentials": True, "max_age": 3600 } }) init_db() FLASK_PORT = int(os.environ.get('FLASK_PORT', '8080')) # Authentication and Authorization Functions def generate_csrf_token(): """Generate a CSRF token for the session""" if 'csrf_token' not in session: session['csrf_token'] = secrets.token_hex(32) return session['csrf_token'] def verify_csrf_token(): """Verify CSRF token from request""" token = request.headers.get('X-CSRF-Token') or request.form.get('csrf_token') if not token or not session.get('csrf_token'): return False return hmac.compare_digest(token, session['csrf_token']) def require_api_key(f): """Decorator to require API key for administrative endpoints""" from functools import wraps @wraps(f) def decorated_function(*args, **kwargs): api_key = request.headers.get('X-API-Key') if not api_key or not hmac.compare_digest(api_key, API_KEY): logger.warning(f'Unauthorized API access attempt from {request.remote_addr}') return jsonify({'error': 'unauthorized', 'message': 'Invalid or missing API key'}), 401 return f(*args, **kwargs) return decorated_function def require_csrf(f): """Decorator to require CSRF token for state-changing operations""" from functools import wraps @wraps(f) def decorated_function(*args, **kwargs): if request.method in ['POST', 'PUT', 'DELETE', 'PATCH']: if not verify_csrf_token(): logger.warning(f'CSRF validation failed from {request.remote_addr}') return jsonify({'error': 'csrf_validation_failed', 'message': 'Invalid or missing CSRF token'}), 403 return f(*args, **kwargs) return decorated_function def require_auth(f): """Decorator to require authentication""" from functools import wraps @wraps(f) def decorated_function(*args, **kwargs): if 'username' not in session: logger.warning(f'Unauthorized access attempt from {request.remote_addr}') return jsonify({'error': 'unauthorized', 'message': 'Authentication required'}), 401 return f(*args, **kwargs) return decorated_function def require_permission(permission): """Decorator to require specific permission""" from functools import wraps def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): username = session.get('username') if not username: return jsonify({'error': 'unauthorized', 'message': 'Authentication required'}), 401 db = get_db() try: user = db.query(User).filter(User.username == username).first() if not user or not user.has_permission(permission): logger.warning(f'Permission denied for {username} on {request.endpoint}') return jsonify({'error': 'forbidden', 'message': f'Permission "{permission}" required'}), 403 finally: pass return f(*args, **kwargs) return decorated_function return decorator # CSRF Token endpoint @app.route('/api/csrf-token', methods=['GET']) def get_csrf_token(): """Get CSRF token for the session""" token = generate_csrf_token() return jsonify({'csrf_token': token}) # Validate critical environment variables in production if not app.debug: required_vars = ['POSTGRESQL_URI', 'SECRET_KEY'] missing = [var for var in required_vars if not os.environ.get(var)] if missing: print(f"WARNING: Missing critical environment variables: {', '.join(missing)}") print("Please configure these in your .env file for production use.") def get_db(): """Create a new database session with automatic cleanup IMPORTANT: Do NOT call db.close() in finally blocks when using scoped_session. The teardown_appcontext handler automatically cleans up sessions. Calling close() manually causes connection pool exhaustion. """ return SessionLocal() @app.teardown_appcontext def cleanup_session(exception=None): """Cleanup scoped database sessions after each request to prevent connection pool exhaustion This is automatically called by Flask after every request, ensuring proper session cleanup without manual intervention in route handlers. """ try: SessionLocal.remove() except Exception as e: logger.error(f"Error during session cleanup: {e}") # Force cleanup on error try: engine.dispose() except: pass def extract_text_from_file(file_storage): """Extract text from an uploaded file (docx, pdf, image, txt).""" filename = file_storage.filename or '' lower = filename.lower() data = file_storage.read() # Reset stream position if needed for certain libraries import io stream = io.BytesIO(data) text = '' try: if lower.endswith('.txt'): text = data.decode(errors='ignore') elif lower.endswith('.docx'): try: import docx doc = docx.Document(stream) text = '\n'.join(p.text for p in doc.paragraphs) except Exception: text = '' elif lower.endswith('.pdf'): try: import PyPDF2 reader = PyPDF2.PdfReader(stream) parts = [] for page in reader.pages[:20]: # safety limit try: parts.append(page.extract_text() or '') except Exception: continue text = '\n'.join(parts) # OCR fallback if minimal text extracted (likely scanned PDF) if len(text.strip()) < 50: try: from pdf2image import convert_from_bytes from PIL import Image import pytesseract images = convert_from_bytes(data, first_page=1, last_page=min(10, len(reader.pages))) ocr_parts = [] for img in images: ocr_parts.append(pytesseract.image_to_string(img)) ocr_text = '\n'.join(ocr_parts) if len(ocr_text.strip()) > len(text.strip()): text = ocr_text except Exception: pass # Fall back to original text layer extraction except Exception: text = '' elif lower.endswith(('.png','.jpg','.jpeg','.tif','.tiff')): try: from PIL import Image import pytesseract img = Image.open(stream) text = pytesseract.image_to_string(img) except Exception: text = '' else: # Attempt generic decode try: text = data.decode(errors='ignore') except Exception: text = '' except Exception: text = '' # Basic cleanup cleaned = '\n'.join(line.rstrip() for line in (text or '').splitlines()) return cleaned.strip() # Admin restore from data.json (profiles and songs) @app.route('/api/admin/restore', methods=['POST']) @rate_limit(max_per_minute=5) @require_api_key # Requires API key for admin operations def admin_restore(): logger.info(f'Admin restore initiated from {request.remote_addr}') # Determine data.json location: prefer backend/data.json, fallback to project root backend_path = os.path.join(os.path.dirname(__file__), 'data.json') root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data.json')) source_path = backend_path if os.path.exists(backend_path) else root_path if os.path.exists(root_path) else None if not source_path: return jsonify({ 'ok': False, 'error': 'data.json not found in backend/ or project root' }), 404 try: with open(source_path, 'r', encoding='utf-8') as f: payload = json.load(f) except Exception as e: return jsonify({ 'ok': False, 'error': f'Failed to read data.json: {str(e)}' }), 400 # Validate payload structure if not isinstance(payload, dict): return jsonify({ 'ok': False, 'error': 'Invalid data format' }), 400 db = get_db() created = { 'profiles': 0, 'songs': 0 } updated = { 'profiles': 0, 'songs': 0 } try: import uuid # Restore profiles profiles = payload.get('profiles', []) for p in profiles: name = (p.get('name') or f"{(p.get('first_name') or '').strip()} {(p.get('last_name') or '').strip()}" ).strip() if not name: continue existing = db.query(Profile).filter(Profile.name == name).first() if existing: changed = False for key in ['email','contact_number','default_key','notes']: if p.get(key) and getattr(existing, key, None) != p.get(key): setattr(existing, key, p.get(key)) changed = True if changed: updated['profiles'] += 1 else: profile_id = str(p.get('id')) if p.get('id') else str(uuid.uuid4()) new_p = Profile( id=profile_id, name=name, email=p.get('email') or '', contact_number=p.get('contact_number') or '', default_key=p.get('default_key') or 'C', notes=p.get('notes') or '' ) db.add(new_p) created['profiles'] += 1 # Restore songs songs = payload.get('songs', []) for s in songs: title = (s.get('title') or '').strip() if not title: continue existing = db.query(Song).filter(Song.title == title).first() lyrics = s.get('lyrics') or s.get('content') or '' chords = s.get('chords') or '' singer = s.get('singer') or s.get('artist') or '' artist = s.get('artist') or '' band = s.get('band') or '' if existing: changed = False if lyrics and (existing.lyrics or '') != lyrics: existing.lyrics = lyrics changed = True if chords and (existing.chords or '') != chords: existing.chords = chords changed = True if singer and (existing.singer or '') != singer: existing.singer = singer changed = True if artist and (existing.artist or '') != artist: existing.artist = artist changed = True if band and (existing.band or '') != band: existing.band = band changed = True if changed: updated['songs'] += 1 else: song_id = str(s.get('id')) if s.get('id') else str(uuid.uuid4()) new_s = Song(id=song_id, title=title, artist=artist, band=band, singer=singer, lyrics=lyrics, chords=chords) db.add(new_s) created['songs'] += 1 db.commit() return jsonify({ 'ok': True, 'profiles_created': created['profiles'], 'profiles_updated': updated['profiles'], 'songs_created': created['songs'], 'songs_updated': updated['songs'], 'source': source_path }) except Exception as e: db.rollback() return jsonify({ 'ok': False, 'error': str(e) }), 500 finally: pass # Session cleanup handled by teardown_appcontext @app.route('/api/upload_lyric', methods=['POST']) @rate_limit(max_per_minute=10) def upload_lyric(): """Accept a lyric file and return extracted text. Does NOT create a Song automatically.""" if 'file' not in request.files: return jsonify({'error':'file_missing'}), 400 f = request.files['file'] if not f.filename: return jsonify({'error':'empty_filename'}), 400 # Sanitize filename to prevent path traversal attacks safe_filename = sanitize_filename(f.filename) # Validate file extension allowed_extensions = {'.txt', '.docx', '.pdf', '.png', '.jpg', '.jpeg', '.tif', '.tiff'} file_ext = os.path.splitext(safe_filename)[1].lower() if file_ext not in allowed_extensions: logger.warning(f'Invalid file upload attempt: {file_ext} from {request.remote_addr}') return jsonify({'error':'invalid_file_type', 'message':'Only text, docx, pdf, and image files are allowed'}), 400 # Validate file size (max 10MB) f.seek(0, 2) # Seek to end file_size = f.tell() f.seek(0) # Reset to beginning if file_size > 10 * 1024 * 1024: return jsonify({'error':'file_too_large', 'max_size': '10MB'}), 413 # Validate filename to prevent path traversal if '..' in f.filename or '/' in f.filename or '\\' in f.filename: return jsonify({'error':'invalid_filename'}), 400 # Optional metadata fields from form title = (request.form.get('title') or f.filename.rsplit('.',1)[0])[:500] # Limit length artist = (request.form.get('artist') or '')[:500] band = (request.form.get('band') or '')[:500] extracted = extract_text_from_file(f) if not extracted: return jsonify({'error':'extraction_failed','title':title}), 422 # Return without saving; front-end will present in modal for editing & saving sample = extracted[:150] + ('...' if len(extracted) > 150 else '') return jsonify({ 'status':'ok', 'title': title, 'artist': artist, 'band': band, 'lyrics': extracted, 'preview': sample, 'length': len(extracted) }) @app.route('/api/providers') @rate_limit(max_per_minute=60) def providers(): """Report which external provider tokens are configured (OpenAI removed).""" return jsonify({ 'local_db': True, 'chartlyrics': True, 'lifeway_link': True }) @app.route('/') def index(): return jsonify({'message': 'House of Prayer Song Lyrics API (Flask)', 'port': FLASK_PORT}) @app.route('/api/health') def health(): return jsonify({'status': 'ok', 'ts': datetime.utcnow().isoformat()}) # Profile Selection Management @app.route('/api/profile-selection/clear', methods=['POST', 'OPTIONS']) def clear_profile_selection(): """Clear profile selection (frontend manages this in localStorage, but endpoint needed for API compatibility)""" if request.method == 'OPTIONS': return '', 204 return jsonify({'status': 'ok', 'message': 'Profile selection cleared'}) # Profiles CRUD @app.route('/api/profiles', methods=['GET','POST']) @rate_limit(max_per_minute=600) @require_auth def profiles(): db = get_db() try: if request.method == 'GET': # Cache profiles list for 5 minutes cache_key = f'profiles_list_{session.get("username", "anonymous")}' cached = cache.get(cache_key) if cached is not None: return jsonify(cached) items = db.query(Profile).all() result = [serialize_profile(p, include_song_count=True, db=db) for p in items] cache.set(cache_key, result, timeout=300) return jsonify(result) import uuid data = request.get_json() or {} # Validate name profile_data = extract_profile_data(data) if not profile_data['name']: return validation_error('name', 'Name is required') profile_id = data.get('id') or str(uuid.uuid4()) p = Profile(id=profile_id, **profile_data) db.add(p) db.commit() # Invalidate cache cache_key = f'profiles_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Profile created: {profile_id} from {request.remote_addr}') return jsonify(serialize_profile(p, include_song_count=True, db=db)) except Exception as e: db.rollback() logger.error(f'Error in profiles endpoint: {e}') return error_response('server_error', str(e), 500) finally: pass # Session cleanup handled by teardown_appcontext @app.route('/api/profiles/', methods=['PUT','DELETE']) @rate_limit(max_per_minute=30) @require_auth @require_permission('edit') def profile_item(pid): # Validate ID if not validate_id(pid): return validation_error('id', 'Invalid profile ID format') db = get_db() try: p = db.query(Profile).filter(Profile.id == pid).first() if not p: return not_found_response('Profile') if request.method == 'PUT': d = request.get_json() or {} profile_data = extract_profile_data(d) update_model_fields(p, profile_data) db.commit() # Invalidate cache cache_key = f'profiles_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Profile updated: {pid}') return jsonify(serialize_profile(p, include_song_count=True, db=db)) # DELETE db.delete(p) db.commit() # Invalidate cache cache_key = f'profiles_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Profile deleted: {pid}') return success_response({'status': 'deleted'}) except Exception as e: db.rollback() logger.error(f'Error in profile_item: {e}') return error_response('server_error', str(e), 500) finally: pass # Session cleanup handled by teardown_appcontext # Songs CRUD + search (local) @app.route('/api/songs', methods=['GET','POST']) @rate_limit(max_per_minute=300) @require_auth def songs(): db = get_db() try: if request.method == 'GET': q = (request.args.get('q','') or '')[:500] items = search_songs(db, Song, q) # Include lyrics preview (first 200 chars) for Database component return jsonify([serialize_song(s, include_full_content=False) for s in items]) import uuid d = request.get_json() or {} # Validate and extract song data song_data = extract_song_data(d) if not song_data['title'].strip(): return validation_error('title', 'Title is required') song_id = d.get('id') or str(uuid.uuid4()) s = Song(id=song_id, **song_data) db.add(s) db.commit() # Invalidate cache cache_key = f'songs_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Song created: {song_id}') return jsonify({'id': s.id}) except Exception as e: db.rollback() logger.error(f'Error in songs endpoint: {e}') return error_response('server_error', str(e), 500) finally: pass # Session cleanup handled by teardown_appcontext @app.route('/api/songs/', methods=['GET','PUT','DELETE']) @rate_limit(max_per_minute=30) @require_auth def song_item(sid): # Validate ID if not validate_id(sid): return validation_error('id', 'Invalid song ID format') db = get_db() try: s = db.query(Song).filter(Song.id == sid).first() if not s: return not_found_response('Song') if request.method == 'GET': # Detach from session to prevent lazy loading db.expunge(s) return jsonify(serialize_song(s, include_full_content=True)) if request.method == 'PUT': d = request.get_json() or {} song_data = extract_song_data(d) update_model_fields(s, song_data) s.updated_at = int(datetime.utcnow().timestamp()) db.commit() # Invalidate cache cache_key = f'songs_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Song updated: {sid}') return success_response() # DELETE db.delete(s) db.commit() # Invalidate cache cache_key = f'songs_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Song deleted: {sid}') return success_response({'status': 'deleted'}) except Exception as e: db.rollback() logger.error(f'Error in song_item: {e}') return error_response('server_error', str(e), 500) finally: pass # Session cleanup handled by teardown_appcontext # Planning (date-based) @app.route('/api/plans', methods=['GET','POST']) @rate_limit(max_per_minute=300) @require_auth def plans(): db = get_db() try: if request.method == 'GET': # Cache plans list for 3 minutes (more volatile data) cache_key = f'plans_list_{session.get("username", "anonymous")}' cached = cache.get(cache_key) if cached is not None: return jsonify(cached) items = db.query(Plan).all() result = [serialize_plan(p) for p in items] cache.set(cache_key, result, timeout=180) return jsonify(result) import uuid d = request.get_json() or {} plan_data = extract_plan_data(d) plan_id = d.get('id') or str(uuid.uuid4()) # Validate date format (basic check) if plan_data['date'] and len(plan_data['date']) > 50: return validation_error('date', 'Invalid date format') plan = Plan(id=plan_id, **plan_data) db.add(plan) db.commit() # Invalidate cache cache_key = f'plans_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Plan created: {plan_id}') return jsonify({'id': plan.id}) except Exception as e: db.rollback() logger.error(f'Error in plans endpoint: {e}') return error_response('server_error', str(e), 500) finally: pass # Session cleanup handled by teardown_appcontext @app.route('/api/plans/', methods=['GET','PUT','DELETE']) @rate_limit(max_per_minute=300) @require_auth def plan_detail(pid): if not validate_id(pid): return validation_error('id', 'Invalid plan ID format') db = get_db() try: plan = db.query(Plan).filter(Plan.id == pid).first() if not plan: return not_found_response('Plan') if request.method == 'GET': return jsonify(serialize_plan(plan)) if request.method == 'PUT': d = request.get_json() or {} plan_data = extract_plan_data(d) update_model_fields(plan, plan_data) # Handle songs if provided if 'songs' in d and isinstance(d['songs'], list): # Delete existing plan songs db.query(PlanSong).filter(PlanSong.plan_id==pid).delete() # Add new songs for idx, song in enumerate(d['songs']): song_id = song.get('id') if isinstance(song, dict) else song if song_id: link = PlanSong( id=str(uuid.uuid4()), plan_id=pid, song_id=str(song_id), order_index=idx ) db.add(link) db.commit() # Invalidate cache cache_key = f'plans_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Plan updated: {pid}') return jsonify(serialize_plan(plan)) # DELETE # First delete all related plan songs db.query(PlanSong).filter(PlanSong.plan_id==pid).delete() db.delete(plan) db.commit() # Invalidate cache cache_key = f'plans_list_{session.get("username", "anonymous")}' cache.delete(cache_key) logger.info(f'Plan deleted: {pid}') return success_response({'status': 'deleted'}) except Exception as e: db.rollback() logger.error(f'Error in plan_detail: {e}') return error_response('server_error', str(e), 500) finally: pass # Session cleanup handled by teardown_appcontext @app.route('/api/plans//songs', methods=['GET','POST']) @rate_limit(max_per_minute=60) def plan_songs(pid): if not pid or len(pid) > 255: return jsonify({'error':'invalid_id'}), 400 db = get_db() try: plan = db.query(Plan).filter(Plan.id == pid).first() if not plan: return jsonify({'error':'plan_not_found'}), 404 if request.method == 'GET': # Optimized: Use JOIN to fetch songs in single query (avoids N+1) results = db.query(PlanSong, Song).\ join(Song, PlanSong.song_id == Song.id).\ filter(PlanSong.plan_id == pid).\ order_by(PlanSong.order_index).\ all() return jsonify([{ 'id': plan_song.id, 'song_id': song.id, 'order_index': plan_song.order_index, 'song': { 'id': song.id, 'title': song.title, 'artist': song.artist or '', 'band': song.band or '', 'singer': song.singer or '' } } for plan_song, song in results]) d = request.get_json() or {} song_id = d.get('song_id') if not song_id: return jsonify({'error': 'song_id_required'}), 400 order_index = int(d.get('order_index') or 0) link_id = str(uuid.uuid4()) link = PlanSong(id=link_id, plan_id=pid, song_id=song_id, order_index=order_index) db.add(link) db.commit() # Clear cache after add # cache.delete_memoized(plan_songs, pid) return jsonify({'id':link.id}) except Exception as e: db.rollback() return jsonify({'error': str(e)}), 500 finally: pass # Session cleanup handled by teardown_appcontext # Profile Songs endpoints @app.route('/api/profiles//songs', methods=['GET','POST']) @rate_limit(max_per_minute=300) def profile_songs(pid): import uuid # Validate profile ID if not pid or len(pid) > 255: return jsonify({'error':'invalid_profile_id'}), 400 db = get_db() try: profile = db.query(Profile).filter(Profile.id == pid).first() if not profile: return jsonify({'error':'profile_not_found'}), 404 if request.method == 'GET': try: # Optimized: Get all profile songs with full song data in fewer queries links = db.query(ProfileSong).filter(ProfileSong.profile_id==pid).all() # Get all song IDs at once song_ids = [l.song_id for l in links] if not song_ids: return jsonify([]) # Fetch all songs in one query songs = db.query(Song).filter(Song.id.in_(song_ids)).all() songs_dict = {s.id: s for s in songs} # Fetch all custom keys in one query keys = db.query(ProfileSongKey).filter( ProfileSongKey.profile_id==pid, ProfileSongKey.song_id.in_(song_ids) ).all() keys_dict = {k.song_id: k.song_key for k in keys} # Build result with full song data result = [] for l in links: song = songs_dict.get(l.song_id) if not song: continue song_key = keys_dict.get(l.song_id, profile.default_key if profile.default_key else 'C') result.append({ 'id': song.id, 'title': song.title or '', 'artist': song.artist or '', 'band': song.band or '', 'singer': song.singer or '', 'lyrics': song.lyrics or '', 'chords': song.chords or '', 'memo': song.memo or '', 'created_at': song.created_at if hasattr(song, 'created_at') else None, 'updated_at': song.updated_at if hasattr(song, 'updated_at') else None, 'song_key': song_key, 'profile_song_id': l.id }) return jsonify(result) except Exception as e: logger.error(f'Error loading profile songs for {pid}: {str(e)}') return jsonify({'error': 'failed_to_load_songs', 'message': str(e)}), 500 d = request.get_json() or {} song_id = d.get('song_id') # Validate song_id if not song_id: return jsonify({'error': 'song_id_required'}), 400 # Check if song exists song = db.query(Song).filter(Song.id == song_id).first() if not song: return jsonify({'error': 'song_not_found'}), 404 # Check if association already exists existing = db.query(ProfileSong).filter( ProfileSong.profile_id==pid, ProfileSong.song_id==song_id ).first() if existing: return jsonify({'id':existing.id, 'exists':True}) # Create profile-song association link_id = str(uuid.uuid4()) link = ProfileSong(id=link_id, profile_id=pid, song_id=song_id) db.add(link) # Save custom key if provided song_key = d.get('song_key') if song_key: key_id = str(uuid.uuid4()) key_record = ProfileSongKey(id=key_id, profile_id=pid, song_id=song_id, song_key=song_key) db.add(key_record) db.commit() logger.info(f'Song {song_id} added to profile {pid}') return jsonify({'id':link.id, 'success': True}) except Exception as e: db.rollback() logger.error(f'Error in profile_songs for {pid}: {str(e)}') return jsonify({'error': 'operation_failed', 'message': str(e)}), 500 finally: pass # Session cleanup handled by teardown_appcontext @app.route('/api/profiles//songs/', methods=['GET','PUT','DELETE']) @rate_limit(max_per_minute=300) def profile_song_item(pid, sid): import uuid db = get_db() try: link = db.query(ProfileSong).filter( ProfileSong.profile_id==pid, ProfileSong.song_id==sid ).first() if not link: return jsonify({'error':'not_found'}),404 if request.method == 'GET': # Get custom key if exists key_record = db.query(ProfileSongKey).filter( ProfileSongKey.profile_id==pid, ProfileSongKey.song_id==sid ).first() profile = db.query(Profile).filter(Profile.id == pid).first() song_key = key_record.song_key if key_record else (profile.default_key if profile else 'C') return jsonify({ 'id': link.id, 'profile_id': link.profile_id, 'song_id': link.song_id, 'song_key': song_key }) if request.method == 'PUT': d = request.get_json() or {} new_key = d.get('song_key') if new_key: # Update or create key record key_record = db.query(ProfileSongKey).filter( ProfileSongKey.profile_id==pid, ProfileSongKey.song_id==sid ).first() if key_record: key_record.song_key = new_key else: key_id = str(uuid.uuid4()) key_record = ProfileSongKey(id=key_id, profile_id=pid, song_id=sid, song_key=new_key) db.add(key_record) db.commit() return jsonify({'status':'ok'}) # DELETE - remove both records db.delete(link) key_record = db.query(ProfileSongKey).filter( ProfileSongKey.profile_id==pid, ProfileSongKey.song_id==sid ).first() if key_record: db.delete(key_record) db.commit() return jsonify({'status':'deleted'}) finally: pass # Session cleanup handled by teardown_appcontext # External search using ChartLyrics API + Local Database @app.route('/api/search_external') @rate_limit(max_per_minute=20) def search_external(): import urllib.request import urllib.parse import xml.etree.ElementTree as ET q = (request.args.get('q', '') or '')[:500].strip() # Limit query length filter_type = request.args.get('filter', 'all') # Validate filter type if filter_type not in ['all', 'title', 'artist', 'band']: filter_type = 'all' if not q: return jsonify({'query': q, 'results': []}) results = [] # First, search local database db = get_db() try: local_songs = db.query(Song).all() def match_local(s): q_lower = q.lower() if filter_type == 'title': return q_lower in (s.title or '').lower() elif filter_type == 'artist': return q_lower in (s.artist or '').lower() elif filter_type == 'band': return q_lower in (s.band or '').lower() else: # all return (q_lower in (s.title or '').lower() or q_lower in (s.artist or '').lower() or q_lower in (s.band or '').lower()) for s in local_songs: if match_local(s): snippet = (s.lyrics or '')[:150] + '...' if len(s.lyrics or '') > 150 else (s.lyrics or '') results.append({ 'title': s.title, 'artist': s.artist, 'band': s.band, 'snippet': snippet, 'source': 'local_db' }) finally: pass # Session cleanup handled by teardown_appcontext # Then search ChartLyrics (free API) try: # ChartLyrics search - expects artist and song # We'll try different combinations based on filter search_terms = [] if filter_type == 'artist': search_terms.append({'artist': q, 'song': ''}) elif filter_type == 'title': search_terms.append({'artist': '', 'song': q}) else: # For 'all' and 'band', try both search_terms.append({'artist': q, 'song': ''}) search_terms.append({'artist': '', 'song': q}) for term in search_terms: try: artist_encoded = urllib.parse.quote(term['artist']) song_encoded = urllib.parse.quote(term['song']) url = f'http://api.chartlyrics.com/apiv1.asmx/SearchLyric?artist={artist_encoded}&song={song_encoded}' with urllib.request.urlopen(url, timeout=5) as response: xml_data = response.read() root = ET.fromstring(xml_data) # Parse XML results (ChartLyrics returns XML) namespace = {'cl': 'http://api.chartlyrics.com/'} for item in root.findall('.//cl:SearchLyricResult', namespace): title = item.find('cl:Song', namespace) artist = item.find('cl:Artist', namespace) lyric_id = item.find('cl:LyricId', namespace) if title is not None and artist is not None: # Get actual lyrics snippet = "Preview available - click to view full lyrics" results.append({ 'title': title.text or 'Unknown', 'artist': artist.text or 'Unknown', 'band': artist.text or 'Unknown', 'snippet': snippet, 'source': 'chartlyrics', 'lyric_id': lyric_id.text if lyric_id is not None else None }) # Limit to 5 results per search if len([r for r in results if r.get('source') == 'chartlyrics']) >= 5: break except: continue except Exception as e: # If external API fails, we still have local results pass # (Genius integration removed per user request) # Remove duplicates and limit total results seen = set() unique_results = [] for r in results: key = (r['title'].lower(), r['artist'].lower()) if key not in seen: seen.add(key) unique_results.append(r) if len(unique_results) >= 10: break # Add Lifeway Worship search link as an additional source entry try: import urllib.parse as _urlparse lifeway_url = f"https://worship.lifeway.com/findAndBuy/home?findMappable=false&searchString={_urlparse.quote(q)}" unique_results.append({ 'title': f'Search on Lifeway: {q}', 'artist': '', 'band': 'Lifeway Worship', 'snippet': 'Open Lifeway Worship results for this query', 'source': 'lifeway', 'url': lifeway_url }) except Exception: pass return jsonify({ 'query': q, 'filter': filter_type, 'results': unique_results, 'sources': 'Local DB + ChartLyrics (+ Lifeway link)' }) @app.route('/api/export/') @rate_limit(max_per_minute=10) def export_plan(plan_id): """Export a plan's songs as a formatted text document.""" if plan_id < 0: return jsonify({'error':'invalid_plan_id'}), 400 db = get_db() try: plan = db.query(Plan).filter(Plan.id == plan_id).first() if not plan: return jsonify({'error':'plan_not_found'}), 404 profile = db.query(Profile).get(plan.profile_id) if plan.profile_id else None plan_songs = db.query(PlanSong).filter(PlanSong.plan_id==plan_id).order_by(PlanSong.order_index).all() lines = [] lines.append('=' * 60) lines.append(f'WORSHIP SETLIST: {plan.date}') if profile: lines.append(f'Profile: {profile.name} (Key: {profile.default_key})') if plan.notes: lines.append(f'Notes: {plan.notes}') lines.append('=' * 60) lines.append('') for idx, ps in enumerate(plan_songs, 1): song = db.query(Song).filter(Song.id == ps.song_id).first() if not song: continue lines.append(f'{idx}. {song.title}') lines.append(f' Artist: {song.artist or "Unknown"} | Band: {song.band or "Unknown"}') lines.append('') if song.lyrics: lines.append(' LYRICS:') for line in song.lyrics.splitlines(): lines.append(f' {line}') lines.append('') if song.chords: lines.append(' CHORDS:') for line in song.chords.splitlines(): lines.append(f' {line}') lines.append('') lines.append('-' * 60) lines.append('') content = '\n'.join(lines) response = app.make_response(content) response.headers['Content-Type'] = 'text/plain; charset=utf-8' response.headers['Content-Disposition'] = f'attachment; filename="setlist_{plan.date}.txt"' return response except Exception as e: return jsonify({'error': str(e)}), 500 finally: pass # Session cleanup handled by teardown_appcontext # ============================================================================== # USER MANAGEMENT ENDPOINTS # ============================================================================== @app.route('/api/auth/login', methods=['POST']) @rate_limit(max_per_minute=5) # Stricter rate limit for login def login(): """Authenticate user with username and password""" data = request.get_json() or {} username = sanitize_text(data.get('username', ''), 255).strip() password = data.get('password', '').strip() if not username or not password: return jsonify({'success': False, 'error': 'username_password_required'}), 400 # Rate limiting: track failed attempts if len(password) > 128: # Prevent DoS via long passwords return jsonify({'success': False, 'error': 'invalid_credentials'}), 401 db = get_db() try: # Query User table user = db.query(User).filter(User.username == username).first() if not user: # Constant-time response to prevent user enumeration # Still hash a dummy password to prevent timing attacks import bcrypt bcrypt.checkpw(b'dummy', bcrypt.gensalt()) logger.warning(f'Failed login attempt for non-existent user: {username} from {request.remote_addr}') return jsonify({'success': False, 'error': 'invalid_credentials'}), 401 # Check if user is active if not user.active: logger.warning(f'Login attempt for disabled account: {username} from {request.remote_addr}') return jsonify({'success': False, 'error': 'account_disabled'}), 403 # Verify password if user.check_password(password): # SECURITY: Regenerate session ID to prevent session fixation old_session_data = dict(session) session.clear() # Update last login user.last_login = datetime.now() db.commit() # Set new session with regenerated ID session['username'] = user.username session['role'] = user.role session['permissions'] = user.get_permissions_list() session['login_time'] = datetime.now().isoformat() session.permanent = True logger.info(f'Successful login for {username} from {request.remote_addr}') return jsonify({ 'success': True, 'username': user.username, 'role': user.role, 'permissions': user.get_permissions_list() }) else: logger.warning(f'Failed login attempt for {username} from {request.remote_addr}') return jsonify({'success': False, 'error': 'invalid_credentials'}), 401 except Exception as e: logger.error(f"Login error: {e}") return jsonify({'success': False, 'error': 'server_error'}), 500 @app.route('/api/auth/logout', methods=['POST']) @rate_limit(max_per_minute=30) def logout(): """Logout current user""" session.clear() return jsonify({'success': True}) @app.route('/api/users', methods=['GET', 'POST']) @rate_limit(max_per_minute=30) @require_auth @require_permission('settings') def users(): """Get all users or create a new user""" db = get_db() try: if request.method == 'GET': # Check if requester is admin or has settings permission username = session.get('username') if not username: return jsonify({'error': 'unauthorized', 'message': 'Not logged in'}), 401 user = db.query(User).filter(User.username == username).first() if not user or (user.role != 'admin' and not user.has_permission('settings')): return jsonify({'error': 'admin_only', 'message': 'Admin access or Settings permission required'}), 403 # Get all users all_users = db.query(User).all() return jsonify([{ 'id': u.id, 'username': u.username, 'role': u.role, 'permissions': u.get_permissions_list(), 'active': u.active, 'created_at': u.created_at.isoformat() if u.created_at else None, 'last_login': u.last_login.isoformat() if u.last_login else None } for u in all_users]) # POST - Create new user # Check if requester is admin or has settings permission username_auth = session.get('username') if not username_auth: return jsonify({'error': 'unauthorized', 'message': 'Not logged in'}), 401 user_auth = db.query(User).filter(User.username == username_auth).first() if not user_auth or (user_auth.role != 'admin' and not user_auth.has_permission('settings')): return jsonify({'error': 'admin_only', 'message': 'Admin access or Settings permission required'}), 403 data = request.get_json() or {} username = data.get('username', '').strip() password = data.get('password', '').strip() role = data.get('role', 'viewer').strip() permissions = data.get('permissions', ['view']) if not username or not password: return jsonify({'error': 'username_password_required'}), 400 # Check if username exists existing = db.query(User).filter(User.username == username).first() if existing: return jsonify({'error': 'username_exists'}), 409 # Create user new_user = User( username=username, role=role, permissions=','.join(permissions) if isinstance(permissions, list) else permissions, active=True ) new_user.set_password(password) db.add(new_user) db.commit() db.refresh(new_user) return jsonify({ 'success': True, 'id': new_user.id, 'username': new_user.username, 'role': new_user.role, 'permissions': new_user.get_permissions_list(), 'active': new_user.active }), 201 except Exception as e: db.rollback() logger.error(f"User management error: {e}") return jsonify({'error': str(e)}), 500 finally: pass @app.route('/api/users/', methods=['GET', 'PUT', 'DELETE']) @rate_limit(max_per_minute=30) @require_auth @require_permission('settings') def user_detail(user_id): """Get, update, or delete a specific user""" db = get_db() try: user = db.query(User).filter(User.id == user_id).first() if not user: return jsonify({'error': 'user_not_found'}), 404 if request.method == 'GET': return jsonify({ 'id': user.id, 'username': user.username, 'role': user.role, 'permissions': user.get_permissions_list(), 'active': user.active, 'created_at': user.created_at.isoformat() if user.created_at else None, 'last_login': user.last_login.isoformat() if user.last_login else None }) # Check admin access for PUT/DELETE auth_user = session.get('username') auth_user_obj = db.query(User).filter(User.username == auth_user).first() if not auth_user_obj or (auth_user_obj.role != 'admin' and not auth_user_obj.has_permission('settings')): return jsonify({'error': 'admin_only', 'message': 'Admin access or Settings permission required'}), 403 if request.method == 'PUT': data = request.get_json() or {} # Update username if provided and different if 'username' in data and data['username'] and data['username'] != user.username: new_username = data['username'].strip() # Check if new username already exists existing = db.query(User).filter(User.username == new_username, User.id != user_id).first() if existing: return jsonify({'success': False, 'error': 'username_already_exists'}), 409 # Update biometric credentials to point to new username from postgresql_models import BiometricCredential biometric_creds = db.query(BiometricCredential).filter(BiometricCredential.user_id == user.id).all() for cred in biometric_creds: cred.username = new_username user.username = new_username # Update password if provided if 'password' in data and data['password']: user.set_password(data['password']) # Update role if provided if 'role' in data: user.role = data['role'] # Update permissions if provided if 'permissions' in data: perms = data['permissions'] user.permissions = ','.join(perms) if isinstance(perms, list) else perms # Update active status if provided if 'active' in data: user.active = bool(data['active']) user.updated_at = datetime.now() db.commit() db.refresh(user) return jsonify({ 'success': True, 'id': user.id, 'username': user.username, 'role': user.role, 'permissions': user.get_permissions_list(), 'active': user.active }) # DELETE db.delete(user) db.commit() return jsonify({'success': True, 'status': 'deleted'}) except Exception as e: db.rollback() logger.error(f"User detail error: {e}") return jsonify({'error': str(e)}), 500 finally: pass @app.route('/api/auth/check', methods=['GET']) @rate_limit(max_per_minute=60) def check_auth(): """Check if current session is authenticated and return user info""" username = session.get('username') role = session.get('role') if not username: return jsonify({'authenticated': False}), 401 # Validate session hasn't expired (additional check) login_time_str = session.get('login_time') if login_time_str: from datetime import datetime, timedelta try: login_time = datetime.fromisoformat(login_time_str) if datetime.now() - login_time > timedelta(hours=8): session.clear() return jsonify({'authenticated': False, 'reason': 'session_expired'}), 401 except (ValueError, TypeError): pass db = get_db() try: # Check User table first user = db.query(User).filter(User.username == username).first() if user: # User found in database if not user.active: session.clear() return jsonify({'authenticated': False}), 401 # Update session with latest user data session['role'] = user.role session['permissions'] = user.get_permissions_list() return jsonify({ 'authenticated': True, 'username': user.username, 'role': user.role, 'permissions': user.get_permissions_list() }) else: # Fallback: session exists but user not in database yet # This handles legacy logins - return session data if role: return jsonify({ 'authenticated': True, 'username': username, 'role': role, 'permissions': session.get('permissions', ['view']) }) else: # Invalid session session.clear() return jsonify({'authenticated': False}), 401 except Exception as e: logger.error(f"Auth check error: {e}") return jsonify({'authenticated': False}), 500 # ============================================================================== # BIOMETRIC AUTHENTICATION ENDPOINTS # ============================================================================== @app.route('/api/biometric/register', methods=['POST']) @rate_limit(max_per_minute=10) def register_biometric(): """Register biometric for a user""" try: data = request.json username = data.get('username') device_name = data.get('deviceName', 'Unknown Device') device_fingerprint = data.get('deviceFingerprint') device_info = data.get('deviceInfo', '') if not all([username, device_fingerprint]): return jsonify({'error': 'Missing username or device fingerprint'}), 400 result = register_biometric_credential( username, device_name, device_fingerprint, device_info ) if result['success']: return jsonify(result) else: return jsonify(result), 400 except Exception as e: logger.error(f"Error in biometric registration: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/biometric/authenticate', methods=['POST']) @rate_limit(max_per_minute=30) def authenticate_biometric_endpoint(): """Authenticate using biometric - device already verified fingerprint""" try: data = request.json username = data.get('username') # Optional - can lookup by device fingerprint alone device_fingerprint = data.get('deviceFingerprint') logger.info(f"Biometric auth attempt for {username or 'device lookup'} from device {device_fingerprint}") if not device_fingerprint: logger.error("Missing device fingerprint") return jsonify({'error': 'Device fingerprint required'}), 400 result = authenticate_biometric_by_device(username, device_fingerprint) if result['success']: # Set Flask session - use username from result (may have been looked up by device) try: username = result['username'] session['authenticated'] = True session['username'] = username session['role'] = result['role'] session['user_id'] = username # Could get actual user_id if needed session['auth_time'] = datetime.now().isoformat() logger.info(f"Biometric auth successful for {username}, role: {result['role']}") except Exception as session_err: logger.error(f"Error setting session: {session_err}") return jsonify(result) else: logger.warning(f"Biometric auth failed for {username}: {result.get('error')}") return jsonify(result), 401 except Exception as e: logger.error(f"Error in biometric authentication: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @app.route('/api/biometric/status/', methods=['GET']) @rate_limit(max_per_minute=30) def get_biometric_status(username): """Get biometric status for a user""" try: result = get_user_biometric_status(username) return jsonify(result) except Exception as e: logger.error(f"Error getting biometric status: {e}") return jsonify({'error': str(e)}), 500 result = get_user_credentials(username) return jsonify(result) except Exception as e: logger.error(f"Error fetching credentials: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/biometric/credentials/', methods=['PUT']) @rate_limit(max_per_minute=30) def toggle_biometric_credential(credential_id): """Enable or disable a biometric credential""" try: data = request.json enabled = data.get('enabled', True) result = toggle_credential(credential_id, enabled) if result['success']: return jsonify(result) else: return jsonify(result), 404 except Exception as e: logger.error(f"Error toggling credential: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/biometric/credentials/', methods=['DELETE']) @rate_limit(max_per_minute=30) def delete_biometric_credential(credential_id): """Delete a biometric credential""" try: result = delete_credential(credential_id) if result['success']: return jsonify(result) else: return jsonify(result), 404 except Exception as e: logger.error(f"Error deleting credential: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/biometric/remove/', methods=['DELETE']) @rate_limit(max_per_minute=30) def remove_user_biometric(username): """Remove all biometric credentials for a user""" try: result = delete_all_user_biometrics(username) if result['success']: return jsonify(result) else: return jsonify(result), 500 except Exception as e: logger.error(f"Error removing biometric: {e}") return jsonify({'error': str(e)}), 500 if __name__ == '__main__': # Production mode: disable debug app.run(host='0.0.0.0', port=FLASK_PORT, debug=False)