c01def696c
- Add brutsalvadi/astro-bloomz as git submodule at site/ - Add edit/ FastAPI sidecar (read/write markdown pages, trigger rebuild) - Add scripts/dev.sh and scripts/build.sh (symlink pages/, run Astro + Pagefind) - Add .gitignore
107 lines
3.3 KiB
Python
107 lines
3.3 KiB
Python
"""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))
|