summaryrefslogtreecommitdiffstats
path: root/takahe
diff options
context:
space:
mode:
Diffstat (limited to 'takahe')
-rw-r--r--takahe/asgi.py2
-rw-r--r--takahe/settings.py278
-rw-r--r--takahe/settings/__init__.py0
-rw-r--r--takahe/settings/base.py120
-rw-r--r--takahe/settings/development.py28
-rw-r--r--takahe/settings/production.py96
-rw-r--r--takahe/settings/testing.py6
-rw-r--r--takahe/wsgi.py2
8 files changed, 280 insertions, 252 deletions
diff --git a/takahe/asgi.py b/takahe/asgi.py
index 3424b23..99a9cfb 100644
--- a/takahe/asgi.py
+++ b/takahe/asgi.py
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings.production")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings")
application = get_asgi_application()
diff --git a/takahe/settings.py b/takahe/settings.py
new file mode 100644
index 0000000..76c8f8b
--- /dev/null
+++ b/takahe/settings.py
@@ -0,0 +1,278 @@
+import secrets
+import urllib.parse
+from pathlib import Path
+from typing import List, Literal, Optional, Union
+
+import dj_database_url
+import sentry_sdk
+from pydantic import AnyUrl, BaseSettings, EmailStr, Field, PostgresDsn, validator
+from sentry_sdk.integrations.django import DjangoIntegration
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+def as_bool(v: Optional[Union[str, List[str]]]):
+ if v is None:
+ return False
+
+ if isinstance(v, str):
+ v = [v]
+
+ return v[0].lower() in ("true", "yes", "t", "1")
+
+
+Environments = Literal["development", "production", "test"]
+
+
+class Settings(BaseSettings):
+ """
+ Pydantic-powered settings, to provide consistent error messages, strong
+ typing, consistent prefixes, .venv support, etc.
+ """
+
+ #: The default database.
+ DATABASE_URL: Optional[PostgresDsn]
+ #: The currently running environment, used for things such as sentry
+ #: error reporting.
+ ENVIRONMENT: Environments = "development"
+ #: Should django run in debug mode?
+ DEBUG: bool = False
+ #: Set a secret key used for signing values such as sessions. Randomized
+ #: by default, so you'll logout everytime the process restarts.
+ SECRET_KEY: str = Field(default_factory=lambda: secrets.token_hex(128))
+ #: Set a secret key used to protect the stator. Randomized by default.
+ STATOR_TOKEN: str = Field(default_factory=lambda: secrets.token_hex(128))
+
+ #: If set, a list of allowed values for the HOST header. The default value
+ #: of '*' means any host will be accepted.
+ ALLOWED_HOSTS: List[str] = Field(default_factory=lambda: ["*"])
+ #: If set, a list of hosts to accept for CORS.
+ CORS_HOSTS: List[str] = Field(default_factory=list)
+ #: If set, a list of hosts to accept for CSRF.
+ CSRF_HOSTS: List[str] = Field(default_factory=list)
+ #: If enabled, trust the HTTP_X_FORWARDED_FOR header.
+ USE_PROXY_HEADERS: bool = False
+
+ #: An optional Sentry DSN for error reporting.
+ SENTRY_DSN: Optional[str] = None
+
+ #: Fallback domain for links.
+ MAIN_DOMAIN: str = "example.com"
+
+ EMAIL_DSN: AnyUrl = "console://localhost"
+ EMAIL_FROM: EmailStr = "test@example.com"
+ AUTO_ADMIN_EMAIL: Optional[EmailStr] = None
+ ERROR_EMAILS: Optional[List[EmailStr]] = None
+
+ MEDIA_URL: str = "/media/"
+ MEDIA_ROOT: str = str(BASE_DIR / "MEDIA")
+ MEDIA_BACKEND: Optional[AnyUrl] = None
+
+ PGHOST: Optional[str] = None
+ PGPORT: int = 5432
+ PGNAME: str = "takahe"
+ PGUSER: str = "postgres"
+ PGPASSWORD: Optional[str] = None
+
+ @validator("PGHOST", always=True)
+ def validate_db(cls, PGHOST, values): # noqa
+ if not values.get("DATABASE_URL") and not PGHOST:
+ raise ValueError("Either DATABASE_URL or PGHOST are required.")
+ return PGHOST
+
+ class Config:
+ env_prefix = "TAKAHE_"
+ env_file = str(BASE_DIR / ".env")
+ env_file_encoding = "utf-8"
+ # Case sensitivity doesn't work on Windows, so might as well be
+ # consistent from the get-go.
+ case_sensitive = False
+
+ # Override the env_prefix so these fields load without TAKAHE_
+ fields = {
+ "PGHOST": {"env": "PGHOST"},
+ "PGPORT": {"env": "PGPORT"},
+ "PGNAME": {"env": "PGNAME"},
+ "PGUSER": {"env": "PGUSER"},
+ "PGPASSWORD": {"env": "PGPASSWORD"},
+ }
+
+
+SETUP = Settings()
+
+SECRET_KEY = SETUP.SECRET_KEY
+DEBUG = SETUP.DEBUG
+
+# Application definition
+
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "django_htmx",
+ "core",
+ "activities",
+ "users",
+ "stator",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "whitenoise.middleware.WhiteNoiseMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "django_htmx.middleware.HtmxMiddleware",
+ "core.middleware.ConfigLoadingMiddleware",
+ "users.middleware.IdentityMiddleware",
+]
+
+ROOT_URLCONF = "takahe.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [BASE_DIR / "templates"],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ "core.context.config_context",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "takahe.wsgi.application"
+
+if SETUP.DATABASE_URL:
+ DATABASES = {"default": dj_database_url.parse(SETUP.DATABASE_URL, conn_max_age=600)}
+else:
+ DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql_psycopg2",
+ "HOST": SETUP.PGHOST,
+ "PORT": SETUP.PGPORT,
+ "NAME": SETUP.PGNAME,
+ "USER": SETUP.PGUSER,
+ "PASSWORD": SETUP.PGPASSWORD,
+ }
+ }
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+]
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_TZ = True
+
+STATIC_URL = "static/"
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+AUTH_USER_MODEL = "users.User"
+
+LOGIN_URL = "/auth/login/"
+LOGOUT_URL = "/auth/logout/"
+LOGIN_REDIRECT_URL = "/"
+LOGOUT_REDIRECT_URL = "/"
+
+STATICFILES_FINDERS = [
+ "django.contrib.staticfiles.finders.FileSystemFinder",
+ "django.contrib.staticfiles.finders.AppDirectoriesFinder",
+]
+
+STATICFILES_DIRS = [BASE_DIR / "static"]
+
+STATIC_ROOT = BASE_DIR / "static-collected"
+
+ALLOWED_HOSTS = SETUP.ALLOWED_HOSTS
+
+AUTO_ADMIN_EMAIL = SETUP.AUTO_ADMIN_EMAIL
+
+STATOR_TOKEN = SETUP.STATOR_TOKEN
+
+CORS_ORIGIN_WHITELIST = SETUP.CORS_HOSTS
+CORS_ALLOW_CREDENTIALS = True
+CORS_PREFLIGHT_MAX_AGE = 604800
+
+CSRF_TRUSTED_ORIGINS = SETUP.CSRF_HOSTS
+
+MEDIA_URL = SETUP.MEDIA_URL
+MEDIA_ROOT = SETUP.MEDIA_ROOT
+MAIN_DOMAIN = SETUP.MAIN_DOMAIN
+
+if SETUP.USE_PROXY_HEADERS:
+ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+
+
+if SETUP.SENTRY_DSN:
+ sentry_sdk.init(
+ dsn=SETUP.SENTRY_DSN,
+ integrations=[
+ DjangoIntegration(),
+ ],
+ traces_sample_rate=1.0,
+ send_default_pii=True,
+ environment=SETUP.ENVIRONMENT,
+ )
+
+SERVER_EMAIL = SETUP.EMAIL_FROM
+if SETUP.EMAIL_DSN:
+ parsed = urllib.parse.urlparse(SETUP.EMAIL_DSN)
+ query = urllib.parse.parse_qs(parsed.query)
+ if parsed.scheme == "console":
+ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
+ elif parsed.scheme == "smtp":
+ EMAIL_HOST = parsed.hostname
+ EMAIL_PORT = parsed.port
+ EMAIl_HOST_USER = parsed.username
+ EMAIL_HOST_PASSWORD = parsed.password
+ EMAIL_USE_TLS = as_bool(query.get("tls"))
+ EMAIL_USE_SSL = as_bool(query.get("ssl"))
+ else:
+ raise ValueError("Unknown schema for EMAIL_DSN.")
+
+
+if SETUP.MEDIA_BACKEND:
+ parsed = urllib.parse.urlparse(SETUP.MEDIA_BACKEND)
+ query = urllib.parse.parse_qs(parsed.query)
+ if parsed.scheme == "gcs":
+ DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage"
+ GS_BUCKET_NAME = parsed.path.lstrip("/")
+ GS_QUERYSTRING_AUTH = False
+ elif parsed.scheme == "s3":
+ DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
+ AWS_STORAGE_BUCKET_NAME = parsed.path.lstrip("/")
+ AWS_ACCESS_KEY_ID = parsed.username
+ AWS_SECRET_ACCESS_KEY = parsed.password
+ port = parsed.port or 443
+ AWS_S3_ENDPOINT_URL = f"{parsed.hostname}:{port}"
+
+if SETUP.ERROR_EMAILS:
+ ADMINS = [("Admin", e) for e in SETUP.ERROR_EMAILS]
diff --git a/takahe/settings/__init__.py b/takahe/settings/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/takahe/settings/__init__.py
+++ /dev/null
diff --git a/takahe/settings/base.py b/takahe/settings/base.py
deleted file mode 100644
index 0ab3035..0000000
--- a/takahe/settings/base.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import os
-from pathlib import Path
-from typing import Optional
-
-BASE_DIR = Path(__file__).resolve().parent.parent.parent
-
-# Application definition
-
-INSTALLED_APPS = [
- "django.contrib.admin",
- "django.contrib.auth",
- "django.contrib.contenttypes",
- "django.contrib.sessions",
- "django.contrib.messages",
- "django.contrib.staticfiles",
- "django_htmx",
- "core",
- "activities",
- "users",
- "stator",
-]
-
-MIDDLEWARE = [
- "django.middleware.security.SecurityMiddleware",
- "whitenoise.middleware.WhiteNoiseMiddleware",
- "django.contrib.sessions.middleware.SessionMiddleware",
- "django.middleware.common.CommonMiddleware",
- "django.middleware.csrf.CsrfViewMiddleware",
- "django.contrib.auth.middleware.AuthenticationMiddleware",
- "django.contrib.messages.middleware.MessageMiddleware",
- "django.middleware.clickjacking.XFrameOptionsMiddleware",
- "django_htmx.middleware.HtmxMiddleware",
- "core.middleware.ConfigLoadingMiddleware",
- "users.middleware.IdentityMiddleware",
-]
-
-ROOT_URLCONF = "takahe.urls"
-
-TEMPLATES = [
- {
- "BACKEND": "django.template.backends.django.DjangoTemplates",
- "DIRS": [BASE_DIR / "templates"],
- "APP_DIRS": True,
- "OPTIONS": {
- "context_processors": [
- "django.template.context_processors.debug",
- "django.template.context_processors.request",
- "django.contrib.auth.context_processors.auth",
- "django.contrib.messages.context_processors.messages",
- "core.context.config_context",
- ],
- },
- },
-]
-
-WSGI_APPLICATION = "takahe.wsgi.application"
-
-DATABASES = {
- "default": {
- "ENGINE": "django.db.backends.postgresql_psycopg2",
- "HOST": os.environ.get("PGHOST", "localhost"),
- "PORT": os.environ.get("PGPORT", 5432),
- "NAME": os.environ.get("PGDATABASE", "takahe"),
- "USER": os.environ.get("PGUSER", "postgres"),
- "PASSWORD": os.environ.get("PGPASSWORD"),
- }
-}
-
-AUTH_PASSWORD_VALIDATORS = [
- {
- "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
- },
- {
- "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
- },
- {
- "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
- },
- {
- "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
- },
-]
-
-LANGUAGE_CODE = "en-us"
-
-TIME_ZONE = "UTC"
-
-USE_I18N = True
-
-USE_TZ = True
-
-STATIC_URL = "static/"
-
-DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
-
-AUTH_USER_MODEL = "users.User"
-
-LOGIN_URL = "/auth/login/"
-LOGOUT_URL = "/auth/logout/"
-LOGIN_REDIRECT_URL = "/"
-LOGOUT_REDIRECT_URL = "/"
-
-STATICFILES_FINDERS = [
- "django.contrib.staticfiles.finders.FileSystemFinder",
- "django.contrib.staticfiles.finders.AppDirectoriesFinder",
-]
-
-STATICFILES_DIRS = [
- BASE_DIR / "static",
-]
-
-STATIC_ROOT = BASE_DIR / "static-collected"
-
-ALLOWED_HOSTS = ["*"]
-
-AUTO_ADMIN_EMAIL: Optional[str] = None
-
-STATOR_TOKEN: Optional[str] = None
-
-SENTRY_ENABLED = False
diff --git a/takahe/settings/development.py b/takahe/settings/development.py
deleted file mode 100644
index ce75c85..0000000
--- a/takahe/settings/development.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import os
-import sys
-
-from .base import * # noqa
-
-# Load secret key from environment with a fallback
-SECRET_KEY = os.environ.get("TAKAHE_SECRET_KEY", "insecure_secret")
-
-# Ensure debug features are on
-DEBUG = True
-
-ALLOWED_HOSTS = ["*"]
-CSRF_TRUSTED_ORIGINS = [
- "http://127.0.0.1:8000",
- "https://127.0.0.1:8000",
-]
-SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
-
-EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
-SERVER_EMAIL = "test@example.com"
-
-MAIN_DOMAIN = os.environ.get("TAKAHE_MAIN_DOMAIN", "example.com")
-if "/" in MAIN_DOMAIN:
- print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path")
- sys.exit(1)
-
-MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")
-MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", BASE_DIR / "media")
diff --git a/takahe/settings/production.py b/takahe/settings/production.py
deleted file mode 100644
index b1034ef..0000000
--- a/takahe/settings/production.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import os
-import sys
-from typing import Optional
-
-from .base import * # noqa
-
-# Ensure debug features are off
-DEBUG = bool(os.environ.get("TAKAHE__SECURITY_HAZARD__DEBUG", False))
-
-# TODO: Allow better setting of allowed_hosts, if we need to
-ALLOWED_HOSTS = ["*"]
-
-CONN_MAX_AGE = 60
-
-### User-configurable options, pulled from the environment ###
-
-# Secret key
-try:
- SECRET_KEY = os.environ["TAKAHE_SECRET_KEY"]
-except KeyError:
- print("You must specify the TAKAHE_SECRET_KEY environment variable!")
- sys.exit(1)
-
-# SSL proxy header
-if "TAKAHE_SECURE_HEADER" in os.environ:
- SECURE_PROXY_SSL_HEADER = (
- "HTTP_" + os.environ["TAKAHE_SECURE_HEADER"].replace("-", "_").upper(),
- "https",
- )
-
-# Fallback domain for links
-MAIN_DOMAIN = os.environ["TAKAHE_MAIN_DOMAIN"]
-if "/" in MAIN_DOMAIN:
- print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path")
- sys.exit(1)
-
-# Email config
-if os.environ.get("TAKAHE_EMAIL_CONSOLE_ONLY"):
- EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
- SERVER_EMAIL = "test@example.com"
-else:
- SERVER_EMAIL = os.environ["TAKAHE_EMAIL_FROM"]
- if "TAKAHE_EMAIL_SENDGRID_KEY" in os.environ:
- EMAIL_HOST = "smtp.sendgrid.net"
- EMAIL_PORT = 587
- EMAIL_HOST_USER: Optional[str] = "apikey"
- EMAIL_HOST_PASSWORD: Optional[str] = os.environ["TAKAHE_EMAIL_SENDGRID_KEY"]
- EMAIL_USE_TLS = True
- else:
- EMAIL_HOST = os.environ["TAKAHE_EMAIL_HOST"]
- EMAIL_PORT = int(os.environ["TAKAHE_EMAIL_PORT"])
- EMAIL_HOST_USER = os.environ.get("TAKAHE_EMAIL_USER")
- EMAIL_HOST_PASSWORD = os.environ.get("TAKAHE_EMAIL_PASSWORD")
- EMAIL_USE_SSL = EMAIL_PORT == 465
- EMAIL_USE_TLS = EMAIL_PORT == 587
-
-AUTO_ADMIN_EMAIL = os.environ.get("TAKAHE_AUTO_ADMIN_EMAIL")
-
-# Media storage
-MEDIA_BACKEND = os.environ.get("TAKAHE_MEDIA_BACKEND", None)
-if MEDIA_BACKEND == "local":
- # Note that this MUST be a fully qualified URL in production
- MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")
- MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", BASE_DIR / "media")
-elif MEDIA_BACKEND == "gcs":
- DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage"
- GS_BUCKET_NAME = os.environ["TAKAHE_MEDIA_BUCKET"]
- GS_QUERYSTRING_AUTH = False
-elif MEDIA_BACKEND == "s3":
- DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
- AWS_STORAGE_BUCKET_NAME = os.environ["TAKAHE_MEDIA_BUCKET"]
-else:
- print("Unknown TAKAHE_MEDIA_BACKEND value")
- sys.exit(1)
-
-# Stator secret token
-STATOR_TOKEN = os.environ.get("TAKAHE_STATOR_TOKEN")
-
-# Error email recipients
-if "TAKAHE_ERROR_EMAILS" in os.environ:
- ADMINS = [("Admin", e) for e in os.environ["TAKAHE_ERROR_EMAILS"].split(",")]
-
-# Sentry integration
-if "SENTRY_DSN" in os.environ:
- import sentry_sdk
- from sentry_sdk.integrations.django import DjangoIntegration
-
- sentry_sdk.init(
- dsn=os.environ["SENTRY_DSN"],
- integrations=[
- DjangoIntegration(),
- ],
- traces_sample_rate=1.0,
- send_default_pii=True,
- )
- SENTRY_ENABLED = True
diff --git a/takahe/settings/testing.py b/takahe/settings/testing.py
deleted file mode 100644
index 39fda96..0000000
--- a/takahe/settings/testing.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .base import * # noqa
-
-# Fixed secret key
-SECRET_KEY = "testing_secret"
-
-MAIN_DOMAIN = "example.com"
diff --git a/takahe/wsgi.py b/takahe/wsgi.py
index c8ad0a0..05ae06f 100644
--- a/takahe/wsgi.py
+++ b/takahe/wsgi.py
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings.production")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "takahe.settings")
application = get_wsgi_application()