331de4bc9e
- New settings page at /settings (localhost-only) accessible via tray icon - PIN manager with 6-digit codes, 5-min TTL, single-use - API token uses secrets.token_urlsafe(32) for 256-bit entropy - Config auto-creates config.yaml on first run with detected local IP - Default PIN and token generated randomly on first startup - Added pin_ttl setting (default 300s) - Updated build_exe.py for onefile exe with settings assets bundled - Added start.bat and start.sh for easy development startup
243 lines
8.3 KiB
Python
243 lines
8.3 KiB
Python
"""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()
|