Initial commit: InviteMe production redesign
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
|
||||
def _env_bool(name, default=False):
|
||||
return os.environ.get(name, str(default)).lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
|
||||
class Config:
|
||||
APP_ENV = os.environ.get("APP_ENV", "development").lower()
|
||||
IS_PRODUCTION = APP_ENV == "production"
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY") or (None if IS_PRODUCTION else secrets.token_hex(32))
|
||||
CREDENTIAL_ENCRYPTION_KEY = os.environ.get("CREDENTIAL_ENCRYPTION_KEY") or SECRET_KEY
|
||||
BASE_URL = os.environ.get("BASE_URL") or None
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or f"sqlite:///{BASE_DIR / 'instance' / 'inviteme.db'}"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True}
|
||||
|
||||
# Uploads
|
||||
# User data must never live below Flask's public static directory.
|
||||
UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER") or BASE_DIR / "instance" / "uploads")
|
||||
PHOTO_FOLDER = UPLOAD_FOLDER / "photos"
|
||||
QR_FOLDER = UPLOAD_FOLDER / "qrcodes"
|
||||
LOGO_FOLDER = UPLOAD_FOLDER / "logos"
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get("MAX_CONTENT_LENGTH", 32 * 1024 * 1024))
|
||||
MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_BYTES", 10 * 1024 * 1024))
|
||||
MAX_IMAGE_PIXELS = int(os.environ.get("MAX_IMAGE_PIXELS", 25_000_000))
|
||||
MAX_ZIP_FILES = int(os.environ.get("MAX_ZIP_FILES", 2_000))
|
||||
MAX_ZIP_UNCOMPRESSED_BYTES = int(os.environ.get("MAX_ZIP_UNCOMPRESSED_BYTES", 250 * 1024 * 1024))
|
||||
MAX_XLSX_UNCOMPRESSED_BYTES = int(os.environ.get("MAX_XLSX_UNCOMPRESSED_BYTES", 50 * 1024 * 1024))
|
||||
MAX_EXCEL_ROWS = int(os.environ.get("MAX_EXCEL_ROWS", 10_000))
|
||||
|
||||
# Email (SMTP)
|
||||
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "smtp.yandex.ru"
|
||||
MAIL_PORT = int(os.environ.get("MAIL_PORT") or 465)
|
||||
MAIL_USE_SSL = _env_bool("MAIL_USE_SSL", True)
|
||||
MAIL_USERNAME = os.environ.get("MAIL_USERNAME") or ""
|
||||
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") or ""
|
||||
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") or MAIL_USERNAME
|
||||
|
||||
# Default event name for migrating existing guests
|
||||
DEFAULT_EVENT_NAME = os.environ.get("DEFAULT_EVENT_NAME") or "Архивное мероприятие"
|
||||
|
||||
# Local timezone for display (e.g. Europe/Moscow)
|
||||
TIMEZONE = os.environ.get("TIMEZONE") or "Europe/Moscow"
|
||||
|
||||
# Session security (HTTPS is required for these flags)
|
||||
SESSION_COOKIE_SECURE = _env_bool("SESSION_COOKIE_SECURE", IS_PRODUCTION)
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Strict"
|
||||
SESSION_COOKIE_NAME = "inviteme_session"
|
||||
PERMANENT_SESSION_LIFETIME = int(os.environ.get("PERMANENT_SESSION_LIFETIME", "28800")) # 8 часов
|
||||
|
||||
# CSRF protection
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_TIME_LIMIT = 3600
|
||||
|
||||
# Rate limiting
|
||||
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI") or "memory://"
|
||||
RATELIMIT_STRATEGY = "fixed-window"
|
||||
TASK_QUEUE_REDIS_URL = os.environ.get("TASK_QUEUE_REDIS_URL") or (
|
||||
RATELIMIT_STORAGE_URI if RATELIMIT_STORAGE_URI.startswith("redis://") else ""
|
||||
)
|
||||
TASK_QUEUE_EAGER = _env_bool("TASK_QUEUE_EAGER", not IS_PRODUCTION)
|
||||
|
||||
# Deployment hardening
|
||||
ALLOW_WEB_SETUP = _env_bool("ALLOW_WEB_SETUP", not IS_PRODUCTION)
|
||||
ENFORCE_HTTPS = _env_bool("ENFORCE_HTTPS", IS_PRODUCTION)
|
||||
TRUST_PROXY_HEADERS = _env_bool("TRUST_PROXY_HEADERS", False)
|
||||
PREFERRED_URL_SCHEME = "https" if IS_PRODUCTION else "http"
|
||||
TRUSTED_HOSTS = [host.strip() for host in os.environ.get("TRUSTED_HOSTS", "").split(",") if host.strip()] or None
|
||||
MIN_PASSWORD_LENGTH = int(os.environ.get("MIN_PASSWORD_LENGTH", "12"))
|
||||
SKIP_DB_BOOTSTRAP = _env_bool("SKIP_DB_BOOTSTRAP", False)
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
if not cls.IS_PRODUCTION:
|
||||
return
|
||||
|
||||
placeholders = {
|
||||
"change-me-in-production",
|
||||
"dev-secret-key-change-in-production",
|
||||
"your-strong-secret-key-here",
|
||||
}
|
||||
if not cls.SECRET_KEY or cls.SECRET_KEY in placeholders or len(str(cls.SECRET_KEY)) < 32:
|
||||
raise RuntimeError("Production requires a unique SECRET_KEY of at least 32 characters")
|
||||
if not cls.CREDENTIAL_ENCRYPTION_KEY or len(str(cls.CREDENTIAL_ENCRYPTION_KEY)) < 32:
|
||||
raise RuntimeError("Production requires CREDENTIAL_ENCRYPTION_KEY of at least 32 characters")
|
||||
if not cls.BASE_URL or not str(cls.BASE_URL).startswith("https://"):
|
||||
raise RuntimeError("Production requires an HTTPS BASE_URL")
|
||||
if not cls.TRUSTED_HOSTS:
|
||||
raise RuntimeError("Production requires TRUSTED_HOSTS")
|
||||
if str(cls.SQLALCHEMY_DATABASE_URI).startswith("sqlite:"):
|
||||
raise RuntimeError("Production requires PostgreSQL via DATABASE_URL")
|
||||
if cls.RATELIMIT_STORAGE_URI == "memory://":
|
||||
raise RuntimeError("Production requires shared rate-limit storage, for example Redis")
|
||||
if not cls.TASK_QUEUE_REDIS_URL.startswith("redis://"):
|
||||
raise RuntimeError("Production requires Redis task queue storage")
|
||||
if cls.TASK_QUEUE_EAGER:
|
||||
raise RuntimeError("Production cannot execute task queue jobs eagerly")
|
||||
if not cls.SESSION_COOKIE_SECURE or not cls.ENFORCE_HTTPS or cls.ALLOW_WEB_SETUP:
|
||||
raise RuntimeError("Unsafe production HTTP or setup configuration")
|
||||
Reference in New Issue
Block a user