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
|
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||||
fields `SnapReport` declares.
|
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
|
## Keep the two documents current
|
||||||
|
|
||||||
This is a running system under continual change, not a build being executed, so
|
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
|
return None
|
||||||
path = CAPTURE_DIR / f"{capture_id}{suffix}"
|
path = CAPTURE_DIR / f"{capture_id}{suffix}"
|
||||||
return path if path.is_file() else None
|
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,
|
password_matches,
|
||||||
token_matches,
|
token_matches,
|
||||||
)
|
)
|
||||||
from app.api.captures import capture_path
|
from app.api.captures import capture_path, delete_capture
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
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)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def logout(response: Response):
|
def logout(response: Response):
|
||||||
response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict")
|
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 datetime import date as Date
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -314,22 +313,6 @@ def debug_snap(payload: SnapReport):
|
||||||
return Response(status_code=204)
|
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")
|
@router.get("/debug/captures/{capture_id}/meta")
|
||||||
def get_debug_capture_metadata(capture_id: str):
|
def get_debug_capture_metadata(capture_id: str):
|
||||||
path = capture_path(capture_id, ".json")
|
path = capture_path(capture_id, ".json")
|
||||||
|
|
@ -414,4 +397,3 @@ async def es_option_search(
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(502, str(exc)) from exc
|
raise HTTPException(502, str(exc)) from exc
|
||||||
return result
|
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
|
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.
|
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
|
Captures are taken in a person's browser but often need inspection by an agent
|
||||||
not. That was deliberate — an unguessable id acting as a capability URL, with a
|
on a different machine. Keeping image retrieval behind the browser's HttpOnly
|
||||||
test asserting it — and the reasoning was sound: it lets someone debugging fetch
|
session left that agent unable to see a supplied capture URL, while filesystem
|
||||||
a capture without holding the chart password.
|
access works only when it is attached to the production container.
|
||||||
|
|
||||||
Changed anyway, because of what a capture is. `getDisplayMedia` returns a
|
The image URL is therefore public again, using its 72-bit capture id as the
|
||||||
picture of somebody's screen, and `preferCurrentTab` is a preference rather than
|
explicit handoff capability. Capture creation and metadata retrieval remain
|
||||||
a constraint, so a mis-click shares a different window. 72 bits of entropy stops
|
authenticated; metadata can contain drawing text and geometry not needed to
|
||||||
guessing, but a capability URL still escapes through everything that records
|
inspect the pixels. `DELETE /api/debug/captures/{id}` is public too, so an agent
|
||||||
URLs: proxy and access logs, browser history, a link pasted into a chat.
|
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
|
The public image and cleanup handlers live in `meta.py`, the deliberately
|
||||||
accepts the session cookie — so a browser that is logged in needs nothing extra,
|
unauthenticated router. Capture creation and metadata remain in `routes.py`,
|
||||||
which was the requirement. An agent on the server reads the files directly from
|
behind the normal chart authentication.
|
||||||
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.
|
|
||||||
|
|
||||||
### 2026-08-11 — alerts carry a number and a local time
|
### 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 goal is not "more Vue"
|
||||||
|
|
||||||
The target is **a pinned, hashed, same-origin frontend** that we can grow
|
The target is **a pinned, hashed, minified, same-origin frontend** that we can
|
||||||
without unpkg and without a Python hasher. It is not a component split, not
|
grow without unpkg and without a Python hasher. View-source today is the
|
||||||
TypeScript, not a router, and not leaving Coolify.
|
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
|
- `chart.js` stays a plain class. Vue still must not wrap chart or series
|
||||||
objects in `ref()` / `reactive()`.
|
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
|
`server.hmr` pointed at the machine the *browser* can see. Only worth it if
|
||||||
the watch-and-refresh loop is actually painful.
|
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
|
`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
|
`frontend/dist/`, pick one and use it everywhere). Production minify is a
|
||||||
someone runs the Vite dev server.
|
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
|
## Production: Dockerfile, not nixpacks
|
||||||
|
|
||||||
|
|
@ -170,15 +205,21 @@ FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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
|
COPY --from=frontend /src/dist /app/dist
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/` and
|
The final stage must not `COPY . .` once `frontend/` exists — that would
|
||||||
`.env` never enter the build context. A missing ignore is how the Schwab
|
ship the unminified source next to the bundle. Copy the Python package and
|
||||||
token or the drawing store gets baked into an image.
|
`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:
|
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 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 add a root `package.json` before phase 1 is live.
|
||||||
- Do not run `npm` from pytest or from the API container.
|
- 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
|
## 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
|
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.
|
up the new JS without a `?v=` rewriter.
|
||||||
|
|
||||||
## When this is done
|
## 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
|
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
|
client, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
|
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
|
assert response.status_code == 201
|
||||||
saved = response.json()
|
saved = response.json()
|
||||||
assert saved["id"].startswith("c-")
|
assert saved["id"].startswith("c-")
|
||||||
# A capture is a picture of somebody's screen. The id is unguessable, but a
|
# The screenshot URL is the intentional handoff from a browser to an agent
|
||||||
# capability URL still escapes through anything that records URLs — proxy
|
# on a different machine. Metadata remains private because it can contain
|
||||||
# logs, browser history, a link pasted into a chat. Retrieval is behind the
|
# the selected drawing's text and geometry.
|
||||||
# same auth as the rest of the API, which costs a browser nothing because it
|
assert probe.get(saved["url"]).content == image
|
||||||
# already holds the session cookie.
|
|
||||||
assert probe.get(saved["url"]).status_code == 401
|
|
||||||
assert probe.get(saved["metadata_url"]).status_code == 401
|
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()
|
details = probe.get(saved["metadata_url"], headers={"X-Chart-Token": "s3cret"}).json()
|
||||||
assert details["id"] == saved["id"]
|
assert details["id"] == saved["id"]
|
||||||
assert details["timeframe"] == "30m"
|
assert details["timeframe"] == "30m"
|
||||||
assert details["viewport_width"] == 1440
|
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):
|
def test_a_capture_is_retrievable_with_the_browser_session_cookie(client, tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue