Add settings page, PIN manager, and auto-config
- 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:
+1
-2
@@ -515,8 +515,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SETTINGS_STATUS.textContent =
|
SETTINGS_STATUS.textContent = strings.settings_saved || "Settings saved";
|
||||||
strings.settings_saved || "Settings saved";
|
|
||||||
currentApiToken = token;
|
currentApiToken = token;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
SETTINGS_STATUS.textContent =
|
SETTINGS_STATUS.textContent =
|
||||||
|
|||||||
+1
-1
@@ -121,7 +121,7 @@
|
|||||||
<button id="settings-access-btn" data-i18n="access">Access</button>
|
<button id="settings-access-btn" data-i18n="access">Access</button>
|
||||||
<p id="settings-error" class="error"></p>
|
<p id="settings-error" class="error"></p>
|
||||||
|
|
||||||
<div id="settings-content" style="display: none;">
|
<div id="settings-content" style="display: none">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
|
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+1
-1
@@ -8,6 +8,6 @@ DASHBOARD_PIN=1234
|
|||||||
API_TOKEN=your-secret-token-here
|
API_TOKEN=your-secret-token-here
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
HOST=0.0.0.0
|
# HOST defaults to auto-detected local IP (set to override)
|
||||||
PORT=8921
|
PORT=8921
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
+6
-1
@@ -36,7 +36,10 @@ a = Analysis(
|
|||||||
["tray_wrapper.py"],
|
["tray_wrapper.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[
|
||||||
|
("settings.html", "."),
|
||||||
|
("settings.css", "."),
|
||||||
|
],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
@@ -48,6 +51,7 @@ a = Analysis(
|
|||||||
"notifications",
|
"notifications",
|
||||||
"telemetry",
|
"telemetry",
|
||||||
"main",
|
"main",
|
||||||
|
"yaml",
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={{}},
|
hooksconfig={{}},
|
||||||
@@ -81,6 +85,7 @@ exe = EXE(
|
|||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon=None,
|
icon=None,
|
||||||
|
onefile=True,
|
||||||
)
|
)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|||||||
+72
-6
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -19,6 +21,7 @@ logger = logging.getLogger("paperdash.config")
|
|||||||
# Try to import yaml, fall back to simple parser
|
# Try to import yaml, fall back to simple parser
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
HAS_YAML = True
|
HAS_YAML = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_YAML = False
|
HAS_YAML = False
|
||||||
@@ -30,16 +33,20 @@ class Config:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.dashboard_pin: str = ""
|
self.dashboard_pin: str = ""
|
||||||
self.api_token: str = ""
|
self.api_token: str = ""
|
||||||
self.host: str = "0.0.0.0"
|
self.host: str = ""
|
||||||
self.port: int = 8921
|
self.port: int = 8921
|
||||||
self.log_level: str = "INFO"
|
self.log_level: str = "INFO"
|
||||||
|
self.pin_ttl: int = 300 # 5 minutes in seconds
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
"""Load configuration from all sources."""
|
"""Load configuration from all sources."""
|
||||||
# 1. Load config.yaml
|
# 1. Load config.yaml (or create if missing)
|
||||||
config_file = self._find_config_file()
|
config_file = self._find_config_file()
|
||||||
if config_file and config_file.exists():
|
if config_file and config_file.exists():
|
||||||
self._load_yaml(config_file)
|
self._load_yaml(config_file)
|
||||||
|
else:
|
||||||
|
# First run — create default config
|
||||||
|
self._create_default_config()
|
||||||
|
|
||||||
# 2. Load .env file
|
# 2. Load .env file
|
||||||
env_file = self._find_env_file()
|
env_file = self._find_env_file()
|
||||||
@@ -53,11 +60,60 @@ class Config:
|
|||||||
if not self.dashboard_pin:
|
if not self.dashboard_pin:
|
||||||
logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled")
|
logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled")
|
||||||
if not self.api_token:
|
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:
|
def _get_base_path(self) -> Path:
|
||||||
"""Get base path (exe directory or cwd)."""
|
"""Get base path (exe directory or cwd)."""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, "frozen", False):
|
||||||
return Path(sys.executable).parent
|
return Path(sys.executable).parent
|
||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
@@ -91,9 +147,10 @@ class Config:
|
|||||||
|
|
||||||
self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin)
|
self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin)
|
||||||
self.api_token = data.get("api_token", self.api_token)
|
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.port = data.get("port", self.port)
|
||||||
self.log_level = data.get("log_level", self.log_level).upper()
|
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}")
|
logger.info(f"Loaded config from {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -121,6 +178,10 @@ class Config:
|
|||||||
self.port = int(value)
|
self.port = int(value)
|
||||||
case "LOG_LEVEL":
|
case "LOG_LEVEL":
|
||||||
self.log_level = value.upper()
|
self.log_level = value.upper()
|
||||||
|
case "PIN_TTL":
|
||||||
|
self.pin_ttl = int(value)
|
||||||
|
case _:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.info(f"Loaded config from {path}")
|
logger.info(f"Loaded config from {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -130,12 +191,16 @@ class Config:
|
|||||||
"""Load configuration from environment variables (highest priority)."""
|
"""Load configuration from environment variables (highest priority)."""
|
||||||
self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin)
|
self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin)
|
||||||
self.api_token = os.environ.get("API_TOKEN", self.api_token)
|
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:
|
try:
|
||||||
self.port = int(os.environ.get("PORT", self.port))
|
self.port = int(os.environ.get("PORT", self.port))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Invalid PORT environment variable")
|
logger.warning("Invalid PORT environment variable")
|
||||||
self.log_level = os.environ.get("LOG_LEVEL", self.log_level).upper()
|
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:
|
def save(self, path: Path | None = None) -> None:
|
||||||
"""Save configuration to YAML file."""
|
"""Save configuration to YAML file."""
|
||||||
@@ -169,6 +234,7 @@ class Config:
|
|||||||
"host": self.host,
|
"host": self.host,
|
||||||
"port": self.port,
|
"port": self.port,
|
||||||
"log_level": self.log_level,
|
"log_level": self.log_level,
|
||||||
|
"pin_ttl": self.pin_ttl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+69
-5
@@ -12,7 +12,14 @@ import os
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import (
|
||||||
|
FastAPI,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketDisconnect,
|
||||||
|
)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
@@ -20,6 +27,7 @@ from .audio_mixer import AudioMixerManager
|
|||||||
from .config import config
|
from .config import config
|
||||||
from .media import MediaManager
|
from .media import MediaManager
|
||||||
from .notifications import NotificationManager, Notification
|
from .notifications import NotificationManager, Notification
|
||||||
|
from .pin_manager import PinManager
|
||||||
from .telemetry import TelemetryManager
|
from .telemetry import TelemetryManager
|
||||||
|
|
||||||
logger = logging.getLogger("paperdash")
|
logger = logging.getLogger("paperdash")
|
||||||
@@ -37,6 +45,7 @@ def check_subsystems() -> dict[str, bool]:
|
|||||||
# Check Voicemeeter
|
# Check Voicemeeter
|
||||||
try:
|
try:
|
||||||
import voicemeeter_api # noqa: F401 # type: ignore
|
import voicemeeter_api # noqa: F401 # type: ignore
|
||||||
|
|
||||||
results["voicemeeter"] = True
|
results["voicemeeter"] = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
results["voicemeeter"] = False
|
results["voicemeeter"] = False
|
||||||
@@ -44,6 +53,7 @@ def check_subsystems() -> dict[str, bool]:
|
|||||||
# Check Windows Audio (pycaw)
|
# Check Windows Audio (pycaw)
|
||||||
try:
|
try:
|
||||||
import pycaw # noqa: F401 # type: ignore
|
import pycaw # noqa: F401 # type: ignore
|
||||||
|
|
||||||
results["windows_audio"] = True
|
results["windows_audio"] = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
results["windows_audio"] = False
|
results["windows_audio"] = False
|
||||||
@@ -51,6 +61,7 @@ def check_subsystems() -> dict[str, bool]:
|
|||||||
# Check Windows Media (winsdk)
|
# Check Windows Media (winsdk)
|
||||||
try:
|
try:
|
||||||
import winsdk # noqa: F401 # type: ignore
|
import winsdk # noqa: F401 # type: ignore
|
||||||
|
|
||||||
results["windows_media"] = True
|
results["windows_media"] = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
results["windows_media"] = False
|
results["windows_media"] = False
|
||||||
@@ -58,6 +69,7 @@ def check_subsystems() -> dict[str, bool]:
|
|||||||
# Check HWiNFO (pyhwinfo)
|
# Check HWiNFO (pyhwinfo)
|
||||||
try:
|
try:
|
||||||
import pyhwinfo # noqa: F401 # type: ignore
|
import pyhwinfo # noqa: F401 # type: ignore
|
||||||
|
|
||||||
results["hwinfo"] = True
|
results["hwinfo"] = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
results["hwinfo"] = False
|
results["hwinfo"] = False
|
||||||
@@ -103,6 +115,12 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.notifications = NotificationManager()
|
app.state.notifications = NotificationManager()
|
||||||
app.state.telemetry = TelemetryManager()
|
app.state.telemetry = TelemetryManager()
|
||||||
|
|
||||||
|
# Initialize PIN manager
|
||||||
|
app.state.pin_manager = PinManager(ttl_seconds=config.pin_ttl)
|
||||||
|
current_pin = app.state.pin_manager.generate()
|
||||||
|
logger.info(f"Dashboard PIN: {current_pin}")
|
||||||
|
logger.info(f"PIN expires in {config.pin_ttl} seconds")
|
||||||
|
|
||||||
# Start background tasks
|
# Start background tasks
|
||||||
app.state.tasks = [
|
app.state.tasks = [
|
||||||
asyncio.create_task(app.state.media.run()),
|
asyncio.create_task(app.state.media.run()),
|
||||||
@@ -110,6 +128,9 @@ async def lifespan(app: FastAPI):
|
|||||||
asyncio.create_task(app.state.telemetry.run()),
|
asyncio.create_task(app.state.telemetry.run()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Start PIN cleanup loop
|
||||||
|
app.state.pin_manager.start_cleanup_loop()
|
||||||
|
|
||||||
logger.info("PaperDash server started")
|
logger.info("PaperDash server started")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -117,6 +138,11 @@ async def lifespan(app: FastAPI):
|
|||||||
for task in app.state.tasks:
|
for task in app.state.tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
await asyncio.gather(*app.state.tasks, return_exceptions=True)
|
await asyncio.gather(*app.state.tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Stop PIN manager
|
||||||
|
if hasattr(app.state, "pin_manager"):
|
||||||
|
app.state.pin_manager.stop()
|
||||||
|
|
||||||
logger.info("PaperDash server stopped")
|
logger.info("PaperDash server stopped")
|
||||||
|
|
||||||
|
|
||||||
@@ -188,6 +214,11 @@ async def websocket_endpoint(
|
|||||||
pin: str = Query(..., description="Dashboard PIN"),
|
pin: str = Query(..., description="Dashboard PIN"),
|
||||||
):
|
):
|
||||||
"""WebSocket endpoint for the E-Ink dashboard client."""
|
"""WebSocket endpoint for the E-Ink dashboard client."""
|
||||||
|
# Validate PIN (marks as used if valid)
|
||||||
|
if not app.state.pin_manager.validate(pin):
|
||||||
|
await websocket.close(code=4001, reason="Invalid or expired PIN")
|
||||||
|
return
|
||||||
|
|
||||||
if await ws_manager.connect(websocket, pin):
|
if await ws_manager.connect(websocket, pin):
|
||||||
try:
|
try:
|
||||||
# Keep connection alive; the server pushes state updates.
|
# Keep connection alive; the server pushes state updates.
|
||||||
@@ -271,6 +302,12 @@ async def create_notification(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_localhost(request: Request) -> bool:
|
||||||
|
"""Check if the request is from localhost."""
|
||||||
|
client_host = request.client.host if request.client else ""
|
||||||
|
return client_host in ("127.0.0.1", "::1", "localhost")
|
||||||
|
|
||||||
|
|
||||||
class SettingsUpdate:
|
class SettingsUpdate:
|
||||||
"""Settings update payload."""
|
"""Settings update payload."""
|
||||||
|
|
||||||
@@ -291,21 +328,23 @@ class SettingsUpdate:
|
|||||||
|
|
||||||
@app.get("/api/v1/settings")
|
@app.get("/api/v1/settings")
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
|
request: Request,
|
||||||
authorization: str | None = None,
|
authorization: str | None = None,
|
||||||
):
|
):
|
||||||
"""Get current settings (requires API token)."""
|
"""Get current settings (requires API token or localhost)."""
|
||||||
if not _verify_token(authorization):
|
if not _verify_token(authorization) and not _is_localhost(request):
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
||||||
return config.to_dict()
|
return config.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/settings")
|
@app.post("/api/v1/settings")
|
||||||
async def update_settings(
|
async def update_settings(
|
||||||
|
request: Request,
|
||||||
settings: SettingsUpdate,
|
settings: SettingsUpdate,
|
||||||
authorization: str | None = None,
|
authorization: str | None = None,
|
||||||
):
|
):
|
||||||
"""Update settings (requires API token)."""
|
"""Update settings (requires API token or localhost)."""
|
||||||
if not _verify_token(authorization):
|
if not _verify_token(authorization) and not _is_localhost(request):
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
||||||
|
|
||||||
if settings.dashboard_pin is not None:
|
if settings.dashboard_pin is not None:
|
||||||
@@ -343,6 +382,31 @@ async def get_status():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings Page (localhost only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings")
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
"""Serve the settings page (localhost only)."""
|
||||||
|
if not _is_localhost(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
settings_html = os.path.join(os.path.dirname(__file__), "settings.html")
|
||||||
|
with open(settings_html) as f:
|
||||||
|
return HTMLResponse(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings.css")
|
||||||
|
async def settings_css(request: Request):
|
||||||
|
"""Serve the settings stylesheet (localhost only)."""
|
||||||
|
if not _is_localhost(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
css_path = os.path.join(os.path.dirname(__file__), "settings.css")
|
||||||
|
with open(css_path) as f:
|
||||||
|
return HTMLResponse(f.read(), media_type="text/css")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Health
|
# Health
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""PIN manager for PaperDash.
|
||||||
|
|
||||||
|
Generates time-limited, single-use PINs for WebSocket authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperdash.pin_manager")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PinEntry:
|
||||||
|
"""A generated PIN with expiration and usage tracking."""
|
||||||
|
|
||||||
|
pin: str
|
||||||
|
created_at: float
|
||||||
|
expires_at: float
|
||||||
|
used: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PinManager:
|
||||||
|
"""Manages time-limited, single-use PINs."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: int = 300) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
ttl_seconds: How long a PIN remains valid (default: 5 minutes)
|
||||||
|
"""
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
self.pins: dict[str, PinEntry] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._cleanup_timer: Optional[threading.Timer] = None
|
||||||
|
|
||||||
|
def generate(self) -> str:
|
||||||
|
"""Generate a new 6-digit PIN.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The generated PIN string.
|
||||||
|
"""
|
||||||
|
pin = secrets.token_hex(3)[:6] # 6 random hex digits
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
entry = PinEntry(
|
||||||
|
pin=pin,
|
||||||
|
created_at=now,
|
||||||
|
expires_at=now + self.ttl_seconds,
|
||||||
|
used=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self.pins[pin] = entry
|
||||||
|
|
||||||
|
logger.info(f"Generated PIN {pin} (expires in {self.ttl_seconds}s)")
|
||||||
|
return pin
|
||||||
|
|
||||||
|
def validate(self, pin: str) -> bool:
|
||||||
|
"""Validate a PIN (marks it as used if valid).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pin: The PIN to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the PIN is valid and not yet used.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
entry = self.pins.get(pin)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if time.time() > entry.expires_at:
|
||||||
|
del self.pins[pin]
|
||||||
|
logger.info(f"PIN {pin} expired")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if already used
|
||||||
|
if entry.used:
|
||||||
|
logger.info(f"PIN {pin} already used")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Mark as used
|
||||||
|
entry.used = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Remove expired PINs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of PINs removed.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
expired = [pin for pin, entry in self.pins.items() if now > entry.expires_at]
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
for pin in expired:
|
||||||
|
del self.pins[pin]
|
||||||
|
|
||||||
|
if expired:
|
||||||
|
logger.info(f"Cleaned up {len(expired)} expired PIN(s)")
|
||||||
|
|
||||||
|
return len(expired)
|
||||||
|
|
||||||
|
def start_cleanup_loop(self) -> None:
|
||||||
|
"""Start periodic cleanup of expired PINs (every 60 seconds)."""
|
||||||
|
self._run_cleanup()
|
||||||
|
|
||||||
|
def _run_cleanup(self) -> None:
|
||||||
|
"""Run one cleanup cycle and schedule the next."""
|
||||||
|
self.cleanup_expired()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._cleanup_timer is not None:
|
||||||
|
self._cleanup_timer.cancel()
|
||||||
|
|
||||||
|
self._cleanup_timer = threading.Timer(60, self._run_cleanup)
|
||||||
|
self._cleanup_timer.daemon = True
|
||||||
|
self._cleanup_timer.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the cleanup loop."""
|
||||||
|
with self._lock:
|
||||||
|
if self._cleanup_timer is not None:
|
||||||
|
self._cleanup_timer.cancel()
|
||||||
|
self._cleanup_timer = None
|
||||||
|
|
||||||
|
def get_active_count(self) -> int:
|
||||||
|
"""Get the number of active (non-expired, unused) PINs."""
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for entry in self.pins.values()
|
||||||
|
if now <= entry.expires_at and not entry.used
|
||||||
|
)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: #5b9aff;
|
||||||
|
}
|
||||||
|
input::placeholder {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
#save-btn {
|
||||||
|
background: #5b9aff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
#reset-btn {
|
||||||
|
background: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status.success {
|
||||||
|
display: block;
|
||||||
|
background: #1a3a1a;
|
||||||
|
color: #6fcf6f;
|
||||||
|
border: 1px solid #2a5a2a;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
display: block;
|
||||||
|
background: #3a1a1a;
|
||||||
|
color: #cf6f6f;
|
||||||
|
border: 1px solid #5a2a2a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PaperDash Settings</title>
|
||||||
|
<link rel="stylesheet" href="settings.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>PaperDash Settings</h1>
|
||||||
|
<p class="subtitle">Changes take effect on next restart</p>
|
||||||
|
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="dashboard-pin">Dashboard PIN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="dashboard-pin"
|
||||||
|
maxlength="4"
|
||||||
|
placeholder="****"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="api-token">API Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="api-token"
|
||||||
|
placeholder="Bearer token for scripts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<input type="text" id="host" placeholder="Local IP address" />
|
||||||
|
<p class="hint">Address to bind the server to</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="port">Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="port"
|
||||||
|
placeholder="8921"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="log-level">Log Level</label>
|
||||||
|
<select id="log-level">
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="reset-btn">Reset</button>
|
||||||
|
<button type="submit" id="save-btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById("settings-form");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/settings");
|
||||||
|
if (!res.ok) throw new Error("Failed to load settings");
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById("dashboard-pin").value =
|
||||||
|
data.dashboard_pin || "";
|
||||||
|
document.getElementById("api-token").value = data.api_token || "";
|
||||||
|
document.getElementById("host").value = data.host || "";
|
||||||
|
document.getElementById("port").value = data.port || 8921;
|
||||||
|
document.getElementById("log-level").value = data.log_level || "INFO";
|
||||||
|
} catch (err) {
|
||||||
|
showStatus("Failed to load settings: " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
dashboard_pin: document.getElementById("dashboard-pin").value.trim(),
|
||||||
|
api_token: document.getElementById("api-token").value.trim(),
|
||||||
|
host: document.getElementById("host").value.trim(),
|
||||||
|
port: parseInt(document.getElementById("port").value, 10),
|
||||||
|
log_level: document.getElementById("log-level").value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to save settings");
|
||||||
|
showStatus("Settings saved. Restart the server to apply.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showStatus("Failed to save: " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSettings() {
|
||||||
|
document.getElementById("dashboard-pin").value = "";
|
||||||
|
document.getElementById("api-token").value = "";
|
||||||
|
document.getElementById("host").value = "";
|
||||||
|
document.getElementById("port").value = 8921;
|
||||||
|
document.getElementById("log-level").value = "INFO";
|
||||||
|
hideStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg, type) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = "status " + type;
|
||||||
|
if (type === "success") {
|
||||||
|
setTimeout(hideStatus, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus() {
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", saveSettings);
|
||||||
|
document
|
||||||
|
.getElementById("reset-btn")
|
||||||
|
.addEventListener("click", resetSettings);
|
||||||
|
loadSettings();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
REM PaperDash Server Startup Script
|
||||||
|
REM Creates venv if needed, installs dependencies, and starts the server
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
REM Create venv if it doesn't exist
|
||||||
|
if not exist "venv" (
|
||||||
|
echo Creating virtual environment...
|
||||||
|
python -m venv venv
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Activate venv
|
||||||
|
call venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
REM Install dependencies
|
||||||
|
echo Installing dependencies...
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
REM Run the server
|
||||||
|
echo Starting PaperDash server...
|
||||||
|
echo Dashboard: http://localhost:8921
|
||||||
|
echo API docs: http://localhost:8921/docs
|
||||||
|
echo.
|
||||||
|
echo Set environment variables before starting:
|
||||||
|
echo DASHBOARD_PIN=1234
|
||||||
|
echo API_TOKEN=your-token
|
||||||
|
echo.
|
||||||
|
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8921 --reload
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PaperDash Server Startup Script
|
||||||
|
# Creates venv if needed, installs dependencies, and starts the server
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Create venv if it doesn't exist
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
echo "Starting PaperDash server..."
|
||||||
|
echo " Dashboard: http://localhost:8921"
|
||||||
|
echo " API docs: http://localhost:8921/docs"
|
||||||
|
echo ""
|
||||||
|
echo "Set environment variables before starting:"
|
||||||
|
echo " DASHBOARD_PIN=1234"
|
||||||
|
echo " API_TOKEN=your-token"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8921 --reload
|
||||||
+35
-3
@@ -8,7 +8,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pystray
|
import pystray
|
||||||
@@ -42,9 +45,9 @@ def _run_server() -> None:
|
|||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"main:app",
|
"main:app",
|
||||||
host="0.0.0.0",
|
host=CONFIG_HOST,
|
||||||
port=8921,
|
port=CONFIG_PORT,
|
||||||
log_level="info",
|
log_level=CONFIG_LOG_LEVEL,
|
||||||
reload=False,
|
reload=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +58,21 @@ def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
|
|||||||
icon.stop()
|
icon.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_settings(icon: pystray.Icon, item: pystray.MenuItem) -> None:
|
||||||
|
"""Open the settings page in the default browser."""
|
||||||
|
logger.info("Opening settings page...")
|
||||||
|
url = "http://127.0.0.1:8921/settings"
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
os.startfile(url)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.run(["open", url])
|
||||||
|
else:
|
||||||
|
subprocess.run(["xdg-open", url])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to open settings page: {e}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Entry point for the compiled .exe."""
|
"""Entry point for the compiled .exe."""
|
||||||
logger.info("PaperDash starting...")
|
logger.info("PaperDash starting...")
|
||||||
@@ -72,6 +90,8 @@ def main() -> None:
|
|||||||
menu = pystray.Menu(
|
menu = pystray.Menu(
|
||||||
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
|
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
|
||||||
pystray.Menu.Separator(),
|
pystray.Menu.Separator(),
|
||||||
|
pystray.MenuItem("Settings", _on_settings),
|
||||||
|
pystray.Menu.Separator(),
|
||||||
pystray.MenuItem("Quit", _on_quit),
|
pystray.MenuItem("Quit", _on_quit),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,5 +120,17 @@ def main() -> None:
|
|||||||
server_process.join(timeout=5)
|
server_process.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_values() -> tuple[str, int, str]:
|
||||||
|
"""Load config and return (host, port, log_level) for the server."""
|
||||||
|
from .config import config
|
||||||
|
|
||||||
|
config.load()
|
||||||
|
return config.host or "127.0.0.1", config.port, config.log_level.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# Load config at module level so _run_server uses detected values
|
||||||
|
CONFIG_HOST, CONFIG_PORT, CONFIG_LOG_LEVEL = _load_config_values()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user