chart/app/api/meta.py
Chris Amow 7590d53b13 Put diagnostic capture retrieval behind the same auth as everything else
Uploading a capture required a token; retrieving one did not. That was a
deliberate capability-URL design with a test asserting it, and the reasoning
held: it lets whoever is debugging fetch a capture without the chart password.

Changed because of what a capture contains. getDisplayMedia returns a picture of
someone's screen, and preferCurrentTab is a preference rather than a constraint,
so a mis-click shares a different window. An unguessable id stops guessing but
not leakage: capability URLs escape through proxy logs, browser history and
pasted links.

Retrieval now uses the dependency the rest of the API uses, which already
accepts the session cookie — so a logged-in browser needs nothing extra, which
was the condition for making this change at all. An agent on the server reads
the capture directory directly; one working over HTTP sends the API token.

Both handlers moved from meta.py to routes.py. meta.py is the deliberately open
router — health, version, login, logout — and a screenshot endpoint did not
belong there. The existing test now asserts 401 without credentials, and a new
one covers the browser path: log in, then retrieve with only the cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:24:04 -05:00

68 lines
2.1 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
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")