Files
refboard-ayon/backend/routes/boards.js
T
Hiren Kangad 6518ed6763 fix: hardening pass — permissions, socket reconnect, canvas setup, arrangements
- Fix 403 on save for public collection viewers (return role in GET board response)
- Add read-only status indicator (StatusBar + StatusIndicator)
- Fix beforeunload save to use fetch+keepalive with auth header
- Socket reconnect now rejoins board room automatically
- Canvas setup uses polling instead of brittle 200ms timer
- Fix double user:left on disconnect (use disconnecting event, snapshot rooms)
- Thread + comment creation wrapped in db.transaction
- Prevent owner downgrade via addCollectionMember (check existing member)
- Bound redirect depth in downloadImage to 5
- Arrangement operations anchor to bounding box top-left (no drift)
- Distribute H/V also anchor to top-left
- Fix annotations fetch to use axios api instance (401 interceptor)
- Replace require() with static import in shortcut-definitions
2026-03-11 08:08:21 +05:30

231 lines
6.8 KiB
JavaScript

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;
// Auto-convert Fabric v1 → v2 on first load
if (board.canvas_state) {
try {
const parsed = typeof board.canvas_state === 'string'
? JSON.parse(board.canvas_state)
: board.canvas_state;
if (!parsed.v || parsed.v < 2) {
const { convertFabricToV2 } = require('../services/scene-converter');
const v2 = convertFabricToV2(parsed);
const v2json = JSON.stringify(v2);
saveBoardCanvas(board.id, v2json, null);
board.canvas_state = v2json;
}
} catch (e) {
// Don't block board load on conversion failure
console.error('[boards] Scene conversion failed:', e);
}
}
const images = getBoardImages(board.id);
// Hydrate video objects with poster/dimensions from DB
// (worker may have finished after canvas was last saved)
let canvasState = board.canvas_state ? JSON.parse(board.canvas_state) : {};
if (canvasState.objects && Array.isArray(canvasState.objects)) {
const videoImages = new Map();
for (const img of images) {
if (img.media_type === 'video' && img.poster_asset_key) {
videoImages.set(img.asset_key, img);
}
}
if (videoImages.size > 0) {
for (const obj of canvasState.objects) {
if (obj.type !== 'video') continue;
const dbImg = videoImages.get(obj.asset);
if (!dbImg) continue;
if (!obj.poster && dbImg.poster_asset_key) obj.poster = dbImg.poster_asset_key;
if (!obj.nativeW && dbImg.native_width) { obj.nativeW = dbImg.native_width; obj.w = dbImg.native_width; }
if (!obj.nativeH && dbImg.native_height) { obj.nativeH = dbImg.native_height; obj.h = dbImg.native_height; }
if (!obj.duration && dbImg.duration) obj.duration = dbImg.duration;
}
}
}
return res.json({
board: {
...board,
canvas_state: canvasState,
},
collection: {
id: collection.id,
name: collection.name,
},
images,
role: result.member?.role || 'viewer',
});
} 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, thumbnail } = req.body;
if (canvas_state === undefined) {
return res.status(400).json({ error: 'canvas_state is required' });
}
saveBoardCanvas(result.board.id, canvas_state, thumbnail || null);
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;