"""Flask application factory and SSE event system.""" import json import os import queue import secrets import sqlite3 import threading from datetime import datetime, timedelta from backend.time_util import now as tz_now, strptime as tz_strptime from flask import Flask from flask_cors import CORS from werkzeug.middleware.proxy_fix import ProxyFix from backend.models import init_db from backend.routes import register_routes from backend.events import EventBus from backend.weather_mgr import fetch_weather, fetch_hourly def create_app(db_path: str = "data.db") -> Flask: app = Flask( __name__, template_folder="../frontend/templates", static_folder="../frontend/static", ) # Generate a secure random key at startup (kept for compatibility). app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) # Defensive check: ensure key is never empty if not app.config["SECRET_KEY"]: app.config["SECRET_KEY"] = secrets.token_hex(32) app.config["DB_PATH"] = db_path # Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # Dual-token auth: access token in Authorization header, refresh token in # an httpOnly cookie. No Flask session cookies are used anymore. # Strict CORS: only allow same-origin requests (no wildcard for cookie auth) CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only # Content size limit: 5MB for uploads app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 # Global event bus app.event_bus = EventBus() init_db(db_path) register_routes(app) # Security headers on every response @app.after_request def _security_headers(resp): resp.headers["X-Content-Type-Options"] = "nosniff" resp.headers["X-Frame-Options"] = "DENY" resp.headers["Referrer-Policy"] = "same-origin" # Strict CSP limits inline styles/scripts — relax if custom HTML used resp.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "connect-src 'self'; " "frame-ancestors 'none'" ) return resp # Startup weather fetch: only if cache is stale (>= 30 min) or empty with app.app_context(): _startup_weather(app, db_path) return app def _startup_weather(app, db_path: str): try: conn = sqlite3.connect(db_path) row = conn.execute( "SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1" ).fetchone() conn.close() except Exception: row = None should_fetch = True if row and row[0]: try: updated = tz_strptime(row[0], "%Y-%m-%d %H:%M:%S") age = tz_now() - updated if age < timedelta(minutes=30): should_fetch = False app.logger.info( f"Startup: weather cache is {int(age.total_seconds()/60)} min old, skip fetch" ) except Exception: pass if should_fetch: try: fetch_weather() fetch_hourly() app.logger.info("Startup: weather fetched (cache stale or empty)") except Exception as e: app.logger.warning(f"Startup weather fetch failed: {e}")