Files
Stride/app/__init__.py
T

149 lines
5.6 KiB
Python

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"),
ORS_API_KEY=os.environ.get("ORS_API_KEY"),
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
)
app.config.update(config or {})
if not app.config.get("ORS_API_KEY") and not production:
routing_key = Path(app.instance_path) / "ors-api-key"
if routing_key.is_file():
app.config["ORS_API_KEY"] = routing_key.read_text(encoding="utf-8").strip()
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 .analysis_library import library
app.register_blueprint(library)
from .training import training
app.register_blueprint(training)
from .planner import planner
app.register_blueprint(planner)
from .sync import register_cli
register_cli(app)
from .records import register_cli as register_records_cli
register_records_cli(app)
from .display import register_cli as register_display_cli
register_display_cli(app)
from .training_signals import register_cli as register_training_cli
register_training_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