From cbbe5eaac1c1b73325fec36934f3587441a6dd24 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 8 May 2026 17:45:34 -0700 Subject: [PATCH] fix: mount all routes at /turnstone prefix for direct LAN access Vite builds with base='/turnstone/' so asset paths in index.html are /turnstone/assets/*. Serving FastAPI at root / meant direct hits to port 8534 got index.html for asset requests (blank page). - All routes now under /turnstone (APIRouter prefix + StaticFiles mount at /turnstone/assets + SPA catch-all at /turnstone/{path}) - Root / redirects to /turnstone/ - Caddy block reverted to no-strip: both direct LAN and Caddy access hit the same paths, no per-host routing differences --- app/rest.py | 55 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/app/rest.py b/app/rest.py index 874b2bf..db86988 100644 --- a/app/rest.py +++ b/app/rest.py @@ -1,8 +1,8 @@ -"""Turnstone REST API — thin HTTP wrapper around the search and ingest services. +"""Turnstone REST API — serves REST API and Vue SPA under the /turnstone prefix. -Serves the Vue SPA from web/dist/ as static files so a single uvicorn process -handles both API routes and the frontend. Caddy strips the /turnstone prefix -before forwarding, so FastAPI always sees paths starting with /api/, /health, etc. +All routes (API + static files) are mounted at /turnstone so the app works +identically whether accessed directly (http://host:8534/turnstone/) or through +Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping. """ from __future__ import annotations @@ -11,9 +11,9 @@ import os from pathlib import Path from typing import Annotated -from fastapi import FastAPI, Query +from fastapi import APIRouter, FastAPI, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from app.services.search import search as _search, list_sources as _list_sources, format_results @@ -21,7 +21,7 @@ from app.services.search import search as _search, list_sources as _list_sources DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) DIST_DIR = Path(__file__).parent.parent / "web" / "dist" -app = FastAPI(title="Turnstone API", version="0.1.0") +app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs", redoc_url=None) app.add_middleware( CORSMiddleware, @@ -30,18 +30,20 @@ app.add_middleware( allow_headers=["*"], ) -# Serve built Vue assets if the dist directory exists. -# Must be mounted before the SPA catch-all route below. -if DIST_DIR.exists(): - app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets") +# Serve built Vue assets at the path Vite embeds in index.html. +if (DIST_DIR / "assets").exists(): + app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets") + +# API router — all routes accessible at /turnstone/api/* and /turnstone/health. +router = APIRouter(prefix="/turnstone") -@app.get("/health") +@router.get("/health") def health() -> dict: return {"status": "ok", "db": str(DB_PATH)} -@app.get("/api/search") +@router.get("/api/search") def search_logs( q: Annotated[str, Query(description="Search query")] = "", source: Annotated[str | None, Query(description="Filter by log source ID (partial match)")] = None, @@ -61,13 +63,10 @@ def search_logs( until=until, limit=limit, ) - return { - "count": len(results), - "results": [dataclasses.asdict(r) for r in results], - } + return {"count": len(results), "results": [dataclasses.asdict(r) for r in results]} -@app.get("/api/diagnose") +@router.get("/api/diagnose") def diagnose( q: Annotated[str, Query(description="Service name or problem description")] = "", source: Annotated[str | None, Query(description="Limit to a specific source ID (partial match)")] = None, @@ -97,15 +96,23 @@ def diagnose( } -@app.get("/api/sources") +@router.get("/api/sources") def list_sources() -> dict: - sources = _list_sources(DB_PATH) - return {"sources": sources} + return {"sources": _list_sources(DB_PATH)} -# SPA catch-all — must be last. Serves index.html for any path that doesn't -# match an API route, enabling Vue Router's client-side navigation. -@app.get("/{path:path}") +app.include_router(router) + + +# Root redirect → /turnstone/ +@app.get("/") +def root_redirect() -> RedirectResponse: + return RedirectResponse(url="/turnstone/") + + +# SPA catch-all — serves index.html for any /turnstone/* path that isn't a +# static asset or API route. Must be registered after include_router. +@app.get("/turnstone/{path:path}") def spa_fallback(path: str) -> FileResponse: if DIST_DIR.exists(): candidate = DIST_DIR / path