Move admin page into API, switch to HTTP Basic Auth

This commit is contained in:
2026-04-27 19:07:23 +02:00
parent 5bb864f34f
commit 6479f74d50
4 changed files with 165 additions and 11 deletions

View File

@@ -14,6 +14,8 @@ def get_db():
conn.row_factory = sqlite3.Row
return conn
# your-very-long-random-admin-key
def init_db():
conn = get_db()
conn.execute("""

View File

@@ -1,23 +1,31 @@
from fastapi import FastAPI, HTTPException, Depends, Header, Query, Request
from fastapi.responses import JSONResponse
from .auth import init_db, verify_api_key, get_db # make sure auth.py exports get_db
from fastapi.responses import JSONResponse, FileResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .auth import init_db, verify_api_key, get_db
from .models import Event, BatchEvents
from .enrichment import enrich_event
from .influx import write_event, client, INFLUX_ORG, INFLUX_BUCKET
import logging
import os
import secrets
import datetime
import pathlib
logging.basicConfig(level=logging.INFO)
# Environment variables
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "admin-secret-change-me")
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") # change in pod YAML!
# Initialise database (creates table if missing)
init_db()
app = FastAPI(title="Signal - Roblox Telemetry")
security = HTTPBasic()
# ---------- HTTP Basic auth dependency ----------
def require_admin(credentials: HTTPBasicCredentials = Depends(security)):
if credentials.username != ADMIN_USERNAME or credentials.password != ADMIN_PASSWORD:
raise HTTPException(status_code=401, detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"})
return True
# ---------- Original endpoints ----------
@app.get("/health")
@@ -35,11 +43,12 @@ async def ingest_event(payload: Event | BatchEvents, game: str = Depends(verify_
write_event(enriched)
return {"success": True}
# ---------- Admin authentication helper ----------
def require_admin(x_admin_key: str = Header(None)):
if not x_admin_key or x_admin_key != ADMIN_API_KEY:
raise HTTPException(status_code=403, detail="Forbidden")
return True
# ---------- Serve the admin HTML ----------
STATIC_DIR = pathlib.Path(__file__).parent.parent / "static"
@app.get("/admin", include_in_schema=False)
async def admin_page(admin: bool = Depends(require_admin)):
return FileResponse(STATIC_DIR / "index.html")
# ---------- Admin: API Keys ----------
@app.get("/admin/keys")

142
api/static/index.html Normal file
View File

@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RBXLogger Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container py-4">
<h1>RBXLogger Admin</h1>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#keys">API Keys</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#events">Events</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#buckets">Buckets</a></li>
</ul>
<div class="tab-content">
<!-- API Keys -->
<div class="tab-pane fade show active" id="keys">
<div class="mb-3">
<h5>Create new key</h5>
<input id="newGame" class="form-control w-25 d-inline" placeholder="Game name">
<button class="btn btn-success" onclick="createKey()">Create</button>
</div>
<table class="table table-bordered" id="keysTable">
<thead><tr><th>Key</th><th>Game</th><th>Active</th><th>Created</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Events -->
<div class="tab-pane fade" id="events">
<div class="mb-3">
<label>Hours back: <input id="hoursBack" value="24" class="form-control w-25 d-inline"></label>
<button class="btn btn-info" onclick="loadEvents()">Load</button>
</div>
<div class="mb-3">
<h5>Delete events</h5>
<input id="delStart" class="form-control w-25 d-inline" placeholder="Start (ISO)">
<input id="delStop" class="form-control w-25 d-inline" placeholder="Stop (ISO)">
<input id="delMeas" class="form-control w-25 d-inline" placeholder="Measurement (optional)">
<button class="btn btn-danger" onclick="deleteEvents()">Delete</button>
</div>
<table class="table table-sm" id="eventsTable">
<thead><tr><th>Time</th><th>Measurement</th><th>Fields</th><th>Tags</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Buckets -->
<div class="tab-pane fade" id="buckets">
<button class="btn btn-secondary mb-2" onclick="loadBuckets()">Refresh</button>
<table class="table" id="bucketsTable">
<thead><tr><th>Name</th><th>ID</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// All fetches automatically use the HTTP Basic Auth credentials
// because the browser sends them with every request to this origin.
async function loadKeys() {
const res = await fetch('/admin/keys');
const keys = await res.json();
const tbody = document.querySelector('#keysTable tbody');
tbody.innerHTML = keys.map(k => `
<tr>
<td><code>${k.key}</code></td>
<td>${k.game}</td>
<td>${k.active ? '✅' : '❌'}</td>
<td>${k.created_at}</td>
<td>
<button class="btn btn-sm btn-warning" onclick="toggleKey('${k.key}')">Toggle</button>
<button class="btn btn-sm btn-danger" onclick="deleteKey('${k.key}')">Delete</button>
</td>
</tr>
`).join('');
}
async function createKey() {
const game = document.getElementById('newGame').value;
if (!game) return alert('Game name required');
await fetch(`/admin/keys?game=${encodeURIComponent(game)}`, { method: 'POST' });
loadKeys();
}
async function toggleKey(key) {
await fetch(`/admin/keys/${key}/toggle`, { method: 'PUT' });
loadKeys();
}
async function deleteKey(key) {
if (!confirm('Delete key?')) return;
await fetch(`/admin/keys/${key}`, { method: 'DELETE' });
loadKeys();
}
async function loadEvents() {
const hrs = document.getElementById('hoursBack').value;
const res = await fetch(`/admin/events?hours=${hrs}`);
const events = await res.json();
const tbody = document.querySelector('#eventsTable tbody');
tbody.innerHTML = events.map(e => `
<tr>
<td>${e.time}</td>
<td>${e.measurement}</td>
<td>${JSON.stringify(e.fields)}</td>
<td>${JSON.stringify(e.tags)}</td>
</tr>
`).join('');
}
async function deleteEvents() {
const start = document.getElementById('delStart').value;
const stop = document.getElementById('delStop').value;
const meas = document.getElementById('delMeas').value;
if (!start || !stop) return alert('Start/stop required');
let url = `/admin/events?start=${encodeURIComponent(start)}&stop=${encodeURIComponent(stop)}`;
if (meas) url += `&measurement=${encodeURIComponent(meas)}`;
if (!confirm('PERMANENTLY DELETE events in this range?')) return;
await fetch(url, { method: 'DELETE' });
alert('Done');
loadEvents();
}
async function loadBuckets() {
const res = await fetch('/admin/buckets');
const buckets = await res.json();
const tbody = document.querySelector('#bucketsTable tbody');
tbody.innerHTML = buckets.map(b => `<tr><td>${b.name}</td><td>${b.id}</td></tr>`).join('');
}
// Initial loads
loadKeys();
loadBuckets();
</script>
</body>
</html>