Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ 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
```

Expand All @@ -70,9 +71,12 @@ 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 <key>` header. The dashboard will prompt for the key on first load.

---

## Docker Image
Expand Down Expand Up @@ -106,6 +110,7 @@ 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**

---
Expand Down
92 changes: 84 additions & 8 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import logging
import os
import secrets
from contextlib import asynccontextmanager
from typing import Any

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from starlette.middleware.base import BaseHTTPMiddleware

from app import database
from app import config as cfg
Expand All @@ -21,10 +22,34 @@

scheduler = AsyncIOScheduler()

API_KEY = os.environ.get("API_KEY", "").strip()


# ── Auth middleware ───────────────────────────────────────────────────────────

class AuthMiddleware(BaseHTTPMiddleware):
"""Require Bearer token on /api/* routes when API_KEY 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":
auth = request.headers.get("authorization", "")
if not auth.startswith("Bearer ") or not secrets.compare_digest(auth[7:], API_KEY):
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
return await call_next(request)


# ── Security headers middleware ──────────────────────────────────────────────

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
return response


def _poll_interval() -> int:
import asyncio
# Sync wrapper — APScheduler calls this synchronously so we read env as fallback
return int(os.environ.get("POLL_INTERVAL", "30"))


Expand All @@ -45,14 +70,21 @@ 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")
else:
logger.warning("API_KEY not set — dashboard is open to anyone on the network")

await poll_sessions()

yield

scheduler.shutdown()


app = FastAPI(title="PlexGeo", lifespan=lifespan)
app = FastAPI(title="PlexGeo", lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(AuthMiddleware)
app.add_middleware(SecurityHeadersMiddleware)

static_dir = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
Expand All @@ -71,7 +103,7 @@ async def get_stats():


@app.get("/api/sessions")
async def get_sessions(limit: int = 100, offset: int = 0):
async def get_sessions(limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0)):
return await database.api_get_sessions(limit, offset)


Expand Down Expand Up @@ -117,6 +149,49 @@ class SettingsUpdate(BaseModel):
outlier_threshold: str | None = None
outlier_min_sessions: str | None = None

@field_validator("plex_url")
@classmethod
def validate_plex_url(cls, v):
if v is not None and v != "" and not v.startswith(("http://", "https://")):
raise ValueError("Must start with http:// or https://")
return v

@field_validator("poll_interval")
@classmethod
def validate_poll_interval(cls, v):
if v is not None:
try:
n = int(v)
except ValueError:
raise ValueError("Must be a number")
if n < 5 or n > 3600:
raise ValueError("Must be between 5 and 3600")
return v

@field_validator("outlier_threshold")
@classmethod
def validate_outlier_threshold(cls, v):
if v is not None:
try:
n = float(v)
except ValueError:
raise ValueError("Must be a number")
if n < 0.01 or n > 1.0:
raise ValueError("Must be between 0.01 and 1.0")
return v

@field_validator("outlier_min_sessions")
@classmethod
def validate_outlier_min_sessions(cls, v):
if v is not None:
try:
n = int(v)
except ValueError:
raise ValueError("Must be a number")
if n < 1 or n > 1000:
raise ValueError("Must be between 1 and 1000")
return v


@app.post("/api/settings")
async def save_settings(body: SettingsUpdate):
Expand Down Expand Up @@ -177,7 +252,8 @@ async def test_connection():
except Unauthorized:
return {"ok": False, "message": "Authentication failed — check your Plex Token"}
except Exception as e:
return {"ok": False, "message": f"Connection failed: {e}"}
logger.error(f"Plex connection test failed: {e}")
return {"ok": False, "message": "Connection failed — check your Plex URL and network"}


@app.get("/health")
Expand Down
Loading
Loading