Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cda9c8c0a | ||
|
|
3e3013045c | ||
|
|
6587c3f865 | ||
|
|
c36ea81c29 | ||
|
|
c00cb94fe7 | ||
|
|
f66a4e2d53 | ||
|
|
ed0c606aae | ||
|
|
ec9be5b05d | ||
|
|
5a4cfcbf64 |
@@ -1,328 +1,177 @@
|
||||
# RefBoard
|
||||
# RefBoard AYON
|
||||
|
||||
A self-hosted, real-time collaborative reference board — like PureRef, but on the web, multiplayer, and with markdown notes, threaded review comments, and PDF support baked in.
|
||||
Kollaborative Referenz-Bildwand (PureRef-on-the-web), umgebaut für den
|
||||
AYON-only-Betrieb. Fork von [metalfinger/refboard](https://github.com/metalfinger/refboard)
|
||||
(vollständige History erhalten).
|
||||
|
||||
Drop images, videos, and PDFs onto an infinite GPU canvas. Pan, zoom, group, align, annotate. Share a board with your team and watch each others' cursors in real time. Pin a comment to a thumbnail and resolve it like a code review.
|
||||
**Das Besondere:** Es gibt keinen RefBoard-Login mehr. Nutzer öffnen Boards
|
||||
aus dem AYON-Launcher heraus und sind automatisch mit ihrer AYON-Identität
|
||||
eingeloggt. Boards gehören zu AYON-Tasks (`<Projekt>/<Task-Pfad>`), nicht zu
|
||||
Personen. Alle Kollegen mit Projektzugriff arbeiten kollaborativ am selben
|
||||
Board (Echtzeit, Socket.IO).
|
||||
|
||||
Built because we needed PureRef's painlessness, Miro's collaboration, and a code-review's threading — without paying three different SaaS subscriptions for them.
|
||||
## Architektur
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
**Canvas**
|
||||
- Infinite, GPU-accelerated canvas (Pixi.js v8) — handles thousands of items without dropping frames
|
||||
- Drag & drop images, videos, and PDFs from your filesystem or clipboard
|
||||
- Drop image URLs directly from the browser
|
||||
- Pan / zoom / fit-all, selection, lasso, group, ungroup
|
||||
- Undo / redo, locked layers, hidden layers
|
||||
- Pen / draw tool, sticky notes, text labels
|
||||
- Markdown cards (BlockNote-powered editor) right on the canvas
|
||||
|
||||
**PureRef-parity power tools**
|
||||
- 40+ keyboard shortcuts mapped to PureRef defaults
|
||||
- Align, distribute, normalize size / scale / width / height
|
||||
- Auto-arrange in grid / row / column / by name / by z-order / random / stack
|
||||
- Optimal pack, overlay compare (Ctrl+Y), flip H/V, reset transform, grayscale, lock
|
||||
- Right-click context menu with everything
|
||||
|
||||
**Real-time collaboration**
|
||||
- Live cursors with name labels
|
||||
- Full-scene sync over Socket.IO with interaction-aware deferral
|
||||
- Presence (online avatars), follow-user mode, share dialog with role-based access (owner / editor / viewer)
|
||||
- Public, read-only sharing links via collection share token
|
||||
|
||||
**Review & threads**
|
||||
- Pin a comment to any object on the canvas → starts a thread
|
||||
- Threaded comments with status (open / resolved)
|
||||
- Review mode toggles a clean overlay for walking through feedback
|
||||
|
||||
**Activity log**
|
||||
- Per-board audit trail visible from the toolbar — uploads, board renames, threads, replies
|
||||
- Live updates over Socket.IO (no refresh needed when collaborators are working)
|
||||
- Time-grouped feed (Today / Yesterday / older), pagination, deactivation-safe author labels
|
||||
|
||||
**Media pipeline**
|
||||
- Image variants (thumbnail / hires / LOD) generated on upload via Sharp
|
||||
- Video poster + duration + dimensions extracted via ffmpeg
|
||||
- PDF → page thumbnails + hires renders via poppler
|
||||
- Background media worker so the upload feels instant
|
||||
|
||||
**Auth & admin**
|
||||
- JWT-based email/password auth
|
||||
- First user is auto-admin
|
||||
- `SEED_ADMIN_*` env vars to bootstrap an admin on first boot
|
||||
- `ALLOW_SELF_REGISTRATION` flag — when off, only admins can create accounts
|
||||
- **Admin dashboard** at `/admin` — list users, create accounts, reset passwords, promote/demote between admin and member, deactivate / reactivate, and toggle self-registration on or off at runtime (admin-only, JWT-gated)
|
||||
- Admin REST endpoints work with either a JWT belonging to an admin user or an `X-API-Key` header for bots
|
||||
|
||||
**Deployment**
|
||||
- One `docker compose up` starts RefBoard + a bundled MinIO for object storage
|
||||
- Dockerfile is multi-stage and self-contained
|
||||
- SQLite (WAL mode) for metadata — zero external DB dependency
|
||||
|
||||
---
|
||||
|
||||
## Quick start (Docker, recommended)
|
||||
|
||||
Requires Docker + Docker Compose v2.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/metalfinger/refboard
|
||||
cd refboard
|
||||
bash scripts/setup.sh # macOS / Linux
|
||||
# or, on Windows PowerShell:
|
||||
# .\scripts\setup.ps1
|
||||
```
|
||||
Launcher (Task ausgewählt)
|
||||
→ "RefBoard öffnen" (Server-Addon-Action)
|
||||
→ POST /api/addons/refboard/<ver>/session (AYON-Auth via Bearer)
|
||||
→ Addon prüft Projektzugriff, legt Single-Use-Ticket an (60 s TTL)
|
||||
← { url: "https://<refboard>/b?ticket=…&project=…&task=…" }
|
||||
→ Launcher öffnet Browser auf dieser URL
|
||||
→ RefBoard-Frontend (Route /b) ruft POST /api/auth/ayon/exchange
|
||||
→ RefBoard-Backend ⇄ Addon /exchange (server-to-server, X-API-Key)
|
||||
→ Ticket wird atomar verbraucht (single-use) → Identität
|
||||
→ RefBoard-Session-JWT wird gesetzt → weiter zum Task-Board
|
||||
```
|
||||
|
||||
The script copies `.env.example` → `.env`, pulls the pre-built multi-arch image from GHCR, brings the stack up, waits for the backend to report healthy, and opens the URL in your browser. Re-running it is safe.
|
||||
Beteiligte Komponenten:
|
||||
|
||||
Prefer the raw commands? They're equivalent to:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open <http://localhost:8000>, create the first admin account on the screen RefBoard shows you, and you're in. No env-var editing required to get started — `JWT_SECRET` is generated and persisted on first boot, and the first user you register is auto-promoted to admin.
|
||||
|
||||
For production / non-localhost installs, see the [going-public checklist](#checklist-when-going-public) below — you'll want to set `JWT_SECRET` explicitly, lock down `CORS_ORIGIN`, and turn off self-registration.
|
||||
|
||||
To build from source instead of pulling (slower; needed only if you've modified the code):
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The default image is `ghcr.io/metalfinger/refboard:latest`. Pin a specific version by setting `REFBOARD_IMAGE=ghcr.io/metalfinger/refboard:v0.5.0` in `.env`.
|
||||
|
||||
MinIO console (S3 dashboard) is at <http://localhost:9001> — login is whatever you set as `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` in `.env` (defaults to `minioadmin` / `minioadmin`).
|
||||
|
||||
Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up — that's also where the auto-generated `JWT_SECRET` lives, so losing it logs everyone out.
|
||||
|
||||
---
|
||||
|
||||
## Manual install (without Docker)
|
||||
|
||||
Requires Node.js 20+, **ffmpeg**, and **poppler-utils** on your `PATH`. The Docker image installs these automatically; for a manual install you need to bring them yourself.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install ffmpeg poppler
|
||||
|
||||
# Debian / Ubuntu
|
||||
sudo apt install ffmpeg poppler-utils
|
||||
```
|
||||
|
||||
If `poppler-utils` is missing, image and video uploads still work, but PDF uploads will fail with a clear `501 POPPLER_MISSING` error rather than crashing.
|
||||
|
||||
You also need an S3-compatible object store reachable from the backend — easiest is to run MinIO standalone.
|
||||
|
||||
```bash
|
||||
# 1. Object storage
|
||||
docker run -d --name minio -p 9000:9000 -p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
-v $(pwd)/.docker-data/minio:/data \
|
||||
minio/minio server /data --console-address ":9001"
|
||||
|
||||
# 2. Backend
|
||||
cd backend
|
||||
npm install
|
||||
DB_PATH=./data/refboard.db \
|
||||
MINIO_ENDPOINT=localhost \
|
||||
node server.js
|
||||
# JWT_SECRET is auto-generated and persisted on first boot — set it explicitly
|
||||
# only if you want to manage it via an external secrets manager. The first user
|
||||
# you register in the browser becomes admin automatically.
|
||||
|
||||
# 3. Frontend (dev mode, separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In dev mode the Vite server proxies `/api` and `/socket.io` to the backend on port 8000 — open the URL Vite prints.
|
||||
|
||||
For production, run `npm run build` in `frontend/` — the backend serves the built `frontend/dist` automatically.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All knobs live in `.env`. See [`.env.example`](.env.example) for the full annotated list. Highlights:
|
||||
|
||||
| Variable | Required? | What it does |
|
||||
| Komponente | Repo | Aufgabe |
|
||||
|---|---|---|
|
||||
| `JWT_SECRET` | yes (in prod) | Signs auth tokens. Make it long and random. |
|
||||
| `DB_PATH` | no | SQLite file path. Defaults to `/app/data/refboard.db` (Docker). |
|
||||
| `MINIO_ENDPOINT` / `_PORT` / `_ACCESS_KEY` / `_SECRET_KEY` / `_BUCKET` | yes | S3-compatible storage. |
|
||||
| `SEED_ADMIN_EMAIL` + `SEED_ADMIN_PASSWORD` | no | Idempotent first-boot admin bootstrap. |
|
||||
| `ALLOW_SELF_REGISTRATION` | no | Initial seed only — sets the runtime toggle on first boot. After that, control it from the admin dashboard. Default `false`. |
|
||||
| `MAX_FILE_SIZE_MB` | no | Per-file upload cap. Default 200. |
|
||||
| `REFBOARD_API_KEY` | no | Enables programmatic upload via X-API-Key header. |
|
||||
| RefBoard AYON (dieses Repo) | `Hermes/refboard-ayon` | Web-App: Canvas + Ticket-Auth + Task-Boards |
|
||||
| AYON Server-Addon | `Hermes/refboard-addon` (ab v0.2.0) | Ticket-Ausstellung, `/exchange`, Launcher-Action |
|
||||
| AYON Server (private Instanz) | — | Identitätsquelle, Token-Prüfung, Access-Groups |
|
||||
|
||||
---
|
||||
### Warum ein Ticket statt des AYON-Tokens in der URL?
|
||||
|
||||
## Putting it on a public domain
|
||||
Der AYON-Session-Token ist langlebig und dürfte nie in Browser-History/Logs
|
||||
landen. Das Ticket ist ein 32-Byte-Zufallswert, der genau einmal eingelöst
|
||||
werden kann und nach 60 s verfällt — die OAuth-"Code"-Äquivalenz. Die
|
||||
Identität kommt ausschließlich vom Addon (`/exchange`), niemals aus der URL.
|
||||
|
||||
RefBoard is just an HTTP server on port 8000 — every reverse-proxy / tunneling option works. The only non-obvious bit is that it uses Socket.IO over WebSockets, so whatever fronts it must allow WS upgrades.
|
||||
### Task-Boards
|
||||
|
||||
Pre-baked deployment configs for the common patterns (Cloudflare Tunnel sidecar, Caddy auto-TLS, Fly.io, Render, single-container FS-storage) live in [`examples/`](examples/README.md). The hand-rolled instructions below are still valid; the examples just spare you the YAML.
|
||||
- Adresse/Name: `<project>/<task_path>` (z. B. `lumenfjord/assets/vegetation/fir/lookdev`)
|
||||
- Board entsteht lazy beim ersten Öffnen in der Sammlung `AYON`
|
||||
- `users`-Tabelle bleibt intern bestehen (SQLite-Fremdschlüssel), wird aber
|
||||
nicht mehr als Login angezeigt; AYON-Nutzer bekommen Einträge
|
||||
`<username>@ayon.local` mit unbenutzbarem Passwort-Sentinel
|
||||
- Legacy-Login/Register-Routen sind noch vorhanden (für Alt-Instanz), im
|
||||
AYON-Betrieb jedoch ungenutzt
|
||||
|
||||
### Cloudflare Tunnel (zero open ports, free TLS, recommended for home / studio servers)
|
||||
## Deployment (orange)
|
||||
|
||||
This is what I run my own instance behind. No router config, no public IP, no Let's Encrypt — Cloudflare proxies the connection through an outbound tunnel from the box.
|
||||
- Container: `refboard-ayon`, Host-Port **8003** → 8000
|
||||
- Stack-Ordner: `/media/orange/RocketChat/refboard-ayon/` (NVMe)
|
||||
- `repo/` — dieser Code (Branch `ayon-integration`)
|
||||
- `compose.yaml` — Stack (inkl. `ayon-private_default` external network:
|
||||
RefBoard erreicht AYON unter `http://ayon-private-server-1:5000`)
|
||||
- `.env` — `JWT_SECRET`, `REFBOARD_API_KEY`, `AYON_EXCHANGE_URL`
|
||||
- `data/` — SQLite + FS-Storage
|
||||
- nginx: `/etc/nginx/sites-enabled/refboard.conf` → `refboard.niklashmotion.art`
|
||||
(WebSocket-Upgrade, 250 M Upload)
|
||||
- Wichtig nach `docker compose up -d`: das `ayon-private_default`-Netz ist
|
||||
als `external: true` deklariert und überlebt Rebuilds dadurch
|
||||
|
||||
### Environment
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `JWT_SECRET` | Signatur-Secret der RefBoard-Sessions |
|
||||
| `REFBOARD_API_KEY` | Shared Secret für `/exchange` (muss mit dem AYON-Secret `refboard_api_key` übereinstimmen) |
|
||||
| `AYON_EXCHANGE_URL` | z. B. `http://ayon-private-server-1:5000/api/addons/refboard/0.2.0/exchange` |
|
||||
| `STORAGE_BACKEND` | `fs` (FS-Storage) |
|
||||
| `CORS_ORIGIN` | CORS-Origin (Default `*`) |
|
||||
|
||||
## Setup in AYON (Schritt für Schritt)
|
||||
|
||||
> Ausführen, sobald `refboard-0.2.0.zip` und der A-Record existieren.
|
||||
|
||||
### 1. Addon installieren (im AYON-Klon, NICHT im Original)
|
||||
|
||||
```bash
|
||||
# 1. Install cloudflared (macOS / Linux examples)
|
||||
brew install cloudflared # macOS
|
||||
# OR
|
||||
sudo apt install cloudflared # Debian/Ubuntu (see Cloudflare docs for repo setup)
|
||||
# zip in den Server-Container entpacken
|
||||
ssh orange@niklashmotion.art 'docker exec -i ayon-private-server-1 python3 -c "
|
||||
import zipfile,io,os,sys
|
||||
z=zipfile.ZipFile(io.BytesIO(sys.stdin.buffer.read()))
|
||||
b=\"/addons/refboard/0.2.0\"
|
||||
os.makedirs(b,exist_ok=True)
|
||||
[open(os.path.join(b,n),\"wb\").write(z.read(n)) for n in z.namelist() if not n.endswith(\"/\")]
|
||||
"' < refboard-0.2.0.zip
|
||||
|
||||
# 2. Authenticate (opens browser to pick a Cloudflare account / zone)
|
||||
cloudflared tunnel login
|
||||
|
||||
# 3. Create a named tunnel
|
||||
cloudflared tunnel create refboard
|
||||
|
||||
# 4. Route a hostname to it (replace example.com with your zone)
|
||||
cloudflared tunnel route dns refboard refboard.example.com
|
||||
|
||||
# 5. Run the tunnel pointed at the local RefBoard
|
||||
cloudflared tunnel run --url http://localhost:8000 refboard
|
||||
# AYON-Server neu starten (scannt /addons nur beim Start)
|
||||
ssh orange@niklashmotion.art 'docker restart ayon-private-server-1'
|
||||
```
|
||||
|
||||
For a permanent install, generate a config at `~/.cloudflared/config.yml`:
|
||||
### 2. Bundle (WebUI: Studio Settings → Bundles)
|
||||
|
||||
```yaml
|
||||
tunnel: refboard
|
||||
credentials-file: /Users/you/.cloudflared/<tunnel-id>.json
|
||||
Neues Bundle anlegen, das die Addons des aktuellen Produktions-Bundles
|
||||
enthält, aber `refboard: 0.2.0`, und als Produktion aktivieren.
|
||||
|
||||
ingress:
|
||||
- hostname: refboard.example.com
|
||||
service: http://localhost:8000
|
||||
- service: http_status:404
|
||||
```
|
||||
### 3. Settings prüfen (WebUI: Studio Settings → Addons → RefBoard)
|
||||
|
||||
Then `cloudflared service install` to make it boot at startup.
|
||||
- `refboard_url`: `https://refboard.niklashmotion.art`
|
||||
- `api_key`: `refboard_api_key` (Secret existiert bereits im AYON-Secret-Store
|
||||
und muss denselben Wert wie `REFBOARD_API_KEY` in der RefBoard-`.env` haben)
|
||||
|
||||
> **Heads-up:** Cloudflare's free plan caps proxied request bodies at **100 MB**. If you regularly upload videos / large PDFs above that, set `MAX_FILE_SIZE_MB` accordingly, or pair Cloudflare Tunnel with a direct path for uploads (e.g. tunnel only the SPA, expose the upload API via something else), or upgrade your Cloudflare plan.
|
||||
### 4. Benutzen
|
||||
|
||||
### Caddy reverse proxy (one-line TLS via Let's Encrypt)
|
||||
1. AYON-Launcher starten und ein Projekt öffnen
|
||||
2. Task auswählen → Kontext-/Aktionsmenü → **„RefBoard öffnen"**
|
||||
3. Browser geht auf, automatisch eingeloggt als der eigene AYON-User, im
|
||||
Board der Task
|
||||
4. Teilen: Kollegen wählen dieselbe Task → „RefBoard öffnen" → alle landen
|
||||
im selben Board und arbeiten in Echtzeit zusammen
|
||||
|
||||
If the box is publicly reachable (cloud VPS, port 443 open):
|
||||
Hinweise:
|
||||
- Der Board-Name ist `<Projekt>/<Task-Pfad>`; im Editor oben wird er als
|
||||
Titel angezeigt
|
||||
- Ohne Task-Auswahl gibt es keinen Action-Eintrag (Bewusst so gebaut:
|
||||
Boards sind taskbezogen)
|
||||
- Tickets sind einmalig und 60 s gültig — bei „Invalid or expired ticket"
|
||||
einfach im Launcher erneut klicken
|
||||
|
||||
```caddy
|
||||
refboard.example.com {
|
||||
reverse_proxy localhost:8000
|
||||
}
|
||||
```
|
||||
## AYON-REST-API des Addons (v0.2.0)
|
||||
|
||||
That's the whole `Caddyfile`. Caddy auto-provisions and renews the certificate, and `reverse_proxy` upgrades WebSockets transparently.
|
||||
Alle Routen unter `/api/addons/refboard/0.2.0/`:
|
||||
|
||||
### nginx reverse proxy
|
||||
| Endpoint | Methode | Auth | Zweck |
|
||||
|---|---|---|---|
|
||||
| `/health` | GET | AYON-User | Status + konfigurierte URL |
|
||||
| `/session` | POST | AYON-User | `{project_name, task_id?}` → `{url}` (Ticket-URL) |
|
||||
| `/exchange` | POST | `X-API-Key` | `{ticket}` → `{ayon_user, display_name, project, task}` (single-use) |
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name refboard.example.com;
|
||||
listen 443 ssl;
|
||||
# ssl_certificate / ssl_certificate_key from certbot or your CA
|
||||
|
||||
client_max_body_size 250M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tailscale (private-to-your-team access without a domain)
|
||||
|
||||
If you don't want it on the public internet at all:
|
||||
## Lokale Entwicklung / Tests
|
||||
|
||||
```bash
|
||||
tailscale serve --bg http://localhost:8000
|
||||
# now reachable at https://<machine>.<tailnet>.ts.net
|
||||
# Image bauen (ARM64-nativ auf orange getestet)
|
||||
docker build -t refboard-ayon:latest .
|
||||
|
||||
# Container + Mock-Addon (Test-Double statt echtem AYON-Addon):
|
||||
# /media/orange/RocketChat/refboard-ayon/mock-exchange.js (container :9000)
|
||||
# /media/orange/RocketChat/refboard-ayon/e2e.sh (Container-interner E2E)
|
||||
```
|
||||
|
||||
Anyone in your tailnet can hit it; no one else can.
|
||||
Der E2E (Ticket-Ausstellung → Exchange → Board-Zugriff → Single-Use →
|
||||
2. Nutzer am selben Board) ist mit dem Mock grün getestet.
|
||||
|
||||
### Checklist when going public
|
||||
## Status: PRODUKTIV (seit 05.09.2026)
|
||||
|
||||
- [ ] Set `JWT_SECRET` explicitly (`openssl rand -base64 64`). For production installs we recommend pinning the secret in `.env` rather than relying on the auto-generated one in the SQLite settings table — easier to rotate, easier to back up to a secrets manager.
|
||||
- [ ] Set `NODE_ENV=production`.
|
||||
- [ ] Set `CORS_ORIGIN=https://your.domain` (drop the wildcard).
|
||||
- [ ] Confirm self-registration is **off** in the admin dashboard (defaults off; only flips on if you set `ALLOW_SELF_REGISTRATION=true` on first boot).
|
||||
- [ ] Restrict the MinIO console (port 9001) to localhost — only the S3 API on 9000 needs to be reachable from the backend, and the backend already proxies media bytes through `/api/images/*`, so MinIO does **not** need to be exposed publicly.
|
||||
- [ ] Keep `./.docker-data/` (or `DB_PATH` + MinIO data dir) backed up — that's all your state.
|
||||
- DNS + Let's-Encrypt-Zertifikat aktiv (`refboard.niklashmotion.art`)
|
||||
- Launcher-Klick → eingeloggt im Task-Board — produktiv im Einsatz
|
||||
|
||||
---
|
||||
## Fehler & Lektionen (Session 04.–05.09.2026)
|
||||
|
||||
## Architecture
|
||||
| # | Fehler | Ursache | Fix/Lektion |
|
||||
|---|---|---|---|
|
||||
| 1 | Launcher zeigte fast keine Actions mehr (`KeyError: 'url'` in `_get_webactions`) | `SimpleActionManifest(icon=…)` erzeugte `{"type":"url"}` **ohne** `url`-Feld; eine einzige kaputte Action kippte **alle** Webactions im Client (try/except um den ganzen Fetch) | Icon-Feld weggelassen; Lektion: Action-Icons brauchen `{"type":"material-symbols","name":…}` oder vollständige URL |
|
||||
| 2 | `/session` → 500 `AttributeError: 'UserAttribModel' object has no attribute 'name'` | AYON-User-attrib heißt `fullName`, nicht `name` | `user.attrib.fullName` |
|
||||
| 3 | Exchange → 500 `uuidv4 is not defined` | Import im neuen db.js-Code vergessen | Lektion: py_compile fängt undefined names nicht — Smoke-Test im Container statt nur Syntax-Check |
|
||||
| 4 | RefBoard konnte AYON nicht erreichen nach `compose up` | Externes Docker-Netz nur top-level deklariert, Service nicht attached; manuelles `docker network connect` überlebt kein Recreate | Netz **im Service-Block** als `external: true` deklarieren |
|
||||
| 5 | Exchange → 403 trotz „richtigem" Secret | `REFBOARD_API_KEY`-Wert enthält `=`; Extraktion mit `cut -d= -f2` bricht am ersten `=` → falscher (gekürzter) Wert in AYON-Secret | Werte mit `sed "s/^KEY=//"` extrahieren; Lektion: Secret-Sync immer per Längen-/Hash-Vergleich verifizieren |
|
||||
| 6 | Settings-POST (204) änderte die DB **nicht** | AYON speichert Settings als Ganzes, filtert aber „identity"-Felder (Werte == Defaults) heraus — ein Feld, das dem Default entspricht, ist nie speicherbar | Lektion: Settings-API ist Full-Replace + Identity-Filter; gezielte Feld-Fixe über die API sind nicht immer möglich |
|
||||
| 7 | App-Kacheln fehlten in jeder Task (Launcher leer bis auf 3 Actions) | **Ynput-Bug in applications 1.4.4:** Runtime liest Pydantic-Defaults (`profiles=[]`), nicht `DEFAULT_VALUES` (mit `all_applications`-Profil); das UI zeigt die Defaults trotzdem — Diskrepanz UI/Runtime | Umgehend: Profil per API explizit setzen (Full-Replace aus Backup + Profil-Feld); langfristig: Ynput-Issue |
|
||||
| 8 | Ticket-Wiederverwendung zeigte 502 statt 401 | RefBoard behandelte 401/403 vom Addon als Infrastruktur-Fehler | 401/403 aus `/exchange` → sauberes 401 „Invalid or expired ticket" |
|
||||
| 9 | Nach Secret-Löschung + Neuanlage Exchange-Fehler | AYON-Secret-Wert ≠ Container-Env (Container läuft mit altem Env bis Recreate) | Nach Secret-Änderungen: `docker compose up -d --force-recreate` |
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ React + TypeScript + Vite │
|
||||
│ Pixi.js v8 canvas · BlockNote markdown · Socket.IO client │
|
||||
└──────────────────┬─────────────────────────────┬───────────┘
|
||||
│ HTTPS / WS │
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌──────────────────────┐
|
||||
│ Express + Socket.IO │ │ Static frontend │
|
||||
│ /api/* REST │ │ (served by backend) │
|
||||
│ /socket.io WS rooms │ └──────────────────────┘
|
||||
│ Media worker (queue) │
|
||||
└────┬──────────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────────┐
|
||||
│ SQLite │ │ MinIO (S3) │
|
||||
│ (WAL) │ │ images / videos │
|
||||
│ metadata │ │ pdf renders │
|
||||
└──────────┘ └──────────────────┘
|
||||
```
|
||||
## Noch offen
|
||||
|
||||
- **Frontend** — React + Vite + TypeScript. Pixi.js v8 powers the canvas (LOD-aware, viewport-culled, GPU-batched). BlockNote provides the markdown editor used for sticky cards. Socket.IO syncs scene state.
|
||||
- **Backend** — Express for REST, Socket.IO for real-time, better-sqlite3 for metadata (WAL mode), Sharp + ffmpeg + poppler for media processing. A background worker handles thumbnails/hires/PDF rasterization out of the request path.
|
||||
- **Storage** — MinIO (or any S3 API). The backend proxies media bytes through `/api/images/*` so URLs stay stable across deployment moves.
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0).
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Admin dashboard frontend (live at `/admin` — user create / reset-password / role / deactivate)
|
||||
- [x] Per-board activity log (uploads, board events, threads, comments — live via Socket.IO)
|
||||
- [x] **Pre-built multi-arch image** at `ghcr.io/metalfinger/refboard` (linux/amd64 + linux/arm64) — published on every push to main
|
||||
- [x] **Zero-edit first boot** — `JWT_SECRET` auto-generated and persisted, first registered user is auto-admin
|
||||
- [x] **FS storage adapter** — `STORAGE_BACKEND=fs` drops the MinIO dependency for single-container installs
|
||||
- [x] **One-click installers** — `scripts/setup.sh` (macOS / Linux) and `scripts/setup.ps1` (Windows)
|
||||
- [x] **PaaS deploy templates** — Fly.io, Render, Cloudflare Tunnel sidecar, Caddy auto-TLS in [`examples/`](examples/README.md)
|
||||
- [ ] Mobile-friendly read-only board view
|
||||
- [ ] Export board → PDF / image grid
|
||||
- [ ] Optional remote storage adapters (S3 direct, R2)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs welcome. The codebase is single-language on the frontend (TypeScript + React) and small on the backend (a few hundred lines per route file). The hardest parts are in `frontend/src/canvas/` (the Pixi-powered scene graph and sync engine).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE). Built and maintained by [Hiren Kangad](https://metalfinger.xyz).
|
||||
- ~~A-Record~~ ✓ erledigt (05.09.)
|
||||
- ~~Zertifikat~~ ✓ erledigt (05.09.)
|
||||
- Nuke-17.0-Pfad-Mismatch in den Applications-Settings
|
||||
(`/usr/local/Nuke17.0v1` erwartet, installiert ist `/opt/Nuke17.0v3`) — Fix im
|
||||
Studio-Settings-WebUI: Executable auf `/usr/local/bin/nuke-ayon` setzen
|
||||
- Alt-Container `refboard` (:8002, standalone) stilllegen, sobald Bestands-
|
||||
Boards nicht mehr gebraucht werden
|
||||
- Ynput-Issue zu #7 (applications 1.4.4 Runtime-Defaults) optional melden
|
||||
|
||||
+83
-3
@@ -429,6 +429,10 @@ function createCollection({ id, name, description, createdBy }) {
|
||||
return getCollection(id);
|
||||
}
|
||||
|
||||
function getCollectionByName(name) {
|
||||
return db.prepare('SELECT * FROM collections WHERE name = ?').get(name);
|
||||
}
|
||||
|
||||
function updateCollection(collectionId, { name, description, isPublic, shareToken }) {
|
||||
const fields = [];
|
||||
const params = [];
|
||||
@@ -816,13 +820,89 @@ async function seedAdminFromEnv() {
|
||||
console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`);
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// AYON integration helpers
|
||||
// ---------------------
|
||||
|
||||
// Collection that holds all AYON task boards.
|
||||
const AYON_COLLECTION_NAME = 'AYON';
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Get-or-create the internal user row backing an AYON identity.
|
||||
* AYON users never log in here — the row only exists so that boards,
|
||||
* images, threads and comments (which reference users.id) keep working.
|
||||
* Password hash is an unusable sentinel.
|
||||
*/
|
||||
function getOrCreateAyonUser(username, displayName) {
|
||||
const existing = getUserByUsername(username);
|
||||
if (existing) return existing;
|
||||
|
||||
const email = `${username}@ayon.local`;
|
||||
const byEmail = getUserByEmail(email);
|
||||
if (byEmail) return byEmail;
|
||||
|
||||
return createUser({
|
||||
id: uuidv4(),
|
||||
email,
|
||||
username,
|
||||
passwordHash: `!ayon:${uuidv4()}`, // no password ever matches this
|
||||
displayName: displayName || username,
|
||||
role: 'member',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get-or-create the board for an AYON task: board name is
|
||||
* `<project>/<task_path>`, living in the shared "AYON" collection.
|
||||
* Returns { board, collection, created }.
|
||||
*/
|
||||
function getOrCreateAyonBoard(project, taskPath) {
|
||||
const name = `${project}/${taskPath}`;
|
||||
|
||||
let collection = getCollectionByName(AYON_COLLECTION_NAME);
|
||||
if (!collection) {
|
||||
// Bootstrap user owns the container collection
|
||||
const owner = getOrCreateAyonUser('ayon-system', 'AYON System');
|
||||
collection = createCollection({
|
||||
id: uuidv4(),
|
||||
name: AYON_COLLECTION_NAME,
|
||||
description: 'Task boards created by the AYON integration',
|
||||
createdBy: owner.id,
|
||||
});
|
||||
addCollectionMember(collection.id, owner.id, 'owner');
|
||||
}
|
||||
const ownerId = collection.created_by;
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT * FROM boards WHERE collection_id = ? AND name = ?'
|
||||
).get(collection.id, name);
|
||||
if (existing) return { board: existing, collection, created: false };
|
||||
|
||||
const board = createBoard({
|
||||
id: uuidv4(),
|
||||
collectionId: collection.id,
|
||||
name,
|
||||
description: `AYON task board (${name})`,
|
||||
createdBy: ownerId,
|
||||
});
|
||||
return { board, collection, created: true };
|
||||
}
|
||||
|
||||
/** Grant a user editor membership on the AYON collection (idempotent). */
|
||||
function grantAyonCollectionAccess(userId) {
|
||||
const collection = getCollectionByName(AYON_COLLECTION_NAME);
|
||||
if (!collection) return;
|
||||
const member = getCollectionMember(collection.id, userId);
|
||||
if (!member) addCollectionMember(collection.id, userId, 'editor');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
// AYON integration
|
||||
getOrCreateAyonUser, getOrCreateAyonBoard, grantAyonCollectionAccess,
|
||||
// Users
|
||||
getUserByEmail, getUserById, getUserByUsername,
|
||||
createUser,
|
||||
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
||||
// Collections
|
||||
getCollections, getCollection, getCollectionByShareToken,
|
||||
createCollection, updateCollection, deleteCollection,
|
||||
getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember,
|
||||
|
||||
@@ -202,4 +202,108 @@ router.put('/password', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------
|
||||
// AYON single-sign-on
|
||||
// ---------------------
|
||||
// Flow: the AYON addon issues a single-use ticket bound to (user, project,
|
||||
// task). The browser presents the ticket here; we redeem it against the
|
||||
// addon's exchange endpoint (server-to-server, API key) and mint our own
|
||||
// session JWT. The ticket is transport, never identity.
|
||||
|
||||
const AYON_EXCHANGE_TIMEOUT_MS = 8000;
|
||||
|
||||
async function redeemTicket(ticket) {
|
||||
const apiKey = process.env.REFBOARD_API_KEY || '';
|
||||
const exchangeUrl = process.env.AYON_EXCHANGE_URL || '';
|
||||
if (!apiKey || !exchangeUrl) {
|
||||
throw Object.assign(new Error('AYON exchange not configured'), { status: 503 });
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), AYON_EXCHANGE_TIMEOUT_MS);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(exchangeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ ticket }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error('AYON exchange unreachable'), { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (resp.status === 404 || resp.status === 401 || resp.status === 403) {
|
||||
// Ticket unknown / already redeemed (single-use) or bad server-to-server
|
||||
// credentials — indistinguishable from the client's perspective.
|
||||
throw Object.assign(new Error('Invalid or expired ticket'), { status: 401 });
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw Object.assign(new Error(`AYON exchange failed (${resp.status})`), { status: 502 });
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/ayon/exchange
|
||||
* Server-to-server style redemption: { ticket } → session token + user.
|
||||
* Also used by the browser entry route below.
|
||||
*/
|
||||
router.post('/ayon/exchange', async (req, res) => {
|
||||
try {
|
||||
const { ticket } = req.body || {};
|
||||
if (!ticket || typeof ticket !== 'string') {
|
||||
return res.status(400).json({ error: 'ticket is required' });
|
||||
}
|
||||
|
||||
const payload = await redeemTicket(ticket.trim());
|
||||
// Expected payload from the addon: { ayon_user, display_name, project, task }
|
||||
const ayonUser = payload.ayon_user;
|
||||
if (!ayonUser) {
|
||||
return res.status(502).json({ error: 'AYON exchange returned no identity' });
|
||||
}
|
||||
|
||||
const {
|
||||
getOrCreateAyonUser, getOrCreateAyonBoard, grantAyonCollectionAccess,
|
||||
} = require('../db');
|
||||
|
||||
const user = getOrCreateAyonUser(ayonUser, payload.display_name);
|
||||
grantAyonCollectionAccess(user.id);
|
||||
|
||||
let boardUrl = null;
|
||||
if (payload.project && payload.task) {
|
||||
const { board } = getOrCreateAyonBoard(payload.project, payload.task);
|
||||
boardUrl = `/board/${board.id}`;
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
return res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
},
|
||||
board_url: boardUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('[auth] ayon exchange error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/ayon/login?ticket=… (browser entry point, hits the SPA route)
|
||||
* The frontend AyonEntry page calls POST /api/auth/ayon/exchange with the
|
||||
* ticket, stores the token and redirects to the task board.
|
||||
*/
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -286,7 +286,14 @@ function downloadImage(imageUrl, maxRedirects = 5) {
|
||||
const parsed = new URL(imageUrl);
|
||||
const client = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
client.get(imageUrl, { timeout: 30000 }, (response) => {
|
||||
// Browser-like headers: many CDNs (film.ai, frameset, etc.) reject
|
||||
// requests without UA/Referer with 403.
|
||||
const requestHeaders = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Referer": parsed.origin + "/",
|
||||
};
|
||||
client.get(imageUrl, { timeout: 30000, headers: requestHeaders }, (response) => {
|
||||
// Follow redirects up to maxRedirects times
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
||||
if (maxRedirects <= 0) {
|
||||
@@ -300,9 +307,11 @@ function downloadImage(imageUrl, maxRedirects = 5) {
|
||||
}
|
||||
|
||||
const contentType = (response.headers['content-type'] || '').split(';')[0].trim();
|
||||
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
|
||||
const GENERIC_TYPES = ['binary/octet-stream', 'application/octet-stream'];
|
||||
if (!ALLOWED_MIME_TYPES.includes(contentType) && !GENERIC_TYPES.includes(contentType)) {
|
||||
return reject(new Error(`Unsupported content type: ${contentType}`));
|
||||
}
|
||||
const typeIsGeneric = GENERIC_TYPES.includes(contentType);
|
||||
|
||||
const chunks = [];
|
||||
let totalSize = 0;
|
||||
@@ -318,8 +327,17 @@ function downloadImage(imageUrl, maxRedirects = 5) {
|
||||
|
||||
response.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
let resolvedType = contentType;
|
||||
if (typeIsGeneric) {
|
||||
// Replace the generic CDN type with what the bytes actually are,
|
||||
// so the correct MIME gets stored and served.
|
||||
resolvedType = sniffMimeExact(buffer);
|
||||
if (!resolvedType) {
|
||||
return reject(new Error('Unsupported content type: binary/octet-stream (unknown magic bytes)'));
|
||||
}
|
||||
}
|
||||
const filename = parsed.pathname.split('/').pop() || 'image';
|
||||
resolve({ buffer, mimeType: contentType, filename });
|
||||
resolve({ buffer, mimeType: resolvedType, filename });
|
||||
});
|
||||
|
||||
response.on('error', reject);
|
||||
@@ -418,4 +436,24 @@ router.use((err, req, res, next) => {
|
||||
next(err);
|
||||
});
|
||||
|
||||
|
||||
// Sniff the real MIME type from magic bytes (for CDNs that serve everything
|
||||
// as binary/octet-stream). Returns the exact MIME or null.
|
||||
function sniffMimeExact(buf) {
|
||||
if (!buf || buf.length < 12) return null;
|
||||
const hex = buf.slice(0, 12).toString('hex');
|
||||
const ascii = buf.slice(0, 12).toString('latin1');
|
||||
if (hex.startsWith('89504e47')) return 'image/png';
|
||||
if (hex.startsWith('ffd8ff')) return 'image/jpeg';
|
||||
if (ascii.startsWith('GIF87a') || ascii.startsWith('GIF89a')) return 'image/gif';
|
||||
if (ascii.startsWith('RIFF') && buf.slice(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
|
||||
if (ascii.startsWith('%PDF')) return 'application/pdf';
|
||||
if (buf.slice(4, 8).toString('latin1') === 'ftyp') {
|
||||
const brand = buf.slice(8, 12).toString('latin1');
|
||||
return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4';
|
||||
}
|
||||
if (hex.startsWith('1a45dfa3')) return 'video/webm';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Produktive Deployment-Konfiguration (orange-desktop).
|
||||
# Stack-Pfad: /media/orange/RocketChat/refboard-ayon/
|
||||
# compose.yaml (diese Datei) + .env + data/ + repo/
|
||||
# DNS: refboard.niklashmotion.art → 89.57.47.213 (nginx vhost: /etc/nginx/sites-enabled/refboard.conf)
|
||||
#
|
||||
# WICHTIG (Lessons siehe README):
|
||||
# - ayon-private_default MUSS als external network IM Service-Block hängen
|
||||
# (top-level reicht nicht — überlebt kein `up --force-recreate`)
|
||||
# - REFBOARD_API_KEY muss EXAKT dem AYON-Secret 'refboard_api_key' entsprechen;
|
||||
# der Wert enthält '=' → nie mit cut -d= extrahieren, immer sed "s/^KEY=//"
|
||||
# - nach Secret-Änderungen: docker compose up -d --force-recreate
|
||||
|
||||
name: refboard-ayon
|
||||
|
||||
services:
|
||||
refboard-ayon:
|
||||
image: refboard-ayon:latest
|
||||
build: ./repo
|
||||
container_name: refboard-ayon
|
||||
environment:
|
||||
PORT: 8000
|
||||
NODE_ENV: production
|
||||
JWT_SECRET: ${JWT_SECRET:?set in .env}
|
||||
JWT_EXPIRES_IN: 7d
|
||||
DB_PATH: /app/data/refboard.db
|
||||
STORAGE_BACKEND: ${STORAGE_BACKEND:-fs}
|
||||
STORAGE_DATA_DIR: /app/data/storage
|
||||
REFBOARD_API_KEY: ${REFBOARD_API_KEY:?set in .env}
|
||||
AYON_EXCHANGE_URL: ${AYON_EXCHANGE_URL:?set in .env}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||
MAX_FILE_SIZE_MB: 200
|
||||
ports:
|
||||
- "0.0.0.0:8003:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- ayon-private_default
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: refboard-ayon_default
|
||||
ayon-private_default:
|
||||
external: true
|
||||
@@ -6,6 +6,7 @@ import CollectionList from './pages/CollectionList';
|
||||
import CollectionDetail from './pages/CollectionDetail';
|
||||
import Editor from './pages/Editor';
|
||||
import Admin from './pages/Admin';
|
||||
import AyonEntry from './pages/AyonEntry';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -32,6 +33,7 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/b" element={<AyonEntry />} />
|
||||
<Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} />
|
||||
<Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} />
|
||||
<Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth';
|
||||
import api from '../api';
|
||||
|
||||
/**
|
||||
* AYON entry point: /b?ticket=…&project=…&task=…
|
||||
* Redeems the single-use ticket issued by the AYON addon, stores the
|
||||
* session token and forwards to the task board. No login form involved.
|
||||
*/
|
||||
export default function AyonEntry() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const ticket = params.get('ticket');
|
||||
if (!ticket) {
|
||||
setError('No ticket provided. Open RefBoard from the AYON launcher.');
|
||||
return;
|
||||
}
|
||||
|
||||
api.post('/api/auth/ayon/exchange', { ticket })
|
||||
.then((res) => {
|
||||
if (res.data?.token && res.data?.user) {
|
||||
login(res.data.token, res.data.user);
|
||||
navigate(res.data.board_url || '/', { replace: true });
|
||||
} else {
|
||||
setError('Unexpected response from server.');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err?.response?.data?.error || 'Ticket exchange failed.');
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', background: '#1a1a1a', color: '#e0e0e0', gap: '12px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
{error ? (
|
||||
<>
|
||||
<div style={{ fontSize: '18px' }}>{error}</div>
|
||||
<a href="/login" style={{ color: '#8ab4f8' }}>Go to login</a>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: '18px' }}>Signing you in via AYON…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Tests
|
||||
|
||||
E2E-Testdoppel ohne echtes AYON-Addon (Phase-1-Verifikation, Stand 04.09.2026).
|
||||
|
||||
## Ablauf auf orange
|
||||
|
||||
```bash
|
||||
# 1. Mock-Addon als Container (simuliert /issue + /exchange des AYON-Addons)
|
||||
docker run -d --name mock-exchange --network refboard-ayon_default \
|
||||
-e REFBOARD_API_KEY=<key aus .env> \
|
||||
-v $PWD/tests/mock-exchange.js:/mock.js:ro \
|
||||
node:20-alpine node /mock.js
|
||||
|
||||
# 2. AYON_EXCHANGE_URL in .env auf http://mock-exchange:9000/exchange setzen
|
||||
# + docker compose up -d --force-recreate
|
||||
|
||||
# 3. E2E im refboard-ayon-Container ausführen
|
||||
docker cp tests/e2e.sh refboard-ayon:/tmp/e2e.sh
|
||||
docker exec refboard-ayon sh /tmp/e2e.sh
|
||||
```
|
||||
|
||||
Erwartet: 8 Schritte grün (Ticket → Exchange → /me → Board 200 → Reuse 401 →
|
||||
2. User am selben Board → fehlendes Ticket 400 → SPA /b 200).
|
||||
|
||||
## Echter Stack
|
||||
|
||||
Im Produktivbetrieb zeigt `AYON_EXCHANGE_URL` auf das echte Addon
|
||||
(`http://ayon-private-server-1:5000/api/addons/refboard/0.2.0/exchange`).
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
# E2E test: ticket exchange -> session -> board access -> single-use -> collab
|
||||
set -e
|
||||
B=http://localhost:8000
|
||||
M=http://mock-exchange:9000
|
||||
|
||||
echo "--- 1. Issue ticket (mock addon) ---"
|
||||
TICKET=$(wget -qO- --header="Content-Type: application/json" \
|
||||
--post-data='{"ayon_user":"niklas","display_name":"Niklas","project":"lumenfjord","task":"assets/chair/modeling"}' \
|
||||
$M/issue | sed 's/.*"ticket":"\([^"]*\)".*/\1/')
|
||||
echo "ticket: ${TICKET:0:8}..."
|
||||
|
||||
echo "--- 2. Browser exchange at RefBoard ---"
|
||||
EX=$(wget -qO- --header="Content-Type: application/json" \
|
||||
--post-data="{\"ticket\":\"$TICKET\"}" $B/api/auth/ayon/exchange)
|
||||
echo "$EX" | head -c 300; echo
|
||||
TOKEN=$(echo "$EX" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
BOARD_URL=$(echo "$EX" | sed 's/.*"board_url":"\([^"]*\)".*/\1/')
|
||||
echo "board_url: $BOARD_URL"
|
||||
|
||||
echo "--- 3. /me with session ---"
|
||||
wget -qO- --header="Authorization: Bearer $TOKEN" $B/api/auth/me
|
||||
echo
|
||||
|
||||
echo "--- 4. Fetch board ---"
|
||||
BOARD_ID=${BOARD_URL##*/}
|
||||
wget -qO- --header="Authorization: Bearer $TOKEN" $B/api/boards/$BOARD_ID | head -c 200
|
||||
echo
|
||||
|
||||
echo "--- 5. Ticket reuse must fail (single-use) ---"
|
||||
CODE=$(wget -qO- --header="Content-Type: application/json" \
|
||||
--post-data="{\"ticket\":\"$TICKET\"}" $B/api/auth/ayon/exchange 2>&1 | tail -1)
|
||||
echo "reuse response: $CODE"
|
||||
|
||||
echo "--- 6. Second user joins SAME board ---"
|
||||
T2T=$(wget -qO- --header="Content-Type: application/json" \
|
||||
--post-data='{"ayon_user":"artist2","display_name":"Artist Two","project":"lumenfjord","task":"assets/chair/modeling"}' \
|
||||
$M/issue | sed 's/.*"ticket":"\([^"]*\)".*/\1/')
|
||||
EX2=$(wget -qO- --header="Content-Type: application/json" \
|
||||
--post-data="{\"ticket\":\"$T2T\"}" $B/api/auth/ayon/exchange)
|
||||
T2=$(echo "$EX2" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
echo "user2 board access:"
|
||||
wget -qO- --header="Authorization: Bearer $T2" $B/api/boards/$BOARD_ID | head -c 120
|
||||
echo
|
||||
|
||||
echo "--- 7. Missing ticket -> 400 ---"
|
||||
wget -qO- --header="Content-Type: application/json" --post-data="{}" $B/api/auth/ayon/exchange 2>&1 | tail -1 || true
|
||||
|
||||
echo "--- 8. Frontend SPA route /b served ---"
|
||||
wget -qO- $B/b | head -c 80
|
||||
echo
|
||||
echo "E2E DONE"
|
||||
@@ -0,0 +1,54 @@
|
||||
// Mock AYON addon exchange endpoint for E2E testing.
|
||||
// POST /issue {ayon_user, display_name, project, task} -> {ticket}
|
||||
// POST /exchange {ticket} -> identity payload (single-use, TTL 120s)
|
||||
// NOT FOR PRODUCTION — test double only.
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const tickets = new Map(); // ticket -> {payload, expires}
|
||||
const TTL = 120000;
|
||||
|
||||
const json = (res, code, body) => {
|
||||
const data = JSON.stringify(body);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(data);
|
||||
};
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(raw || '{}')); } catch { resolve({}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
// Simple API key check on /exchange (mirrors the real addon)
|
||||
if (url.pathname === '/exchange') {
|
||||
const key = process.env.REFBOARD_API_KEY || 'testkey';
|
||||
if (req.headers['x-api-key'] !== key) {
|
||||
return json(res, 401, { error: 'bad api key' });
|
||||
}
|
||||
const body = await readBody(req);
|
||||
const t = tickets.get(body.ticket);
|
||||
if (!t || t.expires < Date.now()) {
|
||||
tickets.delete(body.ticket);
|
||||
return json(res, 404, { error: 'unknown or expired ticket' });
|
||||
}
|
||||
tickets.delete(body.ticket); // single-use
|
||||
return json(res, 200, t.payload);
|
||||
}
|
||||
if (url.pathname === '/issue') {
|
||||
const body = await readBody(req);
|
||||
const ticket = crypto.randomBytes(24).toString('hex');
|
||||
tickets.set(ticket, { payload: body, expires: Date.now() + TTL });
|
||||
return json(res, 200, { ticket });
|
||||
}
|
||||
if (url.pathname === '/health') return json(res, 200, { ok: true });
|
||||
json(res, 404, { error: 'not found' });
|
||||
});
|
||||
|
||||
server.listen(9000, () => console.log('mock exchange on 9000'));
|
||||
Reference in New Issue
Block a user