import os from datetime import timedelta from dotenv import load_dotenv from sqlalchemy.pool import StaticPool # Load .env from the project root (only takes effect locally; no-op in production # if variables are already set in the environment) load_dotenv() basedir = os.path.abspath(os.path.dirname(__file__)) def _require_env(key: str) -> str: """Return the value of a required environment variable, raising if absent.""" value = os.environ.get(key) if not value: raise RuntimeError( f"Required environment variable '{key}' is not set. " f"Add it to your .env file (development) or server environment (production)." ) return value class Config: # ── Security ──────────────────────────────────────────────────────────── # SECRET_KEY must be set externally — no insecure fallback. SECRET_KEY = _require_env('SECRET_KEY') # ── Database ──────────────────────────────────────────────────────────── # DATABASE_URL must be set externally — no hardcoded credentials. SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL') SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = False # ── File uploads ──────────────────────────────────────────────────────── UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} # ── Storage backend (R2 migration) ────────────────────────────────────── # 'local' (default) = files under app/static/uploads, served via url_for('static'). # 's3' = Cloudflare R2 / S3-compatible (added Phase 2). Flip via env only. STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local') # Cloudflare R2 — only used when STORAGE_BACKEND=s3. R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL') R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID') R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY') R2_BUCKET = os.environ.get('R2_BUCKET') R2_PRESIGN_TTL = int(os.environ.get('R2_PRESIGN_TTL', '86400')) # seconds (24h) # When true, media_url HEADs the object and falls back to a local static URL # if it's missing (belt-and-suspenders during an early/partial cutover). # Off by default — cutover is gated on full verification, so objects exist. R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true' # ── Photo capture-time / geo overlay ──────────────────────────────────── # When true (default), POST /api/v1/photos/upload burns a timestamp + GPS # bar into the image before storing it. Set false to store raw uploads. PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true' # ── Session / cookies ─────────────────────────────────────────────────── PERMANENT_SESSION_LIFETIME = timedelta(hours=24) # Secure by default — subclasses must explicitly opt out for local dev. SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' # ── Mail ──────────────────────────────────────────────────────────────── # ── Application base URL (used in email links) ───────────────────────── APP_BASE_URL = os.environ.get('APP_BASE_URL', '') MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local') # ── Digest email secret token (used to authenticate cron trigger) ──────── DIGEST_SECRET = os.environ.get('DIGEST_SECRET') # ── Photo retention (data minimization — GDPR Art. 5(1)(e)) ───────────── # Unset (None) by default: no automatic photo deletion happens unless the # operator opts in. When set, /notifications/purge-old-photos deletes # photo files (not the issue record) for RESOLVED issues older than this # many days. PHOTO_RETENTION_DAYS = int(os.environ['PHOTO_RETENTION_DAYS']) if os.environ.get('PHOTO_RETENTION_DAYS') else None # ── Google Maps (used for GPS map on inspection view) ──────────────────── GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '') MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') # ── SSL vs STARTTLS selection ──────────────────────────────────────────── # Port 465 = implicit SSL → MAIL_USE_SSL=True, MAIL_USE_TLS=False # Port 587 = STARTTLS → MAIL_USE_SSL=False, MAIL_USE_TLS=True # The two flags are mutually exclusive; setting both True breaks Flask-Mail. _mail_port = int(os.environ.get('MAIL_PORT') or 587) MAIL_PORT = _mail_port MAIL_USE_SSL = _mail_port == 465 MAIL_USE_TLS = not MAIL_USE_SSL # STARTTLS only when NOT using implicit SSL class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_ECHO = True # Allow HTTP cookies during local development (HTTP, not HTTPS) SESSION_COOKIE_SECURE = False class ProductionConfig(Config): DEBUG = False # Inherits SESSION_COOKIE_SECURE = True from Config — no override needed. class TestingConfig(Config): """Config for the automated test suite (pytest). Uses an in-memory SQLite database with a StaticPool so the single connection — and therefore the schema created by db.create_all() — persists across every request the test client makes within one app instance. CSRF and rate limiting are disabled so tests can POST directly, and mail is suppressed. DEBUG=True short-circuits the file-logging block in create_app() so the suite never writes to logs/jqc.log. """ TESTING = True DEBUG = True SQLALCHEMY_DATABASE_URI = 'sqlite://' # in-memory SQLALCHEMY_ENGINE_OPTIONS = { 'connect_args': {'check_same_thread': False}, 'poolclass': StaticPool, } WTF_CSRF_ENABLED = False RATELIMIT_ENABLED = False MAIL_SUPPRESS_SEND = True SESSION_COOKIE_SECURE = False config = { 'development': DevelopmentConfig, 'production': ProductionConfig, 'testing': TestingConfig, 'default': DevelopmentConfig, }