forked from DRYTRIX/TimeTracker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfig.py
More file actions
410 lines (358 loc) · 21.7 KB
/
Copy pathconfig.py
File metadata and controls
410 lines (358 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
import os
from datetime import timedelta
class Config:
"""Base configuration class"""
# Flask settings
# In production, SECRET_KEY MUST be set via the SECRET_KEY environment variable.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
_SECRET_KEY_IS_DEFAULT = SECRET_KEY == "dev-secret-key-change-in-production"
FLASK_ENV = os.getenv("FLASK_ENV", "production")
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
# Database settings (default to PostgreSQL)
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL", "postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_pre_ping": True,
"pool_recycle": 300,
}
# Session settings
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() == "true"
SESSION_COOKIE_HTTPONLY = os.getenv("SESSION_COOKIE_HTTPONLY", "true").lower() == "true"
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax")
PERMANENT_SESSION_LIFETIME = timedelta(seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", 86400)))
# Flask-Login remember cookie settings
REMEMBER_COOKIE_DURATION = timedelta(days=int(os.getenv("REMEMBER_COOKIE_DAYS", 365)))
REMEMBER_COOKIE_SECURE = os.getenv("REMEMBER_COOKIE_SECURE", "false").lower() == "true"
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = os.getenv("REMEMBER_COOKIE_SAMESITE", "Lax")
# Application settings
TZ = os.getenv("TZ", "Europe/Rome")
CURRENCY = os.getenv("CURRENCY", "EUR")
ROUNDING_MINUTES = int(os.getenv("ROUNDING_MINUTES", 1))
SINGLE_ACTIVE_TIMER = os.getenv("SINGLE_ACTIVE_TIMER", "true").lower() == "true"
IDLE_TIMEOUT_MINUTES = int(os.getenv("IDLE_TIMEOUT_MINUTES", 30))
# User management (default false for production-safe deployments)
ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "false").lower() == "true"
ADMIN_USERNAMES = [u.strip() for u in os.getenv("ADMIN_USERNAMES", "admin").split(",") if u.strip()]
# Demo mode: single fixed user, credentials shown on login, no other account creation
DEMO_MODE = os.getenv("DEMO_MODE", "false").lower() == "true"
DEMO_USERNAME = (os.getenv("DEMO_USERNAME", "demo") or "demo").strip().lower()
DEMO_PASSWORD = os.getenv("DEMO_PASSWORD", "demo")
# API token default expiry (days); 0 or empty = never expire (not recommended for production)
API_TOKEN_DEFAULT_EXPIRY_DAYS = int(os.getenv("API_TOKEN_DEFAULT_EXPIRY_DAYS", "90"))
# Per-token REST API rate limits (enforced in require_api_token when Redis or local fallback is used)
API_TOKEN_RATE_LIMIT_PER_MINUTE = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_MINUTE", "100"))
API_TOKEN_RATE_LIMIT_PER_HOUR = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_HOUR", "1000"))
# Authentication method: 'none' | 'local' | 'oidc' | 'ldap' | 'both' | 'all'
# 'none' = no password authentication (username only)
# 'local' = password authentication required
# 'oidc' = OIDC/Single Sign-On only
# 'ldap' = LDAP bind only
# 'both' = OIDC + local password (backwards compatible)
# 'all' = local + OIDC + LDAP
_auth_method_raw = os.getenv("AUTH_METHOD", "local").strip().lower()
_auth_method_valid = frozenset({"none", "local", "oidc", "ldap", "both", "all"})
AUTH_METHOD = _auth_method_raw if _auth_method_raw in _auth_method_valid else "local"
# LDAP settings (used when AUTH_METHOD is 'ldap' or 'all')
LDAP_ENABLED = AUTH_METHOD in ("ldap", "all")
LDAP_HOST = os.environ.get("LDAP_HOST", "localhost")
LDAP_PORT = int(os.environ.get("LDAP_PORT", "389"))
LDAP_USE_SSL = os.environ.get("LDAP_USE_SSL", "false").lower() == "true"
LDAP_USE_TLS = os.environ.get("LDAP_USE_TLS", "false").lower() == "true"
LDAP_BIND_DN = os.environ.get("LDAP_BIND_DN", "")
LDAP_BIND_PASSWORD = os.environ.get("LDAP_BIND_PASSWORD", "")
LDAP_BASE_DN = os.environ.get("LDAP_BASE_DN", "dc=example,dc=com")
LDAP_USER_DN = os.environ.get("LDAP_USER_DN", "ou=users")
LDAP_USER_OBJECT_CLASS = os.environ.get("LDAP_USER_OBJECT_CLASS", "inetOrgPerson")
LDAP_USER_LOGIN_ATTR = os.environ.get("LDAP_USER_LOGIN_ATTR", "uid")
LDAP_USER_EMAIL_ATTR = os.environ.get("LDAP_USER_EMAIL_ATTR", "mail")
LDAP_USER_FNAME_ATTR = os.environ.get("LDAP_USER_FNAME_ATTR", "givenName")
LDAP_USER_LNAME_ATTR = os.environ.get("LDAP_USER_LNAME_ATTR", "sn")
LDAP_GROUP_DN = os.environ.get("LDAP_GROUP_DN", "ou=groups")
LDAP_GROUP_OBJECT_CLASS = os.environ.get("LDAP_GROUP_OBJECT_CLASS", "groupOfNames")
LDAP_ADMIN_GROUP = os.environ.get("LDAP_ADMIN_GROUP", "")
LDAP_REQUIRED_GROUP = os.environ.get("LDAP_REQUIRED_GROUP", "")
LDAP_TLS_CA_CERT_FILE = os.environ.get("LDAP_TLS_CA_CERT_FILE", "")
LDAP_TIMEOUT = int(os.environ.get("LDAP_TIMEOUT", "10"))
# OIDC settings (used when AUTH_METHOD is 'oidc', 'both', or 'all')
OIDC_ISSUER = os.getenv("OIDC_ISSUER") # e.g., https://login.microsoftonline.com/<tenant>/v2.0
OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID")
OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET")
OIDC_REDIRECT_URI = os.getenv("OIDC_REDIRECT_URI") # e.g., https://app.example.com/auth/oidc/callback
OIDC_SCOPES = os.getenv("OIDC_SCOPES", "openid profile email")
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "preferred_username")
OIDC_FULL_NAME_CLAIM = os.getenv("OIDC_FULL_NAME_CLAIM", "name")
OIDC_EMAIL_CLAIM = os.getenv("OIDC_EMAIL_CLAIM", "email")
OIDC_GROUPS_CLAIM = os.getenv("OIDC_GROUPS_CLAIM", "groups")
OIDC_ADMIN_GROUP = os.getenv("OIDC_ADMIN_GROUP") # optional
OIDC_ADMIN_EMAILS = [e.strip().lower() for e in os.getenv("OIDC_ADMIN_EMAILS", "").split(",") if e.strip()]
OIDC_POST_LOGOUT_REDIRECT_URI = os.getenv("OIDC_POST_LOGOUT_REDIRECT_URI")
# OIDC group -> RBAC Role name mapping (JSON object).
# Example:
# OIDC_ROLE_GROUP_MAP='{"timetracker-super-admin":"super_admin","timetracker-admin":"admin","timetracker-manager":"manager","timetracker-viewer":"viewer"}'
# Empty/invalid JSON disables the feature; the legacy OIDC_ADMIN_GROUP path keeps working.
_oidc_role_map_raw = os.getenv("OIDC_ROLE_GROUP_MAP", "").strip()
OIDC_ROLE_GROUP_MAP: dict = {}
if _oidc_role_map_raw:
try:
import json as _json
_parsed = _json.loads(_oidc_role_map_raw)
if isinstance(_parsed, dict):
# Ensure values are strings (Role names)
OIDC_ROLE_GROUP_MAP = {str(k): str(v) for k, v in _parsed.items() if k and v}
except Exception:
# Defer the warning to first OIDC callback so it lands in the app log,
# but don't crash the app. OIDC_ROLE_GROUP_MAP stays {} = no-op.
pass
# Sync mode for OIDC role assignment:
# "additive" (default, safe) — only ADD roles matching groups; never revoke.
# "sync" — also REMOVE mapped roles when the group is gone.
# Council recommendation: ship additive first, flip to sync only after observing one
# full re-login cycle for every active user.
OIDC_ROLE_SYNC_MODE = os.getenv("OIDC_ROLE_SYNC_MODE", "additive").strip().lower()
if OIDC_ROLE_SYNC_MODE not in ("additive", "sync"):
OIDC_ROLE_SYNC_MODE = "additive"
# Escape hatch: user IDs that must NEVER have roles revoked by OIDC sync,
# regardless of OIDC_ROLE_SYNC_MODE. Comma-separated integers.
# Strongly recommended for the bootstrap super_admin user (id 1) so a
# misconfigured OIDC_ROLE_GROUP_MAP cannot lock you out of your own instance.
OIDC_NEVER_REVOKE_USER_IDS: set = set()
_never_revoke_raw = os.getenv("OIDC_NEVER_REVOKE_USER_IDS", "").strip()
if _never_revoke_raw:
for _x in _never_revoke_raw.split(","):
_x = _x.strip()
if _x.isdigit():
OIDC_NEVER_REVOKE_USER_IDS.add(int(_x))
# OIDC metadata fetch configuration (for DNS resolution issues)
OIDC_METADATA_FETCH_TIMEOUT = int(os.getenv("OIDC_METADATA_FETCH_TIMEOUT", 10)) # seconds
OIDC_METADATA_RETRY_ATTEMPTS = int(os.getenv("OIDC_METADATA_RETRY_ATTEMPTS", 3)) # number of retries
OIDC_METADATA_RETRY_DELAY = int(os.getenv("OIDC_METADATA_RETRY_DELAY", 2)) # seconds between retries
# DNS resolution strategy: "auto" (try socket then getaddrinfo), "socket", "getaddrinfo", or "both"
OIDC_DNS_RESOLUTION_STRATEGY = os.getenv("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
# TTL for IP address cache in seconds (default: 5 minutes)
OIDC_IP_CACHE_TTL = int(os.getenv("OIDC_IP_CACHE_TTL", 300))
# Background metadata refresh interval in seconds (default: 1 hour, 0 to disable)
OIDC_METADATA_REFRESH_INTERVAL = int(os.getenv("OIDC_METADATA_REFRESH_INTERVAL", 3600))
# Use IP address directly if DNS resolution succeeds via socket (default: true)
OIDC_USE_IP_DIRECTLY = os.getenv("OIDC_USE_IP_DIRECTLY", "true").lower() == "true"
# Try Docker internal service names if external DNS fails (default: true)
OIDC_USE_DOCKER_INTERNAL = os.getenv("OIDC_USE_DOCKER_INTERNAL", "true").lower() == "true"
# Donate UI: unlock code verification. Two options (public key preferred; no secret on server).
#
# Option A - Ed25519 (recommended): Server only has the PUBLIC key. You keep the private key
# and sign the system_id to generate codes. Set DONATE_HIDE_PUBLIC_KEY (PEM string) or
# DONATE_HIDE_PUBLIC_KEY_FILE (path to PEM file). If unset, a file named donate_hide_public.pem
# in the project root is used when present (local builds and Docker when copied into image).
_donate_public_key = os.getenv("DONATE_HIDE_PUBLIC_KEY", "").strip()
if not _donate_public_key:
_pk_file = os.getenv("DONATE_HIDE_PUBLIC_KEY_FILE", "").strip()
if not _pk_file:
# Default: project root (parent of app/) for local builds and Docker (/app)
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_default_pk = os.path.join(_project_root, "donate_hide_public.pem")
if os.path.isfile(_default_pk):
_pk_file = _default_pk
if _pk_file and os.path.isfile(_pk_file):
try:
with open(_pk_file, "r", encoding="utf-8") as f:
_donate_public_key = f.read().strip()
# Refuse to load a private key on the server
if "PRIVATE KEY" in _donate_public_key and "PUBLIC KEY" not in _donate_public_key:
_donate_public_key = ""
except OSError:
_donate_public_key = ""
DONATE_HIDE_PUBLIC_KEY_PEM = _donate_public_key
#
# Option B - HMAC: Code = HMAC-SHA256(secret, system_id). Requires secret on server.
# Use DONATE_HIDE_UNLOCK_SECRET or DONATE_HIDE_UNLOCK_SECRET_FILE (path, first line = secret).
_donate_secret = os.getenv("DONATE_HIDE_UNLOCK_SECRET", "").strip()
if not _donate_secret:
_secret_file = os.getenv("DONATE_HIDE_UNLOCK_SECRET_FILE", "").strip()
if _secret_file and os.path.isfile(_secret_file):
try:
with open(_secret_file, "r", encoding="utf-8") as f:
_donate_secret = (f.read().strip().split("\n")[0] or "").strip()
except OSError:
_donate_secret = ""
DONATE_HIDE_UNLOCK_SECRET = _donate_secret
# Support & Purchase Key page URL (for links to purchase a key to hide donate UI)
SUPPORT_PURCHASE_URL = os.getenv("SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html").strip()
SUPPORT_PORTAL_BASE = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip()
# Optional one-line social proof for support modal (empty = omit block)
SUPPORT_SOCIAL_PROOF_TEXT = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip()
# Backup settings
BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", 30))
BACKUP_TIME = os.getenv("BACKUP_TIME", "02:00")
# Optional override for where backup archives are stored.
# If unset, backups default to: <UPLOAD_FOLDER>/backups
BACKUP_FOLDER = os.getenv("BACKUP_FOLDER", os.getenv("BACKUP_DIR"))
# Pagination
ENTRIES_PER_PAGE = 50
PROJECTS_PER_PAGE = 20
# File upload settings
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
# UPLOAD_FOLDER should be an absolute path (default: /data/uploads)
# This path is used for storing uploaded files like receipts, avatars, logos, etc.
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "/data/uploads")
# CSRF protection
WTF_CSRF_ENABLED = os.getenv("WTF_CSRF_ENABLED", "true").lower() == "true"
WTF_CSRF_TIME_LIMIT = int(os.getenv("WTF_CSRF_TIME_LIMIT", 3600)) # Default: 1 hour
# If true, rejects requests considered insecure for CSRF; keep strict in prod, relaxed in dev
WTF_CSRF_SSL_STRICT = os.getenv("WTF_CSRF_SSL_STRICT", "true").lower() == "true"
# Allow trusted cross-origin posts (behind proxies or when Referer/Origin host differs)
# Comma-separated list of origins, e.g. "https://track.example.com,https://admin.example.com"
WTF_CSRF_TRUSTED_ORIGINS = [
o.strip() for o in os.getenv("WTF_CSRF_TRUSTED_ORIGINS", "https://track.example.com").split(",") if o.strip()
]
# CSRF cookie settings (for double-submit cookie pattern and SPA helpers)
CSRF_COOKIE_NAME = os.getenv("CSRF_COOKIE_NAME", "XSRF-TOKEN")
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "").lower()
# default secure flag: inherit from SESSION_COOKIE_SECURE if unset
CSRF_COOKIE_SECURE = (
(CSRF_COOKIE_SECURE == "true") if CSRF_COOKIE_SECURE in ("true", "false") else SESSION_COOKIE_SECURE
)
CSRF_COOKIE_HTTPONLY = os.getenv("CSRF_COOKIE_HTTPONLY", "false").lower() == "true"
CSRF_COOKIE_SAMESITE = os.getenv("CSRF_COOKIE_SAMESITE", "Lax")
CSRF_COOKIE_DOMAIN = os.getenv("CSRF_COOKIE_DOMAIN")
CSRF_COOKIE_PATH = os.getenv("CSRF_COOKIE_PATH", "/")
# Security headers
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
# Allow same-origin Referer on HTTPS so CSRF checks that rely on Referer can pass
"Referrer-Policy": "strict-origin-when-cross-origin",
}
# Performance instrumentation (optional; no production overhead when disabled)
# Log a single line when request duration exceeds this many milliseconds (0 = disabled)
PERF_LOG_SLOW_REQUESTS_MS = int(os.getenv("PERF_LOG_SLOW_REQUESTS_MS", "0"))
# When true, track DB query count per request and include in slow-request logs
PERF_QUERY_PROFILE = os.getenv("PERF_QUERY_PROFILE", "false").lower() == "true"
# Rate limiting
RATELIMIT_DEFAULT = os.getenv("RATELIMIT_DEFAULT", "") # e.g., "200 per day;50 per hour"
RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://")
# Redis configuration
REDIS_ENABLED = os.getenv("REDIS_ENABLED", "true").lower() == "true"
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
REDIS_DEFAULT_TTL = int(os.getenv("REDIS_DEFAULT_TTL", 3600)) # 1 hour default
# Internationalization
LANGUAGES = {
"en": "English",
"nl": "Nederlands",
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"fi": "Suomi",
"es": "Español",
"pt": "Português",
"no": "Norsk",
"ar": "العربية",
"he": "עברית",
}
# RTL languages
RTL_LANGUAGES = {"ar", "he"}
BABEL_DEFAULT_LOCALE = os.getenv("DEFAULT_LOCALE", "en")
# Comma-separated list of translation directories relative to instance root
BABEL_TRANSLATION_DIRECTORIES = os.getenv("BABEL_TRANSLATION_DIRECTORIES", "translations")
# Versioning
# Prefer explicit app version from environment (e.g., Git tag)
APP_VERSION = os.getenv("APP_VERSION", os.getenv("GITHUB_TAG", None))
if not APP_VERSION:
# If no tag provided, create a dev-build identifier if available
github_run_number = os.getenv("GITHUB_RUN_NUMBER")
APP_VERSION = f"dev-{github_run_number}" if github_run_number else "3.1.0"
# GitHub release check (admin update notification). GITHUB_RELEASES_TOKEN is optional; never log it.
VERSION_CHECK_GITHUB_REPO = os.getenv("VERSION_CHECK_GITHUB_REPO", "DRYTRIX/TimeTracker").strip()
VERSION_CHECK_GITHUB_CACHE_TTL = int(os.getenv("VERSION_CHECK_GITHUB_CACHE_TTL", "43200")) # 12h
VERSION_CHECK_GITHUB_STALE_TTL = int(os.getenv("VERSION_CHECK_GITHUB_STALE_TTL", "604800")) # 7d
VERSION_CHECK_HTTP_TIMEOUT = int(os.getenv("VERSION_CHECK_HTTP_TIMEOUT", "10"))
GITHUB_RELEASES_TOKEN = os.getenv("GITHUB_RELEASES_TOKEN", "").strip() or None
ENABLE_PRE_RELEASE_NOTIFICATIONS = os.getenv("ENABLE_PRE_RELEASE_NOTIFICATIONS", "false").lower() == "true"
# Settings secrets encryption (recommended for production).
# Generate a key with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
SETTINGS_ENCRYPTION_KEY = (os.getenv("SETTINGS_ENCRYPTION_KEY") or "").strip() or None
SETTINGS_ENCRYPTION_KEY_FILE = (os.getenv("SETTINGS_ENCRYPTION_KEY_FILE") or "").strip() or None
# Smart in-app notifications (GET /api/notifications); times are HH:MM 24h in user's timezone.
SMART_NOTIFY_MAX_PER_DAY = max(1, min(10, int(os.getenv("SMART_NOTIFY_MAX_PER_DAY", "2"))))
SMART_NOTIFY_NO_TRACKING_AFTER = os.getenv("SMART_NOTIFY_NO_TRACKING_AFTER", "16:00").strip()
SMART_NOTIFY_SUMMARY_AT = os.getenv("SMART_NOTIFY_SUMMARY_AT", "18:00").strip()
SMART_NOTIFY_LONG_TIMER_HOURS = float(os.getenv("SMART_NOTIFY_LONG_TIMER_HOURS", "4"))
SMART_NOTIFY_END_OF_DAY_AT = os.getenv("SMART_NOTIFY_END_OF_DAY_AT", "17:00").strip()
SMART_NOTIFY_MISSED_CLOCK_IN_AT = os.getenv("SMART_NOTIFY_MISSED_CLOCK_IN_AT", "09:30").strip()
# Fire time-based kinds only during the first N minutes of the configured hour (same idea as email remind-to-log).
SMART_NOTIFY_SCHEDULER_SLOT_MINUTES = max(1, min(59, int(os.getenv("SMART_NOTIFY_SCHEDULER_SLOT_MINUTES", "30"))))
# AI helper (server-side provider configuration; keys are never sent to clients)
AI_ENABLED = os.getenv("AI_ENABLED", "false").lower() == "true"
AI_PROVIDER = os.getenv("AI_PROVIDER", "ollama").strip().lower()
AI_BASE_URL = os.getenv("AI_BASE_URL", "http://127.0.0.1:11434").strip()
AI_MODEL = os.getenv("AI_MODEL", "llama3.1").strip()
AI_API_KEY = os.getenv("AI_API_KEY", "").strip()
AI_TIMEOUT_SECONDS = max(1, int(os.getenv("AI_TIMEOUT_SECONDS", "30")))
AI_CONTEXT_LIMIT = max(5, int(os.getenv("AI_CONTEXT_LIMIT", "40")))
AI_SYSTEM_PROMPT = os.getenv(
"AI_SYSTEM_PROMPT",
"You are TimeTracker's AI helper. Be concise, explain assumptions, and return suggested actions only when the user asks for changes.",
).strip()
# Password reset
PASSWORD_RESET_TOKEN_MAX_AGE_SECONDS = max(300, int(os.getenv("PASSWORD_RESET_TOKEN_MAX_AGE_SECONDS", "3600")))
# Two-factor authentication (TOTP)
REQUIRE_2FA_FOR_ADMINS = os.getenv("REQUIRE_2FA_FOR_ADMINS", "false").lower() == "true"
class DevelopmentConfig(Config):
"""Development configuration"""
FLASK_DEBUG = True
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL", "postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker"
)
# CSRF can be overridden via env var, defaults to False for dev convenience
WTF_CSRF_ENABLED = os.getenv("WTF_CSRF_ENABLED", "false").lower() == "true"
# Relax SSL strictness by default in dev to avoid false negatives on http
WTF_CSRF_SSL_STRICT = os.getenv("WTF_CSRF_SSL_STRICT", "false").lower() == "true"
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
# Allow DATABASE_URL override for CI/CD PostgreSQL testing
# Default to in-memory SQLite for local unit tests
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///:memory:")
WTF_CSRF_ENABLED = False
SECRET_KEY = "test-secret-key"
WTF_CSRF_SSL_STRICT = False
def __init__(self):
# Ensure SQLALCHEMY_DATABASE_URI reflects the current environment at instantiation time,
# not only at module import time. This keeps parity with tests that mutate env vars.
self.SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///:memory:")
class ProductionConfig(Config):
"""Production configuration"""
FLASK_DEBUG = False
# Honor environment with secure-by-default values in production
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true").lower() == "true"
SESSION_COOKIE_HTTPONLY = os.getenv("SESSION_COOKIE_HTTPONLY", "true").lower() == "true"
REMEMBER_COOKIE_SECURE = os.getenv("REMEMBER_COOKIE_SECURE", "true").lower() == "true"
WTF_CSRF_ENABLED = os.getenv("WTF_CSRF_ENABLED", "true").lower() == "true"
WTF_CSRF_SSL_STRICT = os.getenv("WTF_CSRF_SSL_STRICT", "true").lower() == "true"
def __init__(self):
# Enforce that SECRET_KEY is set via environment in production
if self._SECRET_KEY_IS_DEFAULT:
import warnings
warnings.warn(
"SECURITY WARNING: SECRET_KEY is using the default development value. "
"Set the SECRET_KEY environment variable to a secure random value in production.",
RuntimeWarning,
stacklevel=2,
)
if len(self.SECRET_KEY) < 32:
import warnings
warnings.warn(
"SECURITY WARNING: SECRET_KEY is too short. " "Use a key of at least 32 characters for production.",
RuntimeWarning,
stacklevel=2,
)
# Configuration mapping
config = {
"development": DevelopmentConfig,
"testing": TestingConfig,
"production": ProductionConfig,
}