Move admin page into API, switch to HTTP Basic Auth
This commit is contained in:
@@ -3,6 +3,7 @@ WORKDIR /app
|
|||||||
COPY api/requirements.txt .
|
COPY api/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY api/app/ ./app/
|
COPY api/app/ ./app/
|
||||||
|
COPY api/static/ ./static/ # <-- Add this line
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -14,6 +14,8 @@ def get_db():
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
# your-very-long-random-admin-key
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Depends, Header, Query, Request
|
from fastapi import FastAPI, HTTPException, Depends, Header, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
from .auth import init_db, verify_api_key, get_db # make sure auth.py exports get_db
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
from .auth import init_db, verify_api_key, get_db
|
||||||
from .models import Event, BatchEvents
|
from .models import Event, BatchEvents
|
||||||
from .enrichment import enrich_event
|
from .enrichment import enrich_event
|
||||||
from .influx import write_event, client, INFLUX_ORG, INFLUX_BUCKET
|
from .influx import write_event, client, INFLUX_ORG, INFLUX_BUCKET
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import datetime
|
import pathlib
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
# Environment variables
|
# 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()
|
init_db()
|
||||||
|
|
||||||
app = FastAPI(title="Signal - Roblox Telemetry")
|
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 ----------
|
# ---------- Original endpoints ----------
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -35,11 +43,12 @@ async def ingest_event(payload: Event | BatchEvents, game: str = Depends(verify_
|
|||||||
write_event(enriched)
|
write_event(enriched)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
# ---------- Admin authentication helper ----------
|
# ---------- Serve the admin HTML ----------
|
||||||
def require_admin(x_admin_key: str = Header(None)):
|
STATIC_DIR = pathlib.Path(__file__).parent.parent / "static"
|
||||||
if not x_admin_key or x_admin_key != ADMIN_API_KEY:
|
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
@app.get("/admin", include_in_schema=False)
|
||||||
return True
|
async def admin_page(admin: bool = Depends(require_admin)):
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
# ---------- Admin: API Keys ----------
|
# ---------- Admin: API Keys ----------
|
||||||
@app.get("/admin/keys")
|
@app.get("/admin/keys")
|
||||||
|
|||||||
142
api/static/index.html
Normal file
142
api/static/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user