"""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, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles 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") assets_dir: Path = _ROOT / os.environ.get("WIKI_ASSETS_DIR", "assets") 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_\-/]*$") _ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} _MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB def _unique_name(directory: Path, filename: str) -> str: stem, suffix = Path(filename).stem, Path(filename).suffix candidate = filename counter = 1 while (directory / candidate).exists(): candidate = f"{stem}_{counter}{suffix}" counter += 1 return candidate # ── 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) # ── Asset upload ───────────────────────────────────────────────────────────── @app.post("/api/assets") async def upload_asset(file: UploadFile = File(...), user: User = Depends(require_auth)) -> JSONResponse: if not file.filename: raise HTTPException(400, "No filename") ct = file.content_type or "" if ct not in _ALLOWED_IMAGE_TYPES: raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") contents = await file.read() if len(contents) > _MAX_IMAGE_BYTES: raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024 * 1024)} MB)") assets_dir.mkdir(parents=True, exist_ok=True) safe_name = _unique_name(assets_dir, Path(file.filename).name) (assets_dir / safe_name).write_bytes(contents) return JSONResponse({"ok": True, "filename": safe_name, "url": f"/assets/{safe_name}"}) @app.post("/rebuild") async def rebuild(user: User = Depends(require_auth)) -> JSONResponse: try: # Clear Astro content cache so changed files outside site/ are re-read. try: (site_dir / ".astro" / "data-store.json").unlink(missing_ok=True) except Exception: pass proc = await asyncio.create_subprocess_exec( "npm", "run", "build", "--", "--force", 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)) # Serve uploaded assets. Must be mounted after all route definitions. assets_dir.mkdir(parents=True, exist_ok=True) app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")