diff --git a/.env.example b/.env.example index 3b732b8..f1e01bf 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,7 @@ ALERT_COOLDOWN_SECONDS=900 # Notifications and access NTFY_TOPIC= NTFY_SERVER=https://ntfy.sh +# Blank = no auth (fine locally). In production this is set in Coolify, not +# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws. CHART_AUTH_TOKEN= REPLAY_FILE= diff --git a/README.md b/README.md index a617f7f..b9ef73a 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,31 @@ Manual redeploy: curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \ "http://127.0.0.1:8000/api/v1/deploy?uuid=dgvch0xqv8uvjfor7dl8bwl9&force=true" ``` + + +## Access token + +`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave +it blank and the app is wide open, which is what you want locally — nothing +prompts. Set it and every request needs the token, as the `X-Chart-Token` +header or a `?token=` query parameter (WebSocket handshakes can't carry +headers, hence the second form). + +In production the token lives in **Coolify's environment variables**, not in +this repo and not in `.env` — that file is gitignored and never exists in the +built container. Coolify re-injects its env vars into every container it +builds, so the token survives redeploys and reboots. + +The browser asks for it once on the first 401 and keeps it in `localStorage`. +To clear it: `localStorage.removeItem('chart-token')`. + +`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy` +polls the latter from whatever machine you pushed from, and neither reveals +anything about the market data or the configuration. + +## Saved trendlines + +Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`). +In production `/app/data` is a **Coolify persistent volume** — without it the +container filesystem is ephemeral and every deploy would silently wipe every +line you've drawn. diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..6d4804e --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,30 @@ +import secrets + +from fastapi import HTTPException, Request, status + + +def configured_token(app) -> str: + runtime = getattr(app.state, "runtime", None) + return runtime.settings.chart_auth_token if runtime else "" + + +def token_matches(app, presented: str) -> bool: + """True when the caller may proceed. + + An empty CHART_AUTH_TOKEN leaves everything open, which is what local + development wants — the check only engages once a token is configured. + """ + want = configured_token(app) + if not want: + return True + return secrets.compare_digest(presented or "", want) + + +def require_token(request: Request) -> None: + presented = request.headers.get("x-chart-token") or request.query_params.get("token", "") + if not token_matches(request.app, presented): + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "Missing or invalid chart token", + headers={"WWW-Authenticate": "X-Chart-Token"}, + ) diff --git a/app/api/meta.py b/app/api/meta.py new file mode 100644 index 0000000..916f1bd --- /dev/null +++ b/app/api/meta.py @@ -0,0 +1,25 @@ +"""Endpoints that stay reachable without a token. + +`bin/wait-deploy` polls /api/version from whatever machine you pushed from, so +requiring the token here would mean carrying it around just to answer "is my +commit live yet". Neither endpoint exposes anything about the market data or +the configuration. +""" +import os + +from fastapi import APIRouter + +router = APIRouter(prefix="/api") + +# Coolify injects the deployed commit; absent when running locally. +SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev") + + +@router.get("/health") +def health(): + return {"status": "ok", "service": "chart"} + + +@router.get("/version") +def version(): + return {"commit": SOURCE_COMMIT} diff --git a/app/api/routes.py b/app/api/routes.py index e2dbe1a..801b9a4 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,14 +1,17 @@ import time import uuid -from fastapi import APIRouter, HTTPException, Query, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from pydantic import BaseModel, Field from app.bars.models import Timeframe from app.analysis.levels import Side from app.analysis.manual_lines import ManualLine +from app.api.deps import require_token -router = APIRouter(prefix="/api") +# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and +# /version live in app.api.meta and stay open on purpose. +router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) class LineCreate(BaseModel): @@ -37,11 +40,6 @@ class LinePatch(BaseModel): cutoff_t: int | None = None -@router.get("/health") -def health(): - return {"status": "ok", "service": "chart"} - - @router.get("/status") def status(request: Request): runtime = request.app.state.runtime diff --git a/app/api/ws.py b/app/api/ws.py index 1042e51..6a04f5a 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -2,6 +2,7 @@ import asyncio from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from app.api.deps import token_matches from app.bars.models import Timeframe from app.analysis.alerts import AlertEngine from app.analysis.confluence import cluster_levels @@ -47,6 +48,11 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: @router.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): + # Browsers cannot set headers on a WebSocket handshake, so the token comes + # in as a query parameter here. 1008 = policy violation. + if not token_matches(websocket.app, websocket.query_params.get("token", "")): + await websocket.close(code=1008, reason="Missing or invalid chart token") + return await websocket.accept() runtime = websocket.app.state.runtime queue: asyncio.Queue = asyncio.Queue(maxsize=100) diff --git a/main.py b/main.py index 9ef8714..e5eae00 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from app.api.meta import router as meta_router from app.api.routes import router as api_router from app.api.ws import router as ws_router from app.config import Settings @@ -32,6 +33,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="chart", lifespan=lifespan) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +app.include_router(meta_router) app.include_router(api_router) app.include_router(ws_router) diff --git a/static/app.js b/static/app.js index 46000d7..d3bce03 100644 --- a/static/app.js +++ b/static/app.js @@ -1,5 +1,31 @@ const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue; +// Shared access token. Blank when the server runs without CHART_AUTH_TOKEN, +// which is the normal local-development case — nothing prompts. +const TOKEN_KEY = 'chart-token'; +let authToken = localStorage.getItem(TOKEN_KEY) || ''; + +function promptForToken() { + const entered = window.prompt('Access token for this chart', ''); + if (entered === null) return false; + authToken = entered.trim(); + localStorage.setItem(TOKEN_KEY, authToken); + return true; +} + +async function apiFetch(url, options = {}) { + const send = () => fetch(url, { + ...options, + headers: authToken + ? { ...(options.headers || {}), 'X-Chart-Token': authToken } + : { ...(options.headers || {}) }, + }); + const response = await send(); + // A 401 means nothing was written, so retrying the same request is safe. + if (response.status === 401 && promptForToken()) return send(); + return response; +} + const defaultPrefs = { base_tf: '1m', enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false }, @@ -41,13 +67,14 @@ createApp({ const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); async function refreshStatus() { - const response = await fetch('/api/status'); + const response = await apiFetch('/api/status'); if (response.ok) status.value = await response.json(); } function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; - socket = new WebSocket(`${protocol}://${location.host}/ws`); + const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; + socket = new WebSocket(`${protocol}://${location.host}/ws${query}`); socket.onopen = () => { socket.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value })); sendPrefs(); @@ -75,8 +102,10 @@ createApp({ playAlert(); } }; - socket.onclose = () => { + socket.onclose = event => { status.value.stream = 'disconnected'; + // 1008 is the server rejecting our token; ask once, then reconnect. + if (event.code === 1008 && !promptForToken()) return; setTimeout(connect, 2000); }; } @@ -131,7 +160,7 @@ createApp({ drawPoints.value = []; drawMode.value = false; try { - const response = await fetch('/api/lines', { + const response = await apiFetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }), }); @@ -166,7 +195,7 @@ createApp({ syncVisibleLevels(); const responses = []; for (const id of ids) { - responses.push(await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' })); + responses.push(await apiFetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' })); } responses.forEach(response => { if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); @@ -199,7 +228,7 @@ createApp({ } async function updateLineStyle(line, changes) { - const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { + const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }); @@ -210,7 +239,7 @@ createApp({ } async function updateLineGeometry(line) { - const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { + const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ anchor_t: line.anchor_t, anchor_p: line.anchor_p, @@ -224,7 +253,7 @@ createApp({ } async function endLineHere(line) { - const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { + const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cutoff_t: line.cutoff_t }), }); diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4b3c935 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,79 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from app.api.meta import router as meta_router +from app.api.routes import router as api_router +from app.api.ws import router as ws_router +from app.config import Settings +from app.runtime import Runtime + + +@pytest.fixture +def client(tmp_path): + def build(token: str) -> TestClient: + settings = Settings( + chart_auth_token=token, + manual_lines_path=tmp_path / "manual_lines.json", + ) + app = FastAPI() + app.include_router(meta_router) + app.include_router(api_router) + app.include_router(ws_router) + app.state.runtime = Runtime(settings) + return TestClient(app) + + return build + + +def test_open_when_no_token_configured(client): + assert client("").get("/api/bars").status_code == 200 + + +def test_rejects_missing_token(client): + assert client("s3cret").get("/api/bars").status_code == 401 + + +def test_rejects_wrong_token(client): + response = client("s3cret").get("/api/bars", headers={"X-Chart-Token": "nope"}) + assert response.status_code == 401 + + +def test_accepts_header_token(client): + response = client("s3cret").get("/api/bars", headers={"X-Chart-Token": "s3cret"}) + assert response.status_code == 200 + + +def test_accepts_query_token(client): + assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200 + + +def test_writes_are_protected(client): + payload = { + "tf": "1m", + "side": "support", + "anchor_t": 1, + "anchor_p": 1.0, + "end_t": 2, + "end_p": 2.0, + } + assert client("s3cret").post("/api/lines", json=payload).status_code == 401 + + +@pytest.mark.parametrize("path", ["/api/health", "/api/version"]) +def test_meta_endpoints_stay_open(client, path): + """bin/wait-deploy polls /api/version without carrying the token.""" + assert client("s3cret").get(path).status_code == 200 + + +def test_websocket_rejects_missing_token(client): + with pytest.raises(WebSocketDisconnect) as excinfo: + with client("s3cret").websocket_connect("/ws"): + pass + assert excinfo.value.code == 1008 + + +def test_websocket_accepts_query_token(client): + with client("s3cret").websocket_connect("/ws?token=s3cret") as socket: + assert socket.receive_json()["type"] == "snapshot"