diff options
Diffstat (limited to 'takahe')
-rw-r--r-- | takahe/asgi.py | 2 | ||||
-rw-r--r-- | takahe/settings.py | 278 | ||||
-rw-r--r-- | takahe/settings/__init__.py | 0 | ||||
-rw-r--r-- | takahe/settings/base.py | 120 | ||||
-rw-r--r-- | takahe/settings/development.py | 28 | ||||
-rw-r--r-- | takahe/settings/production.py | 96 | ||||
-rw-r--r-- | takahe/settings/testing.py | 6 | ||||
-rw-r--r-- | takahe/wsgi.py | 2 |
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() |