Files
iot-dboard/backend/app.py
T
2026-08-11 19:57:21 +08:00

103 lines
3.5 KiB
Python

"""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}")