import type { FastifyInstance } from 'fastify'; import { eq, and, desc, lt, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { hasPermission, getChannelSpaceId, PermissionBits } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; import { MAX_MESSAGE_LENGTH, type CreateMessageRequest, type UpdateMessageRequest, type PaginatedQuery, type MessageWithUser, type Reaction, type Embed, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; /** * Fetch reactions for a set of message IDs. * Returns a map from messageId to Reaction[]. */ export function fetchReactionsForMessages(messageIds: string[]): Map { if (messageIds.length === 0) return new Map(); const db = getDb(); const reactionRows = db.select() .from(schema.reactions) .where(inArray(schema.reactions.messageId, messageIds)) .all(); // Batch fetch users for reactions const reactionUserIds = [...new Set(reactionRows.map(r => r.userId))]; const reactionUsers = reactionUserIds.length > 0 ? db.select().from(schema.users).where(inArray(schema.users.id, reactionUserIds)).all() : []; const reactionUserMap = new Map(reactionUsers.map(u => [u.id, u])); const map = new Map(); for (const r of reactionRows) { const user = reactionUserMap.get(r.userId); const reaction: Reaction = { id: r.id, messageId: r.messageId, userId: r.userId, emoji: r.emoji, createdAt: r.createdAt, user: user ? sanitizeUser(user) : undefined, }; if (!map.has(r.messageId)) { map.set(r.messageId, []); } map.get(r.messageId)!.push(reaction); } return map; } /** * Fetch reply-to messages for a set of message IDs. * Returns a map from messageId to its reply parent MessageWithUser. */ export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]): Map { const replyToIds = messages .map(m => m.replyToId) .filter((id): id is string => id !== null && id !== undefined); if (replyToIds.length === 0) return new Map(); const db = getDb(); const uniqueReplyIds = [...new Set(replyToIds)]; const replyMessages = db.select() .from(schema.messages) .where(inArray(schema.messages.id, uniqueReplyIds)) .all(); // Fetch users for reply messages const replyUserIds = [...new Set(replyMessages.map(m => m.userId))]; const replyUsers = replyUserIds.length > 0 ? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all() : []; const replyUserMap = new Map(replyUsers.map(u => [u.id, u])); // Fetch attachments for reply messages const replyMsgIds = replyMessages.map(m => m.id); const replyAttachments = replyMsgIds.length > 0 ? db.select().from(schema.attachments).where(inArray(schema.attachments.messageId, replyMsgIds)).all() : []; const replyAttMap = new Map(); for (const att of replyAttachments) { const mid = att.messageId ?? ''; if (!replyAttMap.has(mid)) replyAttMap.set(mid, []); replyAttMap.get(mid)!.push(att); } const map = new Map(); for (const rm of replyMessages) { const user = replyUserMap.get(rm.userId); if (!user) continue; const atts = replyAttMap.get(rm.id) ?? []; map.set(rm.id, { id: rm.id, channelId: rm.channelId, userId: rm.userId, replyToId: rm.replyToId, content: rm.content, editedAt: rm.editedAt, createdAt: rm.createdAt, user: sanitizeUser(user), attachments: atts.map(a => ({ id: a.id, messageId: a.messageId ?? rm.id, filename: a.filename, originalName: a.originalName, mimetype: a.mimetype, size: a.size, thumbnailFilename: a.thumbnailFilename ?? null, width: a.width ?? null, height: a.height ?? null, duration: a.duration ?? null, playable: a.playable ?? null, createdAt: a.createdAt, })), embeds: [], reactions: [], replyTo: null, }); } return map; } export function buildMessageWithUser( message: typeof schema.messages.$inferSelect, user: typeof schema.users.$inferSelect, attachmentRows: (typeof schema.attachments.$inferSelect)[], reactions: Reaction[] = [], replyTo: MessageWithUser | null = null, embedRows: (typeof schema.embeds.$inferSelect)[] = [], ): MessageWithUser { return { id: message.id, channelId: message.channelId, userId: message.userId, replyToId: message.replyToId, content: message.content, editedAt: message.editedAt, createdAt: message.createdAt, user: sanitizeUser(user), attachments: attachmentRows.map(a => ({ id: a.id, messageId: a.messageId ?? message.id, filename: a.filename, originalName: a.originalName, mimetype: a.mimetype, size: a.size, thumbnailFilename: a.thumbnailFilename ?? null, width: a.width ?? null, height: a.height ?? null, duration: a.duration ?? null, playable: a.playable ?? null, federationStatus: a.federationStatus ?? null, federationMeta: a.federationMeta ?? null, createdAt: a.createdAt, })), embeds: embedRows.map(e => embedRowToEmbed(e)), reactions, replyTo, }; } export async function messageRoutes(app: FastifyInstance): Promise { // GET /api/channels/:id/messages - Get messages with cursor pagination app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/channels/:id/messages', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const before = request.query.before; const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100); const spaceId = getChannelSpaceId(id); if (!spaceId) { return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); } if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) { return reply.code(403).send({ error: 'Missing VIEW_CHANNEL or READ_MESSAGE_HISTORY permission', statusCode: 403 }); } const db = getDb(); let messageRows: (typeof schema.messages.$inferSelect)[]; if (before) { messageRows = db.select() .from(schema.messages) .where(and( eq(schema.messages.channelId, id), lt(schema.messages.id, before) )) .orderBy(desc(schema.messages.createdAt)) .limit(limit) .all(); } else { messageRows = db.select() .from(schema.messages) .where(eq(schema.messages.channelId, id)) .orderBy(desc(schema.messages.createdAt)) .limit(limit) .all(); } // Reverse to get chronological order messageRows.reverse(); if (messageRows.length === 0) { return reply.code(200).send([]); } // Batch fetch users const userIds = [...new Set(messageRows.map(m => m.userId))]; const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); const userMap = new Map(users.map(u => [u.id, u])); // Batch fetch attachments const messageIds = messageRows.map(m => m.id); const allAttachments = db.select() .from(schema.attachments) .where(inArray(schema.attachments.messageId, messageIds)) .all(); const attachmentMap = new Map(); for (const att of allAttachments) { const mid = att.messageId ?? ''; if (!attachmentMap.has(mid)) { attachmentMap.set(mid, []); } attachmentMap.get(mid)!.push(att); } // Batch fetch reactions for all messages const reactionsMap = fetchReactionsForMessages(messageIds); // Batch fetch embeds for all messages const embedMap = fetchEmbedsForMessages(messageIds); // Batch fetch reply-to messages const replyToMap = fetchReplyToMessages(messageRows); const messages: MessageWithUser[] = messageRows .map(m => { const user = userMap.get(m.userId); if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is MessageWithUser => m !== null); return reply.code(200).send(messages); }); // POST /api/channels/:id/messages - Create a message app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', { preHandler: authenticate, config: { rateLimit: { max: 5, timeWindow: '5 seconds', keyGenerator: (request: any) => request.userId || request.ip, }, }, }, async (request, reply) => { const { id } = request.params; const { content, attachments: attachmentIds, replyToId } = request.body; const spaceId = getChannelSpaceId(id); if (!spaceId) { return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); } if (!hasPermission(request.userId, spaceId, PermissionBits.SEND_MESSAGES, id)) { return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 }); } if (attachmentIds && attachmentIds.length > 0 && !hasPermission(request.userId, spaceId, PermissionBits.ATTACH_FILES, id)) { return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 }); } const hasContent = content && typeof content === 'string' && content.trim().length > 0; const hasAttachments = attachmentIds && attachmentIds.length > 0; if (!hasContent && !hasAttachments) { return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 }); } if (content && content.length > MAX_MESSAGE_LENGTH) { return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 }); } const db = getDb(); const messageId = generateSnowflake(); const now = Date.now(); // Verify attachment ownership before linking if (attachmentIds && attachmentIds.length > 0) { for (const attId of attachmentIds) { const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get(); if (!att || att.messageId || att.dmMessageId) { return reply.code(400).send({ error: 'Invalid or already-used attachment', statusCode: 400 }); } // Skip ownership check for legacy uploads (null uploaderId) if (att.uploaderId && att.uploaderId !== request.userId) { return reply.code(400).send({ error: 'You do not own this attachment', statusCode: 400 }); } } } // Insert message and link attachments atomically db.transaction((tx) => { tx.insert(schema.messages).values({ id: messageId, channelId: id, userId: request.userId, replyToId: replyToId || null, content: content?.trim() || null, createdAt: now, }).run(); if (attachmentIds && attachmentIds.length > 0) { for (const attId of attachmentIds) { tx.update(schema.attachments) .set({ messageId }) .where(eq(schema.attachments.id, attId)) .run(); } } }); const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); if (!user) { return reply.code(500).send({ error: 'User not found', statusCode: 500 }); } const attachmentRows = db.select() .from(schema.attachments) .where(eq(schema.attachments.messageId, messageId)) .all(); const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); if (!message) { return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 }); } // Hydrate the reply-to message if present let replyTo: MessageWithUser | null = null; if (message.replyToId) { const replyToMap = fetchReplyToMessages([message]); replyTo = replyToMap.get(message.replyToId) ?? null; } const messageWithUser = buildMessageWithUser(message, user, attachmentRows, [], replyTo); // Broadcast via WebSocket connectionManager.sendToChannel(spaceId, id, { type: 'message_created', message: messageWithUser, }); // Resolve embeds asynchronously after responding setImmediate(() => { resolveEmbeds(messageId, content?.trim() || null, id, false, spaceId).catch(() => {}); }); return reply.code(201).send(messageWithUser); }); // PATCH /api/messages/:id - Edit a message (author only) app.patch<{ Params: { id: string }; Body: UpdateMessageRequest }>('/api/messages/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const { content } = request.body; if (!content || typeof content !== 'string' || content.trim().length === 0) { return reply.code(400).send({ error: 'Content is required', statusCode: 400 }); } if (content.length > MAX_MESSAGE_LENGTH) { return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 }); } const db = getDb(); const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); if (!message) { return reply.code(404).send({ error: 'Message not found', statusCode: 404 }); } if (message.userId !== request.userId) { return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 }); } const now = Date.now(); db.update(schema.messages) .set({ content: content.trim(), editedAt: now }) .where(eq(schema.messages.id, id)) .run(); const updatedMessage = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); if (!updatedMessage) { return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 }); } const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get(); if (!user) { return reply.code(500).send({ error: 'User not found', statusCode: 500 }); } const attachmentRows = db.select() .from(schema.attachments) .where(eq(schema.attachments.messageId, id)) .all(); // Delete old embeds synchronously so the broadcast reflects the edit db.delete(schema.embeds).where(eq(schema.embeds.messageId, id)).run(); // Hydrate reactions and reply-to (embeds are empty after deletion) const reactionsMap = fetchReactionsForMessages([id]); const reactions = reactionsMap.get(id) ?? []; let replyTo: MessageWithUser | null = null; if (updatedMessage.replyToId) { const replyToMap = fetchReplyToMessages([updatedMessage]); replyTo = replyToMap.get(updatedMessage.replyToId) ?? null; } const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows, reactions, replyTo, []); // Broadcast edit (with empty embeds — new ones arrive via embeds_resolved) const spaceId = getChannelSpaceId(message.channelId); if (spaceId) { connectionManager.sendToSpace(spaceId, { type: 'message_updated', message: messageWithUser, }); // Resolve new embeds asynchronously (old ones already deleted above) setImmediate(() => { resolveEmbeds(id, content.trim(), message.channelId, false, spaceId).catch(() => {}); }); } return reply.code(200).send(messageWithUser); }); // DELETE /api/messages/:id - Delete a message (author or admin) app.delete<{ Params: { id: string } }>('/api/messages/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); if (!message) { return reply.code(404).send({ error: 'Message not found', statusCode: 404 }); } const spaceId = getChannelSpaceId(message.channelId); if (!spaceId) { return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); } const isAuthor = message.userId === request.userId; const canManageMessages = hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId); if (!isAuthor && !canManageMessages) { return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 }); } // Collect attachment filenames before deleting const attachmentRows = db.select({ filename: schema.attachments.filename }) .from(schema.attachments) .where(eq(schema.attachments.messageId, id)) .all(); // Delete attachments and message atomically db.transaction((tx) => { tx.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run(); tx.delete(schema.messages).where(eq(schema.messages.id, id)).run(); }); // Clean up files from disk after transaction commits deleteAttachmentFiles(attachmentRows); // Broadcast deletion connectionManager.sendToSpace(spaceId, { type: 'message_deleted', messageId: id, channelId: message.channelId, }); return reply.code(200).send({ success: true }); }); }