"""Configuration management for PaperDash. Loads config from: 1. config.yaml (if exists) 2. .env file (if exists) 3. Environment variables (fallback) """ from __future__ import annotations import logging import os import secrets import socket import sys from pathlib import Path from typing import Any logger = logging.getLogger("paperdash.config") # Try to import yaml, fall back to simple parser try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False class Config: """PaperDash configuration.""" def __init__(self) -> None: self.dashboard_pin: str = "" self.api_token: str = "" self.host: str = "" self.port: int = 8921 self.log_level: str = "INFO" self.pin_ttl: int = 300 # 5 minutes in seconds def load(self) -> None: """Load configuration from all sources.""" # 1. Load config.yaml (or create if missing) config_file = self._find_config_file() if config_file and config_file.exists(): self._load_yaml(config_file) else: # First run — create default config self._create_default_config() # 2. Load .env file env_file = self._find_env_file() if env_file and env_file.exists(): self._load_env(env_file) # 3. Environment variables override everything self._load_from_env() # Validate if not self.dashboard_pin: logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled") if not self.api_token: logger.warning( "API_TOKEN not set — /api/v1/notify will reject all requests" ) def _detect_local_ip(self) -> str: """Detect the machine's local network IP address.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: return "127.0.0.1" def _generate_token(self) -> str: """Generate a cryptographically secure API token. Uses secrets.token_urlsafe(32) for 256 bits of entropy, encoded as Base64URL (no padding). """ return secrets.token_urlsafe(32) def _create_default_config(self) -> None: """Create a default config.yaml if one doesn't exist.""" if not HAS_YAML: logger.warning("PyYAML not installed — cannot create default config") return config_file = self._find_config_file() if config_file is None: config_file = Path.cwd() / "config.yaml" data = { "dashboard_pin": "", "api_token": self._generate_token(), "host": self._detect_local_ip(), "port": self.port, "log_level": self.log_level, "pin_ttl": self.pin_ttl, } try: with open(config_file, "w") as f: yaml.dump(data, f, default_flow_style=False) # type: ignore[possibly-undefined] logger.info(f"Created default config at {config_file}") logger.info(f"Dashboard PIN: {data['dashboard_pin']}") logger.info(f"API Token: {data['api_token']}") except Exception as e: logger.error(f"Failed to create default config: {e}") def _get_base_path(self) -> Path: """Get base path (exe directory or cwd).""" if getattr(sys, "frozen", False): return Path(sys.executable).parent return Path.cwd() def _find_config_file(self) -> Path | None: """Find config.yaml relative to the executable or working directory.""" base = self._get_base_path() for candidate in [base] + list(base.parents): config_path = candidate / "config.yaml" if config_path.exists(): return config_path return None def _find_env_file(self) -> Path | None: """Find .env file relative to the executable or working directory.""" base = self._get_base_path() for candidate in [base] + list(base.parents): env_path = candidate / ".env" if env_path.exists(): return env_path return None def _load_yaml(self, path: Path) -> None: """Load configuration from YAML file.""" if not HAS_YAML: logger.warning("PyYAML not installed — skipping config.yaml") return try: with open(path) as f: data = yaml.safe_load(f) or {} # type: ignore[possibly-undefined] self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin) self.api_token = data.get("api_token", self.api_token) self.host = data.get("host", self._detect_local_ip()) self.port = data.get("port", self.port) self.log_level = data.get("log_level", self.log_level).upper() self.pin_ttl = data.get("pin_ttl", self.pin_ttl) logger.info(f"Loaded config from {path}") except Exception as e: logger.error(f"Failed to load config from {path}: {e}") def _load_env(self, path: Path) -> None: """Load configuration from .env file.""" try: with open(path) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, value = line.split("=", 1) key = key.strip() value = value.strip().strip("'\"") match key: case "DASHBOARD_PIN": self.dashboard_pin = value case "API_TOKEN": self.api_token = value case "HOST": self.host = value case "PORT": self.port = int(value) case "LOG_LEVEL": self.log_level = value.upper() case "PIN_TTL": self.pin_ttl = int(value) case _: pass logger.info(f"Loaded config from {path}") except Exception as e: logger.error(f"Failed to load .env from {path}: {e}") def _load_from_env(self) -> None: """Load configuration from environment variables (highest priority).""" self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin) self.api_token = os.environ.get("API_TOKEN", self.api_token) self.host = os.environ.get("HOST") or self._detect_local_ip() try: self.port = int(os.environ.get("PORT", self.port)) except ValueError: logger.warning("Invalid PORT environment variable") self.log_level = os.environ.get("LOG_LEVEL", self.log_level).upper() try: self.pin_ttl = int(os.environ.get("PIN_TTL", self.pin_ttl)) except ValueError: logger.warning("Invalid PIN_TTL environment variable") def save(self, path: Path | None = None) -> None: """Save configuration to YAML file.""" if path is None: path = self._find_config_file() or Path.cwd() / "config.yaml" if not HAS_YAML: logger.error("PyYAML not installed — cannot save config") return data = { "dashboard_pin": self.dashboard_pin, "api_token": self.api_token, "host": self.host, "port": self.port, "log_level": self.log_level, } try: with open(path, "w") as f: yaml.dump(data, f, default_flow_style=False) # type: ignore[possibly-undefined] logger.info(f"Saved config to {path}") except Exception as e: logger.error(f"Failed to save config to {path}: {e}") def to_dict(self) -> dict[str, Any]: """Return configuration as a dictionary (for API responses).""" return { "dashboard_pin": self.dashboard_pin, "api_token": self.api_token, "host": self.host, "port": self.port, "log_level": self.log_level, "pin_ttl": self.pin_ttl, } # Global config instance config = Config()