508 lines
20 KiB
Python
508 lines
20 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 shutil
|
|
import sqlite3
|
|
import tempfile
|
|
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
|
|
|
|
# Git attribution — on VPS the work tree has no .git dir; bare repo is separate.
|
|
# Set GIT_DIR to the bare repo path in the systemd service; GIT_WORK_TREE is _ROOT.
|
|
_GIT_DIR = os.environ.get("GIT_DIR")
|
|
_git_lock = asyncio.Lock()
|
|
|
|
|
|
def _git_env() -> dict:
|
|
env = os.environ.copy()
|
|
if _GIT_DIR:
|
|
env["GIT_DIR"] = _GIT_DIR
|
|
env["GIT_WORK_TREE"] = str(_ROOT)
|
|
env.setdefault("GIT_COMMITTER_NAME", "bincio-wiki")
|
|
env.setdefault("GIT_COMMITTER_EMAIL", "wiki@bincio.wiki")
|
|
return env
|
|
|
|
|
|
async def _git_file_hash(file_path: Path) -> str:
|
|
rel = str(file_path.relative_to(_ROOT))
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"git", "log", "-1", "--format=%H", "--", rel,
|
|
cwd=str(_ROOT), env=_git_env(),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
return stdout.decode().strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
async def _three_way_merge(
|
|
file_path: Path, user_content: str, base_hash: str, handle: str
|
|
) -> tuple[str, bool]:
|
|
"""Returns (merged_content, has_conflict). Falls back to user_content on error."""
|
|
rel = str(file_path.relative_to(_ROOT))
|
|
env = _git_env()
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"git", "show", f"{base_hash}:{rel}",
|
|
cwd=str(_ROOT), env=env,
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
return user_content, False
|
|
base_content = stdout.decode()
|
|
current_content = file_path.read_text(encoding="utf-8")
|
|
tmpdir = tempfile.mkdtemp()
|
|
try:
|
|
p_current = Path(tmpdir) / "current.md"
|
|
p_base = Path(tmpdir) / "base.md"
|
|
p_user = Path(tmpdir) / "user.md"
|
|
p_current.write_text(current_content, encoding="utf-8")
|
|
p_base.write_text(base_content, encoding="utf-8")
|
|
p_user.write_text(user_content, encoding="utf-8")
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"git", "merge-file",
|
|
"-L", "attuale", "-L", "base", "-L", handle,
|
|
str(p_current), str(p_base), str(p_user),
|
|
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await proc.wait()
|
|
return p_current.read_text(encoding="utf-8"), proc.returncode > 0
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
async def _git_commit(file_path: Path, handle: str, verb: str) -> None:
|
|
rel = str(file_path.relative_to(_ROOT))
|
|
env = _git_env()
|
|
try:
|
|
async with _git_lock:
|
|
add = await asyncio.create_subprocess_exec(
|
|
"git", "add", rel,
|
|
cwd=str(_ROOT), env=env,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await add.wait()
|
|
commit = await asyncio.create_subprocess_exec(
|
|
"git", "commit",
|
|
"-m", f"{handle}: {verb} {file_path.stem}",
|
|
"--author", f"{handle} <{handle}@bincio.wiki>",
|
|
cwd=str(_ROOT), env=env,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await commit.wait()
|
|
# post-receive uses `git checkout -f <SHA>` which detaches HEAD, so
|
|
# commits would advance the detached HEAD but not refs/heads/main and
|
|
# would be lost on the next push. Explicitly keep main up to date.
|
|
update_ref = await asyncio.create_subprocess_exec(
|
|
"git", "update-ref", "refs/heads/main", "HEAD",
|
|
cwd=str(_ROOT), env=env,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await update_ref.wait()
|
|
except Exception as e:
|
|
print(f"[git] commit failed for {rel}: {e}", flush=True)
|
|
|
|
|
|
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/log")
|
|
async def get_wiki_log(user: User = Depends(require_auth)) -> JSONResponse:
|
|
env = _git_env()
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"git", "log", "--format=%h|%ar|%aN|%s", "--author=@bincio.wiki", "-n", "50", "--", "pages/", "blog/",
|
|
cwd=str(_ROOT), env=env,
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
entries = []
|
|
for line in stdout.decode().strip().splitlines():
|
|
parts = line.split("|", 3)
|
|
if len(parts) == 4:
|
|
entries.append({"hash": parts[0], "date": parts[1], "author": parts[2], "message": parts[3]})
|
|
return JSONResponse({"log": entries})
|
|
|
|
|
|
@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
|
|
base_hash: str | None = None
|
|
|
|
|
|
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"))]
|
|
|
|
async def _get(slug: str, base: Path) -> JSONResponse:
|
|
path = _slug_to_path(slug, base)
|
|
if not path.exists():
|
|
raise HTTPException(404, "Not found")
|
|
content = path.read_text(encoding="utf-8")
|
|
base_hash = await _git_file_hash(path)
|
|
return JSONResponse({"slug": slug, "content": content, "base_hash": base_hash})
|
|
|
|
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 await _get(slug, pages_dir)
|
|
|
|
@app.post("/pages/{slug:path}")
|
|
async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
|
|
path = _slug_to_path(slug, pages_dir)
|
|
content = body.content
|
|
if body.base_hash and path.exists():
|
|
current_hash = await _git_file_hash(path)
|
|
if current_hash and current_hash != body.base_hash:
|
|
merged, conflict = await _three_way_merge(path, body.content, body.base_hash, user.handle)
|
|
if conflict:
|
|
raise HTTPException(409, detail={
|
|
"message": "Conflitto — risolvi i marcatori e salva di nuovo",
|
|
"content": merged,
|
|
"base_hash": current_hash,
|
|
})
|
|
content = merged
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
await _git_commit(path, user.handle, "edited")
|
|
return JSONResponse({"slug": str(path.relative_to(pages_dir).with_suffix("")), "saved": True})
|
|
|
|
@app.delete("/pages/{slug:path}")
|
|
async def delete_page(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
|
|
path = _slug_to_path(slug, pages_dir)
|
|
result = _delete(slug, pages_dir)
|
|
await _git_commit(path, user.handle, "deleted")
|
|
return result
|
|
|
|
|
|
# ── 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 await _get(slug, stories_dir)
|
|
|
|
@app.post("/stories/{slug:path}")
|
|
async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
|
|
path = _slug_to_path(slug, stories_dir)
|
|
content = body.content
|
|
if body.base_hash and path.exists():
|
|
current_hash = await _git_file_hash(path)
|
|
if current_hash and current_hash != body.base_hash:
|
|
merged, conflict = await _three_way_merge(path, body.content, body.base_hash, user.handle)
|
|
if conflict:
|
|
raise HTTPException(409, detail={
|
|
"message": "Conflitto — risolvi i marcatori e salva di nuovo",
|
|
"content": merged,
|
|
"base_hash": current_hash,
|
|
})
|
|
content = merged
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
await _git_commit(path, user.handle, "edited")
|
|
return JSONResponse({"slug": str(path.relative_to(stories_dir).with_suffix("")), "saved": True})
|
|
|
|
@app.delete("/stories/{slug:path}")
|
|
async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
|
|
path = _slug_to_path(slug, stories_dir)
|
|
result = _delete(slug, stories_dir)
|
|
await _git_commit(path, user.handle, "deleted")
|
|
return result
|
|
|
|
|
|
# ── 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")
|