|
1
|
import logging |
|
2
|
import os |
|
3
|
from pathlib import Path |
|
4
|
|
|
5
|
from django.core.exceptions import ImproperlyConfigured |
|
6
|
|
|
7
|
logger = logging.getLogger(__name__) |
|
8
|
|
|
9
|
VERSION = "0.1.0" |
|
10
|
|
|
11
|
BASE_DIR = Path(__file__).resolve().parent.parent |
|
12
|
|
|
13
|
|
|
14
|
def env_str(name: str, default: str | None = None) -> str | None: |
|
15
|
return os.getenv(name, default) |
|
16
|
|
|
17
|
|
|
18
|
def env_bool(name: str, default: bool = False) -> bool: |
|
19
|
return os.getenv(name, str(default)).lower() in ("true", "1", "yes") |
|
20
|
|
|
21
|
|
|
22
|
def env_int(name: str, default: int = 0) -> int: |
|
23
|
return int(os.getenv(name, str(default))) |
|
24
|
|
|
25
|
|
|
26
|
# --- Security --- |
|
27
|
|
|
28
|
SECRET_KEY = env_str("DJANGO_SECRET_KEY", "change-me-in-production") |
|
29
|
DEBUG = env_bool("DJANGO_DEBUG", False) |
|
30
|
ALLOWED_HOSTS = [h.strip() for h in env_str("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")] |
|
31
|
|
|
32
|
if not DEBUG and SECRET_KEY == "change-me-in-production": |
|
33
|
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a unique, unpredictable value when DEBUG is False.") |
|
34
|
|
|
35
|
# --- Application --- |
|
36
|
|
|
37
|
ROOT_URLCONF = "config.urls" |
|
38
|
WSGI_APPLICATION = "config.wsgi.application" |
|
39
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" |
|
40
|
LOGIN_URL = "/auth/login/" |
|
41
|
LOGIN_REDIRECT_URL = "/" |
|
42
|
LOGOUT_REDIRECT_URL = "/auth/login/" |
|
43
|
|
|
44
|
INSTALLED_APPS = [ |
|
45
|
"django.contrib.admin", |
|
46
|
"django.contrib.auth", |
|
47
|
"django.contrib.contenttypes", |
|
48
|
"django.contrib.sessions", |
|
49
|
"django.contrib.messages", |
|
50
|
"django.contrib.staticfiles", |
|
51
|
"django.contrib.humanize", |
|
52
|
# Third-party |
|
53
|
"import_export", |
|
54
|
"simple_history", |
|
55
|
"django_celery_results", |
|
56
|
"django_celery_beat", |
|
57
|
"corsheaders", |
|
58
|
"constance", |
|
59
|
"constance.backends.database", |
|
60
|
# Project apps |
|
61
|
"core", |
|
62
|
"accounts", |
|
63
|
"organization", |
|
64
|
"projects", |
|
65
|
"pages", |
|
66
|
"fossil", |
|
67
|
"testdata", |
|
68
|
] |
|
69
|
|
|
70
|
# Fossil sync pushes can be large (binary artifacts, images, etc.) |
|
71
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB |
|
72
|
|
|
73
|
MIDDLEWARE = [ |
|
74
|
"corsheaders.middleware.CorsMiddleware", |
|
75
|
"django.middleware.security.SecurityMiddleware", |
|
76
|
"whitenoise.middleware.WhiteNoiseMiddleware", |
|
77
|
"django.contrib.sessions.middleware.SessionMiddleware", |
|
78
|
"django.middleware.common.CommonMiddleware", |
|
79
|
"django.middleware.csrf.CsrfViewMiddleware", |
|
80
|
"django.contrib.auth.middleware.AuthenticationMiddleware", |
|
81
|
"django.contrib.messages.middleware.MessageMiddleware", |
|
82
|
"django.middleware.clickjacking.XFrameOptionsMiddleware", |
|
83
|
"simple_history.middleware.HistoryRequestMiddleware", |
|
84
|
"core.middleware.current_user.CurrentUserMiddleware", |
|
85
|
] |
|
86
|
|
|
87
|
TEMPLATES = [ |
|
88
|
{ |
|
89
|
"BACKEND": "django.template.backends.django.DjangoTemplates", |
|
90
|
"DIRS": [BASE_DIR / "templates"], |
|
91
|
"APP_DIRS": True, |
|
92
|
"OPTIONS": { |
|
93
|
"context_processors": [ |
|
94
|
"django.template.context_processors.debug", |
|
95
|
"django.template.context_processors.request", |
|
96
|
"django.contrib.auth.context_processors.auth", |
|
97
|
"django.contrib.messages.context_processors.messages", |
|
98
|
"core.context_processors.sidebar", |
|
99
|
], |
|
100
|
}, |
|
101
|
}, |
|
102
|
] |
|
103
|
|
|
104
|
# --- Database --- |
|
105
|
|
|
106
|
DATABASES = { |
|
107
|
"default": { |
|
108
|
"ENGINE": "django.db.backends.postgresql", |
|
109
|
"NAME": env_str("POSTGRES_DB", "fossilrepo"), |
|
110
|
"USER": env_str("POSTGRES_USER", "dbadmin"), |
|
111
|
"PASSWORD": env_str("POSTGRES_PASSWORD", "Password123"), |
|
112
|
"HOST": env_str("POSTGRES_HOST", "localhost"), |
|
113
|
"PORT": env_str("POSTGRES_PORT", "5432"), |
|
114
|
} |
|
115
|
} |
|
116
|
|
|
117
|
# --- Cache --- |
|
118
|
|
|
119
|
REDIS_URL = env_str("REDIS_URL", "redis://localhost:6379/1") |
|
120
|
|
|
121
|
CACHES = { |
|
122
|
"default": { |
|
123
|
"BACKEND": "django.core.cache.backends.redis.RedisCache", |
|
124
|
"LOCATION": REDIS_URL, |
|
125
|
} |
|
126
|
} |
|
127
|
|
|
128
|
# --- Auth --- |
|
129
|
|
|
130
|
AUTH_PASSWORD_VALIDATORS = [ |
|
131
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, |
|
132
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, |
|
133
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, |
|
134
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, |
|
135
|
] |
|
136
|
|
|
137
|
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] |
|
138
|
SESSION_ENGINE = "django.contrib.sessions.backends.db" |
|
139
|
SESSION_COOKIE_HTTPONLY = True |
|
140
|
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days |
|
141
|
CSRF_COOKIE_HTTPONLY = True |
|
142
|
|
|
143
|
if not DEBUG: |
|
144
|
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True) |
|
145
|
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True) |
|
146
|
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True) |
|
147
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
|
148
|
SECURE_HSTS_SECONDS = 31536000 # 1 year |
|
149
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True |
|
150
|
SECURE_HSTS_PRELOAD = True |
|
151
|
|
|
152
|
# --- i18n --- |
|
153
|
|
|
154
|
LANGUAGE_CODE = "en-us" |
|
155
|
TIME_ZONE = "UTC" |
|
156
|
USE_I18N = True |
|
157
|
USE_TZ = True |
|
158
|
|
|
159
|
# --- Static --- |
|
160
|
|
|
161
|
STATIC_URL = "/static/" |
|
162
|
STATIC_ROOT = BASE_DIR / "assets" |
|
163
|
STATICFILES_DIRS = [BASE_DIR / "static"] |
|
164
|
STORAGES = { |
|
165
|
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, |
|
166
|
} |
|
167
|
|
|
168
|
# --- Media / S3 --- |
|
169
|
|
|
170
|
USE_S3 = env_bool("USE_S3", False) |
|
171
|
|
|
172
|
if USE_S3: |
|
173
|
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} |
|
174
|
AWS_ACCESS_KEY_ID = env_str("AWS_ACCESS_KEY_ID") |
|
175
|
AWS_SECRET_ACCESS_KEY = env_str("AWS_SECRET_ACCESS_KEY") |
|
176
|
AWS_STORAGE_BUCKET_NAME = env_str("AWS_STORAGE_BUCKET_NAME", "fossilrepo") |
|
177
|
AWS_S3_ENDPOINT_URL = env_str("AWS_S3_ENDPOINT_URL", "") |
|
178
|
AWS_S3_FILE_OVERWRITE = False |
|
179
|
AWS_QUERYSTRING_AUTH = True |
|
180
|
else: |
|
181
|
STORAGES["default"] = {"BACKEND": "django.core.files.storage.FileSystemStorage"} |
|
182
|
MEDIA_URL = "/media/" |
|
183
|
MEDIA_ROOT = BASE_DIR / "media" |
|
184
|
|
|
185
|
# --- Email --- |
|
186
|
|
|
187
|
EMAIL_BACKEND = env_str("DJANGO_EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") |
|
188
|
EMAIL_HOST = env_str("EMAIL_HOST", "localhost") |
|
189
|
EMAIL_PORT = env_int("EMAIL_PORT", 1025) |
|
190
|
DEFAULT_FROM_EMAIL = env_str("FROM_EMAIL", "[email protected]") |
|
191
|
|
|
192
|
# --- Celery --- |
|
193
|
|
|
194
|
CELERY_BROKER_URL = env_str("CELERY_BROKER", "redis://localhost:6379/0") |
|
195
|
CELERY_RESULT_BACKEND = "django-db" |
|
196
|
CELERY_TASK_TRACK_STARTED = True |
|
197
|
CELERY_TASK_TIME_LIMIT = 3600 |
|
198
|
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" |
|
199
|
CELERY_BEAT_SCHEDULE = { |
|
200
|
"fossil-sync-metadata": { |
|
201
|
"task": "fossil.sync_metadata", |
|
202
|
"schedule": 300.0, # every 5 minutes |
|
203
|
}, |
|
204
|
"fossil-check-upstream": { |
|
205
|
"task": "fossil.check_upstream", |
|
206
|
"schedule": 900.0, # every 15 minutes |
|
207
|
}, |
|
208
|
"fossil-dispatch-notifications": { |
|
209
|
"task": "fossil.dispatch_notifications", |
|
210
|
"schedule": 300.0, # every 5 minutes |
|
211
|
}, |
|
212
|
"fossil-daily-digest": { |
|
213
|
"task": "fossil.send_digest", |
|
214
|
"schedule": 86400.0, # daily |
|
215
|
"kwargs": {"mode": "daily"}, |
|
216
|
}, |
|
217
|
"fossil-weekly-digest": { |
|
218
|
"task": "fossil.send_digest", |
|
219
|
"schedule": 604800.0, # weekly |
|
220
|
"kwargs": {"mode": "weekly"}, |
|
221
|
}, |
|
222
|
} |
|
223
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
|
224
|
|
|
225
|
# --- CORS --- |
|
226
|
|
|
227
|
CORS_ALLOW_CREDENTIALS = True |
|
228
|
CORS_ALLOWED_ORIGINS = [o.strip() for o in env_str("CORS_ALLOWED_ORIGINS", "http://localhost:8000").split(",")] |
|
229
|
|
|
230
|
CSRF_TRUSTED_ORIGINS = [o.strip() for o in env_str("CSRF_TRUSTED_ORIGINS", "http://localhost:8000").split(",")] |
|
231
|
|
|
232
|
# --- Rate limiting --- |
|
233
|
|
|
234
|
RATELIMIT_VIEW = "django.views.defaults.permission_denied" |
|
235
|
|
|
236
|
# --- Constance (runtime feature toggles) --- |
|
237
|
|
|
238
|
CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" |
|
239
|
CONSTANCE_CONFIG = { |
|
240
|
"SITE_NAME": ("Fossilrepo", "Display name for the site"), |
|
241
|
"FOSSIL_DATA_DIR": ("/data/repos", "Directory where .fossil repository files are stored"), |
|
242
|
"FOSSIL_STORE_IN_DB": (False, "Store binary snapshots of .fossil files via Django file storage"), |
|
243
|
"FOSSIL_S3_TRACKING": (False, "Track S3/Litestream replication keys and versions"), |
|
244
|
"FOSSIL_S3_BUCKET": ("", "S3 bucket name for Fossil repo replication"), |
|
245
|
"FOSSIL_BINARY_PATH": ("fossil", "Path to the fossil binary"), |
|
246
|
# Git sync settings |
|
247
|
"GIT_SYNC_MODE": ("disabled", "Default sync mode: disabled, on_change, scheduled, both"), |
|
248
|
"GIT_SYNC_SCHEDULE": ("*/15 * * * *", "Default cron schedule for Git sync"), |
|
249
|
"GIT_MIRROR_DIR": ("/data/git-mirrors", "Directory for Git mirror checkouts"), |
|
250
|
"GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"), |
|
251
|
"GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"), |
|
252
|
"GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"), |
|
253
|
"GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"), |
|
254
|
"GITLAB_OAUTH_CLIENT_SECRET": ("", "GitLab OAuth App Client Secret"), |
|
255
|
# Cloudflare Turnstile (optional bot protection on login) |
|
256
|
"TURNSTILE_ENABLED": (False, "Enable Cloudflare Turnstile on the login page"), |
|
257
|
"TURNSTILE_SITE_KEY": ("", "Cloudflare Turnstile site key (public)"), |
|
258
|
"TURNSTILE_SECRET_KEY": ("", "Cloudflare Turnstile secret key (server-side verification)"), |
|
259
|
} |
|
260
|
CONSTANCE_CONFIG_FIELDSETS = { |
|
261
|
"General": ("SITE_NAME",), |
|
262
|
"Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"), |
|
263
|
"Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"), |
|
264
|
"GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"), |
|
265
|
"GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"), |
|
266
|
"Cloudflare Turnstile": ("TURNSTILE_ENABLED", "TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY"), |
|
267
|
} |
|
268
|
|
|
269
|
# --- Sentry --- |
|
270
|
|
|
271
|
SENTRY_DSN = env_str("SENTRY_DSN") |
|
272
|
if SENTRY_DSN: |
|
273
|
import sentry_sdk |
|
274
|
|
|
275
|
sentry_sdk.init(dsn=SENTRY_DSN, traces_sample_rate=0.1) |
|
276
|
|
|
277
|
# --- Logging --- |
|
278
|
|
|
279
|
LOGGING = { |
|
280
|
"version": 1, |
|
281
|
"disable_existing_loggers": False, |
|
282
|
"handlers": {"console": {"class": "logging.StreamHandler"}}, |
|
283
|
"root": {"handlers": ["console"], "level": "INFO"}, |
|
284
|
"loggers": { |
|
285
|
"django": {"handlers": ["console"], "level": "INFO", "propagate": False}, |
|
286
|
}, |
|
287
|
} |
|
288
|
|
|
289
|
# --- Import/Export --- |
|
290
|
|
|
291
|
IMPORT_FORMATS = [] |
|
292
|
EXPORT_FORMATS = [] |
|
293
|
|
|
294
|
# --- Admin --- |
|
295
|
|
|
296
|
ADMIN_SITE_HEADER = "Fossilrepo" |
|
297
|
ADMIN_SITE_TITLE = f"Fossilrepo Admin {VERSION}" |
|
298
|
|