Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion deploy/docker/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,14 @@ async def _timeline_updater():
async def root():
return RedirectResponse("/playground")


# Pre-0.9 docs pointed at /monitor for the dashboard UI, which now lives at
# /dashboard; /monitor is the monitoring API prefix and has no page of its own.
# Only this exact path redirects - /monitor/* stays gated (see public_paths).
@app.get("/monitor", include_in_schema=False)
async def monitor_ui_redirect():
return RedirectResponse("/dashboard")

# ─────────────────── infra / middleware ─────────────────────
redis = aioredis.from_url(_build_redis_url(config))

Expand Down Expand Up @@ -392,7 +400,10 @@ def _current_api_token() -> str:
app.add_middleware(
AuthGateMiddleware,
token_provider=_current_api_token,
public_paths={HEALTH_PATH, "/token", "/"},
# Exact paths only: "/monitor" reaches the redirect above, while every
# "/monitor/*" API route (incl. /monitor/ws and the admin actions) keeps
# requiring a credential.
public_paths={HEALTH_PATH, "/token", "/", "/monitor"},
public_prefixes=_UI_PREFIXES,
)

Expand Down
51 changes: 41 additions & 10 deletions deploy/docker/tests/test_legacy_compat.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""
Behavioral tests for 0.9.x legacy-compatibility handling:

* root redirect - "/" is public and redirects to /playground instead of
dying in the auth gate with a bare 401; /monitor and the
* UI redirects - "/" and "/monitor" are public and redirect to
/playground and /dashboard instead of dying in the auth
gate with a bare 401; the /monitor/* API routes and the
data routes stay gated.
* output_path - /screenshot and /pdf still accept the 0.8.x output_path
field but return a warning saying no file was written,
Expand Down Expand Up @@ -30,10 +31,10 @@ def _bearer() -> dict:
return {"Authorization": f"Bearer {create_access_token({'sub': 'user@x.com'}, scope='data')}"}


# ───────────────────────── root redirect ─────────────────────────
# ───────────────────────── UI redirects ─────────────────────────


class TestRootRedirect:
class TestUiRedirects:
def test_root_is_public_and_redirects_to_playground(self, stock_client):
r = stock_client.get("/", follow_redirects=False)
assert r.status_code in (302, 307), (
Expand All @@ -42,13 +43,43 @@ def test_root_is_public_and_redirects_to_playground(self, stock_client):
)
assert r.headers["location"] == "/playground"

def test_monitor_and_data_routes_stay_gated(self, stock_client):
# /monitor must not serve content without a token; a future
# /monitor -> /dashboard redirect is fine (the target is UI-public),
# so accept 401 or a redirect, never 200.
def test_monitor_redirects_to_dashboard(self, stock_client):
# Pre-0.9 docs sent people to /monitor for the dashboard UI. That exact
# path redirects to /dashboard (UI-public) instead of dead-ending in the
# auth gate with a bare 401.
r = stock_client.get("/monitor", follow_redirects=False)
assert r.status_code in (401, 302, 307, 308)
assert stock_client.get("/monitor/health").status_code == 401
assert r.status_code in (302, 307), (
f"GET /monitor returned {r.status_code}; expected a redirect. The "
f"auth gate must allow the exact path '/monitor' so the route runs."
)
assert r.headers["location"] == "/dashboard"

@pytest.mark.parametrize(
"method,path",
[
("get", "/monitor/health"),
("get", "/monitor/requests"),
("get", "/monitor/browsers"),
("get", "/monitor/timeline"),
("get", "/monitor/logs/errors"),
("post", "/monitor/actions/cleanup"),
("post", "/monitor/actions/kill_browser"),
("post", "/monitor/stats/reset"),
],
)
def test_monitor_api_routes_stay_gated(self, stock_client, method, path):
# The redirect above is exact-path only. Making the /monitor *prefix*
# public would expose request logs, browser state and the destructive
# admin actions without a credential.
assert getattr(stock_client, method)(path).status_code == 401

def test_monitor_websocket_stays_gated(self, stock_client):
# /monitor/ws is where live stats stream; it must not open unauthenticated.
with pytest.raises(Exception):
with stock_client.websocket_connect("/monitor/ws"):
pass

def test_data_routes_stay_gated(self, stock_client):
assert stock_client.post("/crawl", json={"urls": ["https://x"]}).status_code == 401


Expand Down
7 changes: 4 additions & 3 deletions docs/md_v2/core/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -1495,9 +1495,10 @@ Access the **built-in real-time monitoring dashboard** for complete operational
http://localhost:11235/dashboard
```

> ⚠️ The dashboard UI lives at `/dashboard` — **not** `/monitor`, which is the
> API namespace (`/monitor/health`, `/monitor/ws`, …) and returns
> `{"detail": "Authentication required"}` in a browser. On the dashboard, paste
> ⚠️ The dashboard UI lives at `/dashboard`. `/monitor` is the API namespace
> (`/monitor/health`, `/monitor/ws`, …); older docs pointed there, so that exact
> URL now redirects to `/dashboard` for convenience — the `/monitor/*` routes
> themselves still require a token. On the dashboard, paste
> your API token into the **API token** bar (top right) and click **Set**; the
> WebSocket then connects and live stats appear.

Expand Down
Loading