Files
bincio-wiki/edit/server.py
T

663 lines
25 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_\-/]*$")
_SAFE_HANDLE = re.compile(r"^[a-z][a-z0-9_-]{1,19}$")
_SAFE_HASH = re.compile(r"^[0-9a-f]{4,40}$")
_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 _migrate_db() -> None:
"""Add grants_wiki column to invites table if it doesn't exist yet."""
if not _SHARED_DB_PATH.exists():
return
try:
con = sqlite3.connect(_SHARED_DB_PATH, check_same_thread=False)
con.execute("PRAGMA journal_mode=WAL")
has_table = con.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='invites'"
).fetchone()
if has_table:
cols = {row[1] for row in con.execute("PRAGMA table_info(invites)")}
if "grants_wiki" not in cols:
con.execute(
"ALTER TABLE invites ADD COLUMN grants_wiki INTEGER NOT NULL DEFAULT 0"
)
con.commit()
con.close()
except Exception as e:
print(f"[db] migration failed: {e}", flush=True)
_migrate_db()
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
suspended: bool = False
@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, u.suspended "
"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
if row["suspended"]:
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/diff/{commit_hash}")
async def get_diff(commit_hash: str, user: User = Depends(require_auth)) -> JSONResponse:
if not _SAFE_HASH.match(commit_hash):
raise HTTPException(status_code=400, detail="invalid hash")
env = _git_env()
proc = await asyncio.create_subprocess_exec(
"git", "show", commit_hash, "-p", "--no-color", "--format=", "--", "pages/", "blog/",
cwd=str(_ROOT), env=env,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=404, detail="commit not found")
return JSONResponse({"diff": stdout.decode(errors="replace")})
@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, suspended 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")
if row["suspended"]:
raise HTTPException(403, "Account sospeso")
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
# ── Invite endpoints ─────────────────────────────────────────────────────────
@app.get("/api/invites")
async def list_invites(user: User = Depends(require_auth)) -> JSONResponse:
with _db() as con:
rows = con.execute(
"SELECT code, created_at, used_by, used_at FROM invites "
"WHERE created_by = ? ORDER BY created_at DESC",
(user.handle,),
).fetchall()
return JSONResponse({"invites": [
{"code": r["code"], "created_at": r["created_at"],
"used_by": r["used_by"], "used_at": r["used_at"]}
for r in rows
]})
@app.post("/api/invites")
async def create_invite(user: User = Depends(require_auth)) -> JSONResponse:
code = secrets.token_urlsafe(16)
with _db() as con:
con.execute(
"INSERT INTO invites (code, created_by, created_at, grants_wiki, grants_activity) "
"VALUES (?, ?, ?, 1, 0)",
(code, user.handle, int(time.time())),
)
con.commit()
return JSONResponse({"code": code})
@app.delete("/api/invites/{code}")
async def revoke_invite(code: str, user: User = Depends(require_auth)) -> JSONResponse:
with _db() as con:
row = con.execute(
"SELECT created_by, used_by FROM invites WHERE code = ?", (code,)
).fetchone()
if not row:
raise HTTPException(404, "Invito non trovato")
if row["created_by"] != user.handle:
raise HTTPException(403, "Non autorizzato")
if row["used_by"]:
raise HTTPException(409, "Invito già utilizzato, non revocabile")
con.execute("DELETE FROM invites WHERE code = ?", (code,))
con.commit()
return JSONResponse({"ok": True})
class RegisterBody(BaseModel):
code: str
handle: str
display_name: str
password: str
@app.post("/api/auth/register")
async def register(body: RegisterBody) -> JSONResponse:
handle = body.handle.strip().lower()
if not _SAFE_HANDLE.match(handle):
raise HTTPException(400, "Handle non valido (2-20 caratteri: lettere minuscole, numeri, _ o -)")
if len(body.password) < 8:
raise HTTPException(400, "Password troppo corta (minimo 8 caratteri)")
display_name = body.display_name.strip() or handle
now = int(time.time())
with _db() as con:
invite = con.execute(
"SELECT used_by, grants_wiki FROM invites WHERE code = ?", (body.code,)
).fetchone()
if not invite:
raise HTTPException(400, "Codice invito non valido")
if invite["used_by"]:
raise HTTPException(400, "Codice invito già utilizzato")
if not invite["grants_wiki"]:
raise HTTPException(403, "Questo invito non dà accesso al wiki")
if con.execute("SELECT 1 FROM users WHERE handle = ?", (handle,)).fetchone():
raise HTTPException(409, "Handle già in uso, scegline un altro")
pw_hash = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
con.execute(
"INSERT INTO users (handle, display_name, password_hash, is_admin, created_at, wiki_access, activity_access) "
"VALUES (?, ?, ?, 0, ?, 1, 0)",
(handle, display_name, pw_hash, now),
)
con.execute(
"UPDATE invites SET used_by = ?, used_at = ? WHERE code = ?",
(handle, now, body.code),
)
con.commit()
token = secrets.token_hex(32)
with _db() as con:
con.execute(
"INSERT INTO sessions (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, handle, now, now + _SESSION_TTL),
)
con.commit()
resp = JSONResponse({"handle": handle, "display_name": 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
# ── 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",
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")