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
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 <key>` 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.

---

Expand Down Expand Up @@ -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)

---

Expand Down
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}


Expand Down
46 changes: 37 additions & 9 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()

Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
65 changes: 53 additions & 12 deletions app/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,20 @@
</div>
</div>

<div class="settings-section">
<div class="settings-section-title">Security</div>
<div class="settings-section-body">
<div class="field-group">
<label class="field-label">Dashboard Password</label>
<div style="display:flex;gap:8px;align-items:center;">
<input class="field-input" id="s-dashboard-password" type="password" placeholder="Leave blank to disable" autocomplete="new-password"/>
<button class="toggle-btn" onclick="toggleSecret('s-dashboard-password', this)">show</button>
</div>
<div class="field-hint">Set a password to protect the dashboard. Leave empty to allow open access.</div>
</div>
</div>
</div>

<!-- Save -->
<div style="display:flex;align-items:center;gap:12px;">
<button class="save-btn" id="save-btn" onclick="saveSettings()">Save Settings</button>
Expand All @@ -882,7 +896,7 @@
<div id="auth-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.85);display:none;align-items:center;justify-content:center;">
<div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:32px;max-width:360px;width:90%;text-align:center;">
<div style="font-size:18px;font-weight:700;margin-bottom:8px;color:var(--bright);">PlexGeo</div>
<div style="color:var(--muted);margin-bottom:20px;font-size:13px;">Enter your API key to continue</div>
<div style="color:var(--muted);margin-bottom:20px;font-size:13px;">Enter your password to continue</div>
<input id="auth-input" type="password" placeholder="API Key" style="width:100%;padding:10px 12px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--fg);font-family:inherit;font-size:14px;box-sizing:border-box;margin-bottom:12px;">
<div id="auth-error" style="color:#e50914;font-size:12px;margin-bottom:12px;display:none;">Invalid API key</div>
<button onclick="submitAuth()" style="width:100%;padding:10px;border-radius:4px;border:none;background:#e50914;color:#fff;font-family:inherit;font-size:14px;cursor:pointer;font-weight:600;">Login</button>
Expand Down Expand Up @@ -963,7 +977,7 @@
};

// ── Auth ──────────────────────────────────────────────────────────────────────
function getApiKey() { return sessionStorage.getItem('plexgeo-api-key') || ''; }
function getPassword() { return sessionStorage.getItem('plexgeo-password') || ''; }

function showAuthOverlay() {
const overlay = $('auth-overlay');
Expand All @@ -972,25 +986,36 @@
$('auth-input').addEventListener('keydown', e => { if (e.key === 'Enter') submitAuth(); });
}

async function checkAuth() {
try {
const r = await fetch('/api/auth/check');
const data = await r.json();
if (data.auth_required && !getPassword()) showAuthOverlay();
} catch {}
}

async function submitAuth() {
const key = $('auth-input').value.trim();
if (!key) return;
sessionStorage.setItem('plexgeo-api-key', key);
const r = await fetch('/api/stats', { headers: { 'Authorization': 'Bearer ' + key } });
const pw = $('auth-input').value.trim();
if (!pw) return;
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
if (r.status === 401) {
$('auth-error').style.display = 'block';
sessionStorage.removeItem('plexgeo-api-key');
return;
}
sessionStorage.setItem('plexgeo-password', pw);
$('auth-overlay').style.display = 'none';
refresh();
}

// ── API ───────────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const key = getApiKey();
const pw = getPassword();
const headers = { ...(opts.headers || {}) };
if (key) headers['Authorization'] = 'Bearer ' + key;
if (pw) headers['Authorization'] = 'Bearer ' + pw;
const r = await fetch(path, { ...opts, headers });
if (r.status === 401) { showAuthOverlay(); throw new Error('Unauthorized'); }
if (!r.ok) throw new Error(`HTTP ${r.status}`);
Expand Down Expand Up @@ -1042,7 +1067,7 @@

async function ackAlert(id, e) {
e.stopPropagation();
const key = getApiKey();
const key = getPassword();
const headers = {};
if (key) headers['Authorization'] = 'Bearer ' + key;
await fetch(`/api/alerts/${id}/acknowledge`, { method: 'POST', headers });
Expand Down Expand Up @@ -1533,6 +1558,9 @@
$('s-poll-interval').value = s.poll_interval || '30';
$('s-outlier-threshold').value = s.outlier_threshold || '0.10';
$('s-outlier-min-sessions').value = s.outlier_min_sessions || '5';
// Password: show placeholder if set, blank if not
$('s-dashboard-password').placeholder = s.dashboard_password ? 'Password set — enter new value to change' : 'Leave blank to disable';
$('s-dashboard-password').value = '';
} catch(e) {
console.error('Failed to load settings', e);
}
Expand All @@ -1553,13 +1581,17 @@
outlier_min_sessions: $('s-outlier-min-sessions').value,
};

// Only include password if user typed something
const newPw = $('s-dashboard-password').value;
if (newPw !== '') payload.dashboard_password = newPw;

// Only include token if user actually typed something
const token = $('s-plex-token').value.trim();
if (token) payload.plex_token = token;

try {
const hdrs = { 'Content-Type': 'application/json' };
const key = getApiKey();
const key = getPassword();
if (key) hdrs['Authorization'] = 'Bearer ' + key;
const res = await fetch('/api/settings', {
method: 'POST',
Expand All @@ -1571,6 +1603,14 @@
result.className = data.ok ? 'save-ok' : 'save-err';
if (data.ok) {
$('s-plex-token').value = '';
// Update session auth if password was changed
if (payload.dashboard_password !== undefined) {
if (payload.dashboard_password) {
sessionStorage.setItem('plexgeo-password', payload.dashboard_password);
} else {
sessionStorage.removeItem('plexgeo-password');
}
}
await loadSettings();
}
} catch(e) {
Expand All @@ -1593,7 +1633,7 @@

try {
const thdrs = {};
const tkey = getApiKey();
const tkey = getPassword();
if (tkey) thdrs['Authorization'] = 'Bearer ' + tkey;
const res = await fetch('/api/settings/test', { method: 'POST', headers: thdrs });
const data = await res.json();
Expand Down Expand Up @@ -1637,6 +1677,7 @@
const savedTheme = localStorage.getItem('plexgeo-theme') || 'dark';
$('theme-btn').textContent = savedTheme === 'dark' ? '☀ Light' : '☾ Dark';

checkAuth();
refresh();
setInterval(refresh, 30000);
</script>
Expand Down
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ services:
- OUTLIER_MIN_SESSIONS=${OUTLIER_MIN_SESSIONS:-5}
# Generate with: openssl rand -base64 32
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# Optional: set to require authentication on the dashboard
- API_KEY=${API_KEY:-}
volumes:
- plexgeo_data:/data

Expand Down
1 change: 0 additions & 1 deletion unraid/my-plexgeo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
<Config Name="Plex URL" Target="PLEX_URL" Default="" Type="Variable" Display="always" Required="true" Description="URL of your Plex server, e.g. http://192.168.1.50:32400"></Config>
<Config Name="Plex Token" Target="PLEX_TOKEN" Default="" Type="Variable" Display="password" Required="true" Description="Your Plex authentication token (X-Plex-Token)"></Config>
<Config Name="Encryption Key" Target="ENCRYPTION_KEY" Default="" Type="Variable" Display="password" Required="true" Description="Encryption key for sensitive settings. Generate with: openssl rand -base64 32"></Config>
<Config Name="API Key" Target="API_KEY" Default="" Type="Variable" Display="password" Required="false" Description="Optional. If set, the dashboard requires this key to access."></Config>
<Config Name="Poll Interval" Target="POLL_INTERVAL" Default="30" Type="Variable" Display="always" Description="Seconds between Plex polls">30</Config>
<Config Name="Outlier Threshold" Target="OUTLIER_THRESHOLD" Default="0.10" Type="Variable" Display="always" Description="A country must account for less than this fraction of sessions to be flagged">0.10</Config>
<Config Name="Outlier Min Sessions" Target="OUTLIER_MIN_SESSIONS" Default="5" Type="Variable" Display="always" Description="Minimum sessions before outlier detection kicks in">5</Config>
Expand Down
Loading