diff --git a/.env.example b/.env.example index b73c996..f68f5c5 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ SECRET_KEY=replace-this-with-a-long-random-string +APP_ENV=production DATABASE_URL=postgresql+psycopg://user:password@host/dbname APP_BASE_URL=https://myboker.org +RATELIMIT_STORAGE_URI=redis://localhost:6379/0 MAIL_SERVER=smtp.sendgrid.net MAIL_PORT=587 MAIL_USE_TLS=true diff --git a/README.md b/README.md index bdeba2e..66fa1be 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,10 @@ Then open: The app reads these from `.env`: - `SECRET_KEY` +- `APP_ENV` - `DATABASE_URL` - `APP_BASE_URL` +- `RATELIMIT_STORAGE_URI` - `MAIL_SERVER` - `MAIL_PORT` - `MAIL_USE_TLS` @@ -84,6 +86,8 @@ The app reads these from `.env`: - `MAIL_PASSWORD` - `MAIL_DEFAULT_SENDER` +For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY`. Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. + ## Site Admin Access Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials. diff --git a/app.py b/app.py index 529ec5a..5fa6fca 100644 --- a/app.py +++ b/app.py @@ -2,10 +2,11 @@ from __future__ import annotations import click +import os from flask import Flask, render_template from auth import current_user_id, is_logged_in, is_site_admin, normalize_email -from config import Config +from config import DEFAULT_SECRET_KEY, Config, ProductionConfig from db import database_extensions_available, db, init_database from extensions import csrf, limiter, mail from routes.account import account_bp @@ -16,12 +17,22 @@ from storage import ensure_data_file from utils import cents_to_dollars, safe_date_label +def _config_for_environment(): + app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower() + if app_env in {"prod", "production"}: + return ProductionConfig + return Config + + def create_app(config_overrides: dict | None = None) -> Flask: app = Flask(__name__) - app.config.from_object(Config) + app.config.from_object(_config_for_environment()) if config_overrides: app.config.update(config_overrides) + if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: + raise RuntimeError("Set SECRET_KEY before running in production.") + ensure_data_file(app.config["DATA_PATH"]) init_database(app) csrf.init_app(app) diff --git a/config.py b/config.py index 8a81ae4..a8384a2 100644 --- a/config.py +++ b/config.py @@ -7,6 +7,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" +DEFAULT_SECRET_KEY = "change-this-before-deploying" ELIGIBLE_MIN_SESSIONS = 3 APP_VERSION = "2.5.29" @@ -32,7 +33,7 @@ load_local_env(BASE_DIR / ".env") class Config: APP_VERSION: str = APP_VERSION - SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying") + SECRET_KEY: str = os.getenv("SECRET_KEY", DEFAULT_SECRET_KEY) SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL) SQLALCHEMY_TRACK_MODIFICATIONS: bool = False SESSION_COOKIE_HTTPONLY: bool = True @@ -52,6 +53,7 @@ class Config: MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org") MAIL_TIMEOUT: float = float(os.getenv("MAIL_TIMEOUT", "5")) + RATELIMIT_STORAGE_URI: str | None = os.getenv("RATELIMIT_STORAGE_URI") or None class ProductionConfig(Config): diff --git a/migrations/versions/0001_public_foundation.py b/migrations/versions/0001_public_foundation.py index f002d06..8f431e3 100644 --- a/migrations/versions/0001_public_foundation.py +++ b/migrations/versions/0001_public_foundation.py @@ -184,7 +184,6 @@ def downgrade() -> None: op.drop_index(op.f("ix_league_memberships_league_id"), table_name="league_memberships") op.drop_table("league_memberships") op.drop_index(op.f("ix_leagues_slug"), table_name="leagues") - op.drop_index(op.f("ix_leagues_public_key"), table_name="leagues") op.drop_index(op.f("ix_leagues_created_by_user_id"), table_name="leagues") op.drop_table("leagues") op.drop_index(op.f("ix_users_email"), table_name="users") diff --git a/requirements.txt b/requirements.txt index b0ea65e..f5af794 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ Flask-Mail>=0.10,<1.0 Flask-Migrate>=4.0,<5.0 Flask-SQLAlchemy>=3.1,<4.0 Flask-WTF>=1.2,<2.0 +gunicorn>=22,<24 psycopg[binary]>=3.2,<4.0 +redis>=5,<6 diff --git a/tests/test_internal.py b/tests/test_internal.py index 6d2587a..e636a2e 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -103,9 +103,9 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertIn(b"Overview", response.data) self.assertIn(b"admin@example.com", response.data) self.assertIn(b"Active users", response.data) - self.assertIn(b"Returning users", response.data) - self.assertIn(b"Recorded ledger volume", response.data) - self.assertIn(b"Not platform revenue", response.data) + self.assertIn(b"Activity breakdown", response.data) + self.assertIn(b"Ledger events", response.data) + self.assertIn(b"Totals at a glance", response.data) def test_admin_can_search_users(self): self.login_as(self.admin_id)