103 lines
3.7 KiB
Python
103 lines
3.7 KiB
Python
"""The HTML pages, plus the two health endpoints.
|
|
|
|
There is no template engine. The only thing a template would inject is the
|
|
`next` parameter and a status message, both of which the page reads from
|
|
location.search -- so these are static files and the JS does the rest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, Request, status
|
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
|
|
from app import db as db_module
|
|
from app.deps import current_user
|
|
from app.models import User
|
|
|
|
log = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["pages"])
|
|
|
|
WEB = Path(__file__).resolve().parent.parent.parent / "web"
|
|
NO_STORE = {"Cache-Control": "no-store"}
|
|
|
|
|
|
def page(name: str) -> FileResponse:
|
|
return FileResponse(WEB / name, media_type="text/html; charset=utf-8",
|
|
headers=NO_STORE)
|
|
|
|
|
|
@router.get("/", include_in_schema=False)
|
|
def index(user: User | None = Depends(current_user)) -> Response:
|
|
# Redirect on the server. Gating in JS would flash the whole app shell
|
|
# before bouncing, and would show nothing at all with scripting disabled.
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
return page("index.html")
|
|
|
|
|
|
@router.get("/login", include_in_schema=False)
|
|
def login_page(request: Request, user: User | None = Depends(current_user)) -> Response:
|
|
if user is not None:
|
|
nxt = request.query_params.get("next") or "/"
|
|
if not nxt.startswith("/") or nxt.startswith("//"):
|
|
nxt = "/" # only ever redirect within this site
|
|
return RedirectResponse(nxt, status_code=status.HTTP_303_SEE_OTHER)
|
|
return page("login.html")
|
|
|
|
|
|
@router.get("/signup", include_in_schema=False)
|
|
def signup_page(user: User | None = Depends(current_user)) -> Response:
|
|
if user is not None:
|
|
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
|
return page("signup.html")
|
|
|
|
|
|
@router.get("/forgot", include_in_schema=False)
|
|
def forgot_page() -> Response:
|
|
return page("forgot.html")
|
|
|
|
|
|
# Both of these are deliberately inert: they render, read the token out of the
|
|
# query string, and POST it. A mail scanner that GETs every link in a message
|
|
# therefore cannot spend the token before the recipient opens it.
|
|
@router.get("/reset", include_in_schema=False)
|
|
def reset_page() -> Response:
|
|
return page("reset.html")
|
|
|
|
|
|
@router.get("/verify", include_in_schema=False)
|
|
def verify_page() -> Response:
|
|
return page("verify.html")
|
|
|
|
|
|
@router.get("/admin", include_in_schema=False)
|
|
def admin_page(user: User | None = Depends(current_user)) -> Response:
|
|
if user is None:
|
|
return RedirectResponse("/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
if not user.is_admin:
|
|
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
|
return page("admin.html")
|
|
|
|
|
|
@router.get("/healthz", include_in_schema=False)
|
|
def healthz() -> JSONResponse:
|
|
"""Liveness only. Deliberately does not touch MySQL: if it did, a thirty
|
|
second RDS failover would restart the container instead of just failing the
|
|
handful of routes that actually need the database."""
|
|
return JSONResponse({"status": "ok"}, headers=NO_STORE)
|
|
|
|
|
|
@router.get("/readyz", include_in_schema=False)
|
|
def readyz() -> JSONResponse:
|
|
try:
|
|
db_module.ping()
|
|
except Exception as exc: # noqa: BLE001 - report, don't raise
|
|
log.warning("readiness check failed: %s", exc)
|
|
return JSONResponse({"db": "unreachable"},
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
headers=NO_STORE)
|
|
return JSONResponse({"db": "ok"}, headers=NO_STORE)
|