Coverage for config.py: 98%
98 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1"""
2Configuration overrides for settings.py
3"""
5import os
6import sys
7from django.urls import reverse_lazy
8from django.utils.translation import gettext_lazy as _
9from django.contrib.messages import constants as message_constants
10from ivatar.settings import BASE_DIR
11from ivatar.settings import MIDDLEWARE
12from ivatar.settings import INSTALLED_APPS
13from ivatar.settings import TEMPLATES
15ADMIN_USERS = []
16ALLOWED_HOSTS = ["*"]
18# These are modified here because settings.py does 'from config import *'
19# AFTER defining them, and these are mutable objects (lists/dicts).
21INSTALLED_APPS.extend(
22 [
23 "django_extensions",
24 "django_openid_auth",
25 "bootstrap4",
26 "anymail",
27 "ivatar",
28 "ivatar.ivataraccount",
29 "ivatar.tools",
30 ]
31)
33MIDDLEWARE.extend(
34 [
35 "ivatar.middleware.CustomLocaleMiddleware",
36 ]
37)
39# Add OpenTelemetry middleware only if feature flag is enabled
40# Note: This will be checked at runtime, not at import time
41MIDDLEWARE.insert(
42 0,
43 "ivatar.middleware.MultipleProxyMiddleware",
44)
46AUTHENTICATION_BACKENDS = (
47 # Enable this to allow LDAP authentication.
48 # See INSTALL for more information.
49 # 'django_auth_ldap.backend.LDAPBackend',
50 "django_openid_auth.auth.OpenIDBackend",
51 "ivatar.ivataraccount.auth.FedoraOpenIdConnect",
52 "django.contrib.auth.backends.ModelBackend",
53)
55TEMPLATES[0]["DIRS"].extend(
56 [
57 os.path.join(BASE_DIR, "templates"),
58 ]
59)
60TEMPLATES[0]["OPTIONS"]["context_processors"].append(
61 "ivatar.context_processors.basepage",
62)
64OPENID_CREATE_USERS = True
65OPENID_UPDATE_DETAILS_FROM_SREG = True
66SOCIAL_AUTH_JSONFIELD_ENABLED = True
67# Fedora authentication (OIDC). You need to set these two values to use it.
68SOCIAL_AUTH_FEDORA_KEY = None # Also known as client_id
69SOCIAL_AUTH_FEDORA_SECRET = None # Also known as client_secret
71SITE_NAME = os.environ.get("SITE_NAME", "libravatar")
72IVATAR_VERSION = "2.0"
74SCHEMAROOT = "https://www.libravatar.org/schemas/export/0.2"
76SECURE_BASE_URL = os.environ.get(
77 "SECURE_BASE_URL", "https://avatars.linux-kernel.at/avatar/"
78)
79BASE_URL = os.environ.get("BASE_URL", "http://avatars.linux-kernel.at/avatar/")
81LOGIN_REDIRECT_URL = reverse_lazy("profile")
82MAX_LENGTH_EMAIL = 254 # http://stackoverflow.com/questions/386294
84MAX_NUM_PHOTOS = 5
85MAX_NUM_UNCONFIRMED_EMAILS = 5
86MAX_PHOTO_SIZE = 10485760 # in bytes
87MAX_PIXELS = 7000
88AVATAR_MAX_SIZE = 512
89JPEG_QUALITY = 85
91# Stats Optimization
92# Batch size for access count updates to the database
93STATS_BATCH_SIZE = 100
94# Enable async access count updates
95ASYNC_ACCESS_COUNT = True
97# Robohash Performance Optimization
98# Enable optimized robohash implementation for 6-22x performance improvement
99ROBOHASH_OPTIMIZATION_ENABLED = True
101# Robohash Configuration
102# Maximum number of robot parts to cache in memory (each ~50-200KB)
103ROBOHASH_CACHE_SIZE = 150 # ~10-30MB total cache size
104# Maximum number of 1024x1024 upscaled part images in the bounded LRU
105# (~4MB each, ~100MB worst case). Parts are static files that recur across
106# digests, so this removes per-generation decodes and upscales.
107ROBOHASH_UPSCALE_CACHE_SIZE = 24
109# Pagan Avatar Optimization
110# Maximum number of pagan Avatar objects to cache in memory (each ~100-500KB)
111PAGAN_CACHE_SIZE = 100 # ~10-50MB total cache size
113# I'm not 100% sure if single character domains are possible
114# under any tld... so MIN_LENGTH_EMAIL/_URL, might be +1
115MIN_LENGTH_URL = 11 # eg. http://a.io
116MAX_LENGTH_URL = 255 # MySQL can't handle more than that (LP: 1018682)
117MIN_LENGTH_EMAIL = 6 # eg. x@x.xx
118MAX_LENGTH_EMAIL = 254 # http://stackoverflow.com/questions/386294
120BOOTSTRAP4 = {
121 "include_jquery": False,
122 "javascript_in_head": False,
123 "css_url": {
124 "href": "/static/css/bootstrap.min.css",
125 "integrity": "sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB",
126 "crossorigin": "anonymous",
127 },
128 "javascript_url": {
129 "url": "/static/js/bootstrap.min.js",
130 "integrity": "",
131 "crossorigin": "anonymous",
132 },
133 "popper_url": {
134 "url": "/static/js/popper.min.js",
135 "integrity": "sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49",
136 "crossorigin": "anonymous",
137 },
138}
140if "EMAIL_BACKEND" in os.environ:
141 EMAIL_BACKEND = os.environ["EMAIL_BACKEND"] # pragma: no cover
142else:
143 if "test" in sys.argv or "collectstatic" in sys.argv:
144 EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
145 else:
146 try:
147 ANYMAIL = { # pragma: no cover
148 "MAILGUN_API_KEY": os.environ["IVATAR_MAILGUN_API_KEY"],
149 "MAILGUN_SENDER_DOMAIN": os.environ["IVATAR_MAILGUN_SENDER_DOMAIN"],
150 }
151 EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # pragma: no cover
152 except Exception: # pragma: nocover # pylint: disable=broad-except
153 EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
155SERVER_EMAIL = os.environ.get("SERVER_EMAIL", "ivatar@mg.linux-kernel.at")
156DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "ivatar@mg.linux-kernel.at")
158try:
159 from ivatar.settings import DATABASES
160except ImportError: # pragma: no cover
161 DATABASES = [] # pragma: no cover
163if "default" not in DATABASES:
164 DATABASES["default"] = { # pragma: no cover
165 "ENGINE": "django.db.backends.sqlite3",
166 "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
167 }
169if "MYSQL_DATABASE" in os.environ:
170 DATABASES["default"] = { # pragma: no cover
171 "ENGINE": "django.db.backends.mysql",
172 "NAME": os.environ["MYSQL_DATABASE"],
173 "USER": os.environ["MYSQL_USER"],
174 "PASSWORD": os.environ["MYSQL_PASSWORD"],
175 "HOST": "mysql",
176 }
178if "POSTGRESQL_DATABASE" in os.environ:
179 DATABASES["default"] = { # pragma: no cover
180 "ENGINE": "django.db.backends.postgresql",
181 "NAME": os.environ["POSTGRESQL_DATABASE"],
182 "USER": os.environ["POSTGRESQL_USER"],
183 "PASSWORD": os.environ["POSTGRESQL_PASSWORD"],
184 "HOST": "postgresql",
185 }
187# CI/CD config has different naming
188if "POSTGRES_DB" in os.environ:
189 DATABASES["default"] = { # pragma: no cover
190 "ENGINE": "django.db.backends.postgresql",
191 "NAME": os.environ["POSTGRES_DB"],
192 "USER": os.environ["POSTGRES_USER"],
193 "PASSWORD": os.environ["POSTGRES_PASSWORD"],
194 "HOST": os.environ["POSTGRES_HOST"],
195 # Let Django use its default test database naming
196 # "TEST": {
197 # "NAME": os.environ["POSTGRES_DB"],
198 # },
199 }
201SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer"
203USE_X_FORWARDED_HOST = True
204ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [
205 "avatars.linux-kernel.at",
206 "localhost",
207]
209DEFAULT_AVATAR_SIZE = 80
211# Default settings for Gravatar proxy and redirect behavior
212# These can be overridden by URL parameters
213DEFAULT_GRAVATARPROXY = True
214DEFAULT_GRAVATARREDIRECT = False
215FORCEDEFAULT = False
217LANGUAGES = (
218 ("de", _("Deutsch")),
219 ("en", _("English")),
220 ("ca", _("Català")),
221 ("cs", _("Česky")),
222 ("es", _("Español")),
223 ("eu", _("Basque")),
224 ("fr", _("Français")),
225 ("it", _("Italiano")),
226 ("ja", _("日本語")),
227 ("nl", _("Nederlands")),
228 ("pt", _("Português")),
229 ("ru", _("Русский")),
230 ("sq", _("Shqip")),
231 ("tr", _("Türkçe")),
232 ("uk", _("Українська")),
233)
235MESSAGE_TAGS = {
236 message_constants.DEBUG: "debug",
237 message_constants.INFO: "info",
238 message_constants.SUCCESS: "success",
239 message_constants.WARNING: "warning",
240 message_constants.ERROR: "danger",
241}
243CACHES = {
244 "default": {
245 "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
246 "LOCATION": [
247 "127.0.0.1:11211",
248 ],
249 # "OPTIONS": {"MAX_ENTRIES": 1000000},
250 },
251 "filesystem": {
252 "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
253 "LOCATION": "/var/tmp/ivatar_cache",
254 "TIMEOUT": 86400, # 24 hours
255 "OPTIONS": {"MAX_ENTRIES": 1000000},
256 },
257}
259CACHE_RESPONSE = True
260MAX_USER_PHOTO_CACHE_SIZE = 500
261PHOTO_CACHE_TIMEOUT = 604800 # 7 days
262PREGENERATE_PHOTO_SIZES = (28, 48, 80, 128, 256)
263PREGENERATE_THUMBNAILS_ON_SAVE = True
264PREGENERATE_THUMBNAILS_ASYNC = False
265ENABLE_WEBP_CONTENT_NEGOTIATION = True
266WEBP_QUALITY = 85
267PREGENERATE_WEBP_THUMBNAILS = True
269# Bluesky proxy tuning: bound worker time on slow/hung upstreams and cache
270# proxied image payloads.
271BLUESKY_API_TIMEOUT = 5 # seconds, for bsky profile/session API calls
272BLUESKY_IMAGE_TIMEOUT = 5 # seconds, for the avatar CDN download
273BLUESKY_CACHE_TIMEOUT = 3600 # seconds, proxied payload cache TTL
275# Server-side cache TTL for the /stats/ endpoint (it scans the photo table
276# on every uncached request).
277STATS_CACHE_TIMEOUT = 3600 # seconds
279# Trusted URLs for default redirection
280TRUSTED_DEFAULT_URLS = [
281 {"schemes": ["https"], "host_equals": "ui-avatars.com", "path_prefix": "/api/"},
282 {"schemes": ["https"], "host_suffix": ".ui-avatars.com", "path_prefix": "/api/"},
283 {
284 "schemes": ["http", "https"],
285 "host_equals": "gravatar.com",
286 "path_prefix": "/avatar/",
287 },
288 {
289 "schemes": ["http", "https"],
290 "host_suffix": ".gravatar.com",
291 "path_prefix": "/avatar/",
292 },
293 {
294 "schemes": ["http", "https"],
295 "host_equals": "www.gravatar.org",
296 "path_prefix": "/avatar/",
297 },
298 {
299 "schemes": ["https"],
300 "host_equals": "avatars.dicebear.com",
301 "path_prefix": "/api/",
302 },
303 {
304 "schemes": ["https"],
305 "host_equals": "api.dicebear.com",
306 "path_prefix": "/",
307 },
308 {
309 "schemes": ["https"],
310 "host_equals": "badges.fedoraproject.org",
311 "path_prefix": "/static/img/",
312 },
313 {
314 "schemes": ["http"],
315 "host_equals": "www.planet-libre.org",
316 "path_prefix": "/themes/planetlibre/images/",
317 },
318 {"schemes": ["https"], "host_equals": "www.azuracast.com", "path_prefix": "/img/"},
319 {
320 "schemes": ["https"],
321 "host_equals": "reps.mozilla.org",
322 "path_prefix": "/static/base/img/remo/",
323 },
324]
326URL_TIMEOUT = 10
329def map_legacy_config(trusted_url):
330 """
331 For backward compability with the legacy configuration
332 for trusting URLs. Adapts them to fit the new config.
333 """
334 if isinstance(trusted_url, str):
335 return {"url_prefix": trusted_url}
337 return trusted_url
340# Backward compability for legacy behavior
341TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS))
343# Bluesky settings
344BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None)
345BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None)
347# File upload security settings
348FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5MB
349DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5MB
350FILE_UPLOAD_PERMISSIONS = 0o644
352# Enhanced file upload security
353ENABLE_FILE_SECURITY_VALIDATION = True
354ENABLE_EXIF_SANITIZATION = True
355ENABLE_MALICIOUS_CONTENT_SCAN = True
357# Avatar optimization settings
358PAGAN_CACHE_SIZE = 1000 # Number of pagan avatars to cache
360# Logging configuration - can be overridden in local config
361# Example: LOGS_DIR = "/var/log/ivatar" # For production deployments
363# This MUST BE THE LAST!
364if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
365 from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover