0f8bd3dba6
container repo - bincio_wiki/blog/ — new home for all blog content - site/src/content.config.ts — base: '../pages' (was ./src/content/entries) - site/astro.config.mjs — added vite.server.fs.allow: ['..'] so Vite serves files from outside the project root; updated the delete watcher to watch ../pages/ - edit/server.py — default WIKI_PAGES_DIR is now pages (was site/src/content/entries) Content is now versioned in bincio_wiki. The submodule stays clean — it's just the engine.
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""FastAPI edit sidecar for BincioWiki — reads and writes markdown pages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import bcrypt
|
|
from fastapi import Cookie, Depends, FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
_ROOT = Path(__file__).parent.parent
|
|
pages_dir: Path = _ROOT / os.environ.get("WIKI_PAGES_DIR", "pages")
|
|
stories_dir: Path = _ROOT / os.environ.get("WIKI_STORIES_DIR", "blog")
|
|
site_dir: Path = _ROOT / "site"
|
|
# On VPS, copy built dist here so nginx can serve from /var/www with proper permissions.
|
|
_wiki_webroot: Path | None = Path(os.environ["WIKI_WEBROOT"]) if os.environ.get("WIKI_WEBROOT") else None
|
|
|
|
# Shared DB with bincio_activity.
|
|
# Dev default: /tmp/bincio_dev_test/instance.db (created by bincio_activity dev_test.py --fresh).
|
|
# Production: set SHARED_DB_PATH=/var/bincio/data/instance.db in the systemd service.
|
|
_SHARED_DB_PATH = Path(
|
|
os.environ.get("SHARED_DB_PATH", "/tmp/bincio_dev_test/instance.db")
|
|
)
|
|
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
|
_SESSION_TTL = 30 * 24 * 3600 # 30 days (matches bincio_activity)
|
|
_SESSION_COOKIE = "bincio_session"
|
|
|
|
_SAFE_SLUG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_\-/]*$")
|
|
|
|
|
|
# ── Shared DB helpers ─────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class User:
|
|
handle: str
|
|
display_name: str
|
|
is_admin: bool
|
|
wiki_access: bool
|
|
activity_access: bool
|
|
|
|
|
|
@contextmanager
|
|
def _db():
|
|
if not _SHARED_DB_PATH.exists():
|
|
raise HTTPException(503, f"Shared DB not found at {_SHARED_DB_PATH}. "
|
|
"Set SHARED_DB_PATH or run bincio_activity first.")
|
|
con = sqlite3.connect(_SHARED_DB_PATH, check_same_thread=False)
|
|
con.row_factory = sqlite3.Row
|
|
con.execute("PRAGMA journal_mode=WAL")
|
|
con.execute("PRAGMA foreign_keys=ON")
|
|
try:
|
|
yield con
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def _get_session_user(token: str) -> Optional[User]:
|
|
try:
|
|
with _db() as con:
|
|
row = con.execute(
|
|
"SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
|
|
"u.wiki_access, u.activity_access "
|
|
"FROM sessions s JOIN users u ON s.handle = u.handle "
|
|
"WHERE s.token = ?",
|
|
(token,),
|
|
).fetchone()
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
return None
|
|
if not row:
|
|
return None
|
|
if row["expires_at"] < int(time.time()):
|
|
return None
|
|
if not row["wiki_access"]:
|
|
return None
|
|
return User(
|
|
handle=row["handle"],
|
|
display_name=row["display_name"],
|
|
is_admin=bool(row["is_admin"]),
|
|
wiki_access=bool(row["wiki_access"]),
|
|
activity_access=bool(row["activity_access"]),
|
|
)
|
|
|
|
|
|
# ── Auth dependency ───────────────────────────────────────────────────────────
|
|
|
|
async def require_auth(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
|
if not bincio_session:
|
|
raise HTTPException(401, "Authentication required")
|
|
user = _get_session_user(bincio_session)
|
|
if not user:
|
|
raise HTTPException(401, "Authentication required")
|
|
return user
|
|
|
|
|
|
# ── App setup ─────────────────────────────────────────────────────────────────
|
|
|
|
_extra_origin = os.environ.get("WIKI_ORIGIN", "")
|
|
_origins = [_extra_origin] if _extra_origin else []
|
|
|
|
app = FastAPI(title="BincioWiki Edit Sidecar", docs_url=None, redoc_url=None)
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_origins,
|
|
allow_origin_regex=r"https?://localhost(:\d+)?",
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "DELETE"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/me")
|
|
async def me(user: User = Depends(require_auth)) -> JSONResponse:
|
|
return JSONResponse({
|
|
"handle": user.handle,
|
|
"display_name": user.display_name,
|
|
"is_admin": user.is_admin,
|
|
"wiki_access": user.wiki_access,
|
|
"activity_access": user.activity_access,
|
|
})
|
|
|
|
|
|
class LoginBody(BaseModel):
|
|
handle: str
|
|
password: str
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
async def login(body: LoginBody) -> JSONResponse:
|
|
"""Login endpoint for local dev. In production, login via bincio.org."""
|
|
try:
|
|
with _db() as con:
|
|
row = con.execute(
|
|
"SELECT handle, display_name, password_hash, is_admin, "
|
|
"wiki_access, activity_access FROM users WHERE handle = ?",
|
|
(body.handle.strip().lower(),),
|
|
).fetchone()
|
|
except HTTPException:
|
|
raise
|
|
if not row or not bcrypt.checkpw(body.password.encode(), row["password_hash"].encode()):
|
|
raise HTTPException(401, "Credenziali non valide")
|
|
if not row["wiki_access"]:
|
|
raise HTTPException(403, "Accesso al wiki non autorizzato")
|
|
|
|
token = secrets.token_hex(32)
|
|
expires = int(time.time()) + _SESSION_TTL
|
|
with _db() as con:
|
|
con.execute(
|
|
"INSERT INTO sessions (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
|
(token, row["handle"], int(time.time()), expires),
|
|
)
|
|
con.commit()
|
|
|
|
resp = JSONResponse({"handle": row["handle"], "display_name": row["display_name"]})
|
|
kwargs: dict = dict(
|
|
key=_SESSION_COOKIE, value=token,
|
|
httponly=True, samesite="lax", max_age=_SESSION_TTL,
|
|
)
|
|
if _SESSION_DOMAIN:
|
|
kwargs["domain"] = _SESSION_DOMAIN
|
|
resp.set_cookie(**kwargs)
|
|
return resp
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
if bincio_session:
|
|
try:
|
|
with _db() as con:
|
|
con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,))
|
|
con.commit()
|
|
except Exception:
|
|
pass
|
|
resp = JSONResponse({"ok": True})
|
|
kwargs: dict = dict(key=_SESSION_COOKIE)
|
|
if _SESSION_DOMAIN:
|
|
kwargs["domain"] = _SESSION_DOMAIN
|
|
resp.delete_cookie(**kwargs)
|
|
return resp
|
|
|
|
|
|
# ── File helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _slug_to_path(slug: str, base: Path) -> Path:
|
|
if not _SAFE_SLUG.match(slug):
|
|
raise HTTPException(400, "Invalid slug")
|
|
resolved = (base / f"{slug}.md").resolve()
|
|
if not str(resolved).startswith(str(base.resolve())):
|
|
raise HTTPException(400, "Path traversal detected")
|
|
return resolved
|
|
|
|
|
|
class PageBody(BaseModel):
|
|
content: str
|
|
|
|
|
|
def _list(base: Path) -> list[str]:
|
|
if not base.exists():
|
|
return []
|
|
return [str(p.relative_to(base).with_suffix("")) for p in sorted(base.rglob("*.md"))]
|
|
|
|
def _get(slug: str, base: Path) -> JSONResponse:
|
|
path = _slug_to_path(slug, base)
|
|
if not path.exists():
|
|
raise HTTPException(404, "Not found")
|
|
return JSONResponse({"slug": slug, "content": path.read_text(encoding="utf-8")})
|
|
|
|
def _save(slug: str, body: PageBody, base: Path) -> JSONResponse:
|
|
path = _slug_to_path(slug, base)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(body.content, encoding="utf-8")
|
|
return JSONResponse({"slug": slug, "saved": True})
|
|
|
|
def _delete(slug: str, base: Path) -> JSONResponse:
|
|
path = _slug_to_path(slug, base)
|
|
if not path.exists():
|
|
raise HTTPException(404, "Not found")
|
|
path.unlink()
|
|
return JSONResponse({"slug": slug, "deleted": True})
|
|
|
|
|
|
# ── Page endpoints ────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/pages")
|
|
async def list_pages(user: User = Depends(require_auth)) -> JSONResponse:
|
|
return JSONResponse({"pages": _list(pages_dir)})
|
|
|
|
@app.get("/pages/{slug:path}")
|
|
async def get_page(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
|
|
return _get(slug, pages_dir)
|
|
|
|
@app.post("/pages/{slug:path}")
|
|
async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
|
|
return _save(slug, body, pages_dir)
|
|
|
|
@app.delete("/pages/{slug:path}")
|
|
async def delete_page(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
|
|
return _delete(slug, pages_dir)
|
|
|
|
|
|
# ── Story endpoints ───────────────────────────────────────────────────────────
|
|
|
|
@app.get("/stories")
|
|
async def list_stories(user: User = Depends(require_auth)) -> JSONResponse:
|
|
return JSONResponse({"stories": _list(stories_dir)})
|
|
|
|
@app.get("/stories/{slug:path}")
|
|
async def get_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
|
|
return _get(slug, stories_dir)
|
|
|
|
@app.post("/stories/{slug:path}")
|
|
async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
|
|
return _save(slug, body, stories_dir)
|
|
|
|
@app.delete("/stories/{slug:path}")
|
|
async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
|
|
return _delete(slug, stories_dir)
|
|
|
|
|
|
@app.post("/rebuild")
|
|
async def rebuild(user: User = Depends(require_auth)) -> JSONResponse:
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"npm", "run", "build",
|
|
cwd=site_dir,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
raise HTTPException(504, "Build timed out after 120s")
|
|
if proc.returncode == 0 and _wiki_webroot:
|
|
sync = await asyncio.create_subprocess_exec(
|
|
"rsync", "-a", "--delete",
|
|
str(site_dir / "dist") + "/",
|
|
str(_wiki_webroot) + "/",
|
|
)
|
|
await sync.wait()
|
|
return JSONResponse({
|
|
"success": proc.returncode == 0,
|
|
"stdout": stdout.decode()[-3000:] if stdout else "",
|
|
"stderr": stderr.decode()[-3000:] if stderr else "",
|
|
})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|