feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
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
|
||||
|
||||
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=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=str(ROOT / "data" / "garmin"),
|
||||
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
|
||||
)
|
||||
app.config.update(config or {})
|
||||
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)
|
||||
|
||||
@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"
|
||||
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
|
||||
Reference in New Issue
Block a user