improved diag screen capture
This commit is contained in:
parent
edcfd3977e
commit
d523dad1be
7 changed files with 113 additions and 58 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue