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)