""" Configuration management for StarPunk Loads settings from environment variables and .env file """ import os from pathlib import Path from dotenv import load_dotenv def load_config(app, config_override=None): """ Load configuration into Flask app Args: app: Flask application instance config_override: Optional dict to override config values """ # Load .env file load_dotenv() # Site configuration # IndieWeb/OAuth specs require trailing slash for root URLs used as client_id # See: https://indielogin.com/ OAuth client requirements site_url = os.getenv("SITE_URL", "http://localhost:5000") app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/' app.config["SITE_NAME"] = os.getenv("SITE_NAME", "StarPunk") app.config["SITE_AUTHOR"] = os.getenv("SITE_AUTHOR", "Unknown") app.config["SITE_DESCRIPTION"] = os.getenv( "SITE_DESCRIPTION", "A minimal IndieWeb CMS" ) # Authentication app.config["ADMIN_ME"] = os.getenv("ADMIN_ME") app.config["SESSION_SECRET"] = os.getenv("SESSION_SECRET") app.config["SESSION_LIFETIME"] = int(os.getenv("SESSION_LIFETIME", "30")) app.config["INDIELOGIN_URL"] = os.getenv("INDIELOGIN_URL", "https://indielogin.com") # DEPRECATED: TOKEN_ENDPOINT no longer used (v1.0.0-rc.5+) # Endpoints are now discovered from ADMIN_ME profile (ADR-031) if 'TOKEN_ENDPOINT' in os.environ: app.logger.warning( "TOKEN_ENDPOINT is deprecated and will be ignored. " "Remove it from your configuration. " "Endpoints are now discovered automatically from your ADMIN_ME profile. " "See docs/migration/fix-hardcoded-endpoints.md for details." ) # Validate required configuration if not app.config["SESSION_SECRET"]: raise ValueError( "SESSION_SECRET must be set in .env file. " 'Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"' ) # Flask secret key (uses SESSION_SECRET by default) # Note: We check for truthy value to handle empty string in .env flask_secret = os.getenv("FLASK_SECRET_KEY") app.config["SECRET_KEY"] = flask_secret if flask_secret else app.config["SESSION_SECRET"] # Data paths app.config["DATA_PATH"] = Path(os.getenv("DATA_PATH", "./data")) app.config["NOTES_PATH"] = Path(os.getenv("NOTES_PATH", "./data/notes")) app.config["DATABASE_PATH"] = Path(os.getenv("DATABASE_PATH", "./data/starpunk.db")) # Flask environment app.config["ENV"] = os.getenv("FLASK_ENV", "development") app.config["DEBUG"] = os.getenv("FLASK_DEBUG", "1") == "1" # Logging app.config["LOG_LEVEL"] = os.getenv("LOG_LEVEL", "INFO") # Development mode configuration app.config["DEV_MODE"] = os.getenv("DEV_MODE", "false").lower() == "true" app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "") # Application version (use __version__ from package) from starpunk import __version__ app.config["VERSION"] = os.getenv("VERSION", __version__) # RSS feed configuration app.config["FEED_MAX_ITEMS"] = int(os.getenv("FEED_MAX_ITEMS", "50")) app.config["FEED_CACHE_SECONDS"] = int(os.getenv("FEED_CACHE_SECONDS", "300")) # Apply overrides if provided if config_override: app.config.update(config_override) # Normalize SITE_URL trailing slash (in case override provided URL without slash) if "SITE_URL" in app.config: site_url = app.config["SITE_URL"] app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/' # Convert path strings to Path objects (in case overrides provided strings) if isinstance(app.config["DATA_PATH"], str): app.config["DATA_PATH"] = Path(app.config["DATA_PATH"]) if isinstance(app.config["NOTES_PATH"], str): app.config["NOTES_PATH"] = Path(app.config["NOTES_PATH"]) if isinstance(app.config["DATABASE_PATH"], str): app.config["DATABASE_PATH"] = Path(app.config["DATABASE_PATH"]) # Validate configuration validate_config(app) # Ensure data directories exist app.config["DATA_PATH"].mkdir(parents=True, exist_ok=True) app.config["NOTES_PATH"].mkdir(parents=True, exist_ok=True) def validate_config(app): """ Validate application configuration on startup Per ADR-052 and developer Q&A Q14: - Validates at startup (fail fast) - Checks both presence and type of required values - Provides clear error messages - Exits with non-zero status on failure Ensures required configuration is present based on mode (dev/production) and warns prominently if development mode is enabled. Args: app: Flask application instance Raises: ValueError: If required configuration is missing or invalid """ errors = [] # Validate required string fields required_strings = { 'SITE_URL': app.config.get('SITE_URL'), 'SITE_NAME': app.config.get('SITE_NAME'), 'SITE_AUTHOR': app.config.get('SITE_AUTHOR'), 'SESSION_SECRET': app.config.get('SESSION_SECRET'), 'SECRET_KEY': app.config.get('SECRET_KEY'), } for field, value in required_strings.items(): if not value: errors.append(f"{field} is required but not set") elif not isinstance(value, str): errors.append(f"{field} must be a string, got {type(value).__name__}") # Validate required integer fields required_ints = { 'SESSION_LIFETIME': app.config.get('SESSION_LIFETIME'), 'FEED_MAX_ITEMS': app.config.get('FEED_MAX_ITEMS'), 'FEED_CACHE_SECONDS': app.config.get('FEED_CACHE_SECONDS'), } for field, value in required_ints.items(): if value is None: errors.append(f"{field} is required but not set") elif not isinstance(value, int): errors.append(f"{field} must be an integer, got {type(value).__name__}") elif value < 0: errors.append(f"{field} must be non-negative, got {value}") # Validate required Path fields required_paths = { 'DATA_PATH': app.config.get('DATA_PATH'), 'NOTES_PATH': app.config.get('NOTES_PATH'), 'DATABASE_PATH': app.config.get('DATABASE_PATH'), } for field, value in required_paths.items(): if not value: errors.append(f"{field} is required but not set") elif not isinstance(value, Path): errors.append(f"{field} must be a Path object, got {type(value).__name__}") # Validate LOG_LEVEL log_level = app.config.get('LOG_LEVEL', 'INFO').upper() valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] if log_level not in valid_log_levels: errors.append(f"LOG_LEVEL must be one of {valid_log_levels}, got '{log_level}'") # Mode-specific validation dev_mode = app.config.get("DEV_MODE", False) if dev_mode: # Prominently warn about development mode app.logger.warning( "=" * 60 + "\n" "WARNING: Development authentication enabled!\n" "This should NEVER be used in production.\n" "Set DEV_MODE=false for production deployments.\n" + "=" * 60 ) # Require DEV_ADMIN_ME in dev mode if not app.config.get("DEV_ADMIN_ME"): errors.append( "DEV_MODE=true requires DEV_ADMIN_ME to be set. " "Set DEV_ADMIN_ME=https://your-dev-identity.example.com in .env" ) else: # Production mode: ADMIN_ME is required if not app.config.get("ADMIN_ME"): errors.append( "Production mode requires ADMIN_ME to be set. " "Set ADMIN_ME=https://your-site.com in .env" ) # If there are validation errors, fail fast with clear message if errors: error_msg = "\n".join([ "=" * 70, "CONFIGURATION VALIDATION FAILED", "=" * 70, "The following configuration errors were found:", "", *[f" - {error}" for error in errors], "", "Please fix these errors in your .env file and restart.", "=" * 70 ]) raise ValueError(error_msg)