improved diag screen capture

This commit is contained in:
Chris Amow 2026-08-14 23:40:49 -05:00
parent edcfd3977e
commit d523dad1be
7 changed files with 113 additions and 58 deletions

View file

@ -93,6 +93,13 @@ numbers, which is exactly what "works in my headless run" cannot tell you.
Extend it when the next geometry puzzle appears; the endpoint takes whatever
fields `SnapReport` declares.
`?diag=1` also exposes **Capture diagnostic**. The uploaded PNG URL at
`/api/debug/captures/{id}` is deliberately public: its 72-bit id is the
handoff from a browser to an agent on a different machine. Capture upload and
metadata remain authenticated. Inspect only a URL the user explicitly shares,
then immediately `DELETE /api/debug/captures/{id}`. The server also expires
captures after 24 hours and caps the directory at 50 files.
## Keep the two documents current
This is a running system under continual change, not a build being executed, so

View file

@ -57,3 +57,14 @@ def capture_path(capture_id: str, suffix: str) -> Path | None:
return None
path = CAPTURE_DIR / f"{capture_id}{suffix}"
return path if path.is_file() else None
def delete_capture(capture_id: str) -> bool:
if not CAPTURE_ID.fullmatch(capture_id):
return False
image = CAPTURE_DIR / f"{capture_id}.png"
metadata = CAPTURE_DIR / f"{capture_id}.json"
if not image.is_file() and not metadata.is_file():
return False
_remove(capture_id)
return True

View file

@ -20,7 +20,7 @@ from app.api.deps import (
password_matches,
token_matches,
)
from app.api.captures import capture_path
from app.api.captures import capture_path, delete_capture
router = APIRouter(prefix="/api")
@ -66,3 +66,20 @@ def login(credentials: LoginRequest, request: Request, response: Response):
@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)

View file

