takahe/takahe/settings.py

482 wiersze
15 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import os
import secrets
import sys
import urllib.parse
from pathlib import Path
from typing import Literal
import dj_database_url
import django_cache_url
import httpx
import sentry_sdk
from corsheaders.defaults import default_headers
from pydantic import AnyUrl, BaseSettings, EmailStr, Field, validator
from takahe import __version__
BASE_DIR = Path(__file__).resolve().parent.parent
class CacheBackendUrl(AnyUrl):
host_required = False
allowed_schemes = django_cache_url.BACKENDS.keys()
class ImplicitHostname(AnyUrl):
host_required = False
class MediaBackendUrl(AnyUrl):
host_required = False
allowed_schemes = {"s3", "s3-insecure", "gs", "local"}
def as_bool(v: str | list[str] | None):
if v is None:
return False
if isinstance(v, str):
v = [v]
return v[0].lower() in ("true", "yes", "t", "1")
Environments = Literal["debug", "development", "production", "test"]
TAKAHE_ENV_FILE = os.environ.get(
"TAKAHE_ENV_FILE", "test.env" if "pytest" in sys.modules else ".env"
)
class Settings(BaseSettings):
"""
Pydantic-powered settings, to provide consistent error messages, strong
typing, consistent prefixes, .venv support, etc.
"""
#: The default database.
DATABASE_SERVER: ImplicitHostname | None
#: The currently running environment, used for things such as sentry
#: error reporting.
ENVIRONMENT: Environments = "development"
#: Should django run in debug mode?
DEBUG: bool = False
#: Should the debug toolbar be loaded?
DEBUG_TOOLBAR: bool = False
#: Should we atttempt to import the 'local_settings.py'
LOCAL_SETTINGS: 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: "autokey-" + 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: str | None = None
SENTRY_SAMPLE_RATE: float = 1.0
SENTRY_TRACES_SAMPLE_RATE: float = 0.01
SENTRY_CAPTURE_MESSAGES: bool = False
SENTRY_EXPERIMENTAL_PROFILES_TRACES_SAMPLE_RATE: float = 0.0
#: Fallback domain for links.
MAIN_DOMAIN: str = "example.com"
EMAIL_SERVER: AnyUrl = "console://localhost"
EMAIL_FROM: EmailStr = "test@example.com"
AUTO_ADMIN_EMAIL: EmailStr | None = None
ERROR_EMAILS: list[EmailStr] | None = None
#: If set, a list of user agents to completely disallow in robots.txt
#: List formatting must be a valid JSON list, such as `["Agent1", "Agent2"]`
ROBOTS_TXT_DISALLOWED_USER_AGENTS: list[str] = Field(default_factory=list)
MEDIA_URL: str = "/media/"
MEDIA_ROOT: str = str(BASE_DIR / "media")
MEDIA_BACKEND: MediaBackendUrl | None = None
#: S3 ACL to apply to all media objects when MEDIA_BACKEND is set to S3. If using a CDN
#: and/or have public access blocked to buckets this will likely need to be 'private'
MEDIA_BACKEND_S3_ACL: str = "public-read"
#: Maximum filesize when uploading images. Increasing this may increase memory utilization
#: because all images with a dimension greater than 2000px are resized to meet that limit, which
#: is necessary for compatibility with Mastodons image proxy.
MEDIA_MAX_IMAGE_FILESIZE_MB: int = 10
#: Maximum filesize for Avatars. Remote avatars larger than this size will
#: not be fetched and served from media, but served through the image proxy.
AVATAR_MAX_IMAGE_FILESIZE_KB: int = 1000
#: Maximum filesize for Emoji. Attempting to upload Local Emoji larger than this size will be
#: blocked. Remote Emoji larger than this size will not be fetched and served from media, but
#: served through the image proxy.
EMOJI_MAX_IMAGE_FILESIZE_KB: int = 200
#: Request timeouts to use when talking to other servers Either
#: float or tuple of floats for (connect, read, write, pool)
REMOTE_TIMEOUT: float | tuple[float, float, float, float] = 5.0
#: If search features like full text search should be enabled.
#: (placeholder setting, no effect)
SEARCH: bool = True
#: Default cache backend
CACHES_DEFAULT: CacheBackendUrl | None = None
# How long to wait, in days, until remote posts/profiles are pruned from
# our database if nobody local has interacted with them.
# Set to zero to disable.
REMOTE_PRUNE_HORIZON: int = 90
# Stator tuning
STATOR_CONCURRENCY: int = 20
STATOR_CONCURRENCY_PER_MODEL: int = 4
# If user migration is allowed (off by default until outbound is done)
ALLOW_USER_MIGRATION: bool = False
# Web Push keys
# Generate via https://web-push-codelab.glitch.me/
VAPID_PUBLIC_KEY: str | None = None
VAPID_PRIVATE_KEY: str | None = None
PGHOST: str | None = None
PGPORT: int | None = 5432
PGNAME: str = "takahe"
PGUSER: str = "postgres"
PGPASSWORD: str | None = None
@validator("PGHOST", always=True)
def validate_db(cls, PGHOST, values): # noqa
if not values.get("DATABASE_SERVER") and not PGHOST:
raise ValueError("Either DATABASE_SERVER or PGHOST are required.")
return PGHOST
class Config:
env_prefix = "TAKAHE_"
env_file = str(BASE_DIR / TAKAHE_ENV_FILE)
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()
# Don't allow automatic keys in production
if not SETUP.DEBUG and SETUP.SECRET_KEY.startswith("autokey-"):
print("You must set TAKAHE_SECRET_KEY in production")
sys.exit(1)
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.contrib.postgres",
"corsheaders",
"django_htmx",
"hatchway",
"core",
"activities",
"api",
"mediaproxy",
"stator",
"users",
]
MIDDLEWARE = [
"core.middleware.SentryTaggingMiddleware",
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"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.HeadersMiddleware",
"core.middleware.ConfigLoadingMiddleware",
"api.middleware.ApiTokenMiddleware",
"users.middleware.DomainMiddleware",
]
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",
"users.context.user_context",
],
},
},
]
WSGI_APPLICATION = "takahe.wsgi.application"
if SETUP.DATABASE_SERVER:
DATABASES = {
"default": dj_database_url.parse(SETUP.DATABASE_SERVER, conn_max_age=600)
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"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"]
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
}
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
WHITENOISE_MAX_AGE = 3600
STATIC_ROOT = BASE_DIR / "static-collected"
ALLOWED_HOSTS = SETUP.ALLOWED_HOSTS
AUTO_ADMIN_EMAIL = SETUP.AUTO_ADMIN_EMAIL
STATOR_TOKEN = SETUP.STATOR_TOKEN
STATOR_CONCURRENCY = SETUP.STATOR_CONCURRENCY
STATOR_CONCURRENCY_PER_MODEL = SETUP.STATOR_CONCURRENCY_PER_MODEL
ROBOTS_TXT_DISALLOWED_USER_AGENTS = SETUP.ROBOTS_TXT_DISALLOWED_USER_AGENTS
CORS_ORIGIN_ALLOW_ALL = True # Temporary
CORS_ORIGIN_WHITELIST = SETUP.CORS_HOSTS
CORS_ALLOW_CREDENTIALS = True
CORS_PREFLIGHT_MAX_AGE = 604800
CORS_EXPOSE_HEADERS = ("link",)
CORS_ALLOW_HEADERS = (*default_headers, "Idempotency-Key")
JSONLD_MAX_SIZE = 1024 * 50 # 50 KB
CSRF_TRUSTED_ORIGINS = SETUP.CSRF_HOSTS
MEDIA_URL = SETUP.MEDIA_URL
MEDIA_ROOT = SETUP.MEDIA_ROOT
MAIN_DOMAIN = SETUP.MAIN_DOMAIN
if not DEBUG and MAIN_DOMAIN == "example.com":
raise ValueError("You must set a TAKAHE_MAIN_DOMAIN!")
# Debug toolbar should only be loaded at all when debug is on
if DEBUG and SETUP.DEBUG_TOOLBAR:
INSTALLED_APPS.append("debug_toolbar")
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": "core.middleware.show_toolbar"}
MIDDLEWARE.insert(8, "debug_toolbar.middleware.DebugToolbarMiddleware")
if SETUP.USE_PROXY_HEADERS:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
if SETUP.SENTRY_DSN:
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
sentry_experiments = {}
if SETUP.SENTRY_EXPERIMENTAL_PROFILES_TRACES_SAMPLE_RATE > 0:
sentry_experiments[
"profiles_sample_rate"
] = SETUP.SENTRY_EXPERIMENTAL_PROFILES_TRACES_SAMPLE_RATE
sentry_sdk.init(
dsn=SETUP.SENTRY_DSN,
integrations=[
DjangoIntegration(),
HttpxIntegration(),
LoggingIntegration(),
],
traces_sample_rate=SETUP.SENTRY_TRACES_SAMPLE_RATE,
sample_rate=SETUP.SENTRY_SAMPLE_RATE,
send_default_pii=True,
environment=SETUP.ENVIRONMENT,
_experiments=sentry_experiments,
)
sentry_sdk.set_tag("takahe.version", __version__)
SERVER_EMAIL = SETUP.EMAIL_FROM
if SETUP.EMAIL_SERVER:
parsed = urllib.parse.urlparse(SETUP.EMAIL_SERVER)
query = urllib.parse.parse_qs(parsed.query)
if parsed.scheme == "console":
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
elif parsed.scheme == "sendgrid":
EMAIL_HOST = "smtp.sendgrid.net"
EMAIL_PORT = 587
EMAIL_HOST_USER = "apikey"
# urlparse will lowercase it
EMAIL_HOST_PASSWORD = SETUP.EMAIL_SERVER.split("://")[1]
EMAIL_USE_TLS = True
elif parsed.scheme == "smtp":
EMAIL_HOST = parsed.hostname
EMAIL_PORT = parsed.port
if parsed.username is not None:
EMAIL_HOST_USER = urllib.parse.unquote(parsed.username)
if parsed.password is not None:
EMAIL_HOST_PASSWORD = urllib.parse.unquote(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_SERVER.")
if SETUP.MEDIA_BACKEND:
parsed = urllib.parse.urlparse(SETUP.MEDIA_BACKEND)
query = urllib.parse.parse_qs(parsed.query)
if parsed.scheme == "gs":
STORAGES["default"]["BACKEND"] = "core.uploads.TakaheGoogleCloudStorage"
GS_BUCKET_NAME = parsed.path.lstrip("/")
GS_QUERYSTRING_AUTH = False
if parsed.hostname is not None:
port = parsed.port or 443
GS_CUSTOM_ENDPOINT = f"https://{parsed.hostname}:{port}"
elif (parsed.scheme == "s3") or (parsed.scheme == "s3-insecure"):
STORAGES["default"]["BACKEND"] = "core.uploads.TakaheS3Storage"
AWS_STORAGE_BUCKET_NAME = parsed.path.lstrip("/")
AWS_QUERYSTRING_AUTH = False
AWS_DEFAULT_ACL = SETUP.MEDIA_BACKEND_S3_ACL
if parsed.username is not None:
AWS_ACCESS_KEY_ID = parsed.username
AWS_SECRET_ACCESS_KEY = urllib.parse.unquote(parsed.password)
if parsed.hostname is not None:
if parsed.scheme == "s3-insecure":
s3_default_port = 80
s3_scheme = "http"
else:
s3_default_port = 443
s3_scheme = "https"
port = parsed.port or s3_default_port
AWS_S3_ENDPOINT_URL = f"{s3_scheme}://{parsed.hostname}:{port}"
if SETUP.MEDIA_URL is not None:
media_url_parsed = urllib.parse.urlparse(SETUP.MEDIA_URL)
AWS_S3_CUSTOM_DOMAIN = media_url_parsed.hostname
elif parsed.scheme == "local":
if not (MEDIA_ROOT and MEDIA_URL):
raise ValueError(
"You must provide MEDIA_ROOT and MEDIA_URL for a local media backend"
)
if "://" not in MEDIA_URL and not DEBUG:
raise ValueError(
"The MEDIA_URL setting must start with https://your-domain"
)
else:
raise ValueError(f"Unsupported media backend {parsed.scheme}")
CACHES = {
"default": django_cache_url.parse(SETUP.CACHES_DEFAULT or "dummy://"),
}
if SETUP.ERROR_EMAILS:
ADMINS = [("Admin", e) for e in SETUP.ERROR_EMAILS]
TAKAHE_USER_AGENT = (
f"python-httpx/{httpx.__version__} "
f"(Takahe/{__version__}; +https://{SETUP.MAIN_DOMAIN}/)"
)
if SETUP.LOCAL_SETTINGS:
# Let any errors bubble up
from .local_settings import * # noqa