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:
+69
-5
@@ -12,7 +12,14 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
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.responses import HTMLResponse
|
||||
|
||||
@@ -20,6 +27,7 @@ from .audio_mixer import AudioMixerManager
|
||||
from .config import config
|
||||
from .media import MediaManager
|
||||
from .notifications import NotificationManager, Notification
|
||||
from .pin_manager import PinManager
|
||||
from .telemetry import TelemetryManager
|
||||
|
||||
logger = logging.getLogger("paperdash")
|
||||
@@ -37,6 +45,7 @@ def check_subsystems() -> dict[str, bool]:
|
||||
# Check Voicemeeter
|
||||
try:
|
||||
import voicemeeter_api # noqa: F401 # type: ignore
|
||||
|
||||
results["voicemeeter"] = True
|
||||
except ImportError:
|
||||
results["voicemeeter"] = False
|
||||
@@ -44,6 +53,7 @@ def check_subsystems() -> dict[str, bool]:
|
||||
# Check Windows Audio (pycaw)
|
||||
try:
|
||||
import pycaw # noqa: F401 # type: ignore
|
||||
|
||||
results["windows_audio"] = True
|
||||
except ImportError:
|
||||
results["windows_audio"] = False
|
||||
@@ -51,6 +61,7 @@ def check_subsystems() -> dict[str, bool]:
|
||||
# Check Windows Media (winsdk)
|
||||
try:
|
||||
import winsdk # noqa: F401 # type: ignore
|
||||
|
||||
results["windows_media"] = True
|
||||
except ImportError:
|
||||
results["windows_media"] = False
|
||||
@@ -58,6 +69,7 @@ def check_subsystems() -> dict[str, bool]:
|
||||
# Check HWiNFO (pyhwinfo)
|
||||
try:
|
||||
import pyhwinfo # noqa: F401 # type: ignore
|
||||
|
||||
results["hwinfo"] = True
|
||||
except ImportError:
|
||||
results["hwinfo"] = False
|
||||
@@ -103,6 +115,12 @@ async def lifespan(app: FastAPI):
|
||||
app.state.notifications = NotificationManager()
|
||||
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
|
||||
app.state.tasks = [
|
||||
asyncio.create_task(app.state.media.run()),
|
||||
@@ -110,6 +128,9 @@ async def lifespan(app: FastAPI):
|
||||
asyncio.create_task(app.state.telemetry.run()),
|
||||
]
|
||||
|
||||
# Start PIN cleanup loop
|
||||
app.state.pin_manager.start_cleanup_loop()
|
||||
|
||||
logger.info("PaperDash server started")
|
||||
yield
|
||||
|
||||
@@ -117,6 +138,11 @@ async def lifespan(app: FastAPI):
|
||||
for task in app.state.tasks:
|
||||
task.cancel()
|
||||
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")
|
||||
|
||||
|
||||
@@ -188,6 +214,11 @@ async def websocket_endpoint(
|
||||
pin: str = Query(..., description="Dashboard PIN"),
|
||||
):
|
||||
"""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):
|
||||
try:
|
||||
# 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:
|
||||
"""Settings update payload."""
|
||||
|
||||
@@ -291,21 +328,23 @@ class SettingsUpdate:
|
||||
|
||||
@app.get("/api/v1/settings")
|
||||
async def get_settings(
|
||||
request: Request,
|
||||
authorization: str | None = None,
|
||||
):
|
||||
"""Get current settings (requires API token)."""
|
||||
if not _verify_token(authorization):
|
||||
"""Get current settings (requires API token or localhost)."""
|
||||
if not _verify_token(authorization) and not _is_localhost(request):
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
||||
return config.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/v1/settings")
|
||||
async def update_settings(
|
||||
request: Request,
|
||||
settings: SettingsUpdate,
|
||||
authorization: str | None = None,
|
||||
):
|
||||
"""Update settings (requires API token)."""
|
||||
if not _verify_token(authorization):
|
||||
"""Update settings (requires API token or localhost)."""
|
||||
if not _verify_token(authorization) and not _is_localhost(request):
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user