@ -8,7 +8,6 @@ import uuid
from datetime import date as Date
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
@ -314,22 +313,6 @@ def debug_snap(payload: SnapReport):
return Response(status_code=204)
@router.get("/debug/captures/{capture_id}")
def get_debug_capture(capture_id: str):
"""A diagnostic capture, behind the same auth as everything else.
These are screenshots of somebody's screen. The id is 72 bits of entropy so
the URL is unguessable, but a capability URL still leaks through anything
that records URLs — proxy logs, browser history, a pasted link. Requiring a
session costs nothing: a browser already holds the cookie, and an API client
already sends the token.
"""
path = capture_path(capture_id, ".png")
if path is None:
raise HTTPException(404, "Capture not found")
return FileResponse(path, media_type="image/png")
@router.get("/debug/captures/{capture_id}/meta")
def get_debug_capture_metadata(capture_id: str):
path = capture_path(capture_id, ".json")
@ -414,4 +397,3 @@ async def es_option_search(
except ValueError as exc:
raise HTTPException(502, str(exc)) from exc
return result

View file

@ -683,28 +683,23 @@ intraday candles, but on the daily chart it made the SMA itself look like a
staircase. MA line type is now timeframe-aware: stepped on intraday charts and
simple point-to-point lines on `1d`, with both modes pinned by browser coverage.
### 2026-08-11 — diagnostic captures moved behind auth
### 2026-08-11 — diagnostic capture handoff
The capture endpoints were split: uploading required a token, retrieving did
not. That was deliberate — an unguessable id acting as a capability URL, with a
test asserting it — and the reasoning was sound: it lets someone debugging fetch
a capture without holding the chart password.
Captures are taken in a person's browser but often need inspection by an agent
on a different machine. Keeping image retrieval behind the browser's HttpOnly
session left that agent unable to see a supplied capture URL, while filesystem
access works only when it is attached to the production container.
Changed anyway, because of what a capture is. `getDisplayMedia` returns a
picture of somebody's screen, and `preferCurrentTab` is a preference rather than
a constraint, so a mis-click shares a different window. 72 bits of entropy stops
guessing, but a capability URL still escapes through everything that records
URLs: proxy and access logs, browser history, a link pasted into a chat.
The image URL is therefore public again, using its 72-bit capture id as the
explicit handoff capability. Capture creation and metadata retrieval remain
authenticated; metadata can contain drawing text and geometry not needed to
inspect the pixels. `DELETE /api/debug/captures/{id}` is public too, so an agent
can remove an inspected screenshot immediately. The existing 24-hour expiry and
50-capture cap remain the backstop.
Retrieval now uses the same dependency as the rest of the API, which already
accepts the session cookie — so a browser that is logged in needs nothing extra,
which was the requirement. An agent on the server reads the files directly from
the capture directory, and one working over HTTP presents the API token. Neither
path got harder, which is why the trade was worth making.
Both handlers moved from `meta.py` to `routes.py`. `meta.py` is the deliberately
unauthenticated router — health, version, login and logout — and a screenshot
endpoint did not belong in that company.
The public image and cleanup handlers live in `meta.py`, the deliberately
unauthenticated router. Capture creation and metadata remain in `routes.py`,
behind the normal chart authentication.
### 2026-08-11 — alerts carry a number and a local time

View file

@ -11,9 +11,10 @@ Today `static/index.html` loads Vue 3, Lightweight Charts 5.2.0 and Font Awesome
## The goal is not "more Vue"
The target is **a pinned, hashed, same-origin frontend** that we can grow
without unpkg and without a Python hasher. It is not a component split, not
TypeScript, not a router, and not leaving Coolify.
The target is **a pinned, hashed, minified, same-origin frontend** that we can
grow without unpkg and without a Python hasher. View-source today is the
entire app. After this, a casual reader should not get `app.js` back. It is
not a component split, not TypeScript, not a router, and not leaving Coolify.
- `chart.js` stays a plain class. Vue still must not wrap chart or series
objects in `ref()` / `reactive()`.
@ -135,10 +136,44 @@ Optional later, not part of the move: `vite` with `server.host: true` and
`server.hmr` pointed at the machine the *browser* can see. Only worth it if
the watch-and-refresh loop is actually painful.
`vite.config.js` needs nothing clever for the default path — `base: '/'`,
`vite.config.js` needs little for the default path — `base: '/'`,
`build.outDir` set so FastAPI and the watcher agree (repo-root `dist/` or
`frontend/dist/`, pick one and use it everywhere). No `/api` proxy until
someone runs the Vite dev server.
`frontend/dist/`, pick one and use it everywhere). Production minify is a
requirement, not an option; see below. No `/api` proxy until someone runs
the Vite dev server.
## Production minify
Vite's production build already minifies JS and CSS with esbuild. Keep that
on. Do not set `build.minify: false` to "make debugging easier" — that is
what the source tree is for.
```js
build: {
minify: 'esbuild',
sourcemap: false,
cssMinify: true,
}
```
**No source maps in what FastAPI serves.** A `.map` file next to the bundle
is the original source with a different URL. `sourcemap: false` is the
default; do not turn it on in the config that Coolify builds. Local
debugging reads `frontend/src/`, not a map shipped to the browser.
**Do not put the source tree on the production image.** The multi-stage
`COPY . .` below would otherwise copy `frontend/src/` into the container.
Even unmounted, that is one Traefik mistake away from being public. The
final stage copies `dist/` only. `.dockerignore` must list `frontend/`.
`vite build --watch` in compose is a production build in a loop, so local
and prod stay equally minified. That is what we want. Slower than HMR;
acceptable.
This is a speed bump, not a lock. `window.__chart` remains a deliberate
debug handle and e2e depends on it — anyone who knows to open the console
still has the wrapper. Minify so View Source is not the codebase; do not
delete `__chart` to chase real secrecy.
## Production: Dockerfile, not nixpacks
@ -170,15 +205,21 @@ FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY main.py Procfile ./
COPY app ./app
COPY --from=frontend /src/dist /app/dist
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/` and
`.env` never enter the build context. A missing ignore is how the Schwab
token or the drawing store gets baked into an image.
The final stage must not `COPY . .` once `frontend/` exists — that would
ship the unminified source next to the bundle. Copy the Python package and
`dist/` only.
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/`,
`.env` and `frontend/` never enter the *final* build context. A missing
ignore is how the Schwab token, the drawing store, or `App.vue` gets baked
into an image.
Unchanged, and not Coolify's problem:
@ -276,6 +317,8 @@ is what builds it. There is no third path.
- Do not keep `static/` as a fallback once `dist/` is the source of truth.
- Do not add a root `package.json` before phase 1 is live.
- Do not run `npm` from pytest or from the API container.
- Do not ship source maps, serve `frontend/`, or leave `static/` up once
`dist/` is live. Any of those undoes minify.
## Verify
@ -300,7 +343,9 @@ curl -fsS https://chart.amow.com/api/version
```
View-source on `/` should show `/assets/…` with a hash and no unpkg script
tags. A hard refresh on a tab that was open across the deploy should pick
tags. The JS behind that URL should be a single minified file with no
`.map`, and fetching `/frontend/src/App.vue` or `/static/app.js` should
404. A hard refresh on a tab that was open across the deploy should pick
up the new JS without a `?v=` rewriter.
## When this is done

View file

@ -174,7 +174,7 @@ def test_writes_are_protected(client):
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
def test_a_capture_needs_auth_to_upload_and_to_retrieve(
def test_a_capture_needs_auth_to_upload_but_can_be_retrieved_and_deleted(
client, tmp_path, monkeypatch
):
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
@ -202,19 +202,17 @@ def test_a_capture_needs_auth_to_upload_and_to_retrieve(
assert response.status_code == 201
saved = response.json()
assert saved["id"].startswith("c-")
# A capture is a picture of somebody's screen. The id is unguessable, but a
# capability URL still escapes through anything that records URLs — proxy
# logs, browser history, a link pasted into a chat. Retrieval is behind the
# same auth as the rest of the API, which costs a browser nothing because it
# already holds the session cookie.
assert probe.get(saved["url"]).status_code == 401
# The screenshot URL is the intentional handoff from a browser to an agent
# on a different machine. Metadata remains private because it can contain
# the selected drawing's text and geometry.
assert probe.get(saved["url"]).content == image
assert probe.get(saved["metadata_url"]).status_code == 401
assert probe.get(saved["url"], headers={"X-Chart-Token": "s3cret"}).content == image
details = probe.get(saved["metadata_url"], headers={"X-Chart-Token": "s3cret"}).json()
assert details["id"] == saved["id"]
assert details["timeframe"] == "30m"
assert details["viewport_width"] == 1440
assert probe.delete(saved["url"]).status_code == 204
assert probe.get(saved["url"]).status_code == 404
def test_a_capture_is_retrievable_with_the_browser_session_cookie(client, tmp_path, monkeypatch):