Add shared auth, deployment config, and dev tooling
This commit is contained in:
+117
-94
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user