"""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)