Coverage for config.py: 98%

98 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 05:10 +0000

1""" 

2Configuration overrides for settings.py 

3""" 

4 

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 

14 

15ADMIN_USERS = [] 

16ALLOWED_HOSTS = ["*"] 

17 

18# These are modified here because settings.py does 'from config import *' 

19# AFTER defining them, and these are mutable objects (lists/dicts). 

20 

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) 

32 

33MIDDLEWARE.extend( 

34 [ 

35 "ivatar.middleware.CustomLocaleMiddleware", 

36 ] 

37) 

38 

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) 

45 

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) 

54 

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) 

63 

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 

70 

71SITE_NAME = os.environ.get("SITE_NAME", "libravatar") 

72IVATAR_VERSION = "2.0" 

73 

74SCHEMAROOT = "https://www.libravatar.org/schemas/export/0.2" 

75 

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/") 

80 

81LOGIN_REDIRECT_URL = reverse_lazy("profile") 

82MAX_LENGTH_EMAIL = 254 # http://stackoverflow.com/questions/386294 

83 

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 

90 

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 

96 

97# Robohash Performance Optimization 

98# Enable optimized robohash implementation for 6-22x performance improvement 

99ROBOHASH_OPTIMIZATION_ENABLED = True 

100 

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 

108 

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 

112 

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 

119 

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} 

139 

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" 

154 

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") 

157 

158try: 

159 from ivatar.settings import DATABASES 

160except ImportError: # pragma: no cover 

161 DATABASES = [] # pragma: no cover 

162 

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 } 

168 

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 } 

177 

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 } 

186 

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 } 

200 

201SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer" 

202 

203USE_X_FORWARDED_HOST = True 

204ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [ 

205 "avatars.linux-kernel.at", 

206 "localhost", 

207] 

208 

209DEFAULT_AVATAR_SIZE = 80 

210 

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 

216 

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) 

234 

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} 

242 

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} 

258 

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 

268 

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 

274 

275# Server-side cache TTL for the /stats/ endpoint (it scans the photo table 

276# on every uncached request). 

277STATS_CACHE_TIMEOUT = 3600 # seconds 

278 

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] 

325 

326URL_TIMEOUT = 10 

327 

328 

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} 

336 

337 return trusted_url 

338 

339 

340# Backward compability for legacy behavior 

341TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS)) 

342 

343# Bluesky settings 

344BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None) 

345BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None) 

346 

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 

351 

352# Enhanced file upload security 

353ENABLE_FILE_SECURITY_VALIDATION = True 

354ENABLE_EXIF_SANITIZATION = True 

355ENABLE_MALICIOUS_CONTENT_SCAN = True 

356 

357# Avatar optimization settings 

358PAGAN_CACHE_SIZE = 1000 # Number of pagan avatars to cache 

359 

360# Logging configuration - can be overridden in local config 

361# Example: LOGS_DIR = "/var/log/ivatar" # For production deployments 

362 

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