Spaces:
Sleeping
Sleeping
File size: 17,277 Bytes
1fff71f c2efe33 187ecdc 1fff71f c2efe33 1fff71f 7b364ef 187ecdc 7b364ef b1d4138 a70a19f 1fff71f b1d4138 1fff71f 7b364ef 6ccdec2 7b364ef a70a19f 1fff71f 7b364ef cd18a99 fc0bd33 cd18a99 a70a19f cd18a99 a70a19f 018ba2e 76ae346 1fff71f 187ecdc 7cf6441 187ecdc 7cf6441 187ecdc 1fff71f c2efe33 1fff71f 2e18bf2 1fff71f 2e18bf2 1fff71f 1b3481f 1fff71f c2efe33 1fff71f 1ab5126 1fff71f 1ab5126 1fff71f 196e0b9 1fff71f 722c09b 0c9c431 722c09b e01ff01 722c09b e01ff01 722c09b e01ff01 722c09b 1fff71f |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
"""
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
@app.route("/")
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
@app.route("/health")
def health():
"""Health check endpoint for monitoring."""
return jsonify({"status": "ok"}), 200
# Diagnostic endpoint to check environment variables
@app.route("/debug/config")
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."""
@app.before_request
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
@app.after_request
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
@app.after_request
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)
|