From 2b440b02e6b8bd576662cb8a24113590e8762dbf Mon Sep 17 00:00:00 2001 From: brutsalvadi Date: Fri, 1 May 2026 21:55:55 +0200 Subject: [PATCH] Add shared auth, deployment config, and dev tooling --- deploy/migrate.sql | 14 +++ deployment/plan.md | 208 +++++++++++++++++++++++++++++++++ deployment/vps.md | 277 ++++++++++++++++++++++++++++++++++++++++++++ edit/server.py | 211 ++++++++++++++++++--------------- scripts/dev.sh | 16 ++- scripts/dev_test.py | 134 +++++++++++++++++++++ site | 1 + 7 files changed, 766 insertions(+), 95 deletions(-) create mode 100644 deploy/migrate.sql create mode 100644 deployment/plan.md create mode 100644 deployment/vps.md create mode 100644 scripts/dev_test.py create mode 160000 site diff --git a/deploy/migrate.sql b/deploy/migrate.sql new file mode 100644 index 0000000..cb94ed3 --- /dev/null +++ b/deploy/migrate.sql @@ -0,0 +1,14 @@ +-- Migration: add access flags to existing bincio_activity database +-- Run once on the live DB: sqlite3 /var/bincio/data/instance.db < migrate.sql +-- Safe to run on a fresh DB (IF NOT EXISTS / OR IGNORE guards). + +ALTER TABLE users ADD COLUMN wiki_access INTEGER NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN activity_access INTEGER NOT NULL DEFAULT 0; +ALTER TABLE invites ADD COLUMN grants_activity INTEGER NOT NULL DEFAULT 0; + +-- All existing users (registered via bincio_activity) get both access flags. +UPDATE users SET wiki_access = 1, activity_access = 1; + +-- Set caps (adjust if needed before running). +INSERT OR REPLACE INTO settings VALUES ('max_wiki_users', '100'); +INSERT OR REPLACE INTO settings VALUES ('max_activity_users', '30'); diff --git a/deployment/plan.md b/deployment/plan.md new file mode 100644 index 0000000..e381552 --- /dev/null +++ b/deployment/plan.md @@ -0,0 +1,208 @@ +# bincio_wiki — deployment plan + +## Architecture overview + +Three domains, one shared user database: + +``` +bincio.org — auth hub: login, registration, links to the two apps +activity.bincio.org — bincio_activity (moved from bincio.org) +wiki.bincio.org — bincio_wiki (new) + +Shared DB: /var/bincio/data/instance.db + ↑ used by all three, lives with bincio_activity's data +``` + +Login happens at `bincio.org`. The session cookie is set with `domain=.bincio.org` +so it is automatically valid on `activity.bincio.org` and `wiki.bincio.org`. No +per-app login page needed. Each app's FastAPI validates the shared session token. + +After login, the `bincio.org` home page shows the apps the user has access to +(based on their access flags). If not authenticated, the landing page IS the +login form. + +--- + +## User model + +One unified `users` table with two access flags: + +| flag | meaning | cap | +|------------------|-----------------------------------|-----| +| `wiki_access` | can log in to wiki.bincio.org | 100 | +| `activity_access`| can log in to activity.bincio.org | 30 | + +A user can have one or both. Registration is always for wiki first; activity +access is granted separately (invite flag or admin toggle). The caps are +independent: 100 wiki users total, 30 activity users total. + +All existing bincio_activity users get `wiki_access=1, activity_access=1`. +New wiki-only users get `wiki_access=1, activity_access=0`. + +--- + +## Schema changes (bincio_activity DB) + +### New columns on `users` + +```sql +ALTER TABLE users ADD COLUMN wiki_access INTEGER NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN activity_access INTEGER NOT NULL DEFAULT 0; +``` + +Migration for existing users: +```sql +UPDATE users SET wiki_access = 1, activity_access = 1; +``` + +### New column on `invites` + +```sql +ALTER TABLE invites ADD COLUMN grants_activity INTEGER NOT NULL DEFAULT 0; +``` + +The invite creator chooses whether the invite grants activity access, subject +to this rule: **you can only grant access you yourself have.** + +| Inviter type | Can create wiki invite | Can set grants_activity=1 | +|---------------------|------------------------|---------------------------| +| Wiki-only member | Yes (up to 3) | No | +| Activity member | Yes (up to 3) | Yes — their choice | +| Admin | Yes, unlimited | Yes | + +The API enforces this: `POST /api/invites` returns 403 if the caller tries to +set `grants_activity=1` without having `activity_access=1` themselves. The UI +hides the toggle entirely for wiki-only users. + +Caps are enforced at registration time regardless of who issued the invite: if +the wiki is at 100 users or activity is at 30 users, registration fails even +with a valid unused code. + +### Settings table + +```sql +INSERT OR REPLACE INTO settings VALUES ('max_wiki_users', '100'); +INSERT OR REPLACE INTO settings VALUES ('max_activity_users', '30'); +-- remove or ignore the old generic 'max_users' key +``` + +--- + +## What needs to be built + +### 1. bincio.org — auth hub (changes to bincio_activity) + +**FastAPI (`bincio_activity`)** + +- `POST /api/auth/login`: after bcrypt check, also verify the user's access flag + for the app they're logging in from (sent as `app` parameter, or inferred from + `Referer`). Actually: login at bincio.org grants a general session; the flag + check happens at `/api/me` on each subdomain. +- `GET /api/me`: add `wiki_access` and `activity_access` to the response. +- `POST /api/invites`: accepts optional `grants_activity: bool` field. +- Session cookie: change `domain` from unset (host-only) to `.bincio.org` so it + propagates to subdomains. **This is the key change.** +- Cap logic: registration checks `max_wiki_users` (total users with `wiki_access=1`) + and optionally `max_activity_users` if the invite has `grants_activity=1`. + +**Astro (`bincio.org` landing page)** + +- The landing page (`/`) becomes: login form if not authenticated, app selector + if authenticated. +- App selector shows links to `activity.bincio.org` and `wiki.bincio.org` based + on the user's access flags returned by `/api/me`. +- **Invite management** moves here from bincio_activity. The `/invites/` page + stays at `bincio.org` (not at either subdomain) so admins can issue both + wiki-only and wiki+activity invites from one place. The invite creation form + gets a toggle: "wiki only" (default) vs "wiki + activity". +- Invite links always point to `bincio.org/register/?code=XXXXXXXX`. After + registration the user gets `wiki_access=1` always, and `activity_access=1` + only if the invite had `grants_activity=1`. +- The existing `/register/`, `/reset-password/` pages stay at bincio.org. +- Remove the activity app content from bincio.org (it moves to the subdomain). + +**bincio_activity moves to `activity.bincio.org`** + +- nginx: add `activity.bincio.org` server block (same webroot and proxy as + current `bincio.org` block). +- bincio.org nginx: strip activity routes (`/u/`, `/activity/`, `/data/`) and + serve only the auth hub static files + proxy `/api/` to port 4041. +- All internal links in bincio_activity site that are root-relative (`/u/dave`, + `/activity/123`) stay as-is since the app now owns its own domain. + +### 2. bincio_wiki auth (`edit/server.py`) + +- **Shared DB**: connect to `/var/bincio/data/instance.db` (configurable via + `SHARED_DB_PATH` env var, defaults to `../bincio_activity/data/instance.db` + locally). +- **`GET /api/me`**: validate session token from `bincio_session` cookie, check + `wiki_access=1`, return `{handle, display_name, is_admin}` or 401. +- **`POST /api/auth/logout`**: delete session from shared DB. +- No `/api/auth/login` in wiki: login happens at `bincio.org`. +- All CRUD endpoints (`/pages`, `/stories`) require a valid session with + `wiki_access=1`. + +### 3. bincio_wiki auth wall (Astro) + +- **`Base.astro`**: add `fetch('/api/me')` on load → on 401, redirect to + `https://bincio.org/login/?next=https://wiki.bincio.org` (or just bincio.org + with no next param, since the app selector handles it). +- No login page in bincio_wiki — login is centralised at bincio.org. +- The `?next=` redirect is optional / nice-to-have for first iteration. + +--- + +## Phase plan + +### Phase 0 — Schema migration (local + VPS) +- Add `wiki_access`, `activity_access` to users; add `grants_activity` to invites. +- Update settings: `max_wiki_users=100`, `max_activity_users=30`. +- Migration script: `deploy/migrate.sql`. + +### Phase 1 — bincio_activity auth changes +- Cookie domain → `.bincio.org`. +- `/api/me` response: include access flags. +- Login: no flag check (session is general), flag check is per-app at `/api/me`. +- Registration: enforce `max_wiki_users` (wiki_access count). + If invite has `grants_activity=1`, also enforce `max_activity_users`. +- Invite creation: add `grants_activity` field. +- On registration: set `wiki_access=1` always, `activity_access=invite.grants_activity`. + +### Phase 2 — bincio_wiki FastAPI auth +- Connect to shared DB. +- Implement `GET /api/me` with `wiki_access` check. +- Implement `POST /api/auth/logout`. +- Add `require_session()` dependency to all CRUD endpoints. + +### Phase 3 — Astro auth wall (bincio_wiki) +- `Base.astro`: `/api/me` check → redirect to `bincio.org` on 401. +- No login page in wiki. + +### Phase 4 — bincio.org landing page +- Update home page: login form (unauthenticated) / app selector (authenticated). +- Invite form: add activity toggle. +- Keep existing register/reset-password pages. + +### Phase 5 — nginx migration +- Add `activity.bincio.org` server block (certbot for the new subdomain). +- Update `bincio.org` block: serve only auth hub, strip activity routes. +- Add `wiki.bincio.org` server block. + +### Phase 6 — Deploy & verify +- Push both apps to VPS. +- Run migration SQL on the live DB. +- Restart services. +- Smoke test: login at bincio.org, verify cookie reaches both subdomains. + +--- + +## Notes + +- **Local dev**: both apps set `SESSION_DOMAIN` env var; if unset, cookie is + host-only (fine for localhost). In production always set `.bincio.org`. +- **bincio_activity data dir**: stays at `/var/bincio/data/`. The wiki just + opens the DB there; it doesn't own it. +- **Wiki content**: lives at `/var/bincio/wiki/` (pages and stories markdown). +- **Admin tools**: `is_admin=1` users can toggle access flags on other users + via an admin endpoint. First iteration: do it directly in sqlite on the VPS + if needed. diff --git a/deployment/vps.md b/deployment/vps.md new file mode 100644 index 0000000..71cc0dc --- /dev/null +++ b/deployment/vps.md @@ -0,0 +1,277 @@ +# bincio_wiki — VPS configuration + +## Server layout + +``` +/opt/bincio/ bincio_activity code (existing) +/opt/bincio_wiki/ bincio_wiki code (new) + +/var/bincio/data/ bincio_activity data + shared DB + instance.db shared user/session/invite database + / per-user activity data + +/var/bincio/wiki/ bincio_wiki content + pages/ wiki markdown pages + stories/ blog markdown stories + +/var/www/bincio/ bincio_activity static build (existing, bincio.org) +/var/www/bincio/wiki/ bincio_wiki static build (wiki.bincio.org) +``` + +Ports: +- `4041` — bincio_activity FastAPI (existing) +- `4042` — bincio_wiki FastAPI (new) + +--- + +## Deploy procedure + +Builds run **locally**. We push the results to the VPS. + +### bincio_wiki deploy script: `deploy/vps/deploy.sh` + +```bash +#!/usr/bin/env bash +set -e +VPS=root@95.216.55.151 +REMOTE_CODE=/opt/bincio_wiki +REMOTE_WEB=/var/www/bincio/wiki + +echo "Building Astro..." +cd "$(dirname "$0")/../.." +cd site && npm ci --silent && npm run build +cd .. + +echo "Pushing code..." +rsync -az --delete \ + --exclude='.git' \ + --exclude='site/node_modules' \ + --exclude='site/.astro' \ + --exclude='site/dist' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + . "$VPS:$REMOTE_CODE/" + +echo "Pushing static build..." +rsync -az --delete site/dist/ "$VPS:$REMOTE_WEB/" + +echo "Restarting service..." +ssh "$VPS" systemctl restart bincio-wiki + +echo "Done." +``` + +Run with: `bash deploy/vps/deploy.sh` + +--- + +## Environment variables + +### bincio_wiki FastAPI (`edit/server.py`) + +| Variable | Production value | Local default | +|---|---|---| +| `SHARED_DB_PATH` | `/var/bincio/data/instance.db` | `../bincio_activity/data/instance.db` | +| `WIKI_PAGES_DIR` | `/var/bincio/wiki/pages` | `site/src/content/entries` | +| `WIKI_STORIES_DIR` | `/var/bincio/wiki/stories` | `site/src/content/blog` | +| `SESSION_DOMAIN` | `.bincio.org` | *(unset — host-only cookie)* | + +### bincio_activity FastAPI (`bincio/serve/server.py`) + +| Variable | Production value | Local default | +|---|---|---| +| `SESSION_DOMAIN` | `.bincio.org` | *(unset — host-only cookie)* | + +### bincio_activity Astro build + +| Variable | Production value | Purpose | +|---|---|---| +| `PUBLIC_WIKI_URL` | `https://wiki.bincio.org` | Wiki nav link + login redirect for wiki-only users | +| `PUBLIC_EDIT_ENABLED` | `true` | Enables edit UI in production | + +### bincio_activity → bincio_activity (moved to activity subdomain) + +| Variable | Production value | +|---|---| +| `PUBLIC_WIKI_URL` | `https://wiki.bincio.org` | +| `SESSION_DOMAIN` | `.bincio.org` | + +--- + +## systemd service + +`deploy/vps/bincio-wiki.service` — copy to `/etc/systemd/system/` on the VPS. + +```ini +[Unit] +Description=BincioWiki API +After=network.target + +[Service] +WorkingDirectory=/opt/bincio_wiki +ExecStart=/root/.local/bin/uv run uvicorn edit.server:app \ + --host 127.0.0.1 \ + --port 4042 +Environment=SHARED_DB_PATH=/var/bincio/data/instance.db +Environment=WIKI_PAGES_DIR=/var/bincio/wiki/pages +Environment=WIKI_STORIES_DIR=/var/bincio/wiki/stories +Environment=SESSION_DOMAIN=.bincio.org +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +On the VPS: +```bash +cp /opt/bincio_wiki/deploy/vps/bincio-wiki.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable bincio-wiki +systemctl start bincio-wiki +``` + +--- + +## nginx + +### wiki.bincio.org — `deploy/vps/nginx-wiki.conf` + +```nginx +server { + server_name wiki.bincio.org; + root /var/www/bincio/wiki; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:4042; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /pages/ { + proxy_pass http://127.0.0.1:4042; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /stories/ { + proxy_pass http://127.0.0.1:4042; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /rebuild/ { + proxy_pass http://127.0.0.1:4042; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ $uri.html =404; + } + + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/wiki.bincio.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/wiki.bincio.org/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + if ($host = wiki.bincio.org) { + return 301 https://$host$request_uri; + } + listen 80; + server_name wiki.bincio.org; + return 404; +} +``` + +### activity.bincio.org (bincio_activity moves here) + +Add this block to the existing bincio_activity nginx config. The current +`bincio.org` block keeps the `/api/` proxy but loses the activity-specific +routes (see plan.md Phase 5). + +```nginx +server { + server_name activity.bincio.org; + root /var/www/bincio; + index index.html; + + client_max_body_size 2G; + client_body_timeout 300s; + + location /api/ { + proxy_pass http://127.0.0.1:4041; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + } + location /data/ { + alias /var/bincio/data/; + add_header Cache-Control "no-cache, must-revalidate"; + } + location /activity/ { + try_files $uri $uri/ /activity/index.html; + } + location /u/ { + try_files $uri $uri/ /index.html; + } + location / { + try_files $uri $uri/ $uri.html =404; + } + + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/activity.bincio.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/activity.bincio.org/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} +``` + +--- + +## First-time VPS setup (wiki) + +```bash +# 1. Create directories +mkdir -p /var/bincio/wiki/pages /var/bincio/wiki/stories +mkdir -p /var/www/bincio/wiki + +# 2. Push initial deploy +bash deploy/vps/deploy.sh + +# 3. Install and start service +cp /opt/bincio_wiki/deploy/vps/bincio-wiki.service /etc/systemd/system/ +systemctl daemon-reload && systemctl enable --now bincio-wiki + +# 4. SSL certificate for wiki subdomain +certbot --nginx -d wiki.bincio.org + +# 5. Install nginx config +cp /opt/bincio_wiki/deploy/vps/nginx-wiki.conf /etc/nginx/sites-available/bincio-wiki +ln -s /etc/nginx/sites-available/bincio-wiki /etc/nginx/sites-enabled/ +nginx -t && systemctl reload nginx + +# 6. Run DB migration (after schema changes to bincio_activity) +sqlite3 /var/bincio/data/instance.db < /opt/bincio_wiki/deploy/migrate.sql +``` + +--- + +## DB migration script: `deploy/migrate.sql` + +```sql +-- Add access flags to users +ALTER TABLE users ADD COLUMN wiki_access INTEGER NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN activity_access INTEGER NOT NULL DEFAULT 0; + +-- All existing users (bincio_activity members) get both flags +UPDATE users SET wiki_access = 1, activity_access = 1; + +-- Add activity flag to invites +ALTER TABLE invites ADD COLUMN grants_activity INTEGER NOT NULL DEFAULT 0; + +-- Set caps +INSERT OR REPLACE INTO settings VALUES ('max_wiki_users', '100'); +INSERT OR REPLACE INTO settings VALUES ('max_activity_users', '30'); +``` diff --git a/edit/server.py b/edit/server.py index 6c7ffaa..44a3bc2 100644 --- a/edit/server.py +++ b/edit/server.py @@ -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", diff --git a/scripts/dev.sh b/scripts/dev.sh index 774e626..cc3e3e8 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -4,9 +4,23 @@ set -e cd "$(dirname "$0")/.." +# Shared DB: use SHARED_DB_PATH env var if set, else fall back to the +# standard bincio_activity dev location created by its dev_test.py --fresh. +if [[ -z "$SHARED_DB_PATH" ]]; then + SHARED_DB_PATH="/tmp/bincio_dev_test/instance.db" + if [[ ! -f "$SHARED_DB_PATH" ]]; then + echo "⚠ Shared DB not found at $SHARED_DB_PATH" + echo " Run bincio_activity's dev_test.py first:" + echo " cd ../bincio_activity && uv run python scripts/dev_test.py --fresh" + echo " Or set SHARED_DB_PATH to an existing instance.db." + exit 1 + fi +fi +export SHARED_DB_PATH + # Start edit sidecar if requested if [[ "$*" == *"--edit"* ]]; then - echo "Starting edit sidecar on :8001..." + echo "Starting edit sidecar on :8001... (DB: $SHARED_DB_PATH)" uv sync -q uv run uvicorn edit.server:app --reload --port 8001 & SIDECAR_PID=$! diff --git a/scripts/dev_test.py b/scripts/dev_test.py new file mode 100644 index 0000000..ebf20c7 --- /dev/null +++ b/scripts/dev_test.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Wiki dev test. + +Starts the wiki edit sidecar + Astro dev server pointing at the +bincio_activity dev database so both apps share the same users. + +Prerequisites: + Run bincio_activity's dev_test.py first to create the shared DB: + cd ~/src/bincio_activity + uv run python scripts/dev_test.py --fresh + +Run from the bincio_wiki project root: + uv run python scripts/dev_test.py [--wiki-only] + +Options: + --wiki-only Add a wiki-only user (no activity access) to test that path + +Credentials (same as bincio_activity dev): + dave / testpass (admin, wiki + activity) + brut / testpass (wiki + activity) + wiki_user / testpass (wiki only, if --wiki-only is used) + +URL: http://localhost:4321 +""" + +import argparse +import os +import platform +import resource +import subprocess +import sys +from pathlib import Path + +PROJECT_DIR = Path(__file__).resolve().parent.parent +ACTIVITY_DIR = PROJECT_DIR.parent / "bincio_activity" +SHARED_DB = Path("/tmp/bincio_dev_test/instance.db") +PASSWORD = "testpass" + + +def section(msg: str) -> None: + print(f"\n\033[1;36m▸ {msg}\033[0m") + + +def ok(msg: str) -> None: + print(f" \033[32m✓\033[0m {msg}") + + +def warn(msg: str) -> None: + print(f" \033[33m·\033[0m {msg}") + + +def err(msg: str) -> None: + print(f" \033[31m✗\033[0m {msg}", file=sys.stderr) + + +def check_shared_db() -> None: + section("Checking shared DB") + if not SHARED_DB.exists(): + err(f"Shared DB not found at {SHARED_DB}") + err("Run bincio_activity's dev_test.py first:") + err(f" cd {ACTIVITY_DIR}") + err(" uv run python scripts/dev_test.py --fresh") + sys.exit(1) + ok(f"Found {SHARED_DB}") + + +def add_wiki_only_user() -> None: + section("Adding wiki-only test user") + sys.path.insert(0, str(ACTIVITY_DIR)) + from bincio.serve.db import open_db, get_user, create_user + + db = open_db(SHARED_DB.parent) + if get_user(db, "wiki_user"): + warn("user 'wiki_user' already exists — skipping") + else: + create_user(db, "wiki_user", "Wiki User", PASSWORD, is_admin=False, + wiki_access=True, activity_access=False) + ok("wiki-only user 'wiki_user' created") + + +def start_dev() -> None: + section("Starting wiki dev server") + print() + print(" \033[1mCredentials\033[0m") + print(f" dave / {PASSWORD} (admin, wiki + activity)") + print(f" brut / {PASSWORD} (wiki + activity)") + print() + print(" \033[1mURL\033[0m http://localhost:4321") + print() + print(" Press Ctrl+C to stop.\n") + + env = os.environ.copy() + env["SHARED_DB_PATH"] = str(SHARED_DB) + + try: + subprocess.run( + ["bash", "scripts/dev.sh", "--edit"], + cwd=PROJECT_DIR, + env=env, + ) + except KeyboardInterrupt: + pass + + +def raise_open_file_limit() -> None: + if platform.system() != "Darwin": + return + target = 65536 + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + if soft < target: + resource.setrlimit(resource.RLIMIT_NOFILE, (min(target, hard), hard)) + ok(f"open-file limit raised to {min(target, hard)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--wiki-only", action="store_true", + help="Add a wiki-only user (no activity access) for testing") + args = parser.parse_args() + + raise_open_file_limit() + print(f"\033[1mbincio_wiki dev test\033[0m → shared DB: {SHARED_DB}") + + check_shared_db() + + if args.wiki_only: + add_wiki_only_user() + + start_dev() + + +if __name__ == "__main__": + main() diff --git a/site b/site new file mode 160000 index 0000000..5786fd8 --- /dev/null +++ b/site @@ -0,0 +1 @@ +Subproject commit 5786fd827fef80fc952ff72549a080d15ce3f6da