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
+14
View File
@@ -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');
+208
View File
@@ -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.
+277
View File
@@ -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
<handle>/ 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');
```
+107 -84
View File
@@ -9,7 +9,9 @@ import secrets
import sqlite3 import sqlite3
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional
import bcrypt import bcrypt
from fastapi import Cookie, Depends, FastAPI, HTTPException from fastapi import Cookie, Depends, FastAPI, HTTPException
@@ -18,85 +20,84 @@ from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
# Resolved at startup relative to the project root (one level above this file)
_ROOT = Path(__file__).parent.parent _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") stories_dir: Path = _ROOT / os.environ.get("WIKI_STORIES_DIR", "site/src/content/blog")
site_dir: Path = _ROOT / "site" site_dir: Path = _ROOT / "site"
_DB_PATH = _ROOT / "data" / "wiki.db"
# 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_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: # ── Shared DB helpers ─────────────────────────────────────────────────────────
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
@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 @contextmanager
def _db(): 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.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA foreign_keys=ON")
try: try:
yield con yield con
finally: finally:
con.close() con.close()
def _init_db() -> None: def _get_session_user(token: str) -> Optional[User]:
_DB_PATH.parent.mkdir(parents=True, exist_ok=True) try:
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:
return None
with _db() as con: with _db() as con:
row = con.execute( row = con.execute(
"""SELECT u.id, u.username "SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
FROM sessions s JOIN users u ON u.id = s.user_id "u.wiki_access, u.activity_access "
WHERE s.token = ? AND s.expires_at > ?""", "FROM sessions s JOIN users u ON s.handle = u.handle "
(token, int(time.time())), "WHERE s.token = ?",
(token,),
).fetchone() ).fetchone()
return dict(row) if row else None 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"]),
)
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) user = _get_session_user(bincio_session)
if not user: if not user:
raise HTTPException(401, "Authentication required") raise HTTPException(401, "Authentication required")
@@ -109,7 +110,6 @@ _extra_origin = os.environ.get("WIKI_ORIGIN", "")
_origins = [_extra_origin] if _extra_origin else [] _origins = [_extra_origin] if _extra_origin else []
app = FastAPI(title="BincioWiki Edit Sidecar", docs_url=None, redoc_url=None) app = FastAPI(title="BincioWiki Edit Sidecar", docs_url=None, redoc_url=None)
app.add_middleware(GZipMiddleware, minimum_size=1024) app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -122,55 +122,81 @@ app.add_middleware(
# ── Auth endpoints ──────────────────────────────────────────────────────────── # ── 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): class LoginBody(BaseModel):
username: str handle: str
password: str password: str
@app.post("/api/auth/login") @app.post("/api/auth/login")
async def login(body: LoginBody) -> JSONResponse: async def login(body: LoginBody) -> JSONResponse:
"""Login endpoint for local dev. In production, login via bincio.org."""
try:
with _db() as con: with _db() as con:
row = con.execute( row = con.execute(
"SELECT id, username, password_hash FROM users WHERE username = ?", "SELECT handle, display_name, password_hash, is_admin, "
(body.username,), "wiki_access, activity_access FROM users WHERE handle = ?",
(body.handle.strip().lower(),),
).fetchone() ).fetchone()
if not row or not _verify_password(body.password, row["password_hash"]): except HTTPException:
raise
if not row or not bcrypt.checkpw(body.password.encode(), row["password_hash"].encode()):
raise HTTPException(401, "Credenziali non valide") 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 expires = int(time.time()) + _SESSION_TTL
with _db() as con: with _db() as con:
con.execute( con.execute(
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", "INSERT INTO sessions (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, row["id"], expires), (token, row["handle"], int(time.time()), expires),
) )
con.commit() 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 return resp
@app.post("/api/auth/logout") @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: if bincio_session:
try:
with _db() as con: with _db() as con:
con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,)) con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,))
con.commit() con.commit()
except Exception:
pass
resp = JSONResponse({"ok": True}) 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 return resp
@app.get("/api/me")
async def me(user: dict = Depends(require_auth)) -> JSONResponse:
return JSONResponse({"username": user["username"]})
# ── File helpers ────────────────────────────────────────────────────────────── # ── File helpers ──────────────────────────────────────────────────────────────
def _slug_to_path(slug: str, base: Path) -> Path: def _slug_to_path(slug: str, base: Path) -> Path:
if not _SAFE_SLUG.match(slug): 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() resolved = (base / f"{slug}.md").resolve()
if not str(resolved).startswith(str(base.resolve())): if not str(resolved).startswith(str(base.resolve())):
raise HTTPException(400, "Path traversal detected") raise HTTPException(400, "Path traversal detected")
@@ -181,8 +207,6 @@ class PageBody(BaseModel):
content: str content: str
# ── Page endpoints (all require auth) ────────────────────────────────────────
def _list(base: Path) -> list[str]: def _list(base: Path) -> list[str]:
if not base.exists(): if not base.exists():
return [] return []
@@ -211,44 +235,43 @@ def _delete(slug: str, base: Path) -> JSONResponse:
# ── Page endpoints ──────────────────────────────────────────────────────────── # ── Page endpoints ────────────────────────────────────────────────────────────
@app.get("/pages") @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)}) return JSONResponse({"pages": _list(pages_dir)})
@app.get("/pages/{slug:path}") @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) return _get(slug, pages_dir)
@app.post("/pages/{slug:path}") @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) return _save(slug, body, pages_dir)
@app.delete("/pages/{slug:path}") @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) return _delete(slug, pages_dir)
# ── Story endpoints ─────────────────────────────────────────────────────────── # ── Story endpoints ───────────────────────────────────────────────────────────
@app.get("/stories") @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)}) return JSONResponse({"stories": _list(stories_dir)})
@app.get("/stories/{slug:path}") @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) return _get(slug, stories_dir)
@app.post("/stories/{slug:path}") @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) return _save(slug, body, stories_dir)
@app.delete("/stories/{slug:path}") @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) return _delete(slug, stories_dir)
@app.post("/rebuild") @app.post("/rebuild")
async def rebuild(user: dict = Depends(require_auth)) -> JSONResponse: async def rebuild(user: User = Depends(require_auth)) -> JSONResponse:
"""Trigger an astro build of the site (non-blocking)."""
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"npm", "run", "build", "npm", "run", "build",
+15 -1
View File
@@ -4,9 +4,23 @@
set -e set -e
cd "$(dirname "$0")/.." 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 # Start edit sidecar if requested
if [[ "$*" == *"--edit"* ]]; then if [[ "$*" == *"--edit"* ]]; then
echo "Starting edit sidecar on :8001..." echo "Starting edit sidecar on :8001... (DB: $SHARED_DB_PATH)"
uv sync -q uv sync -q
uv run uvicorn edit.server:app --reload --port 8001 & uv run uvicorn edit.server:app --reload --port 8001 &
SIDECAR_PID=$! SIDECAR_PID=$!
+134
View File
@@ -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()
Submodule
+1
Submodule site added at 5786fd827f