chore: prepare standalone public repo

- Remove Mattermost integration (OAuth, channel bridge, file sync watcher,
  frontend import modal). RefBoard now ships as a self-contained app.
- Replace SSO Login screen with email/password form (+ optional register link
  gated by ALLOW_SELF_REGISTRATION).
- Add SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD env-var bootstrap so a fresh
  install ships with an admin account on first boot (idempotent).
- ALLOW_SELF_REGISTRATION flag (default false) gates POST /api/auth/register.
  First user can always register (auto-promoted to admin).
- Drop mattermost_id and mm_file_id columns + board_channel_links table
  from the schema; remove related db helpers and exports.
- Add MIT LICENSE, comprehensive README, .env.example, docker-compose.yml
  (bundles MinIO so one command boots a working stack).
- Expand .gitignore for typical Node + Docker dev artefacts.
This commit is contained in:
Hiren Kangad
2026-04-28 19:58:53 +05:30
parent 24fa9d1252
commit 69b58f73f8
15 changed files with 539 additions and 1327 deletions
+58
View File
@@ -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=
+43 -1
View File
@@ -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/
+21
View File
@@ -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.
+187
View File
@@ -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=<run: openssl rand -base64 64>
# SEED_ADMIN_EMAIL=you@example.com
# SEED_ADMIN_PASSWORD=<a long password>
docker compose up --build
```
Then open <http://localhost:8000> and sign in with the seeded admin email / password.
MinIO console (S3 dashboard) is at <http://localhost:9001> — login is whatever you set as `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` in `.env` (defaults to `minioadmin` / `minioadmin`).
Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up.
---
## 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).
+49 -71
View File
@@ -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;
}
// ---------------------
// BoardChannel 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,
// BoardChannel 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,
};
+11 -3
View File
@@ -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,
});
-319
View File
@@ -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;
-185
View File
@@ -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;
+7 -18
View File
@@ -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();
-323
View File
@@ -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 };
+52
View File
@@ -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
@@ -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<LinkedChannel[]>([]);
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 (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 2000,
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => 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 */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '20px 24px 16px', borderBottom: '1px solid #1e1e1e', flexShrink: 0,
}}>
<h2 style={{
margin: 0, fontSize: '16px', fontWeight: 600,
color: '#e0e0e0', letterSpacing: '-0.3px',
}}>
Import from Mattermost
</h2>
<button
onClick={onClose}
style={{
background: 'none', border: '1px solid #333', borderRadius: '6px',
color: '#888', padding: '4px 12px', cursor: 'pointer', fontSize: '11px',
}}
>
ESC
</button>
</div>
{/* Body */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px 24px' }}>
{/* Error / Success */}
{error && (
<div style={{
padding: '8px 12px', background: 'rgba(255,80,80,0.1)',
border: '1px solid rgba(255,80,80,0.2)', borderRadius: '8px',
color: '#ff6b6b', fontSize: '12px', marginBottom: '12px',
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: '8px 12px', background: 'rgba(74,222,128,0.1)',
border: '1px solid rgba(74,222,128,0.2)', borderRadius: '8px',
color: '#4ade80', fontSize: '12px', marginBottom: '12px',
}}>
{success}
</div>
)}
{/* Pull images section */}
<SectionLabel text="Pull Images from Thread" />
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
<input
type="text"
value={url}
onChange={(e) => 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'; }}
/>
<button
onClick={handlePull}
disabled={pulling || !url.trim()}
style={{
padding: '8px 16px',
background: pulling ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
border: 'none', borderRadius: '8px',
color: '#fff', fontSize: '12px', fontWeight: 600,
cursor: pulling ? 'wait' : 'pointer',
opacity: !url.trim() ? 0.5 : 1,
whiteSpace: 'nowrap',
}}
>
{pulling ? 'Pulling...' : 'Pull Images'}
</button>
</div>
{/* Linked channels section */}
<SectionLabel text="Linked Channels" />
<div style={{ marginBottom: '12px' }}>
{loadingChannels ? (
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
Loading...
</div>
) : linkedChannels.length === 0 ? (
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
No linked channels. Link one below for auto-sync.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{linkedChannels.map((ch) => (
<div
key={ch.id}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: '#1a1a1a',
border: '1px solid #2a2a2a', borderRadius: '8px',
}}
>
<div>
<div style={{ fontSize: '13px', color: '#ccc', fontWeight: 500 }}>
{ch.channelName || ch.channelId}
</div>
<div style={{ fontSize: '10px', color: '#555', marginTop: '2px' }}>
Linked {new Date(ch.linkedAt).toLocaleDateString()}
</div>
</div>
<button
onClick={() => handleUnlink(ch.id)}
style={{
padding: '4px 10px', background: 'transparent',
border: '1px solid #333', borderRadius: '6px',
color: '#888', fontSize: '11px', cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff6b6b';
e.currentTarget.style.color = '#ff6b6b';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#333';
e.currentTarget.style.color = '#888';
}}
>
Unlink
</button>
</div>
))}
</div>
)}
</div>
{/* Link new channel */}
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={linkUrl}
onChange={(e) => 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'; }}
/>
<button
onClick={handleLink}
disabled={linking || !linkUrl.trim()}
style={{
padding: '8px 16px', background: '#1a1a1a',
border: '1px solid #333', borderRadius: '8px',
color: '#ccc', fontSize: '12px', fontWeight: 500,
cursor: linking ? 'wait' : 'pointer',
opacity: !linkUrl.trim() ? 0.5 : 1,
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => {
if (linkUrl.trim()) {
e.currentTarget.style.borderColor = '#4a9eff';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#333';
}}
>
{linking ? 'Linking...' : 'Link Channel'}
</button>
</div>
</div>
</div>
</div>
);
}
function SectionLabel({ text }: { text: string }) {
return (
<div style={{
fontSize: '10px', fontWeight: 700, color: '#4a9eff', letterSpacing: '0.8px',
textTransform: 'uppercase', padding: '0 0 8px', marginBottom: '4px',
}}>
{text}
</div>
);
}
-12
View File
@@ -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({
</ActionBtn>
)}
{/* Mattermost import */}
{onMmImport && (
<ActionBtn onClick={onMmImport} title="Import from Mattermost">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M2 10V4a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
<path d="M5 7h4M7 5v4" strokeLinecap="round" />
</svg>
</ActionBtn>
)}
{/* Export */}
{onExport && (
<ActionBtn onClick={onExport} title="Export as image (Ctrl+Shift+E)">
-16
View File
@@ -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) {
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
)}
{/* Mattermost import modal */}
{showMmImport && resolvedBoardId && (
<MattermostImport
boardId={resolvedBoardId}
onClose={() => 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);
+111 -58
View File
@@ -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 (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -50,12 +57,11 @@ export default function Login() {
backgroundImage: 'radial-gradient(ellipse at 50% 0%, rgba(74,158,255,0.08) 0%, transparent 60%)',
}}>
<div style={{
background: '#161616', borderRadius: '16px', padding: '48px 40px',
background: '#161616', borderRadius: '16px', padding: '40px 36px',
width: '100%', maxWidth: '400px',
border: '1px solid #222', boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
}}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
<div style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: '48px', height: '48px', borderRadius: '12px',
@@ -72,39 +78,86 @@ export default function Login() {
<h1 style={{ margin: '0 0 4px', fontSize: '24px', fontWeight: 700, color: '#f0f0f0', letterSpacing: '-0.5px' }}>
RefBoard
</h1>
<p style={{ margin: 0, fontSize: '13px', color: '#555', letterSpacing: '0.2px' }}>
Sign in with your team account
<p style={{ margin: 0, fontSize: '13px', color: '#666' }}>
{mode === 'login' ? 'Sign in to continue' : 'Create your account'}
</p>
</div>
{error && (
<div style={{
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.15)',
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '20px',
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '16px',
fontSize: '13px', lineHeight: '1.4',
}}>
{error}
</div>
)}
<button
onClick={handleMattermostLogin}
style={{
width: '100%', padding: '14px', marginBottom: '0',
background: '#386fe5', color: '#fff', border: 'none', borderRadius: '8px',
fontSize: '15px', fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
transition: 'background 0.2s',
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#2f5fc4')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#386fe5')}
>
<svg width="18" height="18" viewBox="0 0 500 500" fill="currentColor">
<path d="M250 0C111.93 0 0 111.93 0 250s111.93 250 250 250 250-111.93 250-250S388.07 0 250 0zm127.55 354.07c-2.93 5.77-9.18 8.85-15.57 8.85-2.68 0-5.4-.59-7.97-1.85l-72.76-35.72c-19.7 14.88-43.19 22.66-67.58 22.66-6.2 0-12.5-.5-18.73-1.53-24.83-4.07-47.17-16.41-63.38-34.95-16.63-19.03-25.78-43.42-25.78-68.68 0-12.07 2.1-23.86 6.15-35.12.58-1.62 1.54-3.07 2.79-4.22L250.07 91.53c3.08-2.62 7.51-2.62 10.59 0l135.28 112c1.56 1.29 2.65 3.04 3.13 5-.04 11.84-2.08 23.56-6.02 34.7l-72.76-35.72c-7.72-3.79-17.03-.59-20.82 7.13-3.79 7.72-.59 17.03 7.13 20.82l72.76 35.72c-9.28 21.84-26.16 39.95-47.98 50.72l.01-.01 72.76 35.72c7.72 3.79 10.92 13.1 7.13 20.82l.27-.36z"/>
</svg>
Sign in with Mattermost
</button>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
style={inputStyle}
/>
{mode === 'register' && (
<>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
style={inputStyle}
/>
<input
type="text"
placeholder="Display name (optional)"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
style={inputStyle}
/>
</>
)}
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
required
style={inputStyle}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%', padding: '14px', marginTop: '4px',
background: loading ? '#2a4f9a' : '#386fe5', color: '#fff',
border: 'none', borderRadius: '8px',
fontSize: '15px', fontWeight: 600,
cursor: loading ? 'wait' : 'pointer',
transition: 'background 0.2s',
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
}}
>
{loading ? 'Please wait…' : (mode === 'login' ? 'Sign in' : 'Create account')}
</button>
</form>
{ALLOW_REGISTER && (
<div style={{ textAlign: 'center', marginTop: '20px', fontSize: '13px', color: '#666' }}>
{mode === 'login' ? (
<>Need an account? <a href="#" onClick={(e) => { e.preventDefault(); setMode('register'); setError(''); }} style={{ color: '#4a9eff' }}>Register</a></>
) : (
<>Already have an account? <a href="#" onClick={(e) => { e.preventDefault(); setMode('login'); setError(''); }} style={{ color: '#4a9eff' }}>Sign in</a></>
)}
</div>
)}
</div>
</div>
);