diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4316910 --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# ──────────────────────────────────────────────── +# RefBoard — Environment Configuration +# Copy this file to `.env` and edit values for your install. +# ──────────────────────────────────────────────── + +# ---- Server ---- +PORT=8000 +NODE_ENV=development +CORS_ORIGIN=* + +# ---- JWT auth ---- +# REQUIRED in production. Generate with: openssl rand -base64 64 +JWT_SECRET=change-me-to-a-long-random-string +JWT_EXPIRES_IN=7d + +# ---- SQLite database ---- +# Path inside the running process. Defaults to /app/data/refboard.db (Docker). +# For local non-Docker dev, set this to ./data/refboard.db (relative to backend/). +DB_PATH=/app/data/refboard.db + +# ---- Object storage (MinIO / any S3-compatible) ---- +MINIO_ENDPOINT=minio +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=refboard + +# Public-facing base URL for media. Leave empty to serve via the backend proxy +# (recommended for self-hosted single-box installs). +PUBLIC_URL= + +# Max upload size in MB (per file). Defaults to 200. +MAX_FILE_SIZE_MB=200 + +# ---- First-run admin bootstrap ---- +# When the server starts and no user with this email exists, RefBoard will +# create one with role=admin. Idempotent — runs every boot but only seeds once. +# Leave blank to skip seeding. +SEED_ADMIN_EMAIL= +SEED_ADMIN_PASSWORD= +SEED_ADMIN_USERNAME= +SEED_ADMIN_DISPLAY_NAME= + +# ---- Self-registration ---- +# When "true", the public /api/auth/register endpoint accepts new signups. +# When "false" (default), only admins can create accounts (via /api/admin/users). +# The very first user can always register, regardless of this flag, and is +# auto-promoted to admin. +ALLOW_SELF_REGISTRATION=false + +# Frontend mirror — controls whether the Login screen shows the "Register" link. +# Must be set at *build* time (Vite) for the bundled frontend, not at runtime. +VITE_ALLOW_SELF_REGISTRATION=false + +# ---- Optional: API key for bot / programmatic upload ---- +# If set, /api/upload/api-key endpoints require this header value. +REFBOARD_API_KEY= diff --git a/.gitignore b/.gitignore index 448f7a1..e601059 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,48 @@ +# Dependencies node_modules/ +.pnpm-store/ + +# Build output dist/ +build/ +*.tsbuildinfo + +# Environment .env +.env.local +.env.*.local + +# Local data +data/ +*.db +*.db-journal +*.db-wal +*.db-shm + +# Logs *.log -.worktrees/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Test output +coverage/ +.nyc_output/ + +# Temp +*.tmp +.cache/ + +# Internal tooling (not for public repo) .superpowers/ +.claude/ +docs/superpowers/ + +# Docker volumes (local dev) +.docker-data/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63b35eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hiren Kangad + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f92df1c --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# RefBoard + +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. + +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. + +Built because we needed PureRef's painlessness, Miro's collaboration, and a code-review's threading — without paying three different SaaS subscriptions for them. + +[![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 + +**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 endpoints to list/create/deactivate users (UI dashboard coming next) + +**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 +cp .env.example .env + +# Edit .env and at minimum set: +# JWT_SECRET= +# SEED_ADMIN_EMAIL=you@example.com +# SEED_ADMIN_PASSWORD= + +docker compose up --build +``` + +Then open and sign in with the seeded admin email / password. + +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. + +--- + +## Manual install (without Docker) + +Requires Node.js 20+, ffmpeg, and poppler-utils on your `PATH`. 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 \ +JWT_SECRET=$(openssl rand -base64 64) \ +MINIO_ENDPOINT=localhost \ +SEED_ADMIN_EMAIL=you@example.com \ +SEED_ADMIN_PASSWORD=changeme \ +node server.js + +# 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 | +|---|---|---| +| `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 | When `true`, anyone can register. 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. | + +--- + +## 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 + +- [ ] Per-board activity log (who added/deleted what, when) +- [ ] Admin dashboard frontend (backend endpoints already exist at `/api/admin/users`) +- [ ] 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). diff --git a/backend/db.js b/backend/db.js index 98417a5..4bdcf83 100644 --- a/backend/db.js +++ b/backend/db.js @@ -84,18 +84,6 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_boards_created_by ON boards(created_by); CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id); - CREATE TABLE IF NOT EXISTS board_channel_links ( - id TEXT PRIMARY KEY, - board_id TEXT NOT NULL, - channel_id TEXT NOT NULL, - channel_name TEXT, - created_by TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')), - UNIQUE(board_id, channel_id) - ); - - CREATE INDEX IF NOT EXISTS idx_board_channel_links_board ON board_channel_links(board_id); - CREATE TABLE IF NOT EXISTS media_jobs ( id TEXT PRIMARY KEY, image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, @@ -197,11 +185,6 @@ try { } catch { db.exec("ALTER TABLE images ADD COLUMN media_type TEXT DEFAULT 'image'"); } -try { - db.prepare("SELECT mm_file_id FROM images LIMIT 0").get(); -} catch { - db.exec("ALTER TABLE images ADD COLUMN mm_file_id TEXT"); -} try { db.prepare("SELECT poster_asset_key FROM images LIMIT 0").get(); } catch { @@ -218,12 +201,6 @@ try { db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER"); db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER"); } -try { - db.prepare("SELECT mattermost_id FROM users LIMIT 0").get(); -} catch { - db.exec("ALTER TABLE users ADD COLUMN mattermost_id TEXT"); - db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mattermost_id ON users(mattermost_id)"); -} try { db.prepare('SELECT page_count FROM images LIMIT 0').get(); } catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); } @@ -245,14 +222,6 @@ function getUserByUsername(username) { return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username); } -function getUserByMattermostId(mmId) { - return db.prepare('SELECT * FROM users WHERE mattermost_id = ? AND is_active = 1').get(mmId); -} - -function updateUserMattermostId(userId, mmId) { - db.prepare("UPDATE users SET mattermost_id = ?, updated_at = datetime('now') WHERE id = ?").run(mmId, userId); -} - function createUser({ id, email, username, passwordHash, displayName, role }) { db.prepare(` INSERT INTO users (id, email, username, password_hash, display_name, role) @@ -461,11 +430,11 @@ function saveBoardCanvas(boardId, canvasState, thumbnail) { // --------------------- // Images // --------------------- -function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType, mmFileId }) { +function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType }) { db.prepare(` - INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type, mm_file_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image', mmFileId || null); + INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image'); return db.prepare('SELECT * FROM images WHERE id = ?').get(id); } @@ -489,37 +458,6 @@ function deleteBoardImageRecords(boardId) { return images; } -// --------------------- -// Board–Channel Links -// --------------------- -function createBoardChannelLink({ id, boardId, channelId, channelName, createdBy }) { - db.prepare(` - INSERT INTO board_channel_links (id, board_id, channel_id, channel_name, created_by) - VALUES (?, ?, ?, ?, ?) - `).run(id, boardId, channelId, channelName || null, createdBy); - return db.prepare('SELECT * FROM board_channel_links WHERE id = ?').get(id); -} - -function getBoardChannelLinks(boardId) { - return db.prepare('SELECT * FROM board_channel_links WHERE board_id = ? ORDER BY created_at DESC').all(boardId); -} - -function getBoardChannelLink(linkId) { - return db.prepare('SELECT * FROM board_channel_links WHERE id = ?').get(linkId); -} - -function deleteBoardChannelLink(linkId) { - db.prepare('DELETE FROM board_channel_links WHERE id = ?').run(linkId); -} - -function getAllBoardChannelLinks() { - return db.prepare('SELECT * FROM board_channel_links ORDER BY created_at DESC').all(); -} - -function getImageByMmFileId(boardId, mmFileId) { - return db.prepare('SELECT * FROM images WHERE board_id = ? AND mm_file_id = ?').get(boardId, mmFileId); -} - // --------------------- // Media Jobs // --------------------- @@ -698,11 +636,52 @@ function deleteComment(commentId) { db.prepare('DELETE FROM comments WHERE id = ?').run(commentId); } +// --------------------- +// Seed admin from env (idempotent) +// --------------------- +async function seedAdminFromEnv() { + const email = process.env.SEED_ADMIN_EMAIL; + const password = process.env.SEED_ADMIN_PASSWORD; + if (!email || !password) return; + + const existing = getUserByEmail(email); + if (existing) { + if (existing.role !== 'admin') { + db.prepare("UPDATE users SET role = 'admin', updated_at = datetime('now') WHERE id = ?").run(existing.id); + console.log(`[db] Promoted ${email} to admin via SEED_ADMIN_EMAIL.`); + } + return; + } + + const bcrypt = require('bcryptjs'); + const { v4: uuidv4 } = require('uuid'); + const username = (process.env.SEED_ADMIN_USERNAME || email.split('@')[0]).replace(/[^a-zA-Z0-9_-]/g, ''); + const displayName = process.env.SEED_ADMIN_DISPLAY_NAME || username; + const passwordHash = await bcrypt.hash(password, 12); + + // Avoid username collision + let finalUsername = username; + let suffix = 1; + while (getUserByUsername(finalUsername)) { + finalUsername = `${username}${suffix++}`; + } + + createUser({ + id: uuidv4(), + email, + username: finalUsername, + passwordHash, + displayName, + role: 'admin', + }); + console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`); +} + module.exports = { db, // Users - getUserByEmail, getUserById, getUserByUsername, getUserByMattermostId, - createUser, updateUserMattermostId, + getUserByEmail, getUserById, getUserByUsername, + createUser, getAllUsers, updateUserPassword, deactivateUser, getUserCount, // Collections getCollections, getCollection, getCollectionByShareToken, @@ -713,9 +692,6 @@ module.exports = { getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas, // Images createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords, - // Board–Channel Links - createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink, - getAllBoardChannelLinks, getImageByMmFileId, // Media Jobs createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia, // PDF Pages @@ -725,4 +701,6 @@ module.exports = { incrementThreadCommentCount, decrementThreadCommentCount, // Comments getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, + // Bootstrap + seedAdminFromEnv, }; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 73b4ab6..4d3a29c 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -22,7 +22,8 @@ const router = Router(); */ router.post('/register', async (req, res) => { try { - const { email, username, password, display_name } = req.body; + const { email, username, password, display_name, displayName } = req.body; + const dn = display_name || displayName; if (!email || !username || !password) { return res.status(400).json({ error: 'Email, username, and password are required' }); @@ -31,6 +32,14 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } + const allowRegistration = (process.env.ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true'; + const userCount = getUserCount(); + if (!allowRegistration && userCount > 0) { + return res.status(403).json({ + error: 'Self-registration is disabled. Ask an admin to create your account.', + }); + } + const existing = getUserByEmail(email); if (existing) { return res.status(409).json({ error: 'Email already registered' }); @@ -42,7 +51,6 @@ router.post('/register', async (req, res) => { } const passwordHash = await hashPassword(password); - const userCount = getUserCount(); const role = userCount === 0 ? 'admin' : 'member'; const user = createUser({ @@ -50,7 +58,7 @@ router.post('/register', async (req, res) => { email, username, passwordHash, - displayName: display_name || username, + displayName: dn || username, role, }); diff --git a/backend/routes/mattermost-bridge.js b/backend/routes/mattermost-bridge.js deleted file mode 100644 index f5c295e..0000000 --- a/backend/routes/mattermost-bridge.js +++ /dev/null @@ -1,319 +0,0 @@ -const { Router } = require('express'); -const { v4: uuidv4 } = require('uuid'); -const { authMiddleware } = require('../auth'); -const { - getBoard, getCollectionMember, - createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink, - createImage, getImageByMmFileId, -} = require('../db'); -const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); -const sharp = require('sharp'); - -const router = Router(); - -const MM_URL = process.env.MM_URL || 'http://mattermost:8065'; -const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN || ''; - -const IMAGE_MIME_TYPES = [ - 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', -]; -const VIDEO_MIME_TYPES = [ - 'video/mp4', 'video/webm', 'video/quicktime', -]; -const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES]; - -// All routes require auth -router.use(authMiddleware); - -// --------------------- -// Helpers -// --------------------- - -/** - * Check board access for editor+ role. - * Returns board or null (sends error response). - */ -function checkEditorAccess(req, res) { - const board = getBoard(req.params.boardId); - if (!board) { - res.status(404).json({ error: 'Board not found' }); - return null; - } - const member = getCollectionMember(board.collection_id, req.user.id); - const hierarchy = { owner: 3, editor: 2, viewer: 1 }; - if (!member || (hierarchy[member.role] || 0) < 2) { - res.status(403).json({ error: 'Editor or owner access required' }); - return null; - } - return board; -} - -/** - * Check board access for any authenticated member (viewer+). - */ -function checkViewerAccess(req, res) { - const board = getBoard(req.params.boardId); - if (!board) { - res.status(404).json({ error: 'Board not found' }); - return null; - } - const member = getCollectionMember(board.collection_id, req.user.id); - if (!member) { - res.status(403).json({ error: 'Access denied' }); - return null; - } - return board; -} - -/** - * Make authenticated request to Mattermost API. - */ -async function mmFetch(path, options = {}) { - const url = `${MM_URL}/api/v4${path}`; - const resp = await fetch(url, { - ...options, - headers: { - 'Authorization': `Bearer ${MM_BOT_TOKEN}`, - ...options.headers, - }, - }); - if (!resp.ok) { - const body = await resp.text().catch(() => ''); - throw new Error(`Mattermost API error ${resp.status}: ${body}`); - } - return resp; -} - -/** - * Classify MIME type as image or video. - */ -function classifyMedia(mimeType) { - if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video'; - return 'image'; -} - -/** - * Upload a media file (image or video) to MinIO as a single file. - * GPU handles all scaling natively — no LOD tiers needed. - */ -async function uploadMedia(boardId, imageId, buffer, mimetype) { - const ext = MIME_TO_EXT[mimetype] || '.bin'; - const minioPath = `boards/${boardId}/${imageId}${ext}`; - await putBuffer(minioPath, buffer, mimetype); - - let width = null, height = null; - if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) { - try { - const metadata = await sharp(buffer).metadata(); - width = metadata.width || null; - height = metadata.height || null; - } catch {} - } - return { assetKey: minioPath, minioPath, width, height }; -} - -// --------------------- -// Channel Link Routes -// --------------------- - -/** - * POST /api/boards/:boardId/mm-link - * Link a Mattermost channel to a board. - */ -router.post('/:boardId/mm-link', (req, res) => { - try { - const board = checkEditorAccess(req, res); - if (!board) return; - - const { channelId, channelName } = req.body; - if (!channelId) { - return res.status(400).json({ error: 'channelId is required' }); - } - - const link = createBoardChannelLink({ - id: uuidv4(), - boardId: board.id, - channelId, - channelName: channelName || null, - createdBy: req.user.id, - }); - - return res.status(201).json(link); - } catch (err) { - if (err.message && err.message.includes('UNIQUE constraint failed')) { - return res.status(409).json({ error: 'Channel already linked to this board' }); - } - console.error('[mm-bridge] link error:', err); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/boards/:boardId/mm-link - * List linked channels for a board. - */ -router.get('/:boardId/mm-link', (req, res) => { - try { - const board = checkViewerAccess(req, res); - if (!board) return; - - const links = getBoardChannelLinks(board.id); - return res.json({ links }); - } catch (err) { - console.error('[mm-bridge] list links error:', err); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * DELETE /api/boards/:boardId/mm-link/:linkId - * Unlink a channel from a board. - */ -router.delete('/:boardId/mm-link/:linkId', (req, res) => { - try { - const board = checkEditorAccess(req, res); - if (!board) return; - - const link = getBoardChannelLink(req.params.linkId); - if (!link || link.board_id !== board.id) { - return res.status(404).json({ error: 'Link not found' }); - } - - deleteBoardChannelLink(link.id); - return res.json({ ok: true }); - } catch (err) { - console.error('[mm-bridge] unlink error:', err); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -// --------------------- -// Media Pull Route -// --------------------- - -/** - * POST /api/boards/:boardId/mm-pull - * Manual pull media from a Mattermost channel or thread. - */ -router.post('/:boardId/mm-pull', async (req, res) => { - try { - const board = checkEditorAccess(req, res); - if (!board) return; - - if (!MM_BOT_TOKEN) { - return res.status(503).json({ error: 'Mattermost bot token not configured' }); - } - - const { channelId, threadId } = req.body; - if (!channelId && !threadId) { - return res.status(400).json({ error: 'channelId or threadId is required' }); - } - - // 1. Fetch posts from Mattermost - let postsData; - if (threadId) { - const resp = await mmFetch(`/posts/${threadId}/thread`); - postsData = await resp.json(); - } else { - const resp = await mmFetch(`/channels/${channelId}/posts`); - postsData = await resp.json(); - } - - // postsData.order is array of post IDs, postsData.posts is { id: post } - const posts = postsData.posts || {}; - const order = postsData.order || Object.keys(posts); - - // 2. Collect all file_ids from posts - const fileIds = []; - for (const postId of order) { - const post = posts[postId]; - if (post && post.file_ids && post.file_ids.length > 0) { - fileIds.push(...post.file_ids); - } - } - - if (fileIds.length === 0) { - return res.json({ assets: [], message: 'No files found in posts' }); - } - - // 3. Process each file - const assets = []; - const errors = []; - - for (const fileId of fileIds) { - try { - // Skip if already imported (dedup by mm_file_id) - const existing = getImageByMmFileId(board.id, fileId); - if (existing) { - continue; - } - - // Get file info - const infoResp = await mmFetch(`/files/${fileId}/info`); - const fileInfo = await infoResp.json(); - - const mimeType = fileInfo.mime_type || ''; - if (!ALLOWED_MIME_TYPES.includes(mimeType)) { - continue; // skip non-media files - } - - // Download file - const fileResp = await mmFetch(`/files/${fileId}`); - const arrayBuf = await fileResp.arrayBuffer(); - const buffer = Buffer.from(arrayBuf); - - const imageId = uuidv4(); - const mediaType = classifyMedia(mimeType); - - const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType); - - const publicUrl = getImageUrl(minioPath); - - const image = createImage({ - id: imageId, - boardId: board.id, - filename: fileInfo.name || `mm-${fileId}`, - mimeType, - fileSize: buffer.length, - width, - height, - minioPath, - publicUrl, - uploadedBy: req.user.id, - assetKey, - mediaType, - mmFileId: fileId, - }); - - assets.push({ - id: image.id, - url: publicUrl, - public_url: publicUrl, - width: image.width, - height: image.height, - file_size: image.file_size, - mime_type: image.mime_type, - asset_key: image.asset_key, - media_type: image.media_type, - mm_file_id: fileId, - }); - } catch (fileErr) { - console.error(`[mm-bridge] failed to process file ${fileId}:`, fileErr.message); - errors.push({ fileId, error: fileErr.message }); - } - } - - return res.json({ - assets, - total_files: fileIds.length, - imported: assets.length, - skipped: fileIds.length - assets.length - errors.length, - errors: errors.length > 0 ? errors : undefined, - }); - } catch (err) { - console.error('[mm-bridge] pull error:', err); - return res.status(500).json({ error: 'Failed to pull media from Mattermost' }); - } -}); - -module.exports = router; diff --git a/backend/routes/oauth.js b/backend/routes/oauth.js deleted file mode 100644 index 87f1ab7..0000000 --- a/backend/routes/oauth.js +++ /dev/null @@ -1,185 +0,0 @@ -const { Router } = require('express'); -const { v4: uuidv4 } = require('uuid'); -const crypto = require('crypto'); -const { - getUserByEmail, - getUserByUsername, - createUser, - getUserByMattermostId, - updateUserMattermostId, -} = require('../db'); -const { generateToken } = require('../auth'); - -const router = Router(); - -// OAuth config from env -const CLIENT_ID = process.env.MATTERMOST_OAUTH_CLIENT_ID || ''; -const CLIENT_SECRET = process.env.MATTERMOST_OAUTH_CLIENT_SECRET || ''; -const AUTHORIZE_URL = process.env.MATTERMOST_OAUTH_AUTHORIZE_URL || ''; -const TOKEN_URL = process.env.MATTERMOST_OAUTH_TOKEN_URL || ''; -const USERINFO_URL = process.env.MATTERMOST_OAUTH_USERINFO_URL || ''; -const PUBLIC_URL = process.env.PUBLIC_URL || process.env.REFBOARD_PUBLIC_URL || ''; -const CALLBACK_PATH = '/api/auth/mattermost/callback'; - -// CSRF state store: state -> timestamp (expire after 10 min) -const _pendingStates = new Map(); -const STATE_TTL = 10 * 60 * 1000; - -function _cleanupStates() { - const now = Date.now(); - for (const [state, ts] of _pendingStates) { - if (now - ts > STATE_TTL) _pendingStates.delete(state); - } -} - -function isConfigured() { - return !!(CLIENT_ID && CLIENT_SECRET && AUTHORIZE_URL && TOKEN_URL && USERINFO_URL); -} - -/** - * GET /api/auth/mattermost - * Initiates the OAuth flow — redirects browser to Mattermost authorize page. - */ -router.get('/mattermost', (req, res) => { - if (!isConfigured()) { - return res.status(503).json({ error: 'Mattermost OAuth not configured' }); - } - - _cleanupStates(); - const state = crypto.randomBytes(24).toString('hex'); - _pendingStates.set(state, Date.now()); - - const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`; - const params = new URLSearchParams({ - response_type: 'code', - client_id: CLIENT_ID, - redirect_uri: callbackUrl, - state, - }); - - res.redirect(`${AUTHORIZE_URL}?${params.toString()}`); -}); - -/** - * GET /api/auth/mattermost/callback - * Handles the OAuth callback from Mattermost. - */ -router.get('/mattermost/callback', async (req, res) => { - try { - const { code, state, error: oauthError } = req.query; - - if (oauthError) { - console.error('[oauth] Mattermost returned error:', oauthError); - return res.redirect(`/login?error=${encodeURIComponent('Login was denied')}`); - } - - // Validate CSRF state - if (!state || !_pendingStates.has(state)) { - return res.redirect('/login?error=' + encodeURIComponent('Invalid login session. Please try again.')); - } - _pendingStates.delete(state); - - if (!code) { - return res.redirect('/login?error=' + encodeURIComponent('No authorization code received')); - } - - // Exchange code for access token - const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`; - const tokenResp = await fetch(TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code, - redirect_uri: callbackUrl, - }).toString(), - }); - - if (!tokenResp.ok) { - const text = await tokenResp.text(); - console.error('[oauth] Token exchange failed:', tokenResp.status, text); - return res.redirect('/login?error=' + encodeURIComponent('Login failed. Please try again.')); - } - - const tokenData = await tokenResp.json(); - const accessToken = tokenData.access_token; - - // Fetch user info from Mattermost - const userResp = await fetch(USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!userResp.ok) { - console.error('[oauth] Userinfo fetch failed:', userResp.status); - return res.redirect('/login?error=' + encodeURIComponent('Failed to get user info')); - } - - const mmUser = await userResp.json(); - const mmId = mmUser.id; - const mmEmail = mmUser.email; - const mmUsername = mmUser.username; - const mmDisplayName = [mmUser.first_name, mmUser.last_name].filter(Boolean).join(' ') - || mmUser.nickname || mmUsername; - - // Try to find existing RefBoard user - let user = getUserByMattermostId(mmId); - - if (!user) { - // Try matching by email - user = getUserByEmail(mmEmail); - if (user) { - // Link existing account - updateUserMattermostId(user.id, mmId); - } - } - - if (!user) { - // Auto-create new user - // Handle username collision - let finalUsername = mmUsername; - const existingUsername = getUserByUsername(finalUsername); - if (existingUsername) { - finalUsername = `${mmUsername}_mm`; - } - - user = createUser({ - id: uuidv4(), - email: mmEmail, - username: finalUsername, - passwordHash: `oauth:mattermost:${crypto.randomBytes(16).toString('hex')}`, - displayName: mmDisplayName, - role: 'member', - }); - - updateUserMattermostId(user.id, mmId); - console.log(`[oauth] Created RefBoard user for MM user ${mmUsername} (${mmEmail})`); - } - - // Generate JWT and redirect to frontend - const jwt = generateToken(user); - const userPayload = encodeURIComponent(JSON.stringify({ - id: user.id, - email: user.email, - username: user.username, - display_name: user.display_name, - role: user.role, - })); - - res.redirect(`/login?token=${jwt}&user=${userPayload}`); - } catch (err) { - console.error('[oauth] Callback error:', err); - res.redirect('/login?error=' + encodeURIComponent('Something went wrong. Please try again.')); - } -}); - -/** - * GET /api/auth/mattermost/status - * Check if Mattermost OAuth is configured (for frontend to show/hide button). - */ -router.get('/mattermost/status', (_req, res) => { - res.json({ enabled: isConfigured() }); -}); - -module.exports = router; diff --git a/backend/server.js b/backend/server.js index 7c161c4..d6748ef 100644 --- a/backend/server.js +++ b/backend/server.js @@ -95,22 +95,18 @@ app.get('/api/users/search', (req, res) => { // ---- API routes ---- const authRoutes = require('./routes/auth'); -const oauthRoutes = require('./routes/oauth'); const collectionRoutes = require('./routes/collections'); const boardRoutes = require('./routes/boards'); const uploadRoutes = require('./routes/upload'); const adminRoutes = require('./routes/admin'); -const mmBridgeRoutes = require('./routes/mattermost-bridge'); const threadRoutes = require('./routes/threads'); const pdfRoutes = require('./routes/pdf'); app.use('/api/auth', authRoutes); -app.use('/api/auth', oauthRoutes); app.use('/api/collections', collectionRoutes); app.use('/api/boards', boardRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/admin', adminRoutes); -app.use('/api/boards', mmBridgeRoutes); app.use('/api/boards', threadRoutes); app.use('/api/boards', pdfRoutes); @@ -161,9 +157,15 @@ app.set('io', io); // ---- Initialize services and start ---- async function start() { - require('./db'); + const dbModule = require('./db'); console.log('[server] Database initialized'); + try { + await dbModule.seedAdminFromEnv(); + } catch (err) { + console.error('[server] SEED_ADMIN bootstrap failed:', err.message); + } + try { const { initBucket } = require('./minio'); await initBucket(); @@ -173,14 +175,6 @@ async function start() { console.error('[server] Image uploads will not work until MinIO is available'); } - // Start Mattermost auto-sync watcher (no-op if env vars missing) - try { - const { startWatcher } = require('./services/mm-watcher'); - startWatcher(io); - } catch (err) { - console.error('[server] MM watcher failed to start:', err.message); - } - // Start media processing worker try { const { startMediaWorker } = require('./services/media-worker'); @@ -198,11 +192,6 @@ async function start() { function shutdown(signal) { console.log(`[server] Received ${signal}, shutting down gracefully...`); - try { - const { stopWatcher } = require('./services/mm-watcher'); - stopWatcher(); - } catch {} - try { const { stopMediaWorker } = require('./services/media-worker'); stopMediaWorker(); diff --git a/backend/services/mm-watcher.js b/backend/services/mm-watcher.js deleted file mode 100644 index 246a97b..0000000 --- a/backend/services/mm-watcher.js +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Mattermost Auto-Sync Watcher - * - * Polls linked Mattermost channels for new file attachments and - * automatically imports them into the corresponding RefBoard boards. - * - * Requires MM_URL and MM_BOT_TOKEN env vars. Silently skips if not set. - */ -const https = require('https'); -const http = require('http'); -const { URL } = require('url'); -const { v4: uuidv4 } = require('uuid'); -const sharp = require('sharp'); -const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db'); -const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); - -const MM_URL = process.env.MM_URL; -const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN; -const POLL_INTERVAL_MS = parseInt(process.env.MM_WATCHER_INTERVAL || '30000', 10); - -const IMAGE_MIME_TYPES = [ - 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', -]; -const VIDEO_MIME_TYPES = [ - 'video/mp4', 'video/webm', 'video/quicktime', -]; -const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES]; - -// Track last-check timestamp per channel link (in-memory) -const lastCheckMap = new Map(); - -/** - * Make an authenticated GET request to the Mattermost API. - * Returns parsed JSON. - */ -function mmGet(apiPath) { - return new Promise((resolve, reject) => { - const url = new URL(apiPath, MM_URL); - const client = url.protocol === 'https:' ? https : http; - - const req = client.get(url.toString(), { - headers: { Authorization: `Bearer ${MM_BOT_TOKEN}` }, - timeout: 15000, - }, (res) => { - if (res.statusCode !== 200) { - // Drain and reject - res.resume(); - return reject(new Error(`MM API ${apiPath} returned ${res.statusCode}`)); - } - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString())); - } catch (e) { - reject(new Error(`MM API ${apiPath}: invalid JSON`)); - } - }); - res.on('error', reject); - }); - req.on('error', reject); - }); -} - -/** - * Download a file from Mattermost by file ID. - * Returns { buffer, mimeType, filename }. - */ -function mmDownloadFile(fileId) { - return new Promise((resolve, reject) => { - const url = new URL(`/api/v4/files/${fileId}`, MM_URL); - const client = url.protocol === 'https:' ? https : http; - - const req = client.get(url.toString(), { - headers: { Authorization: `Bearer ${MM_BOT_TOKEN}` }, - timeout: 30000, - }, (res) => { - if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { - // Follow redirect - return mmDownloadFileUrl(res.headers.location).then(resolve).catch(reject); - } - if (res.statusCode !== 200) { - res.resume(); - return reject(new Error(`MM file download ${fileId} returned ${res.statusCode}`)); - } - const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); - const chunks = []; - let totalSize = 0; - const MAX_SIZE = MAX_FILE_SIZE; - - res.on('data', (chunk) => { - totalSize += chunk.length; - if (totalSize > MAX_SIZE) { - res.destroy(); - return reject(new Error(`File ${fileId} too large`)); - } - chunks.push(chunk); - }); - res.on('end', () => { - resolve({ buffer: Buffer.concat(chunks), mimeType: contentType }); - }); - res.on('error', reject); - }); - req.on('error', reject); - }); -} - -/** - * Follow a redirect URL for file download. - */ -function mmDownloadFileUrl(downloadUrl) { - return new Promise((resolve, reject) => { - const parsed = new URL(downloadUrl); - const client = parsed.protocol === 'https:' ? https : http; - - const req = client.get(downloadUrl, { timeout: 30000 }, (res) => { - if (res.statusCode !== 200) { - res.resume(); - return reject(new Error(`File redirect download returned ${res.statusCode}`)); - } - const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - resolve({ buffer: Buffer.concat(chunks), mimeType: contentType }); - }); - res.on('error', reject); - }); - req.on('error', reject); - }); -} - -/** - * Get file metadata from Mattermost. - */ -async function mmGetFileInfo(fileId) { - return mmGet(`/api/v4/files/${fileId}/info`); -} - -/** - * Classify MIME type as image or video. - */ -function classifyMedia(mimeType) { - if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video'; - return 'image'; -} - -/** - * Upload a media file (image or video) to MinIO as a single file. - * GPU handles all image scaling natively — no LOD tiers needed. - */ -async function uploadMedia(boardId, imageId, buffer, mimetype) { - const ext = MIME_TO_EXT[mimetype] || '.bin'; - const minioPath = `boards/${boardId}/${imageId}${ext}`; - await putBuffer(minioPath, buffer, mimetype); - - let width = null, height = null; - if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) { - try { - const meta = await sharp(buffer).metadata(); - width = meta.width || null; - height = meta.height || null; - } catch {} - } - return { assetKey: minioPath, minioPath, width, height }; -} - -/** - * Process a single channel link: fetch new posts, import new files. - * Returns array of newly imported assets for socket notification. - */ -async function processLink(link) { - const { board_id: boardId, channel_id: channelId, id: linkId } = link; - - // Verify board still exists - const board = getBoard(boardId); - if (!board) return []; - - const since = lastCheckMap.get(linkId) || Date.now() - POLL_INTERVAL_MS; - lastCheckMap.set(linkId, Date.now()); - - let postsData; - try { - postsData = await mmGet(`/api/v4/channels/${channelId}/posts?since=${since}`); - } catch (err) { - console.error(`[mm-watcher] Failed to fetch posts for channel ${channelId}:`, err.message); - return []; - } - - if (!postsData || !postsData.order || !postsData.posts) return []; - - const newAssets = []; - - for (const postId of postsData.order) { - const post = postsData.posts[postId]; - if (!post || !post.file_ids || post.file_ids.length === 0) continue; - - for (const fileId of post.file_ids) { - try { - // Deduplication check - const existing = getImageByMmFileId(boardId, fileId); - if (existing) continue; - - // Get file info to check MIME type - const fileInfo = await mmGetFileInfo(fileId); - const mimeType = fileInfo.mime_type || 'application/octet-stream'; - - if (!ALLOWED_MIME_TYPES.includes(mimeType)) { - continue; // Skip non-image/video files - } - - // Download the file - const { buffer } = await mmDownloadFile(fileId); - const imageId = uuidv4(); - const mediaType = classifyMedia(mimeType); - - const { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType); - - const publicUrl = getImageUrl(minioPath); - - // Use the link creator as the uploader - const image = createImage({ - id: imageId, - boardId, - filename: fileInfo.name || `mm-${fileId}`, - mimeType, - fileSize: buffer.length, - width, - height, - minioPath, - publicUrl, - uploadedBy: link.created_by, - assetKey, - mediaType, - mmFileId: fileId, - }); - - newAssets.push({ - assetKey: image.asset_key, - name: image.filename, - width: image.width, - height: image.height, - mediaType: image.media_type, - }); - - console.log(`[mm-watcher] Imported file ${fileInfo.name || fileId} → board ${boardId}`); - } catch (err) { - console.error(`[mm-watcher] Failed to import file ${fileId}:`, err.message); - } - } - } - - return newAssets; -} - -/** - * Single poll cycle: process all links. - */ -async function pollOnce(io) { - let links; - try { - links = getAllBoardChannelLinks(); - } catch (err) { - console.error('[mm-watcher] Failed to query links:', err.message); - return; - } - - if (!links || links.length === 0) return; - - for (const link of links) { - try { - const newAssets = await processLink(link); - if (newAssets.length > 0 && io) { - io.to(`board:${link.board_id}`).emit('board:media-arrived', { - boardId: link.board_id, - assets: newAssets, - }); - } - } catch (err) { - console.error(`[mm-watcher] Error processing link ${link.id}:`, err.message); - } - } -} - -let pollTimer = null; - -/** - * Start the Mattermost watcher. Requires Socket.IO server instance. - * Silently does nothing if MM_URL or MM_BOT_TOKEN are not set. - */ -function startWatcher(io) { - if (!MM_URL || !MM_BOT_TOKEN) { - console.log('[mm-watcher] MM_URL or MM_BOT_TOKEN not set — watcher disabled'); - return; - } - - console.log(`[mm-watcher] Starting watcher (interval: ${POLL_INTERVAL_MS}ms)`); - - // Run first poll after a short delay to let the server finish starting - setTimeout(() => { - pollOnce(io).catch(err => console.error('[mm-watcher] Poll error:', err.message)); - }, 5000); - - pollTimer = setInterval(() => { - pollOnce(io).catch(err => console.error('[mm-watcher] Poll error:', err.message)); - }, POLL_INTERVAL_MS); - - // Don't prevent process exit - if (pollTimer.unref) pollTimer.unref(); -} - -/** - * Stop the watcher (for graceful shutdown). - */ -function stopWatcher() { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - console.log('[mm-watcher] Watcher stopped'); - } -} - -module.exports = { startWatcher, stopWatcher }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4568a09 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + minio: + image: minio/minio:latest + container_name: refboard-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web console + volumes: + - ./.docker-data/minio:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + refboard: + build: . + container_name: refboard + depends_on: + minio: + condition: service_healthy + environment: + PORT: 8000 + NODE_ENV: ${NODE_ENV:-production} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d} + DB_PATH: /app/data/refboard.db + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_USE_SSL: "false" + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} + MINIO_BUCKET: ${MINIO_BUCKET:-refboard} + PUBLIC_URL: ${PUBLIC_URL:-} + MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-200} + CORS_ORIGIN: ${CORS_ORIGIN:-*} + SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-} + SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} + SEED_ADMIN_USERNAME: ${SEED_ADMIN_USERNAME:-} + SEED_ADMIN_DISPLAY_NAME: ${SEED_ADMIN_DISPLAY_NAME:-} + ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false} + REFBOARD_API_KEY: ${REFBOARD_API_KEY:-} + ports: + - "8000:8000" + volumes: + - ./.docker-data/refboard:/app/data + restart: unless-stopped diff --git a/frontend/src/components/MattermostImport.tsx b/frontend/src/components/MattermostImport.tsx deleted file mode 100644 index d39c900..0000000 --- a/frontend/src/components/MattermostImport.tsx +++ /dev/null @@ -1,321 +0,0 @@ -/** - * MattermostImport — modal dialog for importing media from Mattermost. - * - * Allows pulling images from a Mattermost thread/channel URL and managing - * linked channels for auto-sync. - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import api from '../api'; - -interface MattermostImportProps { - boardId: string; - onClose: () => void; - onMediaArrived?: (assets: { assetKey: string; w: number; h: number }[]) => void; -} - -interface LinkedChannel { - id: string; - channelName: string; - channelId: string; - linkedAt: string; -} - -export default function MattermostImport({ boardId, onClose, onMediaArrived }: MattermostImportProps) { - const [url, setUrl] = useState(''); - const [pulling, setPulling] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const [linkedChannels, setLinkedChannels] = useState([]); - const [loadingChannels, setLoadingChannels] = useState(false); - const [linkUrl, setLinkUrl] = useState(''); - const [linking, setLinking] = useState(false); - - // Load linked channels on mount - const loadLinkedChannels = useCallback(async () => { - setLoadingChannels(true); - try { - const res = await api.get(`/api/boards/${boardId}/mm-links`); - setLinkedChannels(res.data?.links || []); - } catch { - // Endpoint may not exist yet — silently ignore - setLinkedChannels([]); - } finally { - setLoadingChannels(false); - } - }, [boardId]); - - useEffect(() => { - loadLinkedChannels(); - }, [loadLinkedChannels]); - - // Close on Escape - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') onClose(); - } - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose]); - - // Pull images from URL - const handlePull = async () => { - if (!url.trim()) return; - setPulling(true); - setError(''); - setSuccess(''); - - try { - const res = await api.post(`/api/boards/${boardId}/mm-pull`, { url: url.trim() }); - const assets = res.data?.assets || []; - const count = assets.length; - setSuccess(`Pulled ${count} image${count !== 1 ? 's' : ''}`); - setUrl(''); - - if (count > 0 && onMediaArrived) { - onMediaArrived(assets); - } - - // Auto-close after brief delay on success - if (count > 0) { - setTimeout(onClose, 800); - } - } catch (err: any) { - setError(err.response?.data?.error || 'Failed to pull images'); - } finally { - setPulling(false); - } - }; - - // Link a channel - const handleLink = async () => { - if (!linkUrl.trim()) return; - setLinking(true); - setError(''); - - try { - await api.post(`/api/boards/${boardId}/mm-links`, { url: linkUrl.trim() }); - setLinkUrl(''); - loadLinkedChannels(); - } catch (err: any) { - setError(err.response?.data?.error || 'Failed to link channel'); - } finally { - setLinking(false); - } - }; - - // Unlink a channel - const handleUnlink = async (linkId: string) => { - try { - await api.delete(`/api/boards/${boardId}/mm-links/${linkId}`); - loadLinkedChannels(); - } catch (err: any) { - setError(err.response?.data?.error || 'Failed to unlink channel'); - } - }; - - return ( -
-
e.stopPropagation()} - style={{ - background: '#141414', border: '1px solid #222', borderRadius: '16px', - width: '100%', maxWidth: '480px', maxHeight: '85vh', - overflow: 'hidden', display: 'flex', flexDirection: 'column', - boxShadow: '0 24px 64px rgba(0,0,0,0.6)', - }} - > - {/* Header */} -
-

