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
This commit is contained in:
Hiren Kangad
2026-03-11 08:08:21 +05:30
parent fc2d9df741
commit 6518ed6763
13 changed files with 141 additions and 84 deletions
+1
View File
@@ -153,6 +153,7 @@ router.get('/:boardId', (req, res) => {
name: collection.name,
},
images,
role: result.member?.role || 'viewer',
});
} catch (err) {
console.error('[boards] get error:', err);
+5
View File
@@ -263,6 +263,11 @@ router.post('/:collectionId/members', (req, res) => {
return res.status(400).json({ error: 'Cannot add yourself' });
}
const existingMember = getCollectionMember(collection.id, targetUser.id);
if (existingMember) {
return res.status(400).json({ error: 'User is already a member' });
}
addCollectionMember(collection.id, targetUser.id, memberRole);
return res.status(201).json({
+4 -7
View File
@@ -5,6 +5,7 @@ const {
getThreadsByBoard,
getThread,
createThread,
createThreadWithComment,
updateThreadStatus,
deleteThread,
getCommentsByBoard,
@@ -82,19 +83,15 @@ router.post('/:boardId/threads', (req, res) => {
const commentId = uuidv4();
const userId = req.user.id;
const thread = createThread({
id: threadId,
const { thread, comment } = createThreadWithComment({
threadId,
boardId: req.params.boardId,
objectId: object_id,
anchorType: anchor_type || 'object',
pinX: pin_x,
pinY: pin_y,
createdBy: userId,
});
const comment = createComment({
id: commentId,
threadId,
commentId,
userId,
authorName: resolveAuthorName(req.user),
authorColor: null,
+6 -3
View File
@@ -178,15 +178,18 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
/**
* Download an image from a URL. Returns { buffer, mimeType, filename }.
*/
function downloadImage(imageUrl) {
function downloadImage(imageUrl, maxRedirects = 5) {
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)
// Follow redirects up to maxRedirects times
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
return downloadImage(response.headers.location).then(resolve).catch(reject);
if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return downloadImage(response.headers.location, maxRedirects - 1).then(resolve).catch(reject);
}
if (response.statusCode !== 200) {