diff --git a/bucket.yaml b/bucket.yaml new file mode 100644 index 0000000..9e9b8de --- /dev/null +++ b/bucket.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: s3.onyxia.sh/v1alpha1 +kind: Policy +metadata: + name: memelord-kkurval-policy +spec: + name: memelord-kkurval-policy + s3InstanceRef: minio/default + policyContent: >- + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::memelord-kkurval", + "arn:aws:s3:::memelord-kkurval/*" + ] + } + ] + } + +--- +apiVersion: s3.onyxia.sh/v1alpha1 +kind: S3User +metadata: + name: memelord-kkurval-bucket +spec: + accessKey: memelord-kkurval-bucket # This is automatically created + policies: + - memelord-kkurval-policy + s3InstanceRef: minio/default + +--- +apiVersion: s3.onyxia.sh/v1alpha1 +kind: Bucket +metadata: + name: memelord-kkurval +spec: + name: memelord-kkurval + s3InstanceRef: minio/default + quota: + default: 100000000 + +# Minio is depricated. No sure what it is for... +# Maps key to file. Instead of filesystem and filename... diff --git a/ingress.yaml b/ingress.yaml new file mode 100644 index 0000000..da35061 --- /dev/null +++ b/ingress.yaml @@ -0,0 +1,53 @@ +# For public access +--- +apiVersion: v1 +kind: Service +metadata: + name: memelord-kkurval-app +spec: + type: ClusterIP + selector: + app: memelord-kkurval-app + ports: + - name: http + port: 80 + targetPort: 8000 + +# Warning: spec.privateKey.rotationPolicy: In cert-manager >= v1.18.0, the default value changed from `Never` to `Always`. +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: memelord-kkurval +spec: + secretName: memelord-kkurval-tls + dnsNames: + - memelord-kkurval.ee-lte-1.codemowers.io + issuerRef: + name: letsencrypt + kind: ClusterIssuer + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: memelord-kkurval + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: memelord-kkurval.ee-lte-1.codemowers.io + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: memelord-kkurval-app + port: + number: 80 + tls: + - secretName: memelord-kkurval-tls + + diff --git a/memelord-kkurval.yaml b/memelord-kkurval.yaml index bd2cca8..877c1b5 100644 --- a/memelord-kkurval.yaml +++ b/memelord-kkurval.yaml @@ -1,177 +1,6 @@ # kubectl create namespace memelord-kkurval # kubectl diff -n memelord-kkurval -f memelord-kkurval.yaml # kubectl apply -n memelord-kkurval -f memelord-kkurval.yaml - - ---- -# For session info, fast database -apiVersion: secretgenerator.mittwald.de/v1alpha1 -kind: StringSecret -metadata: - # Not very good. Find something better then redis - name: memelord-kkurval-redis -spec: - fields: - - fieldName: redis-password - length: "32" - encoding: hex - ---- -apiVersion: dragonflydb.io/v1alpha1 -kind: Dragonfly -metadata: - name: memelord-kkurval-redis -spec: - authentication: - passwordFromSecret: - name: memelord-kkurval-redis - key: redis-password - replicas: 1 - resources: - requests: - cpu: 500m - memory: 500Mi - limits: - cpu: 600m - memory: 750Mi - - - - - - - - ---- -apiVersion: secretgenerator.mittwald.de/v1alpha1 -kind: StringSecret -metadata: - name: memelord-kkurval-database - labels: - cnpg.io/reload: "true" -spec: - data: - username: memelord-kkurval - fields: - - fieldName: password - length: "32" - encoding: hex - -# For regular database data.. ---- -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: memelord-kkurval-database -spec: - instances: 1 - imageName: ghcr.io/cloudnative-pg/postgresql:17 - storage: - size: 1Gi - storageClass: postgres - affinity: - podAntiAffinityType: required - nodeSelector: - codemowers.io/lvm-ubuntu-vg: enterprise-ssd - resources: - requests: - cpu: "100m" - memory: "1Gi" - limits: - cpu: "1" - memory: "4Gi" - postgresql: - parameters: - max_connections: "300" - shared_buffers: "512MB" - effective_cache_size: "2GB" - managed: - roles: - - name: memelord-kkurval - ensure: present - login: true - passwordSecret: - name: memelord-kkurval-database - ---- -apiVersion: postgresql.cnpg.io/v1 -kind: Database -metadata: - name: memelord-kkurval -spec: - name: memelord-kkurval - owner: memelord-kkurval - cluster: - name: memelord-kkurval-database - - - - - - - ---- -apiVersion: s3.onyxia.sh/v1alpha1 -kind: Policy -metadata: - name: memelord-kkurval-policy -spec: - name: memelord-kkurval-policy - s3InstanceRef: minio/default - policyContent: >- - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket", - "s3:GetObject", - "s3:PutObject" - ], - "Resource": [ - "arn:aws:s3:::memelord-kkurval", - "arn:aws:s3:::memelord-kkurval/*" - ] - } - ] - } - ---- -apiVersion: s3.onyxia.sh/v1alpha1 -kind: S3User -metadata: - name: memelord-kkurval-bucket -spec: - accessKey: memelord-kkurval-bucket # This is automatically created - policies: - - memelord-kkurval-policy - s3InstanceRef: minio/default - ---- -apiVersion: s3.onyxia.sh/v1alpha1 -kind: Bucket -metadata: - name: memelord-kkurval -spec: - name: memelord-kkurval - s3InstanceRef: minio/default - quota: - default: 100000000 - -# Minio is depricated. No sure what it is for... -# Maps key to file. Instead of filesystem and filename... - - - - - - - -# Mingi lampi app. Tee Kube deployment ja hiljem vaata kas on OK support. -# Conteiner READ only. Valideerib, et andmed pole lokaalselt salvestanud -# Ehk vaja uue appiga kohe laamendada, et on näha kas app salvestab andmeid korrektselt -# --- apiVersion: apps/v1 kind: Deployment # Stateless rakenduste jaoks. Tõmbab enne uue üles kui vana maha läheb. No client impact @@ -187,31 +16,24 @@ spec: labels: app: memelord-kkurval-app spec: - # securityContext: - # runAsUser: 1000 # Adjust based on /etc/passwd output - # runAsGroup: 1000 # Adjust based on /etc/passwd output - # fsGroup: 1000 # Adjust based on /etc/passwd output + volumes: + - name: settings + projected: + sources: + - configMap: + name: settings containers: - name: memelord image: ghcr.io/l4rm4nd/memelord:latest imagePullPolicy: Always - # securityContext: - # readOnlyRootFilesystem: true - # allowPrivilegeEscalation: false - # runAsNonRoot: true - # capabilities: - # drop: - # - ALL + volumeMounts: + - name: settings + mountPath: /opt/app/myproject/settings.py + readOnly: true + subPath: settings.py ports: - name: http containerPort: 8000 - # volumeMounts: - # - name: tmp - # mountPath: /tmp - # - name: logs - # mountPath: /opt/app/logs - # - name: cache - # mountPath: /var/cache env: - name: DOMAIN value: "memelord-kkurval.ee-lte-1.codemowers.io" @@ -245,6 +67,9 @@ spec: key: redis-password - name: STORAGE_BACKEND value: s3 + # S3/MinIO Storage Configuration + - name: STORAGE_BACKEND + value: "s3" - name: AWS_ACCESS_KEY_ID valueFrom: secretKeyRef: @@ -255,94 +80,758 @@ spec: secretKeyRef: name: memelord-kkurval-bucket key: secretKey + - name: AWS_S3_CUSTOM_DOMAIN + value: minio.ee-lte-1.codemowers.io - name: AWS_STORAGE_BUCKET_NAME value: memelord-kkurval - name: AWS_S3_ENDPOINT_URL value: https://minio.ee-lte-1.codemowers.io/ - name: AWS_S3_REGION_NAME value: ee-lte-1 + - name: DEBUG value: "True" - name: SECURE_COOKIES value: "True" - # volumes: - # - name: tmp - # emptyDir: {} - # - name: logs - # emptyDir: {} - # - name: cache - # emptyDir: {} + - name: OIDC_ENABLED + value: "True" + - name: OIDC_RP_SIGN_ALGO + valueFrom: + secretKeyRef: + name: oidc-client-memelord-kkurval-owner-secrets + key: OIDC_ID_TOKEN_SIGNED_RESPONSE_ALG + - name: OIDC_OP_JWKS_ENDPOINT + value: https://auth.ee-lte-1.codemowers.io/jwks + - name: OIDC_RP_CLIENT_ID + valueFrom: + secretKeyRef: + name: oidc-client-memelord-kkurval-owner-secrets + key: OIDC_CLIENT_ID + - name: OIDC_RP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: oidc-client-memelord-kkurval-owner-secrets + key: OIDC_CLIENT_SECRET + - name: OIDC_OP_AUTHORIZATION_ENDPOINT + valueFrom: + secretKeyRef: + name: oidc-client-memelord-kkurval-owner-secrets + key: OIDC_IDP_AUTH_URI + - name: OIDC_OP_TOKEN_ENDPOINT + valueFrom: + secretKeyRef: + name: oidc-client-memelord-kkurval-owner-secrets + key: OIDC_IDP_TOKEN_URI + - name: OIDC_OP_USER_ENDPOINT + valueFrom: + secretKeyRef: + name: oidc-client-memelord-kkurval-owner-secrets + key: OIDC_IDP_USERINFO_URI - - - -# For public access --- apiVersion: v1 -kind: Service +kind: ConfigMap metadata: - name: memelord-kkurval-app -spec: - type: ClusterIP - selector: - app: memelord-kkurval-app - ports: - - name: http - port: 80 - targetPort: 8000 + name: settings +data: + settings.py: | + """ + Django settings for myproject project. -# Warning: spec.privateKey.rotationPolicy: In cert-manager >= v1.18.0, the default value changed from `Never` to `Always`. ---- -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - name: memelord-kkurval -spec: - secretName: memelord-kkurval-tls - dnsNames: - - memelord-kkurval.ee-lte-1.codemowers.io - issuerRef: - name: letsencrypt - kind: ClusterIssuer + Generated by 'django-admin startproject' using Django 3.2.16. ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: memelord-kkurval - annotations: - traefik.ingress.kubernetes.io/router.entrypoints: websecure -spec: - ingressClassName: traefik - rules: - - host: memelord-kkurval.ee-lte-1.codemowers.io - http: - paths: - - pathType: Prefix - path: "/" - backend: - service: - name: memelord-kkurval-app - port: - number: 80 - tls: - - secretName: memelord-kkurval-tls + For more information on this file, see + https://docs.djangoproject.com/en/3.2/topics/settings/ + For the full list of settings and their values, see + https://docs.djangoproject.com/en/3.2/ref/settings/ + """ + from pathlib import Path + from dotenv import load_dotenv + import os + import pytz + import secrets + from django.utils.html import escape + from django.utils.translation import gettext_lazy as _ + from csp.constants import NONE, SELF, UNSAFE_INLINE, UNSAFE_EVAL + # Load environment variables from .env file + load_dotenv() + # Build paths inside the project like this: BASE_DIR / 'subdir'. + BASE_DIR = Path(__file__).resolve().parent.parent + # get debug modus from env + DEBUG = os.environ.get('DEBUG', 'False').lower() in ['true'] + # get container version from env + VERSION = escape(os.environ.get("VERSION", '')) - -# --- -# apiVersion: v1 -# kind: ConfigMap -# metadata: -# name: settings -# data: -# settings.py: | -# # kopipasteeri uuendatud sisu siia -# # võid proovida eemaldada ka üleliigse a'la Azure pläust -# # Pane kinni faili logimine \ No newline at end of file + # Enable/disable public meme feed feature (must be after import os) + ENABLE_PUBLIC_FEED = os.environ.get('ENABLE_PUBLIC_FEED', 'False').lower() in ['true', '1', 'yes'] + + # auto-generate a secure secret key or use from env variable + SECRET_KEY = os.environ.get("SECRET_KEY", secrets.token_urlsafe(32)) + + # define allowed hosts and trusted domains via env variables + DOMAIN = "" + ALLOWED_HOSTS = ["127.0.0.1"] + CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1:8000"] + + DOMAIN = str(os.environ.get("DOMAIN", "localhost")) + TRUSTED_PORT = str(os.environ.get("PORT", "8000")) + + if DOMAIN: + domains = DOMAIN.split(',') + for domain in domains: + domain = domain.strip().rstrip('/').replace('http://', '').replace('https://', '') + if domain: + ALLOWED_HOSTS.append(domain) + TRUSTED_USER_DOMAIN_HTTP = f"http://{domain}:{TRUSTED_PORT}" + TRUSTED_USER_DOMAIN_HTTP_80_DEFAULT = f"http://{domain}" + TRUSTED_USER_DOMAIN_HTTPS = f"https://{domain}:{TRUSTED_PORT}" + TRUSTED_USER_DOMAIN_HTTPS_443_DEFAULT = f"https://{domain}" + CSRF_TRUSTED_ORIGINS.extend([TRUSTED_USER_DOMAIN_HTTP, TRUSTED_USER_DOMAIN_HTTPS, TRUSTED_USER_DOMAIN_HTTP_80_DEFAULT, TRUSTED_USER_DOMAIN_HTTPS_443_DEFAULT]) + + #Session Management + CSRF_COOKIE_HTTPONLY = True + SESSION_EXPIRE_AT_BROWSER_CLOSE = os.environ.get('SESSION_EXPIRE_AT_BROWSER_CLOSE', 'True').lower() in ['true'] + SESSION_COOKIE_AGE = int(os.environ.get('SESSION_COOKIE_AGE', '30')) * 60 + SESSION_COOKIE_NAME = 'Session' + SESSION_COOKIE_SAMESITE = 'Lax' + + # ============================================================================= + # REDIS CACHE CONFIGURATION (for sessions and general caching) + # ============================================================================= + # If REDIS_HOST is not set, sessions will use database (backward compatible) + REDIS_HOST = os.environ.get("REDIS_HOST", "") + REDIS_PORT = os.environ.get("REDIS_PORT", "6379") + REDIS_DB = os.environ.get("REDIS_DB", "0") + REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") + + if REDIS_HOST: + # Redis is available - use it for caching and sessions + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "PASSWORD": REDIS_PASSWORD if REDIS_PASSWORD else None, + "SOCKET_CONNECT_TIMEOUT": 5, # seconds + "SOCKET_TIMEOUT": 5, # seconds + "RETRY_ON_TIMEOUT": True, + "CONNECTION_POOL_KWARGS": { + "max_connections": 50, + "retry_on_timeout": True, + }, + "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", + "IGNORE_EXCEPTIONS": True, # Don't crash if Redis is down + }, + "KEY_PREFIX": "memelord", + "TIMEOUT": 300, # 5 minutes default + } + } + + # Use Redis for session storage (cloud-native) + SESSION_ENGINE = "django.contrib.sessions.backends.cache" + SESSION_CACHE_ALIAS = "default" + else: + # Redis not configured - use database sessions (backward compatible) + SESSION_ENGINE = "django.contrib.sessions.backends.db" + # No CACHES configuration - Django will use local memory cache + + SECURE_COOKIES = os.environ.get('SECURE_COOKIES', 'False').lower() in ['true'] + + if SECURE_COOKIES: + # transmit cookies over encrypted https only + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + # also set hsts response header + SECURE_HSTS_SECONDS = "31536000" + SECURE_HSTS_PRELOAD = True + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + else: + # transmit cookies over unencrypted http + SESSION_COOKIE_SECURE = False + CSRF_COOKIE_SECURE = False + + # http security response headers + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + X_FRAME_OPTIONS = 'DENY' + REFERRER_POLICY = 'same-origin' + + # Load from environment, default to "'self'" + raw_frame_ancestors = os.environ.get("CSP_FRAME_ANCESTORS", "'none'") + # Split by comma, strip spaces, and keep properly quoted entries + FRAME_ANCESTORS = [item.strip() for item in raw_frame_ancestors.split(',') if item.strip()] + + # Build CSP img-src list dynamically based on storage backend + STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local').lower() + IMG_SRC_LIST = ["'self'", "data:", "blob:", "https://img.logo.dev"] + + # Add S3 domains to CSP if using S3 storage + if STORAGE_BACKEND == 's3': + AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME') + AWS_S3_CUSTOM_DOMAIN = os.environ.get('AWS_S3_CUSTOM_DOMAIN') + AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME', 'us-east-1') + AWS_S3_ENDPOINT_URL = os.environ.get('AWS_S3_ENDPOINT_URL') + AWS_S3_USE_ACCELERATE_ENDPOINT = os.environ.get('AWS_S3_USE_ACCELERATE_ENDPOINT', 'False').lower() in ['true'] + + # Always add custom domain if specified (CDN like CloudFront) + if AWS_S3_CUSTOM_DOMAIN: + IMG_SRC_LIST.append(f"https://{AWS_S3_CUSTOM_DOMAIN}") + + # Detect S3 provider based on endpoint URL + if AWS_S3_ENDPOINT_URL: + # S3-compatible service detected + from urllib.parse import urlparse + parsed_url = urlparse(AWS_S3_ENDPOINT_URL) + endpoint_domain = parsed_url.netloc + endpoint_scheme = parsed_url.scheme or 'https' + + # Add the endpoint domain + IMG_SRC_LIST.append(f"{endpoint_scheme}://{endpoint_domain}") + + # Add bucket-based subdomain format if applicable + if endpoint_domain and AWS_STORAGE_BUCKET_NAME: + IMG_SRC_LIST.append(f"{endpoint_scheme}://{AWS_STORAGE_BUCKET_NAME}.{endpoint_domain}") + + # Provider-specific URL patterns + endpoint_lower = endpoint_domain.lower() if endpoint_domain else "" + + # DigitalOcean Spaces: also add CDN domain + if 'digitaloceanspaces.com' in endpoint_lower: + # DigitalOcean Spaces CDN format: bucket-name.region.cdn.digitaloceanspaces.com + if AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME: + IMG_SRC_LIST.append(f"https://{AWS_STORAGE_BUCKET_NAME}.{AWS_S3_REGION_NAME}.cdn.digitaloceanspaces.com") + + # Cloudflare R2: also add public bucket URL format + elif 'r2.cloudflarestorage.com' in endpoint_lower: + # Cloudflare R2 can use custom domains via public.r2.dev + # Format: https://bucket-name.account-id.r2.dev (if public) + # This is typically set via AWS_S3_CUSTOM_DOMAIN, but we note it here + pass + + # Wasabi: supports path-style and virtual-hosted-style + elif 'wasabisys.com' in endpoint_lower: + # Already covered by endpoint_domain and bucket subdomain + pass + + # Linode Object Storage: supports path-style and virtual-hosted-style + elif 'linodeobjects.com' in endpoint_lower: + # Already covered by endpoint_domain and bucket subdomain + pass + + # Backblaze B2: supports path-style and virtual-hosted-style + elif 'backblazeb2.com' in endpoint_lower: + # Already covered by endpoint_domain and bucket subdomain + pass + + # MinIO: custom deployment, already covered + # Other S3-compatible services: already covered + + else: + # No endpoint URL = Standard AWS S3 + if AWS_STORAGE_BUCKET_NAME: + # Add AWS S3 virtual-hosted-style URLs + IMG_SRC_LIST.append(f"https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com") + IMG_SRC_LIST.append(f"https://{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com") + + # Add path-style URL format (legacy but still supported) + IMG_SRC_LIST.append("https://s3.amazonaws.com") + IMG_SRC_LIST.append(f"https://s3.{AWS_S3_REGION_NAME}.amazonaws.com") + + # Add dual-stack endpoints (IPv6 support) + IMG_SRC_LIST.append(f"https://{AWS_STORAGE_BUCKET_NAME}.s3.dualstack.{AWS_S3_REGION_NAME}.amazonaws.com") + + # Add S3 Transfer Acceleration endpoint if enabled + if AWS_S3_USE_ACCELERATE_ENDPOINT: + IMG_SRC_LIST.append(f"https://{AWS_STORAGE_BUCKET_NAME}.s3-accelerate.amazonaws.com") + IMG_SRC_LIST.append(f"https://{AWS_STORAGE_BUCKET_NAME}.s3-accelerate.dualstack.amazonaws.com") + + # Add Azure domains to CSP if using Azure storage + elif STORAGE_BACKEND == 'azure': + AZURE_ACCOUNT_NAME = os.environ.get('AZURE_ACCOUNT_NAME') + AZURE_CUSTOM_DOMAIN = os.environ.get('AZURE_CUSTOM_DOMAIN') + + if AZURE_CUSTOM_DOMAIN: + IMG_SRC_LIST.append(f"https://{AZURE_CUSTOM_DOMAIN}") + + if AZURE_ACCOUNT_NAME: + IMG_SRC_LIST.append(f"https://{AZURE_ACCOUNT_NAME}.blob.core.windows.net") + + # Add GCS domains to CSP if using GCS storage + elif STORAGE_BACKEND == 'gcs': + GS_BUCKET_NAME = os.environ.get('GS_BUCKET_NAME') + GS_CUSTOM_ENDPOINT = os.environ.get('GS_CUSTOM_ENDPOINT') + + if GS_CUSTOM_ENDPOINT: + IMG_SRC_LIST.append(GS_CUSTOM_ENDPOINT) + + if GS_BUCKET_NAME: + # GCS can use multiple URL formats + IMG_SRC_LIST.append("https://storage.googleapis.com") + IMG_SRC_LIST.append("https://storage.cloud.google.com") + IMG_SRC_LIST.append(f"https://{GS_BUCKET_NAME}.storage.googleapis.com") + + # SFTP/Dropbox typically serve through Django (using 'self' origin) + elif STORAGE_BACKEND == 'sftp': + # SFTP files are retrieved by Django and served through Django views + # Optional: If you have nginx serving the same SFTP directory via HTTP + SFTP_CUSTOM_DOMAIN = os.environ.get('SFTP_CUSTOM_DOMAIN') + if SFTP_CUSTOM_DOMAIN: + IMG_SRC_LIST.append(f"https://{SFTP_CUSTOM_DOMAIN}") + + elif STORAGE_BACKEND == 'dropbox': + # Dropbox files are retrieved by Django and served through Django views + # No additional CSP configuration needed + pass + + CSP_IMG_SRC_EXTRA = os.environ.get('CSP_IMG_SRC_EXTRA', '') + if CSP_IMG_SRC_EXTRA: + extra_domains = [domain.strip() for domain in CSP_IMG_SRC_EXTRA.split(',') if domain.strip()] + IMG_SRC_LIST.extend(extra_domains) + + CONTENT_SECURITY_POLICY = { + "DIRECTIVES": { + "default-src": ["'self'"], + "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"], + "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net"], + "font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"], + "img-src": IMG_SRC_LIST, + "object-src": ["'none'"], + "connect-src": ["'self'"], + "frame-ancestors": FRAME_ANCESTORS, + }, + } + + # Application definition + INSTALLED_APPS = [ + 'myapp', + 'django_celery_beat', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'csp', + 'storages', + ] + + MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + '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', + 'django_http_referrer_policy.middleware.ReferrerPolicyMiddleware', + 'csp.middleware.CSPMiddleware', + ] + + ROOT_URLCONF = 'myproject.urls' + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'myapp/templates/registration')], + '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', + ], + }, + }, + ] + + # Database + # https://docs.djangoproject.com/en/3.2/ref/settings/#databases + + DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite3") + + if DB_ENGINE == "sqlite3": + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'database', 'db.sqlite3'), + } + } + + elif DB_ENGINE == "postgres": + + DB_HOST = os.environ.get("POSTGRES_HOST", "db") + DB_PORT = os.environ.get("POSTGRES_PORT", "5432") + DB_USER = os.environ.get("POSTGRES_USER", "memelord") + DB_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "memelord") + DB_NAME = os.environ.get("POSTGRES_DB", "memelord") + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': DB_NAME, + 'HOST': DB_HOST, + 'PORT': DB_PORT, + 'USER': DB_USER, + 'PASSWORD': DB_PASSWORD, + } + } + + # Password validation + # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + + 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', + }, + ] + + # Internationalization + # https://docs.djangoproject.com/en/3.2/topics/i18n/ + LANGUAGE_CODE = 'en-us' + TIME_ZONE = os.environ.get('TZ', 'Europe/Berlin') + USE_I18N = True + USE_L10N = True + USE_TZ = True + + LANGUAGES = [ + ('en', _('English')), + ('de', _('German')), + ('fr', _('French')), + ('it', _('Italian')), + ] + + LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale') + ] + + # Celery configuration + # http://docs.celeryproject.org/en/latest/configuration.html + + LOGS_DIR = os.path.join(BASE_DIR, 'logs') + + # ============================================================================= + # LOGGING CONFIGURATION + # ============================================================================= + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'file': { + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOGS_DIR, 'django.log'), + 'maxBytes': 1024 * 1024 * 15, # 15MB + 'backupCount': 10, + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console', 'file'], + 'level': os.environ.get('DJANGO_LOG_LEVEL', 'INFO'), + 'propagate': False, + }, + 'myapp': { + 'handlers': ['console', 'file'], + 'level': os.environ.get('DJANGO_LOG_LEVEL', 'INFO'), + 'propagate': False, + }, + 'storages': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG' if DEBUG else 'INFO', + 'propagate': False, + }, + 'boto3': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG' if DEBUG else 'INFO', + 'propagate': False, + }, + 'botocore': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG' if DEBUG else 'INFO', + 'propagate': False, + }, + }, + 'root': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + }, + } + + # Create logs directory if it doesn't exist + os.makedirs(LOGS_DIR, exist_ok=True) + + STATIC_URL = '/static/' + STATIC_ROOT = os.path.join(BASE_DIR, 'myapp', 'static') + + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + LOGIN_URL = '/accounts/login/' + LOGIN_REDIRECT_URL = '/' + LOGOUT_REDIRECT_URL = '/post-logout/' + ALLOW_LOGOUT_GET_METHOD = True + + WSGI_APPLICATION = 'myproject.wsgi.application' + + # check if oidc is enabled + OIDC_ENABLED = os.environ.get('OIDC_ENABLED', 'False').lower() in ['true'] + OIDC_AUTOLOGIN = os.environ.get('OIDC_AUTOLOGIN', 'False').lower() in ['true'] + + # Max file upload size in MB (default: 10MB) + # Can be configured via MAX_UPLOAD_SIZE_MB environment variable + MAX_UPLOAD_SIZE_MB = int(os.environ.get('MAX_UPLOAD_SIZE_MB', '10')) * 1024 * 1024 + + # ============================================================================= + # STORAGE BACKEND CONFIGURATION + # ============================================================================= + # IMPORTANT: This must be set BEFORE MEDIA_URL and MEDIA_ROOT + # Configure storage backend via STORAGE_BACKEND environment variable + # Supported backends: local, s3, azure, gcs, sftp, dropbox + # Default: local (filesystem storage) + + STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local').lower() + + # Django 5.0+ uses STORAGES instead of DEFAULT_FILE_STORAGE + # Set both for compatibility + if STORAGE_BACKEND == 's3': + # Amazon S3 / Compatible S3 Storage (MinIO, DigitalOcean Spaces, etc.) + AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') + AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME') + AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME', 'us-east-1') + AWS_S3_CUSTOM_DOMAIN = os.environ.get('AWS_S3_CUSTOM_DOMAIN') + AWS_S3_ENDPOINT_URL = os.environ.get('AWS_S3_ENDPOINT_URL') # Only set for S3-compatible services (MinIO, etc.) + AWS_DEFAULT_ACL = os.environ.get('AWS_DEFAULT_ACL', 'private') + AWS_S3_OBJECT_PARAMETERS = { + 'CacheControl': 'max-age=86400', + } + AWS_QUERYSTRING_AUTH = os.environ.get('AWS_QUERYSTRING_AUTH', 'True').lower() in ['true'] + AWS_S3_FILE_OVERWRITE = os.environ.get('AWS_S3_FILE_OVERWRITE', 'False').lower() in ['true'] + AWS_LOCATION = os.environ.get('AWS_LOCATION', 'media') + + # Use signature version 4 (required for all regions) + AWS_S3_SIGNATURE_VERSION = 's3v4' + + # Use virtual-hosted-style URLs (bucket-name.s3.region.amazonaws.com) + # This is the default and recommended format + AWS_S3_ADDRESSING_STYLE = 'virtual' + + # Django 5.0+ STORAGES configuration + STORAGES = { + "default": { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + # Legacy setting for older Django versions + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + + # Update MEDIA_URL + if AWS_S3_CUSTOM_DOMAIN: + MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/' + elif AWS_STORAGE_BUCKET_NAME: + # Use region-specific virtual-hosted-style URL + if AWS_S3_REGION_NAME == 'us-east-1': + MEDIA_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/{AWS_LOCATION}/' + else: + MEDIA_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com/{AWS_LOCATION}/' + else: + MEDIA_URL = "/media/" + + MEDIA_ROOT = BASE_DIR / "media" + + elif STORAGE_BACKEND == 'azure': + # Microsoft Azure Blob Storage + AZURE_ACCOUNT_NAME = os.environ.get('AZURE_ACCOUNT_NAME') + AZURE_ACCOUNT_KEY = os.environ.get('AZURE_ACCOUNT_KEY') + AZURE_CONTAINER = os.environ.get('AZURE_CONTAINER', 'media') + AZURE_SSL = os.environ.get('AZURE_SSL', 'True').lower() in ['true'] + AZURE_UPLOAD_MAX_CONN = int(os.environ.get('AZURE_UPLOAD_MAX_CONN', '2')) + AZURE_CONNECTION_TIMEOUT_SECS = int(os.environ.get('AZURE_CONNECTION_TIMEOUT_SECS', '20')) + AZURE_BLOB_MAX_MEMORY_SIZE = os.environ.get('AZURE_BLOB_MAX_MEMORY_SIZE', '2MB') + AZURE_URL_EXPIRATION_SECS = int(os.environ.get('AZURE_URL_EXPIRATION_SECS', '3600')) + AZURE_OVERWRITE_FILES = os.environ.get('AZURE_OVERWRITE_FILES', 'False').lower() in ['true'] + AZURE_LOCATION = os.environ.get('AZURE_LOCATION', '') + AZURE_CUSTOM_DOMAIN = os.environ.get('AZURE_CUSTOM_DOMAIN') + + STORAGES = { + "default": { + "BACKEND": "storages.backends.azure_storage.AzureStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + DEFAULT_FILE_STORAGE = 'storages.backends.azure_storage.AzureStorage' + + if AZURE_CUSTOM_DOMAIN: + MEDIA_URL = f'https://{AZURE_CUSTOM_DOMAIN}/{AZURE_LOCATION}' + elif AZURE_ACCOUNT_NAME: + MEDIA_URL = f'https://{AZURE_ACCOUNT_NAME}.blob.core.windows.net/{AZURE_CONTAINER}/{AZURE_LOCATION}' + else: + MEDIA_URL = "/media/" + + MEDIA_ROOT = BASE_DIR / "media" + + elif STORAGE_BACKEND == 'gcs': + # Google Cloud Storage + GS_BUCKET_NAME = os.environ.get('GS_BUCKET_NAME') + GS_PROJECT_ID = os.environ.get('GS_PROJECT_ID') + GS_CREDENTIALS = os.environ.get('GS_CREDENTIALS') + GS_DEFAULT_ACL = os.environ.get('GS_DEFAULT_ACL', 'private') + GS_FILE_OVERWRITE = os.environ.get('GS_FILE_OVERWRITE', 'False').lower() in ['true'] + GS_LOCATION = os.environ.get('GS_LOCATION', 'media') + GS_CUSTOM_ENDPOINT = os.environ.get('GS_CUSTOM_ENDPOINT') + GS_QUERYSTRING_AUTH = os.environ.get('GS_QUERYSTRING_AUTH', 'True').lower() in ['true'] + + STORAGES = { + "default": { + "BACKEND": "storages.backends.gcloud.GoogleCloudStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + DEFAULT_FILE_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage' + + if GS_CUSTOM_ENDPOINT: + MEDIA_URL = f'{GS_CUSTOM_ENDPOINT}/{GS_LOCATION}/' + elif GS_BUCKET_NAME: + MEDIA_URL = f'https://storage.googleapis.com/{GS_BUCKET_NAME}/{GS_LOCATION}/' + else: + MEDIA_URL = "/media/" + + MEDIA_ROOT = BASE_DIR / "media" + + elif STORAGE_BACKEND == 'sftp': + # SFTP Storage + SFTP_STORAGE_HOST = os.environ.get('SFTP_STORAGE_HOST') + SFTP_STORAGE_ROOT = os.environ.get('SFTP_STORAGE_ROOT', '/media/') + SFTP_STORAGE_PARAMS = { + 'port': int(os.environ.get('SFTP_STORAGE_PORT', '22')), + 'username': os.environ.get('SFTP_STORAGE_USERNAME'), + 'password': os.environ.get('SFTP_STORAGE_PASSWORD'), + 'pkey': os.environ.get('SFTP_STORAGE_PRIVATE_KEY'), + } + SFTP_STORAGE_INTERACTIVE = os.environ.get('SFTP_STORAGE_INTERACTIVE', 'False').lower() in ['true'] + SFTP_STORAGE_FILE_MODE = os.environ.get('SFTP_STORAGE_FILE_MODE') + SFTP_STORAGE_DIR_MODE = os.environ.get('SFTP_STORAGE_DIR_MODE') + SFTP_STORAGE_UID = os.environ.get('SFTP_STORAGE_UID') + SFTP_STORAGE_GID = os.environ.get('SFTP_STORAGE_GID') + SFTP_KNOWN_HOST_FILE = os.environ.get('SFTP_KNOWN_HOST_FILE') + + STORAGES = { + "default": { + "BACKEND": "storages.backends.sftpstorage.SFTPStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + DEFAULT_FILE_STORAGE = 'storages.backends.sftpstorage.SFTPStorage' + MEDIA_URL = "/media/" + MEDIA_ROOT = BASE_DIR / "media" + + elif STORAGE_BACKEND == 'dropbox': + # Dropbox Storage + DROPBOX_OAUTH2_TOKEN = os.environ.get('DROPBOX_OAUTH2_TOKEN') + DROPBOX_ROOT_PATH = os.environ.get('DROPBOX_ROOT_PATH', '/media') + DROPBOX_TIMEOUT = int(os.environ.get('DROPBOX_TIMEOUT', '100')) + DROPBOX_WRITE_MODE = os.environ.get('DROPBOX_WRITE_MODE', 'add') + + STORAGES = { + "default": { + "BACKEND": "storages.backends.dropbox.DropBoxStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + DEFAULT_FILE_STORAGE = 'storages.backends.dropbox.DropBoxStorage' + MEDIA_URL = "/media/" + MEDIA_ROOT = BASE_DIR / "media" + + else: + # Local filesystem storage (default) + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + MEDIA_URL = "/media/" + MEDIA_ROOT = BASE_DIR / "media" + + if OIDC_ENABLED: + # get oidc config from env + OIDC_CREATE_USER = os.environ.get('OIDC_CREATE_USER', 'True').lower() in ['true'] + OIDC_RP_SIGN_ALGO = os.environ.get('OIDC_RP_SIGN_ALGO', 'HS256') + OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT') + OIDC_RP_IDP_SIGN_KEY = os.environ.get('OIDC_RP_IDP_SIGN_KEY') + OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID') + OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET') + OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT') + OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT') + OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT') + OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = float(os.environ.get('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', 900)) + OIDC_USERNAME_ALGO = 'myapp.utils.generate_username' + + # Add 'mozilla_django_oidc.middleware.SessionRefresh' to INSTALLED_APPS + INSTALLED_APPS.append('mozilla_django_oidc') + + # Add 'mozilla_django_oidc' authentication backend + AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'mozilla_django_oidc.auth.OIDCAuthenticationBackend', + ) + + # Add 'mozilla_django_oidc.middleware.SessionRefresh' to MIDDLEWARE + # https://mozilla-django-oidc.readthedocs.io/en/stable/installation.html#validate-id-tokens-by-renewing-them + MIDDLEWARE.append('mozilla_django_oidc.middleware.SessionRefresh') + + # Fix http callback issue in mozilla-django-oidc by forcing https; https://github.com/mozilla/mozilla-django-oidc/issues/417 + # OIDC should only be setup behind a TLS reverse proxy anyways + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") \ No newline at end of file diff --git a/postgres.yaml b/postgres.yaml new file mode 100644 index 0000000..f804187 --- /dev/null +++ b/postgres.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: secretgenerator.mittwald.de/v1alpha1 +kind: StringSecret +metadata: + name: memelord-kkurval-database + labels: + cnpg.io/reload: "true" +spec: + data: + username: memelord-kkurval + fields: + - fieldName: password + length: "32" + encoding: hex + +# For regular database data.. +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: memelord-kkurval-database +spec: + instances: 1 + imageName: ghcr.io/cloudnative-pg/postgresql:17 + storage: + size: 1Gi + storageClass: postgres + affinity: + podAntiAffinityType: required + nodeSelector: + codemowers.io/lvm-ubuntu-vg: enterprise-ssd + resources: + requests: + cpu: "100m" + memory: "1Gi" + limits: + cpu: "1" + memory: "4Gi" + postgresql: + parameters: + max_connections: "300" + shared_buffers: "512MB" + effective_cache_size: "2GB" + managed: + roles: + - name: memelord-kkurval + ensure: present + login: true + passwordSecret: + name: memelord-kkurval-database + +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: memelord-kkurval +spec: + name: memelord-kkurval + owner: memelord-kkurval + cluster: + name: memelord-kkurval-database diff --git a/redis.yaml b/redis.yaml new file mode 100644 index 0000000..824fbb2 --- /dev/null +++ b/redis.yaml @@ -0,0 +1,31 @@ +--- +# For session info, fast database +apiVersion: secretgenerator.mittwald.de/v1alpha1 +kind: StringSecret +metadata: + # Not very good. Find something better then redis + name: memelord-kkurval-redis +spec: + fields: + - fieldName: redis-password + length: "32" + encoding: hex + +--- +apiVersion: dragonflydb.io/v1alpha1 +kind: Dragonfly +metadata: + name: memelord-kkurval-redis +spec: + authentication: + passwordFromSecret: + name: memelord-kkurval-redis + key: redis-password + replicas: 1 + resources: + requests: + cpu: 500m + memory: 500Mi + limits: + cpu: 600m + memory: 750Mi \ No newline at end of file