Add shared auth, deployment config, and dev tooling

This commit is contained in:
brutsalvadi
2026-05-01 21:55:55 +02:00
parent c416dbc226
commit 2b440b02e6
7 changed files with 766 additions and 95 deletions
+117 -94
View File
@@ -9,7 +9,9 @@ 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
@@ -18,85 +20,84 @@ 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", "site/src/content/entries")
pages_dir: Path = _ROOT / os.environ.get("WIKI_PAGES_DIR", "site/src/content/entries")
stories_dir: Path = _ROOT / os.environ.get("WIKI_STORIES_DIR", "site/src/content/blog")
site_dir: Path = _ROOT / "site"
_DB_PATH = _ROOT / "data" / "wiki.db"
site_dir: Path = _ROOT / "site"
# 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_\-/]*$")
_SESSION_TTL = 7 * 24 * 3600 # 7 days
def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
# ── Shared DB helpers ─────────────────────────────────────────────────────────
@dataclass
class User:
handle: str
display_name: str
is_admin: bool
wiki_access: bool
activity_access: bool
def _verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
# ── Database ──────────────────────────────────────────────────────────────────
@contextmanager
def _db():
con = sqlite3.connect(_DB_PATH)
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 _init_db() -> None:
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with _db() as con:
con.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
con.commit()
# Seed first admin user from env vars if the table is empty
count = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
admin_user = os.environ.get("WIKI_ADMIN_USER")
admin_pass = os.environ.get("WIKI_ADMIN_PASSWORD")
if admin_user and admin_pass:
ph = _hash_password(admin_pass)
con.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
(admin_user, ph),
)
con.commit()
_init_db()
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _get_session_user(token: str | None) -> dict | None:
if not token:
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
with _db() as con:
row = con.execute(
"""SELECT u.id, u.username
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?""",
(token, int(time.time())),
).fetchone()
return dict(row) if row else 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"]),
)
async def require_auth(bincio_session: str | None = Cookie(default=None)) -> dict:
# ── 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")
@@ -109,7 +110,6 @@ _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,
@@ -122,55 +122,81 @@ app.add_middleware(
# ── 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):
username: str
handle: str
password: str
@app.post("/api/auth/login")
async def login(body: LoginBody) -> JSONResponse:
with _db() as con:
row = con.execute(
"SELECT id, username, password_hash FROM users WHERE username = ?",
(body.username,),
).fetchone()
if not row or not _verify_password(body.password, row["password_hash"]):
"""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")
token = secrets.token_urlsafe(32)
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, user_id, expires_at) VALUES (?, ?, ?)",
(token, row["id"], expires),
"INSERT INTO sessions (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, row["handle"], int(time.time()), expires),
)
con.commit()
resp = JSONResponse({"username": row["username"]})
resp.set_cookie("bincio_session", token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
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: str | None = Cookie(default=None)) -> JSONResponse:
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
if bincio_session:
with _db() as con:
con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,))
con.commit()
try:
with _db() as con:
con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,))
con.commit()
except Exception:
pass
resp = JSONResponse({"ok": True})
resp.delete_cookie("bincio_session", httponly=True, samesite="lax")
kwargs: dict = dict(key=_SESSION_COOKIE)
if _SESSION_DOMAIN:
kwargs["domain"] = _SESSION_DOMAIN
resp.delete_cookie(**kwargs)
return resp
@app.get("/api/me")
async def me(user: dict = Depends(require_auth)) -> JSONResponse:
return JSONResponse({"username": user["username"]})
# ── File helpers ──────────────────────────────────────────────────────────────
def _slug_to_path(slug: str, base: Path) -> Path:
if not _SAFE_SLUG.match(slug):
raise HTTPException(400, "Invalid slug — only alphanumeric, hyphens, underscores, and slashes allowed")
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")
@@ -181,8 +207,6 @@ class PageBody(BaseModel):
content: str
# ── Page endpoints (all require auth) ────────────────────────────────────────
def _list(base: Path) -> list[str]:
if not base.exists():
return []
@@ -211,44 +235,43 @@ def _delete(slug: str, base: Path) -> JSONResponse:
# ── Page endpoints ────────────────────────────────────────────────────────────
@app.get("/pages")
async def list_pages(user: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
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: dict = Depends(require_auth)) -> JSONResponse:
async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
return _delete(slug, stories_dir)
@app.post("/rebuild")
async def rebuild(user: dict = Depends(require_auth)) -> JSONResponse:
"""Trigger an astro build of the site (non-blocking)."""
async def rebuild(user: User = Depends(require_auth)) -> JSONResponse:
try:
proc = await asyncio.create_subprocess_exec(
"npm", "run", "build",