"""FastAPI edit sidecar for BincioWiki — reads and writes markdown pages.""" from __future__ import annotations import os import re import subprocess from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel # Resolved at startup relative to the project root (one level above this file) _ROOT = Path(__file__).parent.parent pages_dir: Path = _ROOT / os.environ.get("WIKI_PAGES_DIR", "pages") site_dir: Path = _ROOT / "site" _SAFE_SLUG = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$") app = FastAPI(title="BincioWiki Edit Sidecar", docs_url=None, redoc_url=None) app.add_middleware(GZipMiddleware, minimum_size=1024) app.add_middleware( CORSMiddleware, allow_origin_regex=r"https?://localhost(:\d+)?", allow_methods=["GET", "POST", "DELETE"], allow_headers=["Content-Type"], ) def _slug_to_path(slug: str) -> Path: if not _SAFE_SLUG.match(slug): raise HTTPException(400, "Invalid page slug — only alphanumeric, hyphens, underscores, and slashes allowed") resolved = (pages_dir / f"{slug}.md").resolve() if not str(resolved).startswith(str(pages_dir.resolve())): raise HTTPException(400, "Path traversal detected") return resolved class PageBody(BaseModel): content: str @app.get("/pages") async def list_pages() -> JSONResponse: """Return all wiki page slugs.""" if not pages_dir.exists(): return JSONResponse({"pages": []}) slugs = [ str(p.relative_to(pages_dir).with_suffix("")) for p in sorted(pages_dir.rglob("*.md")) ] return JSONResponse({"pages": slugs}) @app.get("/pages/{slug:path}") async def get_page(slug: str) -> JSONResponse: """Return raw markdown content for a page.""" path = _slug_to_path(slug) if not path.exists(): raise HTTPException(404, "Page not found") return JSONResponse({"slug": slug, "content": path.read_text(encoding="utf-8")}) @app.post("/pages/{slug:path}") async def save_page(slug: str, body: PageBody) -> JSONResponse: """Create or update a wiki page.""" path = _slug_to_path(slug) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(body.content, encoding="utf-8") return JSONResponse({"slug": slug, "saved": True}) @app.delete("/pages/{slug:path}") async def delete_page(slug: str) -> JSONResponse: """Delete a wiki page.""" path = _slug_to_path(slug) if not path.exists(): raise HTTPException(404, "Page not found") path.unlink() return JSONResponse({"slug": slug, "deleted": True}) @app.post("/rebuild") async def rebuild() -> JSONResponse: """Trigger an astro build of the site.""" try: result = subprocess.run( ["npm", "run", "build"], cwd=site_dir, capture_output=True, text=True, timeout=120, ) return JSONResponse({ "success": result.returncode == 0, "stdout": result.stdout[-3000:] if result.stdout else "", "stderr": result.stderr[-3000:] if result.stderr else "", }) except subprocess.TimeoutExpired: raise HTTPException(504, "Build timed out after 120s") except Exception as e: raise HTTPException(500, str(e))