- Import from Mattermost -

- -
- - {/* Body */} -
- {/* Error / Success */} - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} - - {/* Pull images section */} - -
- setUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handlePull(); }} - placeholder="https://chat.metalfinger.xyz/team/pl/..." - style={{ - flex: 1, padding: '8px 12px', background: '#1a1a1a', - border: '1px solid #333', borderRadius: '8px', - color: '#e0e0e0', fontSize: '13px', outline: 'none', - }} - onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }} - onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }} - /> - -
- - {/* Linked channels section */} - -
- {loadingChannels ? ( -
- Loading... -
- ) : linkedChannels.length === 0 ? ( -
- No linked channels. Link one below for auto-sync. -
- ) : ( -
- {linkedChannels.map((ch) => ( -
-
-
- {ch.channelName || ch.channelId} -
-
- Linked {new Date(ch.linkedAt).toLocaleDateString()} -
-
- -
- ))} -
- )} -
- - {/* Link new channel */} -
- setLinkUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleLink(); }} - placeholder="Channel URL to link..." - style={{ - flex: 1, padding: '8px 12px', background: '#1a1a1a', - border: '1px solid #333', borderRadius: '8px', - color: '#e0e0e0', fontSize: '13px', outline: 'none', - }} - onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }} - onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }} - /> - -
-
-
-
- ); -} - -function SectionLabel({ text }: { text: string }) { - return ( -
- {text} -
- ); -} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 647596c..cc4d704 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -30,7 +30,6 @@ interface ToolbarProps { onToggleLayers?: () => void; showLayers?: boolean; onToggleHelp?: () => void; - onMmImport?: () => void; onExport?: () => void; onRefreshPreview?: () => void; previewRefreshing?: boolean; @@ -165,7 +164,6 @@ export default function Toolbar({ onToggleLayers, showLayers, onToggleHelp, - onMmImport, onExport, onRefreshPreview, previewRefreshing, @@ -330,16 +328,6 @@ export default function Toolbar({ )} - {/* Mattermost import */} - {onMmImport && ( - - - - - - - )} - {/* Export */} {onExport && ( diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 0a9b649..8fc98d1 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -24,7 +24,6 @@ import TextFormatToolbar from '../components/TextFormatToolbar'; import { getStickyWidthForSize } from '../canvas/stickyPresets'; import VideoControls from '../components/VideoControls'; import ShortcutsHelp from '../components/ShortcutsHelp'; -import MattermostImport from '../components/MattermostImport'; import Minimap from '../components/Minimap'; import UploadPanel from '../components/UploadPanel'; import ExportDialog from '../components/ExportDialog'; @@ -118,7 +117,6 @@ export default function Editor({ isPublicView }: EditorProps) { const [showLayers, setShowLayers] = useState(false); const [showGrid, setShowGrid] = useState(true); const [showHelp, setShowHelp] = useState(false); - const [showMmImport, setShowMmImport] = useState(false); const [showExport, setShowExport] = useState(false); const [refreshingPreview, setRefreshingPreview] = useState(false); const [reviewMode, setReviewMode] = useState(false); @@ -1093,7 +1091,6 @@ export default function Editor({ isPublicView }: EditorProps) { onToggleLayers={() => setShowLayers((v) => !v)} showLayers={showLayers} onToggleHelp={() => setShowHelp((v) => !v)} - onMmImport={() => setShowMmImport(true)} onExport={() => setShowExport(true)} onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview} previewRefreshing={refreshingPreview} @@ -1701,19 +1698,6 @@ export default function Editor({ isPublicView }: EditorProps) { setShowHelp(false)} /> )} - {/* Mattermost import modal */} - {showMmImport && resolvedBoardId && ( - setShowMmImport(false)} - onMediaArrived={(assets) => { - if (inboxZoneRef.current) { - inboxZoneRef.current.addMedia(assets); - } - }} - /> - )} - {/* Markdown card portals (read-only previews on canvas) */} {mdCardIds.map(id => { const mountPoint = mdOverlay?.getMountPoint(id); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index f6d9639..48f53a2 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,48 +1,55 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from '../auth'; +import api from '../api'; + +const ALLOW_REGISTER = (import.meta.env.VITE_ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true'; export default function Login() { + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); const { login, user } = useAuth(); - const [searchParams] = useSearchParams(); - // Handle OAuth callback — token + user in URL params - useEffect(() => { - const token = searchParams.get('token'); - const userParam = searchParams.get('user'); - const errorParam = searchParams.get('error'); - - if (errorParam) { - setError(errorParam); - window.history.replaceState({}, '', '/login'); - return; - } - - if (token && userParam) { - try { - const userData = JSON.parse(userParam); - login(token, userData); - navigate('/', { replace: true }); - } catch { - setError('Failed to complete login. Please try again.'); - window.history.replaceState({}, '', '/login'); - } - } - }, [searchParams, login, navigate]); - - // If already logged in, redirect - useEffect(() => { - if (user) { - navigate('/', { replace: true }); - } + React.useEffect(() => { + if (user) navigate('/', { replace: true }); }, [user, navigate]); - function handleMattermostLogin() { - window.location.href = '/api/auth/mattermost'; + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const path = mode === 'login' ? '/api/auth/login' : '/api/auth/register'; + const body = mode === 'login' + ? { email, password } + : { email, username, displayName: displayName || username, password }; + const res = await api.post(path, body); + if (res.data?.token && res.data?.user) { + login(res.data.token, res.data.user); + navigate('/', { replace: true }); + } else { + setError('Unexpected response from server.'); + } + } catch (err: any) { + setError(err?.response?.data?.error || 'Authentication failed.'); + } finally { + setLoading(false); + } } + const inputStyle: React.CSSProperties = { + width: '100%', padding: '12px 14px', marginBottom: '12px', + background: '#0d0d0d', color: '#f0f0f0', + border: '1px solid #2a2a2a', borderRadius: '8px', + fontSize: '14px', boxSizing: 'border-box', + }; + return (
- {/* Logo */} -
+
RefBoard -

- Sign in with your team account +

+ {mode === 'login' ? 'Sign in to continue' : 'Create your account'}

{error && (
{error}
)} - +
+ setEmail(e.target.value)} + autoComplete="email" + required + style={inputStyle} + /> + {mode === 'register' && ( + <> + setUsername(e.target.value)} + autoComplete="username" + required + style={inputStyle} + /> + setDisplayName(e.target.value)} + style={inputStyle} + /> + + )} + setPassword(e.target.value)} + autoComplete={mode === 'login' ? 'current-password' : 'new-password'} + required + style={inputStyle} + /> + +
+ + {ALLOW_REGISTER && ( +
+ )}
);