135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
"""Flask application factory and SSE event system."""
|
|
import os
|
|
import secrets
|
|
from datetime import 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, get_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
|
|
|
|
|
|
# Startup weather fetch lock: only one Gunicorn worker may fetch at a time
|
|
# (others skip; the cache is then fresh so the regular scheduler job handles it).
|
|
_WEATHER_STARTUP_LOCK_KEY = 0x5745415448455253 # "WEATHERS"
|
|
|
|
|
|
def _startup_weather(app, db_path: str):
|
|
# Multi-worker guard: without this, every worker would hit the weather API
|
|
# concurrently on boot. Whoever wins the advisory lock runs the check/fetch.
|
|
lock_conn = None
|
|
got_lock = False
|
|
try:
|
|
import psycopg2
|
|
lock_conn = psycopg2.connect(db_path, connect_timeout=5)
|
|
cur = lock_conn.cursor()
|
|
cur.execute("SELECT pg_try_advisory_lock(%s)", (_WEATHER_STARTUP_LOCK_KEY,))
|
|
got_lock = bool(cur.fetchone()[0])
|
|
cur.close()
|
|
except Exception:
|
|
if lock_conn is not None:
|
|
try:
|
|
lock_conn.close()
|
|
except Exception:
|
|
pass
|
|
return
|
|
if not got_lock:
|
|
try:
|
|
lock_conn.close()
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
try:
|
|
try:
|
|
conn = get_db(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["updated_at"]:
|
|
try:
|
|
updated = tz_strptime(row["updated_at"], "%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}")
|
|
finally:
|
|
try:
|
|
lock_conn.close() # releases the advisory lock
|
|
except Exception:
|
|
pass
|