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