Add shared auth, deployment config, and dev tooling
This commit is contained in:
@@ -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');
|
||||||
@@ -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.
|
||||||
@@ -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');
|
||||||
|
```
|
||||||
+117
-94
@@ -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:
|
with _db() as con:
|
||||||
con.executescript("""
|
row = con.execute(
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
"SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"u.wiki_access, u.activity_access "
|
||||||
username TEXT UNIQUE NOT NULL,
|
"FROM sessions s JOIN users u ON s.handle = u.handle "
|
||||||
password_hash TEXT NOT NULL
|
"WHERE s.token = ?",
|
||||||
);
|
(token,),
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
).fetchone()
|
||||||
token TEXT PRIMARY KEY,
|
except HTTPException:
|
||||||
user_id INTEGER NOT NULL,
|
raise
|
||||||
expires_at INTEGER NOT NULL,
|
except Exception:
|
||||||
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
|
return None
|
||||||
with _db() as con:
|
if not row:
|
||||||
row = con.execute(
|
return None
|
||||||
"""SELECT u.id, u.username
|
if row["expires_at"] < int(time.time()):
|
||||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
return None
|
||||||
WHERE s.token = ? AND s.expires_at > ?""",
|
if not row["wiki_access"]:
|
||||||
(token, int(time.time())),
|
return None
|
||||||
).fetchone()
|
return User(
|
||||||
return dict(row) if row else None
|
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:
|
||||||
with _db() as con:
|
"""Login endpoint for local dev. In production, login via bincio.org."""
|
||||||
row = con.execute(
|
try:
|
||||||
"SELECT id, username, password_hash FROM users WHERE username = ?",
|
with _db() as con:
|
||||||
(body.username,),
|
row = con.execute(
|
||||||
).fetchone()
|
"SELECT handle, display_name, password_hash, is_admin, "
|
||||||
if not row or not _verify_password(body.password, row["password_hash"]):
|
"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")
|
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:
|
||||||
with _db() as con:
|
try:
|
||||||
con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,))
|
with _db() as con:
|
||||||
con.commit()
|
con.execute("DELETE FROM sessions WHERE token = ?", (bincio_session,))
|
||||||
|
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
@@ -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=$!
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user