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:
Hiren
2026-03-09 13:57:49 +05:30
commit e47777d237
43 changed files with 10276 additions and 0 deletions
+202
View File
@@ -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;
+178
View File
@@ -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;
+186
View File
@@ -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;
+312
View File
@@ -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;
+248
View File
@@ -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;