""" Runbooks / docs index API — read-only search and preview (PR1.1). GET /api/runbooks/search, /api/runbooks/preview, /api/runbooks/raw. """ from __future__ import annotations import re from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from .auth import require_auth from . import docs_store as store runbooks_router = APIRouter(prefix="/api/runbooks", tags=["runbooks-docs"]) @runbooks_router.get("/status") async def runbooks_status( _auth: str = Depends(require_auth), ): """Return docs index status: indexed_files, indexed_chunks, last_indexed_at, docs_root, fts_available.""" return await store.get_docs_index_status() def _safe_path(path: str) -> bool: """Reject path traversal and non-docs paths.""" if not path or ".." in path or path.startswith("/"): return False norm = path.replace("\\", "/").strip() return bool(re.match(r"^(docs/|runbook/|release/)?[\w/\-.]+\.md$", norm, re.I)) @runbooks_router.get("/search") async def runbooks_search( q: str = Query(..., min_length=1, max_length=200), doc_type: Optional[str] = Query(None, description="runbook | release | spec"), limit: int = Query(10, ge=1, le=50), _auth: str = Depends(require_auth), ): """Search runbooks/docs by full text. Returns path, title, snippet.""" items = await store.search_docs(q=q, doc_type=doc_type, limit=limit) return {"items": items} @runbooks_router.get("/preview") async def runbooks_preview( path: str = Query(..., description="Relative path, e.g. runbook/rehearsal-v1-30min-checklist.md"), _auth: str = Depends(require_auth), ): """Get path, title, and sections (heading + excerpt) for a doc.""" if not _safe_path(path): raise HTTPException(status_code=400, detail="Invalid path") out = await store.get_preview(path) if not out: raise HTTPException(status_code=404, detail="Not found") return out @runbooks_router.get("/raw") async def runbooks_raw( path: str = Query(..., description="Relative path to markdown file"), _auth: str = Depends(require_auth), ): """Get raw markdown content (read-only).""" if not _safe_path(path): raise HTTPException(status_code=400, detail="Invalid path") content = await store.get_raw(path) if content is None: raise HTTPException(status_code=404, detail="Not found") from fastapi.responses import PlainTextResponse return PlainTextResponse(content, media_type="text/markdown; charset=utf-8")