Files
AdventureLog/backend/server/main/settings.py
Sean Morley 5291c8ad3a API Key, Webkit Fixes, Rate Limit Support & Other Misc. Fixes (#1094)
* Refactor AdventureLog Bot workflow to improve issue validation handling and encapsulate comment and close logic

* feat: add API key management to settings page

- Implemented API key creation, deletion, and display functionality.
- Updated the settings page to fetch and show existing API keys.
- Added UI elements for creating new API keys and copying them to clipboard.
- Enhanced request handling to ensure proper trailing slashes for API endpoints.

* feat: add API Keys documentation and update contributing guidelines

* fix: update appVersion to reflect the latest build

* fix: update @tailwindcss/typography to version 0.5.19

* fix: update @tailwindcss/typography to version 0.5.19

* chore: update dependencies in pnpm-lock.yaml

- dompurify: upgraded from 3.3.1 to 3.3.3
- emoji-picker-element: upgraded from 1.29.0 to 1.29.1
- @sveltejs/adapter-node: updated to use @sveltejs/kit@2.55.0
- @sveltejs/adapter-vercel: updated to use @sveltejs/kit@2.55.0
- @sveltejs/kit: upgraded from 2.53.3 to 2.55.0
- @types/node: upgraded from 22.19.13 to 22.19.15
- autoprefixer: updated postcss version from 8.5.6 to 8.5.8
- baseline-browser-mapping: upgraded from 2.10.0 to 2.10.8
- daisyui: updated postcss version from 8.5.6 to 8.5.8
- prettier-plugin-svelte: upgraded from 3.5.0 to 3.5.1
- svelte-check: updated postcss version from 8.5.6 to 8.5.8
- devalue: upgraded from 5.6.3 to 5.6.4
- electron-to-chromium: upgraded from 1.5.302 to 1.5.313
- caniuse-lite: upgraded from 1.0.30001774 to 1.0.30001780
- mlly: upgraded from 1.8.0 to 1.8.1
- node-releases: upgraded from 2.0.27 to 2.0.36
- tar: upgraded from 7.5.9 to 7.5.11
- tinyexec: upgraded from 1.0.2 to 1.0.4

* fix: update appVersion to include the latest build identifier

* fix: enhance authentication fallback for protected media access

* feat(auth): add 'mobile-qr' to trailing slash list for URL handling

* Translated using Weblate (French)

Currently translated at 99.9% (1091 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1092 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ko/

* Translated using Weblate (German)

Currently translated at 100.0% (1092 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1092 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

* Added translation using Weblate (Catalan)

* Translated using Weblate (Catalan)

Currently translated at 1.2% (14 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ca/

* Docs: Reorder immich API permissions to natural order (#1086)

* Refactor AdventureLog Bot workflow to improve issue validation handling and encapsulate comment and close logic (#1068)

* Reorder immich API permissions to natural order

---------

Co-authored-by: Sean Morley <git@seanmorley.com>

* Translated using Weblate (Turkish)

Currently translated at 100.0% (1093 of 1093 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1093 of 1093 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

* Translated using Weblate (German)

Currently translated at 100.0% (1093 of 1093 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* Add ENABLE_RATE_LIMITS configuration for backend rate limiting

* Set tabindex to -1 for dropdown menus to improve accessibility

* Refactor settings page: Simplify HTML structure and improve date formatting for API keys

* Update DEFAULT_SCHEMA_CLASS to use OpenAPI schema in REST framework settings

* fix: update error message for key copying and enhance usage instructions for API key

* Implement feature X to enhance user experience and fix bug Y in module Z

* feat: add .dockerignore and update Dockerfile for improved build process

* fix: add missing svelte-i18n>esbuild override in pnpm-lock and pnpm-workspace files

* refactor: update frontend CI workflow for improved quality checks and dependency management

* Refactor code structure for improved readability and maintainability

* fix: add vite>esbuild override in pnpm-lock and pnpm-workspace files

* refactor: enhance accessibility and semantics of button elements across multiple components

* feat: update API key deletion confirmation messages in multiple languages and improve server URL configuration

* fix: update djangorestframework version constraint and drf-yasg version in requirements

* fix: update appVersion to v0.12.0-main-040426 and refactor button elements to improve accessibility in CollectionCard and CollectionItineraryPlanner components

* feat: implement developer unlock feature for mobile login in Avatar component

---------

Co-authored-by: lesensei <alain-gh@lespeps.eu>
Co-authored-by: Hosted Weblate user 141821 <clearstripe@users.noreply.hosted.weblate.org>
Co-authored-by: Alex <div@alexe.at>
Co-authored-by: AntonPalmqvist <apq@users.noreply.hosted.weblate.org>
Co-authored-by: Marc Llopart <marc@medullar.com>
Co-authored-by: Stephan Zwicknagl <64196842+stephanzwicknagl@users.noreply.github.com>
Co-authored-by: Orhun <orhunavcu@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
2026-04-04 21:00:02 -04:00

404 lines
15 KiB
Python

"""
AdventureLog Server settings
Reference:
- Django settings: https://docs.djangoproject.com/en/stable/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
from dotenv import load_dotenv
from os import getenv
from pathlib import Path
from urllib.parse import urlparse
from publicsuffix2 import get_sld
# ---------------------------------------------------------------------------
# Environment & Paths
# ---------------------------------------------------------------------------
# Load environment variables from .env file early so getenv works everywhere.
load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Quick-start development settings - unsuitable for production
# See Django deployment checklist for production hardening.
# ---------------------------------------------------------------------------
# Core Security & Debug
# ---------------------------------------------------------------------------
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = getenv('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = getenv('DEBUG', 'true').lower() == 'true'
# ALLOWED_HOSTS = [
# 'localhost',
# '127.0.0.1',
# 'server'
# ]
ALLOWED_HOSTS = ['*'] # In production, restrict to known hosts.
# ---------------------------------------------------------------------------
# Installed Apps
# ---------------------------------------------------------------------------
INSTALLED_APPS = (
"allauth_ui",
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'rest_framework',
'rest_framework.authtoken',
'allauth',
'allauth.account',
'allauth.mfa',
'allauth.headless',
'allauth.socialaccount',
'allauth.socialaccount.providers.github',
'allauth.socialaccount.providers.openid_connect',
'invitations',
'drf_yasg',
'djmoney',
'corsheaders',
'adventures',
'worldtravel',
'users',
'integrations',
'django.contrib.gis',
# 'achievements', # Not done yet, will be added later in a future update
'widget_tweaks',
'slippers',
)
# ---------------------------------------------------------------------------
# Middleware
# ---------------------------------------------------------------------------
MIDDLEWARE = (
'whitenoise.middleware.WhiteNoiseMiddleware',
'adventures.middleware.XSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForAPIKeyMiddleware',
'adventures.middleware.DisableCSRFForMobileLoginSignup',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'adventures.middleware.OverrideHostMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
)
# ---------------------------------------------------------------------------
# Caching
# ---------------------------------------------------------------------------
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
'LOCATION': '127.0.0.1:11211',
'TIMEOUT': 60 * 60 * 24, # Optional: 1 day cache
}
}
# For backwards compatibility for Django 1.8
MIDDLEWARE_CLASSES = MIDDLEWARE
ROOT_URLCONF = 'main.urls'
# WSGI_APPLICATION = 'demo.wsgi.application'
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
# Using legacy PG environment variables for compatibility with existing setups
def env(*keys, default=None):
"""Return the first non-empty environment variable from a list of keys."""
for key in keys:
value = os.getenv(key)
if value:
return value
return default
DATABASES = {
'default': {
'ENGINE': 'django.contrib.gis.db.backends.postgis',
'NAME': env('PGDATABASE', 'POSTGRES_DB'),
'USER': env('PGUSER', 'POSTGRES_USER'),
'PASSWORD': env('PGPASSWORD', 'POSTGRES_PASSWORD'),
'HOST': env('PGHOST', default='localhost'),
'PORT': int(env('PGPORT', default='5432')),
'OPTIONS': {
'sslmode': 'prefer', # Prefer SSL, but allow non-SSL connections
},
}
}
# Internationalization
# https://docs.djangoproject.com/en/1.7/topics/i18n/
# ---------------------------------------------------------------------------
# Internationalization
# ---------------------------------------------------------------------------
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# ---------------------------------------------------------------------------
# Frontend URL & Cookies
# ---------------------------------------------------------------------------
# Derive frontend URL from environment and configure cookie behavior.
unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000')
FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"'))
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_NAME = 'sessionid'
# Secure cookies if frontend is served over HTTPS
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
CSRF_COOKIE_SECURE = FRONTEND_URL.startswith('https')
# Dynamically determine cookie domain to support subdomains while avoiding IPs
hostname = urlparse(FRONTEND_URL).hostname
is_ip_address = hostname.replace('.', '').isdigit()
is_single_label = '.' not in hostname # single-label hostnames (e.g., "localhost")
if is_ip_address or is_single_label:
SESSION_COOKIE_DOMAIN = None
else:
cookie_domain = get_sld(hostname)
SESSION_COOKIE_DOMAIN = f".{cookie_domain}" if cookie_domain else hostname
# ---------------------------------------------------------------------------
# Static & Media Files
# ---------------------------------------------------------------------------
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' # Must match NGINX root for media serving
STATICFILES_DIRS = [BASE_DIR / 'static']
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
}
}
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
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',
],
},
},
]
ALLAUTH_UI_THEME = "dim"
# ---------------------------------------------------------------------------
# Authentication & Accounts
# ---------------------------------------------------------------------------
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'false').lower() == 'true'
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
SOCIALACCOUNT_ALLOW_SIGNUP = getenv('SOCIALACCOUNT_ALLOW_SIGNUP', 'false').lower() == 'true'
AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.CustomAccountAdapter'
INVITATIONS_ADAPTER = ACCOUNT_ADAPTER
INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True
INVITATIONS_EMAIL_SUBJECT_PREFIX = 'AdventureLog: '
SOCIALACCOUNT_ADAPTER = 'users.adapters.CustomSocialAccountAdapter'
ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm'
SESSION_SAVE_EVERY_REQUEST = True
LOGIN_REDIRECT_URL = FRONTEND_URL # Redirect to frontend after login
SOCIALACCOUNT_LOGIN_ON_GET = True
INVITATIONS_INVITE_FORM = 'users.form_overrides.UseAdminInviteForm'
INVITATIONS_SIGNUP_REDIRECT_URL = f"{FRONTEND_URL}/signup"
HEADLESS_FRONTEND_URLS = {
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",
"account_reset_password": f"{FRONTEND_URL}/user/reset-password",
"account_reset_password_from_key": f"{FRONTEND_URL}/user/reset-password/{{key}}",
"account_signup": f"{FRONTEND_URL}/signup",
# Fallback if handshake with provider fails and `next` URL is lost.
"socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback",
}
AUTHENTICATION_BACKENDS = [
'users.backends.NoPasswordAuthBackend',
# 'allauth.account.auth_backends.AuthenticationBackend',
# 'django.contrib.auth.backends.ModelBackend',
]
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = getenv('ACCOUNT_EMAIL_VERIFICATION', 'none') # 'none', 'optional', 'mandatory'
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Auto-link by email
SOCIALACCOUNT_AUTO_SIGNUP = True # Allow auto-signup post adapter checks
# Enable or disable app-level rate limiting/throttling globally.
# Defaults to disabled for local/dev convenience.
ENABLE_RATE_LIMITS = getenv('ENABLE_RATE_LIMITS', 'false').lower() == 'true'
FORCE_SOCIALACCOUNT_LOGIN = getenv('FORCE_SOCIALACCOUNT_LOGIN', 'false').lower() == 'true' # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.
if getenv('EMAIL_BACKEND', 'console') == 'console':
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
else:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = getenv('EMAIL_HOST')
EMAIL_USE_TLS = getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
EMAIL_PORT = getenv('EMAIL_PORT', 587)
EMAIL_USE_SSL = getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
EMAIL_HOST_USER = getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = getenv('EMAIL_HOST_PASSWORD')
DEFAULT_FROM_EMAIL = getenv('DEFAULT_FROM_EMAIL')
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = 'smtp.resend.com'
# EMAIL_USE_TLS = False
# EMAIL_PORT = 2465
# EMAIL_USE_SSL = True
# EMAIL_HOST_USER = 'resend'
# EMAIL_HOST_PASSWORD = ''
# DEFAULT_FROM_EMAIL = 'mail@mail.user.com'
# ---------------------------------------------------------------------------
# Account Rate Limits
# ---------------------------------------------------------------------------
# Configure rate limits for allauth authentication actions to prevent abuse
# Format: "action": "count/period/scope"
# Examples: "5/m/user" = 5 per minute per user, "20/m/ip" = 20 per minute per IP
ACCOUNT_RATE_LIMITS = {
"change_password": "5/m/user", # 5 password changes per minute per user
"change_phone": "1/m/user", # 1 phone change per minute per user
"manage_email": "10/m/user", # 10 email management actions per minute per user
"reset_password": "20/m/ip,5/m/key", # 20 per minute per IP, 5 per minute per email
"reauthenticate": "10/m/user", # 10 reauthentication attempts per minute per user
"reset_password_from_key": "20/m/ip", # 20 password resets per minute per IP
"signup": "20/m/ip", # 20 signups per minute per IP (prevents mass registration)
"login": "30/m/ip", # 30 login attempts per minute per IP
"login_failed": "10/m/ip,5/5m/key", # 10 failed logins per minute per IP, 5 per 5 min per user
"confirm_email": "1/3m/key", # 1 email confirmation per 3 minutes per email
} if ENABLE_RATE_LIMITS else {}
# ---------------------------------------------------------------------------
# Django REST Framework
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'users.authentication.APIKeyAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema',
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.UserRateThrottle',
] if ENABLE_RATE_LIMITS else [],
'DEFAULT_THROTTLE_RATES': {
'user': '100000/day',
'image_proxy': '1000/minute',
} if ENABLE_RATE_LIMITS else {},
}
if DEBUG:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
)
else:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
)
# ---------------------------------------------------------------------------
# CORS & CSRF
# ---------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CORS_ALLOW_CREDENTIALS = True
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
'file': {
'class': 'logging.FileHandler',
'filename': 'scheduler.log',
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
'loggers': {
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
},
}
# ---------------------------------------------------------------------------
# Public URLs & Third-Party Integrations
# ---------------------------------------------------------------------------
PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
# Major release version of AdventureLog, not including the patch version date.
ADVENTURELOG_RELEASE_VERSION = 'v0.12.0'
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v3.1'
# External service keys (do not hardcode secrets)
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')