macOS screen recordings are HEVC inside a .mov container, which Chromium, Firefox and stock Electron can't decode. The file uploaded fine and a server-side ffmpeg poster was generated, but inline <video> playback failed silently — stuck at 0:00 with no error, since AttachmentRenderer had no error handling. Root cause: the system had no concept of web-playability. Server detects, client degrades: - mediaPlayable.ts: classifyVideoPlayable(mimetype, codec) — tri-state (false = known-undecodable e.g. HEVC/ProRes, true = web codec in web container, null = unknown/optimistic). Never widens `false` beyond codecs that fail everywhere, so ffmpeg-less instances keep prior behaviour. - probeMediaMeta now captures the video codec_name; the upload finish hook stores the verdict in the new attachments.playable column (migration 0007). - Flag propagated through every serializer: space messages, DMs, WS, and federation relay (outbound + inbound) — federation-compatible. - VideoAttachment component: playable===false renders a download card (poster + "Can't play here — download" + name/duration/size) with no dead-player flash; otherwise plays inline with an onError fallback to the same card. Specs updated: uploads.md, database.md, federation.md.
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
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<string, Reaction[]> {
|
|
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<string, Reaction[]>();
|
|
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<string, MessageWithUser> {
|
|
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<string, (typeof schema.attachments.$inferSelect)[]>();
|
|
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<string, MessageWithUser>();
|
|
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<void> {
|
|
// 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<string, (typeof schema.attachments.$inferSelect)[]>();
|
|
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 });
|
|
});
|
|
}
|