admin: internal-only superadmin console (users, payments, DB stats)
New independent `admin` service (admin.main:app) on the same image, reusing app.db/app.models read-only. Never runs migrations or the scheduler; issues SELECTs only. - Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by default when the password is empty. - Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network. - Pages: overview stats, user list + search, per-user history/payment detail, DB usage (information_schema size + row estimates). - Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile bakes admin/ into runtime + test stages. - Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8946dee2e0
commit
411094d7b8
20 changed files with 1143 additions and 1 deletions
140
admin/main.py
Normal file
140
admin/main.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Superadmin console FastAPI app.
|
||||
|
||||
Independent of ``app.main``: it wires only its own routes and reuses
|
||||
``app.db``'s session factory for read-only queries. No lifespan, no
|
||||
migrations, no scheduler.
|
||||
|
||||
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Query, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from admin import auth, queries
|
||||
from app.db import get_session
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||
|
||||
|
||||
def _fmt_bytes(n: int | None) -> str:
|
||||
if not n:
|
||||
return "0 B"
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(n)
|
||||
for u in units:
|
||||
if size < 1024 or u == units[-1]:
|
||||
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
|
||||
size /= 1024
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def _fmt_dt(dt: datetime | None) -> str:
|
||||
if dt is None:
|
||||
return "—"
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
templates.env.filters["bytes"] = _fmt_bytes
|
||||
templates.env.filters["dt"] = _fmt_dt
|
||||
|
||||
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
|
||||
|
||||
|
||||
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
|
||||
async def healthz() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def login_form(request: Request):
|
||||
# Already signed in? Skip the form.
|
||||
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
|
||||
if cookie and auth.verify_session(cookie):
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", {"error": None})
|
||||
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def login_submit(request: Request, password: str = Form(...)):
|
||||
if not auth.check_password(password):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html", {"error": "Incorrect password."},
|
||||
status_code=401,
|
||||
)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(
|
||||
auth.SESSION_COOKIE_NAME,
|
||||
auth.sign_session(),
|
||||
max_age=auth.SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
# secure=False: the console is served over http on a localhost SSH
|
||||
# tunnel, never public TLS. A secure cookie would never be sent.
|
||||
secure=False,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/logout", include_in_schema=False)
|
||||
async def logout():
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
|
||||
stats = await queries.overview_stats(session)
|
||||
recent, _ = await queries.list_users(session, limit=10)
|
||||
return templates.TemplateResponse(
|
||||
request, "overview.html", {"stats": stats, "recent": recent},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def users_list(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
q: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
):
|
||||
per_page = 200
|
||||
offset = (page - 1) * per_page
|
||||
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
|
||||
return templates.TemplateResponse(
|
||||
request, "users.html",
|
||||
{"rows": rows, "total": total, "q": q or "",
|
||||
"page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def user_detail(
|
||||
request: Request, user_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
detail = await queries.user_detail(session, user_id)
|
||||
if detail is None:
|
||||
return templates.TemplateResponse(
|
||||
request, "not_found.html", {"user_id": user_id}, status_code=404,
|
||||
)
|
||||
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
|
||||
|
||||
|
||||
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
|
||||
dependencies=[Depends(auth.require_admin)])
|
||||
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
|
||||
stats = await queries.db_stats(session)
|
||||
return templates.TemplateResponse(request, "db.html", {"stats": stats})
|
||||
Loading…
Add table
Add a link
Reference in a new issue