import os import secrets from datetime import UTC, timedelta from pathlib import Path from zoneinfo import ZoneInfo import click from flask import Flask, render_template, request from .extensions import csrf, db, limiter, login_manager, migrate ROOT = Path(__file__).resolve().parents[1] def create_app(config=None): app = Flask(__name__, instance_path=os.environ.get("INSTANCE_PATH", str(ROOT / "instance"))) production = os.environ.get("APP_ENV") == "production" app.config.from_mapping( SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///dashboard.db"), SECRET_KEY=os.environ.get("SECRET_KEY"), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=production, PERMANENT_SESSION_LIFETIME=timedelta(days=7), MAX_CONTENT_LENGTH=16 * 1024 * 1024, LOCAL_SETUP=not production, DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")), TOKEN_ENCRYPTION_KEY=os.environ.get("TOKEN_ENCRYPTION_KEY"), RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"), ) app.config.update(config or {}) if production: from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) if production and (not app.config["SECRET_KEY"] or not os.environ.get("DATABASE_URL")): raise RuntimeError("Production requires SECRET_KEY and DATABASE_URL.") if not app.config["SECRET_KEY"]: secret_path = Path(app.instance_path) / ".app-secret" secret_path.parent.mkdir(parents=True, exist_ok=True) try: fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as secret: secret.write(secrets.token_hex(32)) except FileExistsError: pass app.config["SECRET_KEY"] = secret_path.read_text() uri = app.config["SQLALCHEMY_DATABASE_URI"] if uri.startswith("postgres://"): app.config["SQLALCHEMY_DATABASE_URI"] = uri.replace( "postgres://", "postgresql+psycopg://", 1 ) elif uri.startswith("postgresql://"): app.config["SQLALCHEMY_DATABASE_URI"] = uri.replace( "postgresql://", "postgresql+psycopg://", 1 ) db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) login_manager.login_view = "web.login" csrf.init_app(app) limiter.init_app(app) from .models import User from .routes import web app.register_blueprint(web) from .sync import register_cli register_cli(app) @login_manager.user_loader def load_user(user_id): return db.session.get(User, int(user_id)) if user_id.isdigit() else None @app.template_filter("duration") def duration(value): value = round(value or 0) return ( f"{value // 3600}:{value % 3600 // 60:02d}:{value % 60:02d}" if value >= 3600 else f"{value // 60}:{value % 60:02d}" ) @app.template_filter("localdate") def localdate(value, timezone="Australia/Sydney", fmt="%d %b %Y"): return value.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).strftime(fmt) @app.after_request def security_headers(response): if not response.content_type.startswith(("text/css", "application/javascript", "image/")): response.headers["Cache-Control"] = "no-store" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" if request.path.startswith("/shared/runs/"): # OSM tiles require a Referer; never include the share token in it. response.headers["Referrer-Policy"] = "strict-origin" response.headers["X-Robots-Tag"] = "noindex, nofollow" return response for code in (400, 403, 404, 413, 429): app.register_error_handler( code, lambda error: (render_template("error.html", code=error.code), error.code) ) @app.cli.command("import-local") @click.option("--email", required=True) @click.option("--account-key", required=True) def import_local(email, account_key): """Operator-only command to assign an existing local Garmin export to a user.""" import re from .services import import_cache if not re.fullmatch(r"[a-f0-9]{24}", account_key): raise click.ClickException("Invalid account key") user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower())) if not user: raise click.ClickException("User not found") user.local_account_key = account_key count = import_cache(user, app.config["DATA_ROOT"]) db.session.commit() click.echo(f"Imported {count} activities.") return app