Spaces:
Sleeping
Sleeping
| """ | |
| Flask application entry point. | |
| Feature: 012-profile-contact-ui | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import time | |
| from datetime import timedelta | |
| from flask import Flask, jsonify, request, g | |
| from flask_session import Session | |
| from dotenv import load_dotenv | |
| from opentelemetry import trace | |
| from opentelemetry.trace import Status, StatusCode | |
| from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator | |
| from .services.auth_service import auth_service | |
| from .services.storage_service import init_db | |
| from .utils.tracing import init_tracer | |
| # Load environment variables | |
| load_dotenv() | |
| class JSONFormatter(logging.Formatter): | |
| """Custom JSON formatter for structured logging.""" | |
| def format(self, record): | |
| """Format log record as JSON.""" | |
| log_obj = { | |
| "timestamp": self.formatTime(record, self.datefmt), | |
| "level": record.levelname, | |
| "module": record.module, | |
| "message": record.getMessage(), | |
| } | |
| # Add exception info if present | |
| if record.exc_info: | |
| log_obj["exception"] = self.formatException(record.exc_info) | |
| # Add extra fields if present | |
| if hasattr(record, "request_id"): | |
| log_obj["request_id"] = record.request_id | |
| if hasattr(record, "user_id"): | |
| log_obj["user_id"] = record.user_id | |
| if hasattr(record, "duration_ms"): | |
| log_obj["duration_ms"] = record.duration_ms | |
| if hasattr(record, "backend_latency_ms"): | |
| log_obj["backend_latency_ms"] = record.backend_latency_ms | |
| if hasattr(record, "status_code"): | |
| log_obj["status_code"] = record.status_code | |
| if hasattr(record, "method"): | |
| log_obj["method"] = record.method | |
| if hasattr(record, "path"): | |
| log_obj["path"] = record.path | |
| return json.dumps(log_obj) | |
| def create_app(): | |
| """Create and configure Flask application.""" | |
| app = Flask(__name__) | |
| # Configuration | |
| app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key-change-me") | |
| # CRITICAL: Use server-side sessions to avoid cookie size limits (4KB) | |
| # OAuth state tokens make cookies exceed size limit, causing state to be lost | |
| app.config["SESSION_TYPE"] = "filesystem" # Store sessions server-side | |
| app.config["SESSION_FILE_DIR"] = "/app/data/flask_sessions" # Session storage directory | |
| app.config["SESSION_PERMANENT"] = False # Default to non-permanent (override per-session) | |
| app.config["SESSION_USE_SIGNER"] = True # Sign session cookie for security | |
| # Session cookie configuration | |
| # For HTTPS (HF Spaces): Set SESSION_COOKIE_SECURE=true | |
| secure_cookie = os.getenv("SESSION_COOKIE_SECURE", "False").lower() == "true" | |
| app.config["SESSION_COOKIE_SECURE"] = secure_cookie | |
| app.config["SESSION_COOKIE_HTTPONLY"] = ( | |
| os.getenv("SESSION_COOKIE_HTTPONLY", "True").lower() == "true" | |
| ) | |
| # Use "None" for cross-site OAuth (HF Spaces), "Lax" for same-site (local dev) | |
| samesite_value = os.getenv("SESSION_COOKIE_SAMESITE", "Lax") | |
| app.config["SESSION_COOKIE_SAMESITE"] = None if samesite_value == "None" else samesite_value | |
| app.config["SESSION_COOKIE_NAME"] = "prepmate_session" # Explicit session cookie name | |
| # Don't set SESSION_COOKIE_DOMAIN - let Flask handle it automatically | |
| app.config["SESSION_COOKIE_PATH"] = "/" # Ensure cookie is valid for all paths | |
| app.config["PERMANENT_SESSION_LIFETIME"] = timedelta( | |
| seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", "2592000")) | |
| ) | |
| # CRITICAL: Log ALL environment variables for OAuth debugging | |
| print("="*80) | |
| print("[CONFIG] ENVIRONMENT VARIABLES CHECK") | |
| print(f"[CONFIG] SESSION_COOKIE_SECURE env var: {os.getenv('SESSION_COOKIE_SECURE', 'NOT SET')}") | |
| print(f"[CONFIG] SESSION_COOKIE_SAMESITE env var: {os.getenv('SESSION_COOKIE_SAMESITE', 'NOT SET')}") | |
| print(f"[CONFIG] PREFERRED_URL_SCHEME env var: {os.getenv('PREFERRED_URL_SCHEME', 'NOT SET')}") | |
| print(f"[CONFIG] SECRET_KEY env var: {'SET' if os.getenv('SECRET_KEY') else 'NOT SET'}") | |
| print("-"*80) | |
| print("[CONFIG] FLASK CONFIG VALUES:") | |
| print(f"[CONFIG] SESSION_COOKIE_SECURE={secure_cookie}") | |
| print(f"[CONFIG] SESSION_COOKIE_SAMESITE={app.config['SESSION_COOKIE_SAMESITE']}") | |
| print(f"[CONFIG] SESSION_COOKIE_NAME={app.config['SESSION_COOKIE_NAME']}") | |
| print(f"[CONFIG] PERMANENT_SESSION_LIFETIME={app.config['PERMANENT_SESSION_LIFETIME']}") | |
| print("="*80) | |
| # FAIL LOUDLY if OAuth won't work | |
| if not secure_cookie or app.config['SESSION_COOKIE_SAMESITE'] != None: | |
| print("⚠️ WARNING: OAuth will FAIL - cookie won't persist!") | |
| print("⚠️ Set these in HuggingFace Space Settings → Variables:") | |
| print(" SESSION_COOKIE_SECURE=true") | |
| print(" SESSION_COOKIE_SAMESITE=None") | |
| # Authlib configuration for OAuth state management | |
| app.config["AUTHLIB_INSECURE_TRANSPORT"] = os.getenv("FLASK_ENV") == "development" | |
| # Trust proxy headers for HTTPS detection (needed for HuggingFace Spaces) | |
| app.config["PREFERRED_URL_SCHEME"] = os.getenv("PREFERRED_URL_SCHEME", "http") | |
| # Configure ProxyFix middleware to trust X-Forwarded-* headers | |
| from werkzeug.middleware.proxy_fix import ProxyFix | |
| app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) | |
| # Initialize database | |
| init_db() | |
| # Initialize Flask-Session (must be BEFORE OAuth init) | |
| # This stores sessions server-side to avoid 4KB cookie size limit | |
| print("="*80) | |
| print("[FLASK-SESSION] INITIALIZING SERVER-SIDE SESSIONS") | |
| print(f"[FLASK-SESSION] SESSION_TYPE: {app.config.get('SESSION_TYPE')}") | |
| print(f"[FLASK-SESSION] SESSION_FILE_DIR: {app.config.get('SESSION_FILE_DIR')}") | |
| print(f"[FLASK-SESSION] SESSION_PERMANENT: {app.config.get('SESSION_PERMANENT')}") | |
| print(f"[FLASK-SESSION] SESSION_USE_SIGNER: {app.config.get('SESSION_USE_SIGNER')}") | |
| # Check if session directory exists | |
| session_dir = app.config.get('SESSION_FILE_DIR') | |
| if session_dir and os.path.exists(session_dir): | |
| print(f"[FLASK-SESSION] Session directory exists: {session_dir}") | |
| print(f"[FLASK-SESSION] Directory writable: {os.access(session_dir, os.W_OK)}") | |
| else: | |
| print(f"[FLASK-SESSION] ⚠️ WARNING: Session directory does NOT exist: {session_dir}") | |
| Session(app) | |
| # Verify Flask-Session is active | |
| print(f"[FLASK-SESSION] Session interface type: {type(app.session_interface).__name__}") | |
| print(f"[FLASK-SESSION] Session interface module: {type(app.session_interface).__module__}") | |
| if 'flask_session' in type(app.session_interface).__module__: | |
| print("[FLASK-SESSION] ✅ Server-side sessions ACTIVE") | |
| else: | |
| print("[FLASK-SESSION] ❌ ERROR: Still using client-side sessions!") | |
| print("="*80) | |
| # Initialize OAuth | |
| auth_service.init_app(app) | |
| # Initialize Jaeger tracing | |
| init_tracer("prepmate-webapp") | |
| # Configure logging | |
| setup_logging(app) | |
| # Register middleware | |
| register_middleware(app) | |
| # Register blueprints | |
| from .routes import auth, profile, contacts, settings | |
| app.register_blueprint(auth.bp) | |
| app.register_blueprint(profile.bp) | |
| app.register_blueprint(contacts.contacts_bp) | |
| app.register_blueprint(settings.bp) | |
| # Root route - redirect to login | |
| def index(): | |
| """Root route - redirect to login page.""" | |
| from flask import session, redirect, url_for, render_template | |
| if "user_id" in session: | |
| return redirect(url_for("profile.view_profile")) | |
| return render_template("login.html") | |
| # Health check endpoint | |
| def health(): | |
| """Health check endpoint for monitoring.""" | |
| return jsonify({"status": "ok"}), 200 | |
| # Diagnostic endpoint to check environment variables | |
| def debug_config(): | |
| """Show current configuration for debugging.""" | |
| return jsonify({ | |
| "environment_variables": { | |
| "SESSION_COOKIE_SECURE": os.getenv("SESSION_COOKIE_SECURE", "NOT SET"), | |
| "SESSION_COOKIE_SAMESITE": os.getenv("SESSION_COOKIE_SAMESITE", "NOT SET"), | |
| "PREFERRED_URL_SCHEME": os.getenv("PREFERRED_URL_SCHEME", "NOT SET"), | |
| "SECRET_KEY": "SET" if os.getenv("SECRET_KEY") else "NOT SET", | |
| }, | |
| "flask_config": { | |
| "SESSION_COOKIE_SECURE": app.config.get("SESSION_COOKIE_SECURE"), | |
| "SESSION_COOKIE_SAMESITE": str(app.config.get("SESSION_COOKIE_SAMESITE")), | |
| "SESSION_COOKIE_NAME": app.config.get("SESSION_COOKIE_NAME"), | |
| "SESSION_COOKIE_PATH": app.config.get("SESSION_COOKIE_PATH"), | |
| "PREFERRED_URL_SCHEME": app.config.get("PREFERRED_URL_SCHEME"), | |
| } | |
| }), 200 | |
| return app | |
| def setup_logging(app): | |
| """Configure structured JSON logging.""" | |
| log_level = os.getenv("LOG_LEVEL", "INFO") | |
| # Create handler with JSON formatter | |
| handler = logging.StreamHandler() | |
| handler.setFormatter(JSONFormatter()) | |
| # Configure root logger | |
| logging.basicConfig( | |
| level=getattr(logging, log_level), | |
| handlers=[handler], | |
| ) | |
| # Configure app logger | |
| app.logger.handlers = [handler] | |
| app.logger.setLevel(getattr(logging, log_level)) | |
| def register_middleware(app): | |
| """Register Flask middleware for request logging and timing.""" | |
| def before_request(): | |
| """Start request timer and generate request ID, create tracing span.""" | |
| from .utils.tracing import is_tracing_enabled | |
| g.start_time = time.time() | |
| g.request_id = request.headers.get("X-Request-ID", os.urandom(8).hex()) | |
| # Skip tracing if disabled or for health endpoint | |
| if not is_tracing_enabled() or request.path == "/health": | |
| return | |
| # Extract parent span context from headers if present | |
| try: | |
| ctx = TraceContextTextMapPropagator().extract(carrier=request.headers) | |
| except Exception: | |
| ctx = None | |
| # Start a new span for this request | |
| tracer = trace.get_tracer(__name__) | |
| span = tracer.start_span( | |
| name=f"{request.method} {request.path}", | |
| context=ctx, | |
| ) | |
| span.set_attribute("http.method", request.method) | |
| span.set_attribute("http.url", request.url) | |
| span.set_attribute("request_id", g.request_id) | |
| g.span = span | |
| def after_request(response): | |
| """Log request completion with duration and finish span.""" | |
| if hasattr(g, "start_time"): | |
| duration_ms = (time.time() - g.start_time) * 1000 | |
| # Skip logging for Streamlit internal requests unless in debug mode | |
| is_streamlit_internal = request.path.startswith("/_stcore") | |
| is_debug_mode = app.logger.level == logging.DEBUG | |
| if not is_streamlit_internal or is_debug_mode: | |
| # Get user_id from session if available | |
| user_id = None | |
| try: | |
| from flask import session | |
| user_id = session.get("user_id") | |
| except Exception: | |
| pass | |
| # Log request with structured data | |
| extra = { | |
| "request_id": g.request_id, | |
| "duration_ms": round(duration_ms, 2), | |
| "status_code": response.status_code, | |
| "method": request.method, | |
| "path": request.path, | |
| } | |
| if user_id: | |
| extra["user_id"] = user_id | |
| if hasattr(g, "backend_latency_ms"): | |
| extra["backend_latency_ms"] = round(g.backend_latency_ms, 2) | |
| app.logger.info( | |
| f"{request.method} {request.path} {response.status_code}", | |
| extra=extra, | |
| ) | |
| # Finish tracing span | |
| if hasattr(g, "span"): | |
| span = g.span | |
| span.set_attribute("http.status_code", response.status_code) | |
| if response.status_code >= 400: | |
| span.set_status(Status(StatusCode.ERROR)) | |
| if hasattr(g, "backend_latency_ms"): | |
| span.set_attribute("backend_latency_ms", round(g.backend_latency_ms, 2)) | |
| span.end() | |
| return response | |
| # Session debugging and cookie SameSite=None fix | |
| def debug_and_fix_session_cookie(response): | |
| """Debug session cookie setting and force SameSite=None if needed.""" | |
| from flask import session | |
| # CRITICAL FIX: Force Flask-Session to set cookie when session is modified | |
| # Flask-Session only sets cookies for new sessions or when session.permanent changes | |
| # But we need it to ALWAYS set a cookie when session is modified to replace old client-side cookies | |
| if session.modified and not response.headers.get('Set-Cookie'): | |
| # Manually call save_session to force cookie setting | |
| app.session_interface.save_session(app, session, response) | |
| # ALWAYS log, even if session is empty | |
| print(f"[AFTER-REQUEST] Path: {request.path}, Status: {response.status_code}") | |
| print(f"[AFTER-REQUEST] Session modified: {session.modified}, Has data: {bool(session)}, Keys: {list(session.keys())}") | |
| print(f"[AFTER-REQUEST] Session interface: {type(app.session_interface).__name__}") | |
| # Log session state and response cookies | |
| set_cookie_headers = response.headers.getlist('Set-Cookie') | |
| has_session_cookie = any('prepmate_session' in cookie for cookie in set_cookie_headers) | |
| print(f"[AFTER-REQUEST] Set-Cookie count: {len(set_cookie_headers)}") | |
| print(f"[AFTER-REQUEST] Has prepmate_session cookie: {has_session_cookie}") | |
| print(f"[AFTER-REQUEST] All Set-Cookie headers: {set_cookie_headers}") | |
| if has_session_cookie: | |
| # Check if cookie looks like session ID (server-side) or full session (client-side) | |
| for cookie in set_cookie_headers: | |
| if 'prepmate_session' in cookie: | |
| # Extract cookie value (between = and ;) | |
| cookie_value = cookie.split('=')[1].split(';')[0] if '=' in cookie else '' | |
| print(f"[AFTER-REQUEST] Cookie value length: {len(cookie_value)} chars") | |
| print(f"[AFTER-REQUEST] Cookie value preview: {cookie_value[:50]}...") | |
| if cookie_value.startswith('.'): | |
| print("[AFTER-REQUEST] ⚠️ Cookie looks like CLIENT-SIDE session (starts with '.')") | |
| elif len(cookie_value) < 100: | |
| print("[AFTER-REQUEST] ✅ Cookie looks like SERVER-SIDE session ID (short)") | |
| else: | |
| print("[AFTER-REQUEST] ⚠️ Cookie looks like CLIENT-SIDE session (long)") | |
| # CRITICAL FIX: Flask's session interface doesn't properly set SameSite=None and Partitioned | |
| # Manually fix any prepmate_session cookies that are missing these attributes | |
| print("[AFTER-REQUEST] Fixing SameSite=None and Partitioned for session cookie") | |
| new_cookies = [] | |
| for cookie in set_cookie_headers: | |
| if 'prepmate_session' in cookie: | |
| # Check if SameSite=None is missing | |
| if 'SameSite=None' not in cookie: | |
| cookie = cookie.rstrip(';') + '; SameSite=None' | |
| print(f"[AFTER-REQUEST] Added SameSite=None to session cookie") | |
| # Check if Partitioned is missing (required for CHIPS - Cookies Having Independent Partitioned State) | |
| if 'Partitioned' not in cookie: | |
| cookie = cookie.rstrip(';') + '; Partitioned' | |
| print(f"[AFTER-REQUEST] Added Partitioned to session cookie") | |
| new_cookies.append(cookie) | |
| else: | |
| # Keep non-session cookies unchanged | |
| new_cookies.append(cookie) | |
| # Replace all Set-Cookie headers | |
| response.headers.remove('Set-Cookie') | |
| for cookie in new_cookies: | |
| response.headers.add('Set-Cookie', cookie) | |
| print(f"[AFTER-REQUEST] Final Set-Cookie headers: {response.headers.getlist('Set-Cookie')}") | |
| else: | |
| print("[AFTER-REQUEST] ⚠️ WARNING: No session cookie found in response!") | |
| print(f"[AFTER-REQUEST] This means Flask-Session did not set a cookie") | |
| print(f"[AFTER-REQUEST] Session was modified: {session.modified}") | |
| print(f"[AFTER-REQUEST] Session is new: {getattr(session, 'new', 'N/A')}") | |
| return response | |
| # Create app instance | |
| app = create_app() | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000")), debug=True) | |