feat: RefBoard v0.4.0 — collaborative reference board with layers, groups & polished UI
Full-featured PureRef-style collaborative canvas for game dev teams: - Layer panel with visibility, lock, drag reorder, group/ungroup (Ctrl+G/Shift+G) - Arrangement tools (grid, row, column) via right-click context menu - Copy to system clipboard (Ctrl+C writes PNG for external paste in Paint etc.) - Number shortcuts (1-5) for tool selection with visible shortcut badges - Premium dark UI across all pages (Login, Collections, Boards, Editor) - Socket.IO rooms for cursors, transforms, and presence notifications - MinIO image storage with backend proxy, drag/drop and paste upload
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || (() => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('JWT_SECRET environment variable is required in production');
|
||||
}
|
||||
return 'refboard-dev-secret-do-not-use-in-prod';
|
||||
})();
|
||||
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
const REFBOARD_API_KEY = process.env.REFBOARD_API_KEY || '';
|
||||
|
||||
function generateToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
async function comparePassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware: validates Bearer token from Authorization header.
|
||||
* Attaches decoded user to req.user.
|
||||
*/
|
||||
function authMiddleware(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const decoded = verifyToken(token);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware: checks that authenticated user has admin role.
|
||||
* Must be used after authMiddleware.
|
||||
*/
|
||||
function adminMiddleware(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware: validates X-API-Key header against REFBOARD_API_KEY env var.
|
||||
* Used for admin/bot API routes.
|
||||
*/
|
||||
function apiKeyMiddleware(req, res, next) {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (!REFBOARD_API_KEY) {
|
||||
return res.status(503).json({ error: 'API key not configured on server' });
|
||||
}
|
||||
if (!apiKey || apiKey !== REFBOARD_API_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid or missing API key' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
verifyToken,
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
authMiddleware,
|
||||
adminMiddleware,
|
||||
apiKeyMiddleware,
|
||||
JWT_SECRET,
|
||||
};
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/app/data/refboard.db';
|
||||
|
||||
// Ensure directory exists
|
||||
const dbDir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// ---------------------
|
||||
// Schema
|
||||
// ---------------------
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
share_token TEXT UNIQUE,
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_members (
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (collection_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
canvas_state TEXT DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
minio_path TEXT NOT NULL,
|
||||
public_url TEXT,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_created_by ON collections(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_boards_collection ON boards(collection_id);
|
||||
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);
|
||||
`);
|
||||
|
||||
// ---------------------
|
||||
// User helpers
|
||||
// ---------------------
|
||||
function getUserByEmail(email) {
|
||||
return db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email);
|
||||
}
|
||||
|
||||
function getUserById(id) {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(id);
|
||||
}
|
||||
|
||||
function getUserByUsername(username) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
||||
}
|
||||
|
||||
function createUser({ id, email, username, passwordHash, displayName, role }) {
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, username, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, email, username, passwordHash, displayName, role || 'member');
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
function getAllUsers() {
|
||||
return db.prepare('SELECT id, email, username, display_name, role, is_active, created_at, updated_at FROM users').all();
|
||||
}
|
||||
|
||||
function updateUserPassword(userId, passwordHash) {
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(passwordHash, userId);
|
||||
}
|
||||
|
||||
function deactivateUser(userId) {
|
||||
db.prepare("UPDATE users SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(userId);
|
||||
}
|
||||
|
||||
function getUserCount() {
|
||||
return db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Collection helpers
|
||||
// ---------------------
|
||||
function getCollections(userId, search, limit = 50, offset = 0) {
|
||||
let query = `
|
||||
SELECT DISTINCT c.*, cm.role as member_role,
|
||||
(SELECT COUNT(*) FROM boards WHERE collection_id = c.id) as board_count
|
||||
FROM collections c
|
||||
LEFT JOIN collection_members cm ON c.id = cm.collection_id AND cm.user_id = ?
|
||||
WHERE (cm.user_id IS NOT NULL OR c.is_public = 1)
|
||||
`;
|
||||
const params = [userId];
|
||||
|
||||
if (search) {
|
||||
query += ' AND (c.name LIKE ? OR c.description LIKE ?)';
|
||||
const searchTerm = `%${search}%`;
|
||||
params.push(searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.updated_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
return db.prepare(query).all(...params);
|
||||
}
|
||||
|
||||
function getCollection(collectionId) {
|
||||
return db.prepare('SELECT * FROM collections WHERE id = ?').get(collectionId);
|
||||
}
|
||||
|
||||
function getCollectionByShareToken(shareToken) {
|
||||
return db.prepare('SELECT * FROM collections WHERE share_token = ?').get(shareToken);
|
||||
}
|
||||
|
||||
function createCollection({ id, name, description, createdBy }) {
|
||||
db.prepare(`
|
||||
INSERT INTO collections (id, name, description, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(id, name, description || '', createdBy);
|
||||
return getCollection(id);
|
||||
}
|
||||
|
||||
function updateCollection(collectionId, { name, description, isPublic, shareToken }) {
|
||||
const fields = [];
|
||||
const params = [];
|
||||
|
||||
if (name !== undefined) { fields.push('name = ?'); params.push(name); }
|
||||
if (description !== undefined) { fields.push('description = ?'); params.push(description); }
|
||||
if (isPublic !== undefined) { fields.push('is_public = ?'); params.push(isPublic ? 1 : 0); }
|
||||
if (shareToken !== undefined) { fields.push('share_token = ?'); params.push(shareToken); }
|
||||
|
||||
if (fields.length === 0) return getCollection(collectionId);
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
params.push(collectionId);
|
||||
|
||||
db.prepare(`UPDATE collections SET ${fields.join(', ')} WHERE id = ?`).run(...params);
|
||||
return getCollection(collectionId);
|
||||
}
|
||||
|
||||
function deleteCollection(collectionId) {
|
||||
db.prepare('DELETE FROM collections WHERE id = ?').run(collectionId);
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Collection members
|
||||
// ---------------------
|
||||
function getCollectionMembers(collectionId) {
|
||||
return db.prepare(`
|
||||
SELECT cm.collection_id, cm.user_id, cm.role, cm.added_at,
|
||||
u.email, u.username, u.display_name
|
||||
FROM collection_members cm
|
||||
JOIN users u ON cm.user_id = u.id
|
||||
WHERE cm.collection_id = ?
|
||||
`).all(collectionId);
|
||||
}
|
||||
|
||||
function getCollectionMember(collectionId, userId) {
|
||||
return db.prepare('SELECT * FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, userId);
|
||||
}
|
||||
|
||||
function addCollectionMember(collectionId, userId, role = 'editor') {
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO collection_members (collection_id, user_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(collectionId, userId, role);
|
||||
}
|
||||
|
||||
function removeCollectionMember(collectionId, userId) {
|
||||
db.prepare('DELETE FROM collection_members WHERE collection_id = ? AND user_id = ?').run(collectionId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a collection (is member, or collection is public).
|
||||
* Returns the member record or null.
|
||||
*/
|
||||
function checkCollectionAccess(collectionId, userId) {
|
||||
const collection = getCollection(collectionId);
|
||||
if (!collection) return { collection: null, member: null };
|
||||
const member = getCollectionMember(collectionId, userId);
|
||||
if (!member && !collection.is_public) return { collection, member: null };
|
||||
return { collection, member: member || { role: 'viewer' } };
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Board helpers
|
||||
// ---------------------
|
||||
function getCollectionBoards(collectionId, search, limit = 50, offset = 0) {
|
||||
let query = `
|
||||
SELECT b.*,
|
||||
(SELECT COUNT(*) FROM images WHERE board_id = b.id) as image_count
|
||||
FROM boards b
|
||||
WHERE b.collection_id = ?
|
||||
`;
|
||||
const params = [collectionId];
|
||||
|
||||
if (search) {
|
||||
query += ' AND (b.name LIKE ? OR b.description LIKE ?)';
|
||||
const searchTerm = `%${search}%`;
|
||||
params.push(searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
query += ' ORDER BY b.updated_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
return db.prepare(query).all(...params);
|
||||
}
|
||||
|
||||
function getBoard(boardId) {
|
||||
return db.prepare('SELECT * FROM boards WHERE id = ?').get(boardId);
|
||||
}
|
||||
|
||||
function createBoard({ id, collectionId, name, description, createdBy }) {
|
||||
db.prepare(`
|
||||
INSERT INTO boards (id, collection_id, name, description, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, collectionId, name, description || '', createdBy);
|
||||
return getBoard(id);
|
||||
}
|
||||
|
||||
function updateBoard(boardId, { name, description }) {
|
||||
const fields = [];
|
||||
const params = [];
|
||||
|
||||
if (name !== undefined) { fields.push('name = ?'); params.push(name); }
|
||||
if (description !== undefined) { fields.push('description = ?'); params.push(description); }
|
||||
|
||||
if (fields.length === 0) return getBoard(boardId);
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
params.push(boardId);
|
||||
|
||||
db.prepare(`UPDATE boards SET ${fields.join(', ')} WHERE id = ?`).run(...params);
|
||||
return getBoard(boardId);
|
||||
}
|
||||
|
||||
function deleteBoard(boardId) {
|
||||
db.prepare('DELETE FROM boards WHERE id = ?').run(boardId);
|
||||
}
|
||||
|
||||
function saveBoardCanvas(boardId, canvasState) {
|
||||
db.prepare("UPDATE boards SET canvas_state = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState), boardId);
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Images
|
||||
// ---------------------
|
||||
function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy }) {
|
||||
db.prepare(`
|
||||
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy);
|
||||
return db.prepare('SELECT * FROM images WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function getBoardImages(boardId) {
|
||||
return db.prepare('SELECT * FROM images WHERE board_id = ? ORDER BY created_at DESC').all(boardId);
|
||||
}
|
||||
|
||||
function getImage(imageId) {
|
||||
return db.prepare('SELECT * FROM images WHERE id = ?').get(imageId);
|
||||
}
|
||||
|
||||
function deleteImage(imageId) {
|
||||
const image = getImage(imageId);
|
||||
db.prepare('DELETE FROM images WHERE id = ?').run(imageId);
|
||||
return image;
|
||||
}
|
||||
|
||||
function deleteBoardImageRecords(boardId) {
|
||||
const images = getBoardImages(boardId);
|
||||
db.prepare('DELETE FROM images WHERE board_id = ?').run(boardId);
|
||||
return images;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
// Users
|
||||
getUserByEmail, getUserById, getUserByUsername, createUser,
|
||||
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
||||
// Collections
|
||||
getCollections, getCollection, getCollectionByShareToken,
|
||||
createCollection, updateCollection, deleteCollection,
|
||||
getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember,
|
||||
checkCollectionAccess,
|
||||
// Boards
|
||||
getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas,
|
||||
// Images
|
||||
createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
const Minio = require('minio');
|
||||
const path = require('path');
|
||||
|
||||
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'localhost';
|
||||
const MINIO_PORT = parseInt(process.env.MINIO_PORT || '9000', 10);
|
||||
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin';
|
||||
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'minioadmin';
|
||||
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
|
||||
const MINIO_BUCKET = process.env.MINIO_BUCKET || 'refboard';
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL || `http://${MINIO_ENDPOINT}:${MINIO_PORT}`;
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: MINIO_ENDPOINT,
|
||||
port: MINIO_PORT,
|
||||
useSSL: MINIO_USE_SSL,
|
||||
accessKey: MINIO_ACCESS_KEY,
|
||||
secretKey: MINIO_SECRET_KEY,
|
||||
});
|
||||
|
||||
const MIME_TO_EXT = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the bucket if it doesn't already exist.
|
||||
* Sets a public read policy so images are accessible via URL.
|
||||
*/
|
||||
async function initBucket() {
|
||||
const exists = await minioClient.bucketExists(MINIO_BUCKET);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(MINIO_BUCKET, '');
|
||||
// Set public read policy
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${MINIO_BUCKET}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
await minioClient.setBucketPolicy(MINIO_BUCKET, JSON.stringify(policy));
|
||||
}
|
||||
console.log(`[minio] Bucket "${MINIO_BUCKET}" ready`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image buffer to MinIO.
|
||||
* Returns the minio object path (used as key for future operations).
|
||||
*/
|
||||
async function uploadImage(boardId, imageId, buffer, mimeType) {
|
||||
const ext = MIME_TO_EXT[mimeType] || '.bin';
|
||||
const objectName = `boards/${boardId}/${imageId}${ext}`;
|
||||
|
||||
await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, {
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
|
||||
return objectName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single object by its minio path.
|
||||
*/
|
||||
async function deleteImage(minioPath) {
|
||||
await minioClient.removeObject(MINIO_BUCKET, minioPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all objects under the boards/{boardId}/ prefix.
|
||||
*/
|
||||
async function deleteBoardImages(boardId) {
|
||||
const prefix = `boards/${boardId}/`;
|
||||
const objectsList = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = minioClient.listObjectsV2(MINIO_BUCKET, prefix, true);
|
||||
stream.on('data', (obj) => {
|
||||
objectsList.push(obj.name);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', async () => {
|
||||
if (objectsList.length > 0) {
|
||||
await minioClient.removeObjects(MINIO_BUCKET, objectsList);
|
||||
}
|
||||
resolve(objectsList.length);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public URL for an image given its minio path.
|
||||
*/
|
||||
function getImageUrl(minioPath) {
|
||||
// Return relative URL — images served via backend proxy at /api/images/*
|
||||
return `/api/images/${minioPath}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
minioClient,
|
||||
initBucket,
|
||||
uploadImage,
|
||||
deleteImage,
|
||||
deleteBoardImages,
|
||||
getImageUrl,
|
||||
MINIO_BUCKET,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "refboard-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "RefBoard — collaborative reference board backend",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.33.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
const { Router } = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { apiKeyMiddleware, hashPassword } = require('../auth');
|
||||
const {
|
||||
createUser,
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
deactivateUser,
|
||||
updateUserPassword,
|
||||
createCollection,
|
||||
getCollection,
|
||||
updateCollection,
|
||||
addCollectionMember,
|
||||
createBoard,
|
||||
} = require('../db');
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(apiKeyMiddleware);
|
||||
|
||||
// ---- User management ----
|
||||
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const { email, username, password, display_name, role } = req.body;
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: 'Email, username, and password are required' });
|
||||
}
|
||||
|
||||
const existing = getUserByEmail(email);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({
|
||||
id: uuidv4(),
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
displayName: display_name || username,
|
||||
role: role || 'member',
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[admin] create user error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', (req, res) => {
|
||||
try {
|
||||
const users = getAllUsers();
|
||||
return res.json({ users });
|
||||
} catch (err) {
|
||||
console.error('[admin] list users error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:userId', (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.params.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
deactivateUser(req.params.userId);
|
||||
return res.json({ message: 'User deactivated' });
|
||||
} catch (err) {
|
||||
console.error('[admin] deactivate user error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/:userId/password', async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body;
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: 'Password is required' });
|
||||
}
|
||||
|
||||
const user = getUserById(req.params.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
updateUserPassword(req.params.userId, passwordHash);
|
||||
|
||||
return res.json({ message: 'Password reset successfully' });
|
||||
} catch (err) {
|
||||
console.error('[admin] reset password error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Collection management ----
|
||||
|
||||
router.post('/collections', (req, res) => {
|
||||
try {
|
||||
const { name, description, user_id } = req.body;
|
||||
if (!name || !user_id) {
|
||||
return res.status(400).json({ error: 'Collection name and user_id are required' });
|
||||
}
|
||||
|
||||
const user = getUserById(user_id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const collectionId = uuidv4();
|
||||
const collection = createCollection({
|
||||
id: collectionId,
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
createdBy: user_id,
|
||||
});
|
||||
|
||||
addCollectionMember(collectionId, user_id, 'owner');
|
||||
|
||||
return res.status(201).json({ collection });
|
||||
} catch (err) {
|
||||
console.error('[admin] create collection error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/collections/:collectionId/share', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
let shareToken = collection.share_token;
|
||||
if (!shareToken) {
|
||||
shareToken = uuidv4();
|
||||
}
|
||||
|
||||
const updated = updateCollection(collection.id, {
|
||||
isPublic: true,
|
||||
shareToken,
|
||||
});
|
||||
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL || '';
|
||||
return res.json({
|
||||
is_public: true,
|
||||
share_token: updated.share_token,
|
||||
share_url: `${PUBLIC_URL}/c/${updated.share_token}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[admin] share collection error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Board management (create board in collection) ----
|
||||
|
||||
router.post('/boards', (req, res) => {
|
||||
try {
|
||||
const { collection_id, name, description, user_id } = req.body;
|
||||
if (!collection_id || !name || !user_id) {
|
||||
return res.status(400).json({ error: 'collection_id, name, and user_id are required' });
|
||||
}
|
||||
|
||||
const user = getUserById(user_id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const collection = getCollection(collection_id);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const board = createBoard({
|
||||
id: uuidv4(),
|
||||
collectionId: collection_id,
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
createdBy: user_id,
|
||||
});
|
||||
|
||||
return res.status(201).json({ board });
|
||||
} catch (err) {
|
||||
console.error('[admin] create board error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,178 @@
|
||||
const { Router } = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
createUser,
|
||||
getUserById,
|
||||
getUserCount,
|
||||
} = require('../db');
|
||||
const {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
authMiddleware,
|
||||
} = require('../auth');
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Create a new user. The first user automatically becomes admin.
|
||||
*/
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, username, password, display_name } = req.body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: 'Email, username, and password are required' });
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const existing = getUserByEmail(email);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const existingUsername = getUserByUsername(username);
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username already taken' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const userCount = getUserCount();
|
||||
const role = userCount === 0 ? 'admin' : 'member';
|
||||
|
||||
const user = createUser({
|
||||
id: uuidv4(),
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
displayName: display_name || username,
|
||||
role,
|
||||
});
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
return res.status(201).json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[auth] register error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate with email + password, receive JWT.
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await comparePassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[auth] login error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Return the current authenticated user.
|
||||
*/
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[auth] me error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password
|
||||
* Change password (requires current password).
|
||||
*/
|
||||
router.put('/password', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { current_password, new_password } = req.body;
|
||||
|
||||
if (!current_password || !new_password) {
|
||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||
}
|
||||
if (new_password.length < 6) {
|
||||
return res.status(400).json({ error: 'New password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const user = getUserById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const valid = await comparePassword(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
const { updateUserPassword } = require('../db');
|
||||
const passwordHash = await hashPassword(new_password);
|
||||
updateUserPassword(user.id, passwordHash);
|
||||
|
||||
return res.json({ message: 'Password updated successfully' });
|
||||
} catch (err) {
|
||||
console.error('[auth] password change error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,186 @@
|
||||
const { Router } = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { authMiddleware } = require('../auth');
|
||||
const {
|
||||
getBoard,
|
||||
createBoard,
|
||||
updateBoard,
|
||||
deleteBoard,
|
||||
saveBoardCanvas,
|
||||
getBoardImages,
|
||||
getCollection,
|
||||
getCollectionMember,
|
||||
} = require('../db');
|
||||
const { deleteBoardImages: deleteBoardMinioImages } = require('../minio');
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
function hasCollectionRole(member, minRole) {
|
||||
if (!member) return false;
|
||||
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
|
||||
return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve board + check collection access. Returns { board, member } or sends error.
|
||||
*/
|
||||
function resolveBoard(req, res, minRole = 'viewer') {
|
||||
const board = getBoard(req.params.boardId);
|
||||
if (!board) {
|
||||
res.status(404).json({ error: 'Board not found' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = getCollection(board.collection_id);
|
||||
if (!collection) {
|
||||
res.status(404).json({ error: 'Collection not found' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const member = getCollectionMember(board.collection_id, req.user.id);
|
||||
// Allow access if member has sufficient role, or collection is public (viewer-level)
|
||||
if (minRole === 'viewer' && collection.is_public) {
|
||||
return { board, collection, member: member || { role: 'viewer' } };
|
||||
}
|
||||
if (!hasCollectionRole(member, minRole)) {
|
||||
res.status(403).json({ error: `${minRole} access required` });
|
||||
return null;
|
||||
}
|
||||
|
||||
return { board, collection, member };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/boards
|
||||
* Create a board inside a collection. Editor+ on the collection.
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { collection_id, name, description } = req.body;
|
||||
if (!collection_id || !name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'collection_id and name are required' });
|
||||
}
|
||||
|
||||
const collection = getCollection(collection_id);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection_id, req.user.id);
|
||||
if (!hasCollectionRole(member, 'editor')) {
|
||||
return res.status(403).json({ error: 'Editor access required on collection' });
|
||||
}
|
||||
|
||||
const board = createBoard({
|
||||
id: uuidv4(),
|
||||
collectionId: collection_id,
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
createdBy: req.user.id,
|
||||
});
|
||||
|
||||
return res.status(201).json({ board });
|
||||
} catch (err) {
|
||||
console.error('[boards] create error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/boards/:boardId
|
||||
* Get board with canvas_state and images. Viewer+ on collection.
|
||||
*/
|
||||
router.get('/:boardId', (req, res) => {
|
||||
try {
|
||||
const result = resolveBoard(req, res, 'viewer');
|
||||
if (!result) return;
|
||||
|
||||
const { board, collection } = result;
|
||||
const images = getBoardImages(board.id);
|
||||
|
||||
return res.json({
|
||||
board: {
|
||||
...board,
|
||||
canvas_state: board.canvas_state ? JSON.parse(board.canvas_state) : {},
|
||||
},
|
||||
collection: {
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
},
|
||||
images,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[boards] get error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/boards/:boardId
|
||||
* Update board name/description. Editor+ on collection.
|
||||
*/
|
||||
router.put('/:boardId', (req, res) => {
|
||||
try {
|
||||
const result = resolveBoard(req, res, 'editor');
|
||||
if (!result) return;
|
||||
|
||||
const { name, description } = req.body;
|
||||
const updated = updateBoard(result.board.id, { name, description });
|
||||
|
||||
return res.json({ board: updated });
|
||||
} catch (err) {
|
||||
console.error('[boards] update error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/boards/:boardId
|
||||
* Delete board + images. Owner on collection.
|
||||
*/
|
||||
router.delete('/:boardId', async (req, res) => {
|
||||
try {
|
||||
const result = resolveBoard(req, res, 'owner');
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
await deleteBoardMinioImages(result.board.id);
|
||||
} catch (e) {
|
||||
console.error('[boards] MinIO cleanup error:', e);
|
||||
}
|
||||
|
||||
deleteBoard(result.board.id);
|
||||
|
||||
return res.json({ message: 'Board deleted' });
|
||||
} catch (err) {
|
||||
console.error('[boards] delete error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/boards/:boardId/save
|
||||
* Save canvas state. Editor+ on collection.
|
||||
*/
|
||||
router.post('/:boardId/save', (req, res) => {
|
||||
try {
|
||||
const result = resolveBoard(req, res, 'editor');
|
||||
if (!result) return;
|
||||
|
||||
const { canvas_state } = req.body;
|
||||
if (canvas_state === undefined) {
|
||||
return res.status(400).json({ error: 'canvas_state is required' });
|
||||
}
|
||||
|
||||
saveBoardCanvas(result.board.id, canvas_state);
|
||||
|
||||
return res.json({ message: 'Canvas saved' });
|
||||
} catch (err) {
|
||||
console.error('[boards] save error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,312 @@
|
||||
const { Router } = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { authMiddleware } = require('../auth');
|
||||
const {
|
||||
getCollections,
|
||||
getCollection,
|
||||
createCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
getCollectionMembers,
|
||||
getCollectionMember,
|
||||
addCollectionMember,
|
||||
removeCollectionMember,
|
||||
getCollectionBoards,
|
||||
getUserByEmail,
|
||||
} = require('../db');
|
||||
const { deleteBoardImages: deleteBoardMinioImages } = require('../minio');
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
function hasRole(member, minRole) {
|
||||
if (!member) return false;
|
||||
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
|
||||
return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/collections
|
||||
* List collections the user has access to.
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const { search, limit, offset } = req.query;
|
||||
const collections = getCollections(
|
||||
req.user.id,
|
||||
search || null,
|
||||
parseInt(limit, 10) || 50,
|
||||
parseInt(offset, 10) || 0
|
||||
);
|
||||
return res.json({ collections });
|
||||
} catch (err) {
|
||||
console.error('[collections] list error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/collections
|
||||
* Create a new collection. Creator becomes owner.
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Collection name is required' });
|
||||
}
|
||||
|
||||
const collectionId = uuidv4();
|
||||
const collection = createCollection({
|
||||
id: collectionId,
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
createdBy: req.user.id,
|
||||
});
|
||||
|
||||
addCollectionMember(collectionId, req.user.id, 'owner');
|
||||
|
||||
return res.status(201).json({ collection });
|
||||
} catch (err) {
|
||||
console.error('[collections] create error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/collections/:collectionId
|
||||
* Get collection with boards and members.
|
||||
*/
|
||||
router.get('/:collectionId', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!member && !collection.is_public) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const members = getCollectionMembers(collection.id);
|
||||
const boards = getCollectionBoards(collection.id);
|
||||
|
||||
return res.json({ collection, members, boards });
|
||||
} catch (err) {
|
||||
console.error('[collections] get error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/collections/:collectionId
|
||||
* Update collection. Editor+ only.
|
||||
*/
|
||||
router.put('/:collectionId', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!hasRole(member, 'editor')) {
|
||||
return res.status(403).json({ error: 'Editor or owner access required' });
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
const updated = updateCollection(collection.id, { name, description });
|
||||
|
||||
return res.json({ collection: updated });
|
||||
} catch (err) {
|
||||
console.error('[collections] update error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/collections/:collectionId
|
||||
* Delete collection and all boards + images. Owner only.
|
||||
*/
|
||||
router.delete('/:collectionId', async (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!hasRole(member, 'owner')) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
|
||||
// Delete all board images from MinIO
|
||||
const boards = getCollectionBoards(collection.id);
|
||||
for (const board of boards) {
|
||||
try {
|
||||
await deleteBoardMinioImages(board.id);
|
||||
} catch (e) {
|
||||
console.error(`[collections] MinIO cleanup error for board ${board.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cascade deletes boards, images via FK
|
||||
deleteCollection(collection.id);
|
||||
|
||||
return res.json({ message: 'Collection deleted' });
|
||||
} catch (err) {
|
||||
console.error('[collections] delete error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/collections/:collectionId/share
|
||||
* Get share info.
|
||||
*/
|
||||
router.get('/:collectionId/share', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!member) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL || '';
|
||||
return res.json({
|
||||
is_public: !!collection.is_public,
|
||||
share_token: collection.share_token,
|
||||
share_url: collection.share_token
|
||||
? `${PUBLIC_URL}/c/${collection.share_token}`
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[collections] share info error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/collections/:collectionId/share
|
||||
* Toggle public sharing. Owner only.
|
||||
*/
|
||||
router.post('/:collectionId/share', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!hasRole(member, 'owner')) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
|
||||
const { is_public } = req.body;
|
||||
const makePublic = is_public !== undefined ? !!is_public : !collection.is_public;
|
||||
|
||||
let shareToken = collection.share_token;
|
||||
if (makePublic && !shareToken) {
|
||||
shareToken = uuidv4();
|
||||
}
|
||||
|
||||
const updated = updateCollection(collection.id, {
|
||||
isPublic: makePublic,
|
||||
shareToken: makePublic ? shareToken : collection.share_token,
|
||||
});
|
||||
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL || '';
|
||||
return res.json({
|
||||
is_public: !!updated.is_public,
|
||||
share_token: updated.share_token,
|
||||
share_url: updated.share_token
|
||||
? `${PUBLIC_URL}/c/${updated.share_token}`
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[collections] share toggle error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/collections/:collectionId/members
|
||||
* Add a member. Owner only.
|
||||
*/
|
||||
router.post('/:collectionId/members', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!hasRole(member, 'owner')) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
|
||||
const { email, role } = req.body;
|
||||
const validRoles = ['viewer', 'editor'];
|
||||
const memberRole = validRoles.includes(role) ? role : 'editor';
|
||||
|
||||
const targetUser = getUserByEmail(email);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
if (targetUser.id === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot add yourself' });
|
||||
}
|
||||
|
||||
addCollectionMember(collection.id, targetUser.id, memberRole);
|
||||
|
||||
return res.status(201).json({
|
||||
member: {
|
||||
collection_id: collection.id,
|
||||
user_id: targetUser.id,
|
||||
email: targetUser.email,
|
||||
display_name: targetUser.display_name,
|
||||
role: memberRole,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[collections] add member error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/collections/:collectionId/members/:userId
|
||||
* Remove a member. Owner only.
|
||||
*/
|
||||
router.delete('/:collectionId/members/:userId', (req, res) => {
|
||||
try {
|
||||
const collection = getCollection(req.params.collectionId);
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const member = getCollectionMember(collection.id, req.user.id);
|
||||
if (!hasRole(member, 'owner')) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
|
||||
if (req.params.userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot remove yourself' });
|
||||
}
|
||||
|
||||
removeCollectionMember(collection.id, req.params.userId);
|
||||
|
||||
return res.json({ message: 'Member removed' });
|
||||
} catch (err) {
|
||||
console.error('[collections] remove member error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,248 @@
|
||||
const { Router } = require('express');
|
||||
const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
const { authMiddleware } = require('../auth');
|
||||
const { getBoard, getCollectionMember, createImage } = require('../db');
|
||||
const { uploadImage, getImageUrl } = require('../minio');
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// Multer config: memory storage, 50MB limit, image types only
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Unsupported file type: ${file.mimetype}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// All routes require auth
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* Helper: check board access for editor+
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image dimensions using sharp. Returns { width, height } or null for SVG.
|
||||
*/
|
||||
async function getImageDimensions(buffer, mimeType) {
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
return { width: null, height: null };
|
||||
}
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
return { width: metadata.width || null, height: metadata.height || null };
|
||||
} catch {
|
||||
return { width: null, height: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/upload/boards/:boardId/images
|
||||
* Upload an image file to a board.
|
||||
*/
|
||||
router.post('/boards/:boardId/images', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const board = checkEditorAccess(req, res);
|
||||
if (!board) return;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image file provided' });
|
||||
}
|
||||
|
||||
const imageId = uuidv4();
|
||||
const { buffer, originalname, mimetype, size } = req.file;
|
||||
|
||||
// Get dimensions
|
||||
const { width, height } = await getImageDimensions(buffer, mimetype);
|
||||
|
||||
// Upload to MinIO
|
||||
const minioPath = await uploadImage(board.id, imageId, buffer, mimetype);
|
||||
const publicUrl = getImageUrl(minioPath);
|
||||
|
||||
// Save record
|
||||
const image = createImage({
|
||||
id: imageId,
|
||||
boardId: board.id,
|
||||
filename: originalname,
|
||||
mimeType: mimetype,
|
||||
fileSize: size,
|
||||
width,
|
||||
height,
|
||||
minioPath,
|
||||
publicUrl,
|
||||
uploadedBy: req.user.id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
id: image.id,
|
||||
url: publicUrl,
|
||||
public_url: publicUrl,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
file_size: image.file_size,
|
||||
mime_type: image.mime_type,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'File too large (max 50MB)' });
|
||||
}
|
||||
console.error('[upload] error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Download an image from a URL. Returns { buffer, mimeType, filename }.
|
||||
*/
|
||||
function downloadImage(imageUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(imageUrl);
|
||||
const client = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
client.get(imageUrl, { timeout: 30000 }, (response) => {
|
||||
// Follow redirects (up to 5)
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
||||
return downloadImage(response.headers.location).then(resolve).catch(reject);
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
|
||||
}
|
||||
|
||||
const contentType = (response.headers['content-type'] || '').split(';')[0].trim();
|
||||
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
|
||||
return reject(new Error(`Unsupported content type: ${contentType}`));
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_FILE_SIZE) {
|
||||
response.destroy();
|
||||
return reject(new Error('Downloaded file too large (max 50MB)'));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const filename = parsed.pathname.split('/').pop() || 'image';
|
||||
resolve({ buffer, mimeType: contentType, filename });
|
||||
});
|
||||
|
||||
response.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/upload/boards/:boardId/images/from-url
|
||||
* Download an image from a URL and store it.
|
||||
*/
|
||||
router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
||||
try {
|
||||
const board = checkEditorAccess(req, res);
|
||||
if (!board) return;
|
||||
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Download image
|
||||
const { buffer, mimeType, filename } = await downloadImage(url);
|
||||
|
||||
const imageId = uuidv4();
|
||||
|
||||
// Get dimensions
|
||||
const { width, height } = await getImageDimensions(buffer, mimeType);
|
||||
|
||||
// Upload to MinIO
|
||||
const minioPath = await uploadImage(board.id, imageId, buffer, mimeType);
|
||||
const publicUrl = getImageUrl(minioPath);
|
||||
|
||||
// Save record
|
||||
const image = createImage({
|
||||
id: imageId,
|
||||
boardId: board.id,
|
||||
filename,
|
||||
mimeType,
|
||||
fileSize: buffer.length,
|
||||
width,
|
||||
height,
|
||||
minioPath,
|
||||
publicUrl,
|
||||
uploadedBy: req.user.id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
id: image.id,
|
||||
url: publicUrl,
|
||||
public_url: publicUrl,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
file_size: image.file_size,
|
||||
mime_type: image.mime_type,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[upload] from-url error:', err);
|
||||
const message = err.message.includes('Unsupported') || err.message.includes('too large')
|
||||
? err.message
|
||||
: 'Failed to download image from URL';
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle multer errors
|
||||
router.use((err, req, res, next) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'File too large (max 50MB)' });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (err.message && err.message.includes('Unsupported file type')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,178 @@
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
// ---- Load environment ----
|
||||
const PORT = parseInt(process.env.PORT || '8000', 10);
|
||||
|
||||
// ---- Express app ----
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// ---- Health check ----
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// ---- Image proxy (MinIO → browser) ----
|
||||
app.get('/api/images/*', async (req, res) => {
|
||||
try {
|
||||
const objectPath = req.params[0]; // everything after /api/images/
|
||||
if (!objectPath) return res.status(400).json({ error: 'Missing path' });
|
||||
const { minioClient, MINIO_BUCKET } = require('./minio');
|
||||
const stat = await minioClient.statObject(MINIO_BUCKET, objectPath);
|
||||
if (stat.metaData?.['content-type']) {
|
||||
res.setHeader('Content-Type', stat.metaData['content-type']);
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
const stream = await minioClient.getObject(MINIO_BUCKET, objectPath);
|
||||
stream.pipe(res);
|
||||
} catch (err) {
|
||||
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
|
||||
return res.status(404).json({ error: 'Image not found' });
|
||||
}
|
||||
console.error('[server] image proxy error:', err);
|
||||
return res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- User search (authenticated) ----
|
||||
app.get('/api/users/search', (req, res) => {
|
||||
try {
|
||||
const { authMiddleware } = require('./auth');
|
||||
authMiddleware(req, res, () => {
|
||||
const { q } = req.query;
|
||||
if (!q || q.length < 1) return res.json({ users: [] });
|
||||
const { getAllUsers } = require('./db');
|
||||
const all = getAllUsers();
|
||||
const query = q.toLowerCase();
|
||||
const matched = all
|
||||
.filter(u => u.is_active &&
|
||||
(u.email.toLowerCase().includes(query) ||
|
||||
u.username.toLowerCase().includes(query) ||
|
||||
(u.display_name || '').toLowerCase().includes(query)))
|
||||
.slice(0, 10)
|
||||
.map(u => ({ id: u.id, email: u.email, username: u.username, display_name: u.display_name }));
|
||||
return res.json({ users: matched });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[server] user search error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- API routes ----
|
||||
const authRoutes = require('./routes/auth');
|
||||
const collectionRoutes = require('./routes/collections');
|
||||
const boardRoutes = require('./routes/boards');
|
||||
const uploadRoutes = require('./routes/upload');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/collections', collectionRoutes);
|
||||
app.use('/api/boards', boardRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Public shared collection route (no auth required)
|
||||
app.get('/api/c/:shareToken', (req, res) => {
|
||||
try {
|
||||
const { getCollectionByShareToken, getCollectionBoards } = require('./db');
|
||||
const collection = getCollectionByShareToken(req.params.shareToken);
|
||||
if (!collection || !collection.is_public) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
const boards = getCollectionBoards(collection.id);
|
||||
return res.json({ collection, boards });
|
||||
} catch (err) {
|
||||
console.error('[server] shared collection error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Static frontend files ----
|
||||
const frontendDist = path.join(__dirname, '..', 'frontend', 'dist');
|
||||
app.use(express.static(frontendDist));
|
||||
|
||||
// SPA fallback — send index.html for non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).json({ error: 'API endpoint not found' });
|
||||
}
|
||||
res.sendFile(path.join(frontendDist, 'index.html'), (err) => {
|
||||
if (err) {
|
||||
res.status(404).json({ error: 'Frontend not built yet' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Global error handler ----
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[server] Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// ---- Create HTTP server + Socket.IO ----
|
||||
const server = http.createServer(app);
|
||||
|
||||
const { setupSocket } = require('./socket');
|
||||
const io = setupSocket(server);
|
||||
|
||||
// ---- Initialize services and start ----
|
||||
async function start() {
|
||||
require('./db');
|
||||
console.log('[server] Database initialized');
|
||||
|
||||
try {
|
||||
const { initBucket } = require('./minio');
|
||||
await initBucket();
|
||||
console.log('[server] MinIO initialized');
|
||||
} catch (err) {
|
||||
console.error('[server] MinIO initialization failed:', err.message);
|
||||
console.error('[server] Image uploads will not work until MinIO is available');
|
||||
}
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[server] RefBoard backend listening on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Graceful shutdown ----
|
||||
function shutdown(signal) {
|
||||
console.log(`[server] Received ${signal}, shutting down gracefully...`);
|
||||
|
||||
io.close(() => {
|
||||
console.log('[server] Socket.IO closed');
|
||||
});
|
||||
|
||||
server.close(() => {
|
||||
console.log('[server] HTTP server closed');
|
||||
|
||||
try {
|
||||
const { db } = require('./db');
|
||||
db.close();
|
||||
console.log('[server] Database closed');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.error('[server] Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('[server] Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const { getBoard, getCollection, getCollectionMember } = require('../db');
|
||||
|
||||
// Track active users per room: Map<roomName, Map<userId, userInfo>>
|
||||
const activeUsers = new Map();
|
||||
|
||||
function getRoomName(boardId) {
|
||||
return `board:${boardId}`;
|
||||
}
|
||||
|
||||
function getActiveUsersInRoom(roomName) {
|
||||
if (!activeUsers.has(roomName)) {
|
||||
activeUsers.set(roomName, new Map());
|
||||
}
|
||||
return activeUsers.get(roomName);
|
||||
}
|
||||
|
||||
function setupBoardRoom(io, socket) {
|
||||
|
||||
// ---- Join / Leave ----
|
||||
|
||||
socket.on('board:join', ({ boardId }, callback) => {
|
||||
try {
|
||||
const board = getBoard(boardId);
|
||||
if (!board) return callback?.({ error: 'Board not found' });
|
||||
|
||||
const collection = getCollection(board.collection_id);
|
||||
if (!collection) return callback?.({ error: 'Collection not found' });
|
||||
|
||||
const member = getCollectionMember(board.collection_id, socket.userId);
|
||||
if (!member && !collection.is_public) return callback?.({ error: 'Access denied' });
|
||||
|
||||
const roomName = getRoomName(boardId);
|
||||
|
||||
// Leave any previous board room
|
||||
for (const room of socket.rooms) {
|
||||
if (room.startsWith('board:') && room !== roomName) {
|
||||
leaveRoom(io, socket, room);
|
||||
}
|
||||
}
|
||||
|
||||
socket.join(roomName);
|
||||
socket.currentBoardId = boardId;
|
||||
|
||||
const roomUsers = getActiveUsersInRoom(roomName);
|
||||
const userInfo = {
|
||||
id: socket.userId,
|
||||
display_name: socket.userDisplayName,
|
||||
email: socket.userEmail,
|
||||
role: member?.role || 'viewer',
|
||||
};
|
||||
roomUsers.set(socket.userId, userInfo);
|
||||
|
||||
socket.to(roomName).emit('user:joined', userInfo);
|
||||
|
||||
const users = Array.from(roomUsers.values());
|
||||
callback?.({ ok: true, users });
|
||||
|
||||
console.log(`[socket] ${socket.userDisplayName} joined ${roomName} (${users.length} users)`);
|
||||
} catch (err) {
|
||||
console.error('[socket] board:join error:', err);
|
||||
callback?.({ error: 'Failed to join board' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('board:leave', ({ boardId }, callback) => {
|
||||
const roomName = getRoomName(boardId);
|
||||
leaveRoom(io, socket, roomName);
|
||||
callback?.({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Full scene sync (Excalidraw-style) ----
|
||||
|
||||
socket.on('scene:update', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.to(roomName).emit('scene:update', {
|
||||
...data,
|
||||
userId: socket.userId,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Lightweight transform (during drag/resize/rotate) ----
|
||||
|
||||
socket.on('object:transform', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.to(roomName).emit('object:transform', {
|
||||
...data,
|
||||
userId: socket.userId,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cursor movement ----
|
||||
|
||||
socket.on('cursor:move', (data) => {
|
||||
if (!socket.currentBoardId) return;
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
socket.to(roomName).emit('cursor:moved', {
|
||||
...data,
|
||||
userId: socket.userId,
|
||||
displayName: socket.userDisplayName,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Disconnect cleanup ----
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
for (const room of socket.rooms) {
|
||||
if (room.startsWith('board:')) {
|
||||
leaveRoom(io, socket, room);
|
||||
}
|
||||
}
|
||||
if (socket.currentBoardId) {
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
leaveRoom(io, socket, roomName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function leaveRoom(io, socket, roomName) {
|
||||
socket.leave(roomName);
|
||||
|
||||
const roomUsers = getActiveUsersInRoom(roomName);
|
||||
roomUsers.delete(socket.userId);
|
||||
|
||||
socket.to(roomName).emit('user:left', {
|
||||
id: socket.userId,
|
||||
display_name: socket.userDisplayName,
|
||||
});
|
||||
|
||||
if (roomUsers.size === 0) {
|
||||
activeUsers.delete(roomName);
|
||||
}
|
||||
|
||||
if (socket.currentBoardId && roomName === getRoomName(socket.currentBoardId)) {
|
||||
socket.currentBoardId = null;
|
||||
}
|
||||
|
||||
console.log(`[socket] ${socket.userDisplayName} left ${roomName}`);
|
||||
}
|
||||
|
||||
module.exports = { setupBoardRoom };
|
||||
@@ -0,0 +1,61 @@
|
||||
const { Server } = require('socket.io');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { getUserById } = require('../db');
|
||||
const { setupBoardRoom } = require('./board-room');
|
||||
|
||||
/**
|
||||
* Set up Socket.IO on the given HTTP server.
|
||||
* Returns the io instance.
|
||||
*/
|
||||
function setupSocket(httpServer) {
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
methods: ['GET', 'POST'],
|
||||
},
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 60000,
|
||||
});
|
||||
|
||||
// JWT authentication middleware
|
||||
io.use((socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth?.token;
|
||||
if (!token) {
|
||||
return next(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
const user = getUserById(decoded.id);
|
||||
if (!user) {
|
||||
return next(new Error('User not found'));
|
||||
}
|
||||
|
||||
// Store user info on socket
|
||||
socket.userId = user.id;
|
||||
socket.userEmail = user.email;
|
||||
socket.userDisplayName = user.display_name;
|
||||
socket.userRole = user.role;
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(new Error('Invalid token'));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connections
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`[socket] User connected: ${socket.userDisplayName} (${socket.userId})`);
|
||||
|
||||
// Set up board room handlers
|
||||
setupBoardRoom(io, socket);
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`);
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
module.exports = { setupSocket };
|
||||
Reference in New Issue
Block a user