From 5a4cfcbf649fdad4d405b62dd3404a00fa29df3c Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 4 Sep 2026 13:40:33 +0000 Subject: [PATCH 1/5] feat: AYON single-sign-on (ticket exchange, task boards, browser entry) - POST /api/auth/ayon/exchange: redeem single-use ticket (issued by the AYON addon) via AYON_EXCHANGE_URL, mint session JWT, get-or-create internal user row and the / board in the AYON collection - db: getOrCreateAyonUser / getOrCreateAyonBoard / grantAyonCollectionAccess - frontend: /b route redeems ticket from URL and forwards to the board - password login/register paths untouched (legacy instance support) --- backend/db.js | 85 ++++++++++++++++++++++++- backend/routes/auth.js | 103 +++++++++++++++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/pages/AyonEntry.tsx | 56 +++++++++++++++++ 4 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/AyonEntry.tsx diff --git a/backend/db.js b/backend/db.js index 93b2d26..0b5acd3 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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,88 @@ 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'; + +/** + * 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 + * `/`, 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, diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e56719b..aa315e4 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -202,4 +202,107 @@ 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) { + // Ticket unknown or already redeemed (single-use) + 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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 585b8d2..3bb17a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/AyonEntry.tsx b/frontend/src/pages/AyonEntry.tsx new file mode 100644 index 0000000..b014d38 --- /dev/null +++ b/frontend/src/pages/AyonEntry.tsx @@ -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 ( +
+ {error ? ( + <> +
{error}
+ Go to login + + ) : ( +
Signing you in via AYON…
+ )} +
+ ); +} -- 2.54.0 From ec9be5b05d84553514465a543dec7ffb1f21beca Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 4 Sep 2026 13:53:52 +0000 Subject: [PATCH 2/5] fix: import uuidv4 in ayon helpers --- backend/db.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/db.js b/backend/db.js index 0b5acd3..3d0e402 100644 --- a/backend/db.js +++ b/backend/db.js @@ -826,6 +826,7 @@ async function seedAdminFromEnv() { // 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. -- 2.54.0 From ed0c606aaeb4455e141b3de02611e92db4871148 Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 4 Sep 2026 14:33:30 +0000 Subject: [PATCH 3/5] docs: AYON setup + usage guide (verified against live stack) --- README.md | 411 ++++++++++++++++-------------------------------------- 1 file changed, 118 insertions(+), 293 deletions(-) diff --git a/README.md b/README.md index 0a817f2..3c0bcd7 100644 --- a/README.md +++ b/README.md @@ -1,328 +1,153 @@ -# 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 (`/`), 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: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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//session (AYON-Auth via Bearer) + → Addon prüft Projektzugriff, legt Single-Use-Ticket an (60 s TTL) + ← { url: "https:///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 , 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 — 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: `/` (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 + `@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/.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 `/`; 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://..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 +## Noch offen -- [ ] 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. - ---- - -## Architecture - -``` -┌────────────────────────────────────────────────────────────┐ -│ 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 │ - └──────────┘ └──────────────────┘ -``` - -- **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 `refboard` → `89.57.47.213` (IONOS-Panel, einzeln anlegen) +- danach: `certbot --nginx -d refboard.niklashmotion.art` +- Addon v0.2.0 installieren + Bundle (siehe oben) -- 2.54.0 From f66a4e2d53451790645512d07182cdc9ec5fd333 Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 4 Sep 2026 15:01:01 +0000 Subject: [PATCH 4/5] fix: treat 401/403 from exchange as invalid-ticket (single-use UX) --- backend/routes/auth.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/routes/auth.js b/backend/routes/auth.js index aa315e4..d9ef54e 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -236,8 +236,9 @@ async function redeemTicket(ticket) { } finally { clearTimeout(timer); } - if (resp.status === 404) { - // Ticket unknown or already redeemed (single-use) + 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) { -- 2.54.0 From c00cb94fe7a3425513e085bee3ea176b7eb2a5fc Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 4 Sep 2026 23:42:36 +0000 Subject: [PATCH 5/5] docs+deploy: production status, lessons learned (9 bugs), deploy compose, e2e test tools --- README.md | 30 ++++++++++++++++++++--- deploy/compose.yaml | 45 +++++++++++++++++++++++++++++++++++ tests/README.md | 28 ++++++++++++++++++++++ tests/e2e.sh | 52 ++++++++++++++++++++++++++++++++++++++++ tests/mock-exchange.js | 54 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 deploy/compose.yaml create mode 100644 tests/README.md create mode 100644 tests/e2e.sh create mode 100644 tests/mock-exchange.js diff --git a/README.md b/README.md index 3c0bcd7..99e30b7 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,32 @@ docker build -t refboard-ayon:latest . Der E2E (Ticket-Ausstellung → Exchange → Board-Zugriff → Single-Use → 2. Nutzer am selben Board) ist mit dem Mock grün getestet. +## Status: PRODUKTIV (seit 05.09.2026) + +- 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) + +| # | 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` | + ## Noch offen -- A-Record `refboard` → `89.57.47.213` (IONOS-Panel, einzeln anlegen) -- danach: `certbot --nginx -d refboard.niklashmotion.art` -- Addon v0.2.0 installieren + Bundle (siehe oben) +- ~~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 diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..1ff0054 --- /dev/null +++ b/deploy/compose.yaml @@ -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 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d0a6478 --- /dev/null +++ b/tests/README.md @@ -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= \ + -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`). diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100644 index 0000000..bafb925 --- /dev/null +++ b/tests/e2e.sh @@ -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" diff --git a/tests/mock-exchange.js b/tests/mock-exchange.js new file mode 100644 index 0000000..778e2c6 --- /dev/null +++ b/tests/mock-exchange.js @@ -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')); -- 2.54.0