Add settings page, PIN manager, and auto-config
Build Android APK / build (push) Failing after 4m21s
Build Server .exe / build (push) Failing after 2m34s

- 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
This commit is contained in:
Kareem Horstink
2026-07-03 08:21:46 +00:00
parent 4e3befbb82
commit 331de4bc9e
12 changed files with 652 additions and 19 deletions
+72 -6
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import logging
import os
import secrets
import socket
import sys
from pathlib import Path
from typing import Any
@@ -19,6 +21,7 @@ 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
@@ -30,16 +33,20 @@ class Config:
def __init__(self) -> None:
self.dashboard_pin: str = ""
self.api_token: str = ""
self.host: str = "0.0.0.0"
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
# 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()
@@ -53,11 +60,60 @@ class Config:
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")
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):
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path.cwd()
@@ -91,9 +147,10 @@ class Config:
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.host)
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:
@@ -121,6 +178,10 @@ class Config:
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:
@@ -130,12 +191,16 @@ class Config:
"""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", self.host)
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."""
@@ -169,6 +234,7 @@ class Config:
"host": self.host,
"port": self.port,
"log_level": self.log_level,
"pin_ttl": self.pin_ttl,
}