chart/app/api/meta.py

85 lines
2.8 KiB
Python

"""Endpoints that stay reachable without a token.
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, and
the browser needs /api/login before it has a session, so these routes stay
outside the protected API router.
"""
import os
import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.api.deps import (
SESSION_COOKIE,
SESSION_MAX_AGE,
configured_token,
create_session,
password_matches,
token_matches,
)
from app.api.captures import capture_path, delete_capture
router = APIRouter(prefix="/api")
# Coolify injects the deployed commit; absent when running locally.
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
class LoginRequest(BaseModel):
password: str
@router.get("/health")
def health():
return {"status": "ok", "service": "chart"}
@router.get("/version")
def version():
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(credentials: LoginRequest, request: Request, response: Response):
presented_token = request.headers.get("x-chart-token", "")
token_login = bool(configured_token(request.app)) and token_matches(
request.app, presented_token
)
if not token_login and not password_matches(request.app, credentials.password):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect password")
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0]
response.set_cookie(
SESSION_COOKIE,
create_session(request.app),
max_age=SESSION_MAX_AGE,
httponly=True,
secure=request.url.scheme == "https" or forwarded_proto == "https",
samesite="strict",
path="/",
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response):
response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict")
@router.get("/debug/captures/{capture_id}")
def get_debug_capture(capture_id: str):
"""A short-lived diagnostic screenshot shared by its unguessable id."""
path = capture_path(capture_id, ".png")
if path is None:
raise HTTPException(404, "Capture not found")
return FileResponse(path, media_type="image/png")
@router.delete("/debug/captures/{capture_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_debug_capture(capture_id: str):
"""Erase a public diagnostic screenshot after inspection."""
if not delete_capture(capture_id):
raise HTTPException(404, "Capture not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)