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:
+9
-1
@@ -552,6 +552,14 @@ function createThread({ id, boardId, objectId, anchorType, pinX, pinY, createdBy
|
||||
return getThread(id);
|
||||
}
|
||||
|
||||
function createThreadWithComment({ threadId, boardId, objectId, anchorType, pinX, pinY, createdBy, commentId, userId, authorName, authorColor, content }) {
|
||||
return db.transaction(() => {
|
||||
const thread = createThread({ id: threadId, boardId, objectId, anchorType, pinX, pinY, createdBy });
|
||||
const comment = createComment({ id: commentId, threadId, userId, authorName, authorColor, content });
|
||||
return { thread, comment };
|
||||
})();
|
||||
}
|
||||
|
||||
function updateThreadStatus(threadId, status, resolvedBy) {
|
||||
if (status === 'resolved') {
|
||||
db.prepare(`
|
||||
@@ -650,7 +658,7 @@ module.exports = {
|
||||
// Media Jobs
|
||||
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
|
||||
// Threads
|
||||
getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread,
|
||||
getThreadsByBoard, getThread, createThread, createThreadWithComment, updateThreadStatus, deleteThread,
|
||||
incrementThreadCommentCount, decrementThreadCommentCount,
|
||||
// Comments
|
||||
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -150,16 +150,13 @@ function setupBoardRoom(io, socket) {
|
||||
|
||||
// ---- Disconnect cleanup ----
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
for (const room of socket.rooms) {
|
||||
socket.on('disconnecting', () => {
|
||||
const rooms = [...socket.rooms];
|
||||
for (const room of rooms) {
|
||||
if (room.startsWith('board:')) {
|
||||
leaveRoom(io, socket, room);
|
||||
}
|
||||
}
|
||||
if (socket.currentBoardId) {
|
||||
const roomName = getRoomName(socket.currentBoardId);
|
||||
leaveRoom(io, socket, roomName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user