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 pydantic import AnyUrl, BaseSettings, EmailStr, Field, validator from sentry_sdk.integrations.django import DjangoIntegration from takahe.takahe import __version__ import saml2 from saml2.saml import NAMEID_FORMAT_PERSISTENT BASE_DIR = '/srv/takahe' 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", "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["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 = "production" #: 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: str | None = None SENTRY_SAMPLE_RATE: float = 1.0 SENTRY_TRACES_SAMPLE_RATE: float = 1.0 SENTRY_CAPTURE_MESSAGES: bool = False #: 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 MEDIA_URL: str = "/media/" MEDIA_ROOT: str = os.path.join(BASE_DIR, "media") MEDIA_BACKEND: MediaBackendUrl | None = None #: 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 Mastodon’s 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 #: User icon (avatar) caching backend CACHES_AVATARS: CacheBackendUrl | None = None #: Media caching backend CACHES_MEDIA: CacheBackendUrl | 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 = '/etc/sysconfig/takahe' 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", "corsheaders", "takahe.core", "takahe.activities", "takahe.api", "takahe.mediaproxy", "takahe.stator", "takahe.users", "djangosaml2", ] MIDDLEWARE = [ "takahe.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", "takahe.core.middleware.HeadersMiddleware", "takahe.core.middleware.ConfigLoadingMiddleware", "takahe.api.middleware.ApiTokenMiddleware", "takahe.users.middleware.IdentityMiddleware", "takahe.activities.middleware.EmojiDefaultsLoadingMiddleware", "djangosaml2.middleware.SamlSessionMiddleware", ] ROOT_URLCONF = "takahe.takahe.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [os.path.join(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", "takahe.core.context.config_context", ], }, }, ] WSGI_APPLICATION = "takahe.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 = "/saml2/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 = [os.path.join(BASE_DIR, "static")] STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" WHITENOISE_MAX_AGE = 3600 STATIC_ROOT = os.path.join(BASE_DIR, "static-collected") ALLOWED_HOSTS = SETUP.ALLOWED_HOSTS AUTO_ADMIN_EMAIL = SETUP.AUTO_ADMIN_EMAIL STATOR_TOKEN = SETUP.STATOR_TOKEN CORS_ORIGIN_ALLOW_ALL = True # Temporary 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=SETUP.SENTRY_TRACES_SAMPLE_RATE, sample_rate=SETUP.SENTRY_SAMPLE_RATE, send_default_pii=True, environment=SETUP.ENVIRONMENT, ) 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 EMAIL_HOST_USER = urllib.parse.unquote(parsed.username) 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": DEFAULT_FILE_STORAGE = "takahe.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": DEFAULT_FILE_STORAGE = "takahe.core.uploads.TakaheS3Storage" AWS_STORAGE_BUCKET_NAME = parsed.path.lstrip("/") AWS_QUERYSTRING_AUTH = False AWS_DEFAULT_ACL = "public-read" 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: port = parsed.port or 443 AWS_S3_ENDPOINT_URL = f"https://{parsed.hostname}:{port}" 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" ) else: raise ValueError(f"Unsupported media backend {parsed.scheme}") CACHES = { "default": django_cache_url.parse(SETUP.CACHES_DEFAULT or "dummy://"), "avatars": django_cache_url.parse(SETUP.CACHES_AVATARS or "dummy://"), "media": django_cache_url.parse(SETUP.CACHES_MEDIA 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}/)" ) AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', 'djangosaml2.backends.Saml2Backend', ) SAML_SESSION_COOKIE_NAME = 'takahe_test_session' SESSION_COOKIE_SECURE = False SESSION_EXPIRE_AT_BROWSER_CLOSE = True SAML_DJANGO_USER_MAIN_ATTRIBUTE = 'email' SAML_ATTRIBUTE_MAPPING = { 'email': ('email', ), } SAML_CONFIG = { # full path to the xmlsec1 binary programm 'xmlsec_binary': '/usr/bin/xmlsec1', # your entity id, usually your subdomain plus the url to the metadata view 'entityid': 'https://__REPLACE_ME__/saml2/metadata/', 'attribute_map_dir': os.path.join(BASE_DIR, 'attribute-maps'), 'allow_unknown_attributes': False, 'service': { 'sp' : { 'name': 'Federated Takahe sample SP', 'name_id_format': saml2.saml.NAMEID_FORMAT_PERSISTENT, 'endpoints': { 'assertion_consumer_service': [ ('https://__REPLACE_ME__/saml2/acs/', saml2.BINDING_HTTP_POST), ], 'single_logout_service': [ ('https://__REPLACE_ME__/saml2/ls/', saml2.BINDING_HTTP_REDIRECT), ('https://__REPLACE_ME__/saml2/ls/post', saml2.BINDING_HTTP_POST), ], }, 'signing_algorithm': saml2.xmldsig.SIG_RSA_SHA256, 'digest_algorithm': saml2.xmldsig.DIGEST_SHA256, 'force_authn': False, 'name_id_format_allow_create': False, 'required_attributes': ['uid', 'email'], 'want_response_signed': True, 'authn_requests_signed': True, 'logout_requests_signed': True, 'want_assertions_signed': True, 'only_use_keys_in_metadata': True, 'allow_unsolicited': False, 'metadata': { # in production, use local file # 'local': [os.path.join(BASE_DIR, 'remote_metadata.xml')], 'remote': [{"url": "https://libsso.net/realms/LibertaCasa/protocol/saml/descriptor"},], }, 'debug': 0, 'key_file': '__REPLACE_ME__', # private part 'cert_file': '__REPLACE_ME__', # public part # Encryption # 'encryption_keypairs': [{ # 'key_file': '__REPLACE_ME__', # private part # 'cert_file': '__REPLACE_ME__', # public part # }], } LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { 'console': { 'class': 'logging.StreamHandler', }, }, 'root': { 'handlers': ['console'], 'level': 'INFO', }, }