diff --git a/README.md b/README.md index 1c720e7..1559db4 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ docker run -d \ -e PLEX_URL=http://your-plex-ip:32400 \ -e PLEX_TOKEN=your-plex-token \ -e ENCRYPTION_KEY=your-generated-key \ - -e API_KEY=your-secret-key \ ghcr.io/inch-high/plexgeo:latest ``` @@ -71,11 +70,9 @@ docker compose up -d | `POLL_INTERVAL` | `30` | Seconds between Plex polls | | `OUTLIER_THRESHOLD` | `0.10` | A country must account for <10% of sessions to be flagged | | `OUTLIER_MIN_SESSIONS` | `5` | Minimum sessions before outlier detection kicks in | -| `API_KEY` | — | Optional. If set, the dashboard requires this key to access | - > If `ENCRYPTION_KEY` is not set, a temporary key is generated in memory. Encrypted settings (e.g. your Plex token stored via the UI) will be lost on container restart. -> If `API_KEY` is set, all API endpoints require an `Authorization: Bearer ` header. The dashboard will prompt for the key on first load. +> You can set a **dashboard password** in Settings to protect the UI. The password is stored encrypted in the database. --- @@ -110,8 +107,8 @@ Available for `linux/amd64` and `linux/arm64`. - `PLEX_URL` = your Plex server URL - `PLEX_TOKEN` = your Plex token - `ENCRYPTION_KEY` = your generated key - - `API_KEY` = a secret password for dashboard access (optional) 6. Click **Apply** +7. Set a dashboard password in **Settings** after first login (optional) --- diff --git a/app/config.py b/app/config.py index c772163..8daf7d5 100644 --- a/app/config.py +++ b/app/config.py @@ -27,6 +27,7 @@ "poll_interval": ("30", False, "POLL_INTERVAL"), "outlier_threshold": ("0.10", False, "OUTLIER_THRESHOLD"), "outlier_min_sessions": ("5", False, "OUTLIER_MIN_SESSIONS"), + "dashboard_password": ("", True, None), } diff --git a/app/main.py b/app/main.py index f9b8a45..08c6033 100644 --- a/app/main.py +++ b/app/main.py @@ -22,19 +22,21 @@ scheduler = AsyncIOScheduler() -API_KEY = os.environ.get("API_KEY", "").strip() +# Paths that never require auth +AUTH_EXEMPT = {"/health", "/api/auth/check", "/api/auth/login"} # ── Auth middleware ─────────────────────────────────────────────────────────── class AuthMiddleware(BaseHTTPMiddleware): - """Require Bearer token on /api/* routes when API_KEY is set.""" + """Require password on /api/* routes when dashboard_password is set.""" async def dispatch(self, request: Request, call_next): - if API_KEY and request.url.path.startswith("/api/"): - # /health is exempt for Docker healthcheck - if request.url.path != "/health": + if request.url.path.startswith("/api/") and request.url.path not in AUTH_EXEMPT: + password = await cfg.get("dashboard_password") + if password: auth = request.headers.get("authorization", "") - if not auth.startswith("Bearer ") or not secrets.compare_digest(auth[7:], API_KEY): + token = auth[7:] if auth.startswith("Bearer ") else "" + if not token or not secrets.compare_digest(token, password): return JSONResponse({"detail": "Unauthorized"}, status_code=401) return await call_next(request) @@ -70,10 +72,11 @@ async def lifespan(app: FastAPI): scheduler.start() logger.info(f"Plex poller started (every {interval}s)") - if API_KEY: - logger.info("API_KEY is set — authentication enabled") + password = await cfg.get("dashboard_password") + if password: + logger.info("Dashboard password is set — authentication enabled") else: - logger.warning("API_KEY not set — dashboard is open to anyone on the network") + logger.warning("No dashboard password set — dashboard is open to anyone on the network") await poll_sessions() @@ -95,6 +98,30 @@ async def root(): return FileResponse(os.path.join(static_dir, "index.html")) +# ── Auth API ───────────────────────────────────────────────────────────────── + +@app.get("/api/auth/check") +async def auth_check(): + """Check whether auth is required (no auth needed to call this).""" + password = await cfg.get("dashboard_password") + return {"auth_required": bool(password)} + + +class LoginBody(BaseModel): + password: str + + +@app.post("/api/auth/login") +async def auth_login(body: LoginBody): + """Verify password (no auth needed to call this).""" + stored = await cfg.get("dashboard_password") + if not stored: + return {"ok": True} + if secrets.compare_digest(body.password, stored): + return {"ok": True} + return JSONResponse({"ok": False, "message": "Incorrect password"}, status_code=401) + + # ── Data API ───────────────────────────────────────────────────────────────── @app.get("/api/stats") @@ -148,6 +175,7 @@ class SettingsUpdate(BaseModel): poll_interval: str | None = None outlier_threshold: str | None = None outlier_min_sessions: str | None = None + dashboard_password: str | None = None @field_validator("plex_url") @classmethod diff --git a/app/static/index.html b/app/static/index.html index ba28de9..8e052ba 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -863,6 +863,20 @@ +
+
Security
+
+
+ +
+ + +
+
Set a password to protect the dashboard. Leave empty to allow open access.
+
+
+
+
@@ -882,7 +896,7 @@