from __future__ import annotations import base64 import hashlib import ipaddress import os from pathlib import Path from urllib.parse import urlparse from django.core.exceptions import ImproperlyConfigured BASE_DIR = Path(__file__).resolve().parent.parent def env_bool(name: str, default: bool = False) -> bool: return os.getenv(name, "1" if default else "0").lower() in {"1", "true", "yes", "on"} def env_list(name: str, default: str = "") -> list[str]: return [item.strip() for item in os.getenv(name, default).split(",") if item.strip()] def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int: try: value = int(os.getenv(name, str(default))) except ValueError as exc: raise ImproperlyConfigured(f"{name} moet een geheel getal zijn.") from exc if not minimum <= value <= maximum: raise ImproperlyConfigured(f"{name} moet tussen {minimum} en {maximum} liggen.") return value def env_cidrs(name: str, default: str = "") -> list[str]: networks: list[str] = [] for value in env_list(name, default): try: networks.append(str(ipaddress.ip_network(value, strict=False))) except ValueError as exc: raise ImproperlyConfigured(f"{name} bevat een ongeldig IP-netwerk: {value}") from exc return networks def merge_unique(*groups: list[str]) -> list[str]: result: list[str] = [] seen: set[str] = set() for group in groups: for value in group: key = value.casefold() if key not in seen: seen.add(key) result.append(value) return result def normalize_public_base_url(value: str) -> tuple[str, str]: raw = value.strip().rstrip("/") if not raw: return "", "" parsed = urlparse(raw) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise ImproperlyConfigured("PUBLIC_BASE_URL moet een geldige http(s)-URL zijn.") if parsed.username or parsed.password or parsed.query or parsed.fragment: raise ImproperlyConfigured( "PUBLIC_BASE_URL mag geen credentials, query of fragment bevatten." ) if parsed.path not in {"", "/"}: raise ImproperlyConfigured("PUBLIC_BASE_URL mag geen applicatiepad bevatten.") try: port = parsed.port except ValueError as exc: raise ImproperlyConfigured("PUBLIC_BASE_URL bevat een ongeldige poort.") from exc host = parsed.hostname.casefold() display_host = f"[{host}]" if ":" in host else host default_port = 443 if parsed.scheme == "https" else 80 port_suffix = f":{port}" if port and port != default_port else "" return f"{parsed.scheme}://{display_host}{port_suffix}", host def configured_public_base_url() -> tuple[str, str]: raw = os.getenv("PUBLIC_BASE_URL", "").strip() if not raw: app_host = os.getenv("APP_HOST", "").strip() if app_host: scheme = os.getenv("APP_SCHEME", "https").strip().lower() or "https" public_port = os.getenv("APP_PUBLIC_PORT", "").strip() raw = f"{scheme}://{app_host}{f':{public_port}' if public_port else ''}" return normalize_public_base_url(raw) def _validate_production_security( *, debug: bool, secret_key: str, allowed_hosts: list[str], csrf_trusted_origins: list[str], session_cookie_secure: bool, csrf_cookie_secure: bool, secure_ssl_redirect: bool, public_base_url: str = "", demo_mode_enabled: bool = False, demo_read_only: bool = True, ) -> None: if debug: return if secret_key.strip().lower() in {"", "change-me", "dev-only-change-me", "secret", "insecure"}: raise ImproperlyConfigured( "Onveilige DJANGO_SECRET_KEY in productie. Stel een sterke waarde in via environment." ) if len(secret_key.strip()) < 50: raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.") if not allowed_hosts: raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.") if any(host.strip() == "*" for host in allowed_hosts): raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie geen wildcard bevatten.") if not csrf_trusted_origins: raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.") if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins): raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS moet HTTPS-origins bevatten in productie.") if not session_cookie_secure or not csrf_cookie_secure: raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.") if not secure_ssl_redirect: raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.") if public_base_url and not public_base_url.startswith("https://"): raise ImproperlyConfigured("PUBLIC_BASE_URL moet HTTPS gebruiken in productie.") if demo_mode_enabled and not demo_read_only: raise ImproperlyConfigured("Een publieke productiedemo moet alleen-lezen zijn.") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me") DEBUG = env_bool("DJANGO_DEBUG", True) PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url() ALLOWED_HOSTS = merge_unique( env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver"), [PUBLIC_HOST] if PUBLIC_HOST else [], ) CSRF_TRUSTED_ORIGINS = merge_unique( env_list("DJANGO_CSRF_TRUSTED_ORIGINS"), [PUBLIC_BASE_URL] if PUBLIC_BASE_URL else [], ) TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels") VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens" VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.17").strip() or "0.3.17" LANGUAGE_CODE = "nl-be" USE_I18N = True USE_TZ = True INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "apps.core", "apps.profiles", "apps.sources", "apps.jobs", "apps.notifications", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "apps.core.middleware.DemoReadOnlyMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "apps.core.middleware.SecurityHeadersMiddleware", ] ROOT_URLCONF = "config.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.navigation_context", ], }, } ] WSGI_APPLICATION = "config.wsgi.application" ASGI_APPLICATION = "config.asgi.application" DATABASE_URL = os.getenv("DATABASE_URL", "").strip() POSTGRES_DB = os.getenv("POSTGRES_DB", "vacatureradar") POSTGRES_USER = os.getenv("POSTGRES_USER", "vacatureradar") POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "") POSTGRES_HOST = os.getenv("POSTGRES_HOST", "postgres") POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") def _postgres_database_from_env() -> dict[str, str | int]: if POSTGRES_PASSWORD.lower().startswith("change_me") or POSTGRES_PASSWORD == "CHANGE_ME": return {} if not (POSTGRES_PASSWORD and POSTGRES_HOST and POSTGRES_DB and POSTGRES_USER): return {} return { "ENGINE": "django.db.backends.postgresql", "NAME": POSTGRES_DB, "USER": POSTGRES_USER, "PASSWORD": POSTGRES_PASSWORD, "HOST": POSTGRES_HOST, "PORT": POSTGRES_PORT, "CONN_MAX_AGE": 60, "OPTIONS": {"connect_timeout": 10}, } if DATABASE_URL: parsed = urlparse(DATABASE_URL) DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": parsed.path.lstrip("/"), "USER": parsed.username, "PASSWORD": parsed.password, "HOST": parsed.hostname, "PORT": parsed.port or 5432, "CONN_MAX_AGE": 60, "OPTIONS": {"connect_timeout": 10}, } } elif postgres_cfg := _postgres_database_from_env(): DATABASES = {"default": postgres_cfg} else: DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "local" / "db.sqlite3", } } 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"}, ] STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / "static"] STORAGES = { "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, "staticfiles": { "BACKEND": ( "whitenoise.storage.CompressedStaticFilesStorage" if DEBUG else "whitenoise.storage.CompressedManifestStaticFilesStorage" ) }, } MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" LOGIN_URL = "login" LOGIN_REDIRECT_URL = "dashboard:today" LOGOUT_REDIRECT_URL = "login" # Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback. AUTHENTICATION_BACKENDS = [ "apps.core.auth_backends.EmailOrUsernameModelBackend", "django.contrib.auth.backends.ModelBackend", ] PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600))) # Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd. ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False) ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "") ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "") ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "") ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "") ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read") ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS") # Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet. # Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie. DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", DEBUG) DEMO_READ_ONLY = env_bool("DEMO_READ_ONLY", True) DEMO_SESSION_SECONDS = env_int_range("DEMO_SESSION_SECONDS", 3600, minimum=300, maximum=86400) DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo") DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local") EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar ") EMAIL_HOST = os.getenv("SMTP_HOST", "") EMAIL_PORT = int(os.getenv("SMTP_PORT", "587")) EMAIL_HOST_USER = os.getenv("SMTP_USER", "") EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "") EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True) M365_TENANT_ID = os.getenv("M365_TENANT_ID", "") M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "") M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "") M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER) M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default" M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120) SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" _PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://") SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS) CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS) CSRF_COOKIE_SAMESITE = "Lax" SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_HTTPS) TRUST_PROXY_HEADERS = env_bool("TRUST_PROXY_HEADERS", bool(PUBLIC_BASE_URL)) SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if TRUST_PROXY_HEADERS else None USE_X_FORWARDED_HOST = env_bool("USE_X_FORWARDED_HOST", False) TRUSTED_PROXY_CIDRS = env_cidrs("TRUSTED_PROXY_CIDRS") # Interne containerprobes blijven HTTP; publieke applicatieroutes worden wel omgeleid. SECURE_REDIRECT_EXEMPT = [r"^health/(?:live|ready)/$"] SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0")) SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True) SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False) SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" X_FRAME_OPTIONS = "DENY" SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False) REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") CACHE_URL = os.getenv("CACHE_URL", "" if DEBUG else REDIS_URL).strip() _IS_TESTING = bool(os.getenv("PYTEST_CURRENT_TEST") or "pytest" in os.sys.modules) if CACHE_URL and not _IS_TESTING: CACHES = { "default": { "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": CACHE_URL, "KEY_PREFIX": "vacatureradar", "TIMEOUT": 300, } } else: CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "vacatureradar-local", } } HEALTHCHECK_REQUIRE_CACHE = env_bool("HEALTHCHECK_REQUIRE_CACHE", not DEBUG) CELERY_BROKER_URL = REDIS_URL CELERY_RESULT_BACKEND = REDIS_URL CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False) CELERY_TASK_EAGER_PROPAGATES = True CELERY_TASK_ACKS_LATE = True CELERY_WORKER_PREFETCH_MULTIPLIER = 1 CELERY_TASK_DEFAULT_QUEUE = "default" CELERY_TASK_TIME_LIMIT = 300 CELERY_TASK_SOFT_TIME_LIMIT = 270 CELERY_TIMEZONE = TIME_ZONE IMAP_MAX_MESSAGES_PER_POLL = env_int_range( "IMAP_MAX_MESSAGES_PER_POLL", 200, minimum=1, maximum=1000 ) IMAP_MAX_MESSAGE_BYTES = env_int_range( "IMAP_MAX_MESSAGE_BYTES", 1000000, minimum=1024, maximum=10000000 ) IMAP_CONNECT_TIMEOUT_SECONDS = env_int_range( "IMAP_CONNECT_TIMEOUT_SECONDS", 15, minimum=3, maximum=60 ) MAILBOX_CREDENTIAL_KEYS = env_list("MAILBOX_CREDENTIAL_KEYS") if DEBUG and not MAILBOX_CREDENTIAL_KEYS: MAILBOX_CREDENTIAL_KEYS = [ base64.urlsafe_b64encode( hashlib.sha256(f"vacatureradar-mailbox:{SECRET_KEY}".encode()).digest() ).decode("ascii") ] CELERY_BEAT_SCHEDULE = { "runtime-heartbeat": { "task": "apps.core.tasks.record_runtime_heartbeat", "schedule": 60.0, }, "schedule-mailbox-polls": { "task": "apps.sources.tasks.schedule_mailbox_polls", "schedule": 300.0, }, "schedule-due-sources": { "task": "apps.sources.tasks.schedule_due_sources", "schedule": 900.0, }, "expire-old-jobs": { "task": "apps.jobs.tasks.update_job_lifecycle", "schedule": 3600.0, }, "build-daily-digests": { "task": "apps.notifications.tasks.build_due_digests", "schedule": 900.0, }, "build-due-reminders": { "task": "apps.notifications.tasks.build_due_reminders", "schedule": 900.0, }, "enforce-source-health": { "task": "apps.sources.tasks.enforce_source_health", "schedule": 900.0, }, "recover-source-health": { "task": "apps.sources.tasks.recover_source_health", "schedule": 3600.0, }, "cleanup-raw-documents": { "task": "apps.sources.tasks.cleanup_raw_documents", "schedule": 3600.0, }, } FETCHER_USER_AGENT = os.getenv("FETCHER_USER_AGENT", "VacatureRadar/0.1 (+local-personal-use)") FETCHER_TIMEOUT_SECONDS = float(os.getenv("FETCHER_TIMEOUT_SECONDS", "20")) FETCHER_MAX_BYTES = int(os.getenv("FETCHER_MAX_BYTES", "3000000")) FETCHER_MAX_REDIRECTS = int(os.getenv("FETCHER_MAX_REDIRECTS", "5")) FETCHER_ALLOW_NONSTANDARD_PORTS = env_bool("FETCHER_ALLOW_NONSTANDARD_PORTS", False) SOURCE_POLICY_MODE = os.getenv("SOURCE_POLICY_MODE", "strict") SOURCE_LEASE_TTL_SECONDS = float(os.getenv("SOURCE_LEASE_TTL_SECONDS", "180")) SOURCE_ORIGIN_MIN_INTERVAL_SECONDS = int(os.getenv("SOURCE_ORIGIN_MIN_INTERVAL_SECONDS", "0")) SOURCE_SUCCESS_JITTER_SECONDS = int(os.getenv("SOURCE_SUCCESS_JITTER_SECONDS", "120")) SOURCE_FAILURE_BACKOFF_BASE_SECONDS = int(os.getenv("SOURCE_FAILURE_BACKOFF_BASE_SECONDS", "60")) SOURCE_FAILURE_BACKOFF_MAX_SECONDS = int(os.getenv("SOURCE_FAILURE_BACKOFF_MAX_SECONDS", "3600")) SOURCE_FAILURE_TIMEOUT_BASE_SECONDS = int(os.getenv("SOURCE_FAILURE_TIMEOUT_BASE_SECONDS", "120")) SOURCE_FAILURE_JITTER_SECONDS = int(os.getenv("SOURCE_FAILURE_JITTER_SECONDS", "45")) SOURCE_REVIEW_TTL_DAYS = int(os.getenv("SOURCE_REVIEW_TTL_DAYS", "90")) ROBOTS_CACHE_TTL_SECONDS = float(os.getenv("ROBOTS_CACHE_TTL_SECONDS", "3600")) ROBOTS_FETCH_TIMEOUT_SECONDS = float(os.getenv("ROBOTS_FETCH_TIMEOUT_SECONDS", "5")) ROBOTS_MAX_BYTES = int(os.getenv("ROBOTS_MAX_BYTES", "131072")) RAW_DOCUMENT_RETENTION_DAYS = int(os.getenv("RAW_DOCUMENT_RETENTION_DAYS", "7")) MANUAL_IMPORT_PASTE_MAX_BYTES = int(os.getenv("MANUAL_IMPORT_PASTE_MAX_BYTES", "12000")) DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv("DATA_UPLOAD_MAX_MEMORY_SIZE", str(8 * 1024 * 1024))) FILE_UPLOAD_MAX_MEMORY_SIZE = 0 DATA_UPLOAD_MAX_NUMBER_FILES = 0 OLLAMA_ENABLED = env_bool("OLLAMA_ENABLED", False) OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") OLLAMA_TIMEOUT_SECONDS = float(os.getenv("OLLAMA_TIMEOUT_SECONDS", "60")) DIGEST_RECIPIENT = os.getenv("DIGEST_RECIPIENT", "") AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS = int(os.getenv("AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS", "8")) AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")) AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS = int(os.getenv("AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS", "300")) MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS = int( os.getenv("MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS", "12") ) MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS = int( os.getenv("MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS", "120") ) MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS = int( os.getenv("MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS", "300") ) _validate_production_security( debug=DEBUG, secret_key=SECRET_KEY, allowed_hosts=ALLOWED_HOSTS, csrf_trusted_origins=CSRF_TRUSTED_ORIGINS, session_cookie_secure=SESSION_COOKIE_SECURE, csrf_cookie_secure=CSRF_COOKIE_SECURE, secure_ssl_redirect=SECURE_SSL_REDIRECT, public_base_url=PUBLIC_BASE_URL, demo_mode_enabled=DEMO_MODE_ENABLED, demo_read_only=DEMO_READ_ONLY, ) LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "jsonish": { "format": "%(asctime)s %(levelname)s %(name)s %(message)s", } }, "handlers": { "console": {"class": "logging.StreamHandler", "formatter": "jsonish"}, }, "root": {"handlers": ["console"], "level": os.getenv("LOG_LEVEL", "INFO")}, } # Testomgeving hoeft geen Redis te hebben. if os.getenv("PYTEST_CURRENT_TEST") or "pytest" in os.sys.modules: CELERY_TASK_ALWAYS_EAGER = True EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"