diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts index 934efc316..a72d2080a 100644 --- a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts +++ b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts @@ -17,309 +17,350 @@ */ import { - Attachment, - Channel, - Message, - MessageCreateEvent, - MessageDeleteEvent, - MessageUpdateEvent, - Snowflake, - SpacebarApiErrors, - emitEvent, - getPermission, - getRights, - uploadFile, - NewUrlUserSignatureData, + Attachment, + Channel, + Message, + MessageCreateEvent, + MessageDeleteEvent, + MessageUpdateEvent, + Snowflake, + SpacebarApiErrors, + emitEvent, + getPermission, + getRights, + uploadFile, + NewUrlUserSignatureData, } from "@spacebar/util"; +import { In } from "typeorm"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; import multer from "multer"; import { handleMessage, postHandleMessage, route } from "../../../../../util"; import { MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageEditSchema } from "@spacebar/schemas"; +import { resolveMessageInChannel, computeProjectionsForMessage } from "../../../../../util/helpers/MessageProjection"; const router = Router({ mergeParams: true }); // TODO: message content/embed string length limit const messageUpload = multer({ - limits: { - fileSize: 1024 * 1024 * 100, - fields: 10, - files: 1, - }, - storage: multer.memoryStorage(), + limits: { + fileSize: 1024 * 1024 * 100, + fields: 10, + files: 1, + }, + storage: multer.memoryStorage(), }); // max upload 50 mb router.patch( - "/", - route({ - requestBody: "MessageEditSchema", - permission: "SEND_MESSAGES", - right: "SEND_MESSAGES", - responses: { - 200: { - body: "Message", - }, - 400: { - body: "APIErrorResponse", - }, - 403: {}, - 404: {}, - }, - }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - let body = req.body as MessageEditSchema; - - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - relations: { attachments: true }, - }); - - const permissions = await getPermission(req.user_id, undefined, channel_id); - - const rights = await getRights(req.user_id); - - if (req.user_id !== message.author_id) { - if (!rights.has("MANAGE_MESSAGES")) { - permissions.hasThrow("MANAGE_MESSAGES"); - body = { flags: body.flags }; - // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins - } - } else rights.hasThrow("SELF_EDIT_MESSAGES"); - - // no longer necessary, somehow resolved by updating the type of `attachments`...? - // //@ts-expect-error Something is wrong with message_reference here, TS complains since "channel_id" is optional in MessageCreateSchema - const new_message = await handleMessage({ - ...message, - // TODO: should message_reference be overridable? - message_reference: message.message_reference, - ...body, - author_id: message.author_id, - channel_id, - id: message_id, - edited_timestamp: new Date(), - }); - - await Promise.all([ - new_message.save(), - await emitEvent({ - event: "MESSAGE_UPDATE", - channel_id, - data: { - ...new_message.toJSON(), - nonce: undefined, - member: new_message.member?.toPublicMember(), - }, - } as MessageUpdateEvent), - ]); - - postHandleMessage(new_message); - - // TODO: a DTO? - return res.json({ - ...new_message.toJSON(), - id: new_message.id, - type: new_message.type, - channel_id: new_message.channel_id, - member: new_message.member?.toPublicMember(), - author: new_message.author?.toPublicUser(), - attachments: new_message.attachments, - embeds: new_message.embeds, - mentions: new_message.embeds, - mention_roles: new_message.mention_roles, - mention_everyone: new_message.mention_everyone, - pinned: new_message.pinned, - timestamp: new_message.timestamp, - edited_timestamp: new_message.edited_timestamp, - - // these are not in the Discord.com response - mention_channels: new_message.mention_channels, - }); - }, + "/", + route({ + requestBody: "MessageEditSchema", + permission: "SEND_MESSAGES", + right: "SEND_MESSAGES", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + let body = req.body as MessageEditSchema; + + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + const permissions = await getPermission(req.user_id, undefined, channel_id); + + const rights = await getRights(req.user_id); + + if (req.user_id !== message.author_id) { + if (!rights.has("MANAGE_MESSAGES")) { + permissions.hasThrow("MANAGE_MESSAGES"); + body = { flags: body.flags }; + // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins + } + } else rights.hasThrow("SELF_EDIT_MESSAGES"); + + // no longer necessary, somehow resolved by updating the type of `attachments`...? + // //@ts-expect-error Something is wrong with message_reference here, TS complains since "channel_id" is optional in MessageCreateSchema + const new_message = await handleMessage({ + ...message, + // TODO: should message_reference be overridable? + message_reference: message.message_reference, + ...body, + author_id: message.author_id, + channel_id, + id: message_id, + edited_timestamp: new Date(), + }); + + await new_message.save(); + + const projections = await computeProjectionsForMessage(new_message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: projection.channelId, + data: { + ...new_message.toProjectedJSON(projection.channelId), + nonce: undefined, + member: new_message.member?.toPublicMember(), + }, + } as MessageUpdateEvent), + ), + ); + + postHandleMessage(new_message); + + return res.json({ + ...new_message.toJSON(), + id: new_message.id, + type: new_message.type, + channel_id: new_message.channel_id, + member: new_message.member?.toPublicMember(), + author: new_message.author?.toPublicUser(), + attachments: new_message.attachments, + embeds: new_message.embeds, + mentions: new_message.embeds, + mention_roles: new_message.mention_roles, + mention_everyone: new_message.mention_everyone, + pinned: new_message.pinned, + timestamp: new_message.timestamp, + edited_timestamp: new_message.edited_timestamp, + + // these are not in the Discord.com response + mention_channels: new_message.mention_channels, + }); + }, ); // Backfill message with specific timestamp router.put( - "/", - messageUpload.single("file"), - (req, res, next) => { - if (req.body.payload_json) { - req.body = JSON.parse(req.body.payload_json); - } - - next(); - }, - route({ - requestBody: "MessageCreateSchema", - permission: "SEND_MESSAGES", - right: "SEND_BACKDATED_EVENTS", - responses: { - 200: { - body: "Message", - }, - 400: { - body: "APIErrorResponse", - }, - 403: {}, - 404: {}, - }, - }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - const body = req.body as MessageCreateSchema; - const attachments: (MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.attachments ?? []; - - const rights = await getRights(req.user_id); - rights.hasThrow("SEND_MESSAGES"); - - // regex to check if message contains anything other than numerals ( also no decimals ) - if (!message_id.match(/^\+?\d+$/)) { - throw new HTTPError("Message IDs must be positive integers", 400); - } - - const snowflake = Snowflake.deconstruct(message_id); - if (Date.now() < snowflake.timestamp) { - // message is in the future - throw SpacebarApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE; - } - - const exists = await Message.findOne({ - where: { id: message_id, channel_id: channel_id }, - }); - if (exists) { - throw SpacebarApiErrors.CANNOT_REPLACE_BY_BACKFILL; - } - - if (req.file) { - try { - const file = await uploadFile(`/attachments/${req.params.channel_id}`, req.file); - attachments.push(Attachment.create({ ...file, proxy_url: file.url })); - } catch (error) { - return res.status(400).json(error); - } - } - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: { recipients: { user: true } }, - }); - - const embeds = body.embeds || []; - if (body.embed) embeds.push(body.embed); - const message = await handleMessage({ - ...body, - type: 0, - pinned: false, - author_id: req.user_id, - id: message_id, - embeds, - channel_id, - attachments, - edited_timestamp: undefined, - timestamp: new Date(snowflake.timestamp), - }); - - //Fix for the client bug - delete message.member; - - await Promise.all([ - message.save(), - emitEvent({ - event: "MESSAGE_CREATE", - channel_id: channel_id, - data: message, - } as MessageCreateEvent), - channel.save(), - ]); - - // no await as it shouldnt block the message send function and silently catch error - postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); - - return res.json( - message.withSignedAttachments( - new NewUrlUserSignatureData({ - ip: req.ip, - userAgent: req.headers["user-agent"] as string, - }), - ), - ); - }, + "/", + messageUpload.single("file"), + async (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + requestBody: "MessageCreateSchema", + permission: "SEND_MESSAGES", + right: "SEND_BACKDATED_EVENTS", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + const body = req.body as MessageCreateSchema; + const attachments: (MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.attachments ?? []; + + const rights = await getRights(req.user_id); + rights.hasThrow("SEND_MESSAGES"); + + // regex to check if message contains anything other than numerals ( also no decimals ) + if (!message_id.match(/^\+?\d+$/)) { + throw new HTTPError("Message IDs must be positive integers", 400); + } + + const snowflake = Snowflake.deconstruct(message_id); + if (Date.now() < snowflake.timestamp) { + // message is in the future + throw SpacebarApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE; + } + + const exists = await Message.findOne({ + where: { id: message_id, channel_id: channel_id }, + }); + if (exists) { + throw SpacebarApiErrors.CANNOT_REPLACE_BY_BACKFILL; + } + + if (req.file) { + try { + const file = await uploadFile(`/attachments/${req.params.channel_id}`, req.file); + attachments.push(Attachment.create({ ...file, proxy_url: file.url })); + } catch (error) { + return res.status(400).json(error); + } + } + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients", "recipients.user"], + }); + + const embeds = body.embeds || []; + if (body.embed) embeds.push(body.embed); + const message = await handleMessage({ + ...body, + type: 0, + pinned: false, + author_id: req.user_id, + id: message_id, + embeds, + channel_id, + attachments, + edited_timestamp: undefined, + timestamp: new Date(snowflake.timestamp), + }); + + //Fix for the client bug + delete message.member; + + await Promise.all([ + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: channel_id, + data: message, + } as MessageCreateEvent), + channel.save(), + ]); + + // no await as it shouldnt block the message send function and silently catch error + postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); + + return res.json( + message.withSignedAttachments( + new NewUrlUserSignatureData({ + ip: req.ip, + userAgent: req.headers["user-agent"] as string, + }), + ), + ); + }, ); router.get( - "/", - route({ - permission: "VIEW_CHANNEL", - responses: { - 200: { - body: "Message", - }, - 400: { - body: "APIErrorResponse", - }, - 403: {}, - 404: {}, - }, - }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - relations: { attachments: true }, - }); - - const permissions = await getPermission(req.user_id, undefined, channel_id); - - if (message.author_id !== req.user_id) permissions.hasThrow("READ_MESSAGE_HISTORY"); - - return res.json(message); - }, + "/", + route({ + permission: "VIEW_CHANNEL", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + const permissions = await getPermission(req.user_id, undefined, channel_id); + + if (message.author_id !== req.user_id) permissions.hasThrow("READ_MESSAGE_HISTORY"); + + if (!message.reply_ids) { + const replies = await Message.find({ + where: { + channel_id: message.channel_id, + message_reference: { + message_id: message.id, + }, + }, + select: ["id"], + }); + + if (replies.length > 0) { + message.reply_ids = replies.map((r) => r.id); + await Message.update({ id: message.id }, { reply_ids: message.reply_ids }); + } else { + message.reply_ids = []; + await Message.update({ id: message.id }, { reply_ids: [] }); + } + } + + return res.json(message.toJSON()); + }, ); router.delete( - "/", - route({ - responses: { - 204: {}, - 400: { - body: "APIErrorResponse", - }, - 404: {}, - }, - }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - const message = await Message.findOneOrFail({ - where: { id: message_id }, - }); - - const rights = await getRights(req.user_id); - - if (message.author_id !== req.user_id) { - if (!rights.has("MANAGE_MESSAGES")) { - const permission = await getPermission(req.user_id, channel.guild_id, channel_id); - permission.hasThrow("MANAGE_MESSAGES"); - } - } else rights.hasThrow("SELF_DELETE_MESSAGES"); - - await Message.delete({ id: message_id }); - - await emitEvent({ - event: "MESSAGE_DELETE", - channel_id, - data: { - id: message_id, - channel_id, - guild_id: channel.guild_id, - }, - } as MessageDeleteEvent); - - res.sendStatus(204); - }, + "/", + route({ + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + const rights = await getRights(req.user_id); + + if (message.author_id !== req.user_id) { + if (!rights.has("MANAGE_MESSAGES")) { + const permission = await getPermission(req.user_id, channel.guild_id, channel_id); + permission.hasThrow("MANAGE_MESSAGES"); + } + } else rights.hasThrow("SELF_DELETE_MESSAGES"); + + if (message.message_reference?.message_id) { + const parentMessage = await Message.findOne({ + where: { id: message.message_reference.message_id }, + }); + if (parentMessage?.reply_ids) { + parentMessage.reply_ids = parentMessage.reply_ids.filter((id) => id !== message_id); + await Message.update({ id: parentMessage.id }, { reply_ids: parentMessage.reply_ids }); + } + } + + if (message.reply_ids?.length) { + const permissions = await getPermission(req.user_id, channel.guild_id, channel_id); + if (permissions.has("MANAGE_MESSAGES")) { + await Message.delete({ id: In(message.reply_ids) }); + } + } + + await Message.delete({ id: message_id }); + + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_DELETE", + channel_id: projection.channelId, + data: { + id: message_id, + channel_id: projection.channelId, + guild_id: channel.guild_id, + }, + } as MessageDeleteEvent), + ), + ); + + res.sendStatus(204); + }, ); export default router; diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts index b21dbd349..a63a848b8 100644 --- a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts +++ b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts @@ -29,12 +29,17 @@ import { MessageReactionRemoveEmojiEvent, MessageReactionRemoveEvent, User, - arrayRemove, } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; import { In } from "typeorm"; import { PartialEmoji, PublicMemberProjection, PublicUserProjection } from "@spacebar/schemas"; +import { + resolveMessageInChannel, + computeProjectionsForMessage, + getIntimacyBroadcastRuleForChannel, + filterReactionsForIntimacyBroadcast, +} from "../../../../../util/helpers/MessageProjection"; const router = Router({ mergeParams: true }); // TODO: check if emoji is really an unicode emoji or a properly encoded external emoji @@ -74,17 +79,26 @@ router.delete( where: { id: channel_id }, }); - await Message.update({ id: message_id, channel_id }, { reactions: [] }); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); - await emitEvent({ - event: "MESSAGE_REACTION_REMOVE_ALL", - channel_id, - data: { - channel_id, - message_id, - guild_id: channel.guild_id, - }, - } as MessageReactionRemoveAllEvent); + message.reactions = []; + await message.save(); + + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_REACTION_REMOVE_ALL", + channel_id: projection.channelId, + data: { + channel_id: projection.channelId, + message_id, + guild_id: channel.guild_id, + }, + } as MessageReactionRemoveAllEvent), + ), + ); res.sendStatus(204); }, @@ -107,27 +121,30 @@ router.delete( const { message_id, channel_id } = req.params; const emoji = getEmoji(req.params.emoji); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name); if (!already_added) throw new HTTPError("Reaction not found", 404); - arrayRemove(message.reactions, already_added); - - await Promise.all([ - message.save(), - emitEvent({ - event: "MESSAGE_REACTION_REMOVE_EMOJI", - channel_id, - data: { - channel_id, - message_id, - guild_id: message.guild_id, - emoji, - }, - } as MessageReactionRemoveEmojiEvent), - ]); + message.reactions.remove(already_added); + + await message.save(); + + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_REACTION_REMOVE_EMOJI", + channel_id: projection.channelId, + data: { + channel_id: projection.channelId, + message_id, + guild_id: message.guild_id, + emoji, + }, + } as MessageReactionRemoveEmojiEvent), + ), + ); res.sendStatus(204); }, @@ -152,10 +169,17 @@ router.get( const { message_id, channel_id } = req.params; const emoji = getEmoji(req.params.emoji); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); - const reaction = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + const intimacyRule = await getIntimacyBroadcastRuleForChannel(message.source_channel_id || message.channel_id!); + const isIntimacyBroadcast = intimacyRule !== null; + + let reactions = message.reactions; + if (isIntimacyBroadcast) { + reactions = filterReactionsForIntimacyBroadcast(reactions, req.user_id, message.author_id); + } + + const reaction = reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name); if (!reaction) throw new HTTPError("Reaction not found", 404); const users = ( @@ -193,9 +217,7 @@ router.put( const channel = await Channel.findOneOrFail({ where: { id: channel_id }, }); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name); if (!already_added) req.permission?.hasThrow("ADD_REACTIONS"); @@ -231,18 +253,24 @@ router.put( }) ).toPublicMember(); - await emitEvent({ - event: "MESSAGE_REACTION_ADD", - channel_id, - data: { - user_id: req.user_id, - channel_id, - message_id, - guild_id: channel.guild_id, - emoji, - member, - }, - } as MessageReactionAddEvent); + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_REACTION_ADD", + channel_id: projection.channelId, + data: { + user_id: req.user_id, + channel_id: projection.channelId, + message_id, + guild_id: channel.guild_id, + emoji, + member, + }, + } as MessageReactionAddEvent), + ), + ); res.sendStatus(204); }, @@ -269,9 +297,7 @@ router.delete( const channel = await Channel.findOneOrFail({ where: { id: channel_id }, }); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); if (user_id === "@me") user_id = req.user_id; else { @@ -284,22 +310,28 @@ router.delete( already_added.count--; - if (already_added.count <= 0) arrayRemove(message.reactions, already_added); + if (already_added.count <= 0) message.reactions.remove(already_added); else already_added.user_ids.splice(already_added.user_ids.indexOf(user_id), 1); await message.save(); - await emitEvent({ - event: "MESSAGE_REACTION_REMOVE", - channel_id, - data: { - user_id: req.user_id, - channel_id, - message_id, - guild_id: channel.guild_id, - emoji, - }, - } as MessageReactionRemoveEvent); + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_REACTION_REMOVE", + channel_id: projection.channelId, + data: { + user_id: req.user_id, + channel_id: projection.channelId, + message_id, + guild_id: channel.guild_id, + emoji, + }, + } as MessageReactionRemoveEvent), + ), + ); res.sendStatus(204); }, @@ -326,9 +358,7 @@ router.delete( const channel = await Channel.findOneOrFail({ where: { id: channel_id }, }); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); if (user_id === "@me") user_id = req.user_id; else { @@ -341,22 +371,28 @@ router.delete( already_added.count--; - if (already_added.count <= 0) arrayRemove(message.reactions, already_added); + if (already_added.count <= 0) message.reactions.remove(already_added); else already_added.user_ids.splice(already_added.user_ids.indexOf(user_id), 1); await message.save(); - await emitEvent({ - event: "MESSAGE_REACTION_REMOVE", - channel_id, - data: { - user_id: req.user_id, - channel_id, - message_id, - guild_id: channel.guild_id, - emoji, - }, - } as MessageReactionRemoveEvent); + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_REACTION_REMOVE", + channel_id: projection.channelId, + data: { + user_id: req.user_id, + channel_id: projection.channelId, + message_id, + guild_id: channel.guild_id, + emoji, + }, + } as MessageReactionRemoveEvent), + ), + ); res.sendStatus(204); }, diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/replies.ts b/src/api/routes/channels/#channel_id/messages/#message_id/replies.ts new file mode 100644 index 000000000..84162e940 --- /dev/null +++ b/src/api/routes/channels/#channel_id/messages/#message_id/replies.ts @@ -0,0 +1,165 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Message, Channel, getPermission, getRights, emitEvent, MessageDeleteBulkEvent } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import { In } from "typeorm"; +import { route } from "../../../../../util"; +import { resolveMessageInChannel } from "../../../../../util/helpers/MessageProjection"; + +const router = Router(); + +router.get( + "/", + route({ + permission: "VIEW_CHANNEL", + responses: { + 200: { body: "APIMessageArray" }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const permissions = await getPermission(req.user_id, undefined, channel_id); + permissions.hasThrow("READ_MESSAGE_HISTORY"); + + const parentMessage = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + if (!parentMessage.reply_ids) { + const replies = await Message.find({ + where: { + channel_id: channel_id, + message_reference: { + message_id: message_id, + }, + }, + select: ["id"], + }); + + if (replies.length > 0) { + parentMessage.reply_ids = replies.map((r) => r.id); + await Message.update({ id: message_id }, { reply_ids: parentMessage.reply_ids }); + } else { + parentMessage.reply_ids = []; + await Message.update({ id: message_id }, { reply_ids: [] }); + } + } + + const replyMessages = await Message.find({ + where: { + id: In(parentMessage.reply_ids || []), + channel_id: channel_id, + }, + relations: ["author", "webhook", "application", "mentions", "mention_roles", "mention_channels", "sticker_items", "attachments"], + order: { timestamp: "ASC" }, + }); + + return res.json( + replyMessages.map((m) => { + const json = m.toJSON(); + json.reply_ids = m.reply_ids ?? undefined; + return json; + }), + ); + }, +); + +router.delete( + "/", + route({ + responses: { + 204: {}, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + + const permissions = await getPermission(req.user_id, channel.guild_id, channel_id); + + const parentMessage = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + if (!parentMessage.reply_ids) { + const replies = await Message.find({ + where: { + channel_id: channel_id, + message_reference: { + message_id: message_id, + }, + }, + select: ["id"], + }); + + if (replies.length > 0) { + parentMessage.reply_ids = replies.map((r) => r.id); + await Message.update({ id: message_id }, { reply_ids: parentMessage.reply_ids }); + } else { + parentMessage.reply_ids = []; + await Message.update({ id: message_id }, { reply_ids: [] }); + } + } + + if (parentMessage.reply_ids?.length) { + const replyMessages = await Message.find({ + where: { id: In(parentMessage.reply_ids) }, + select: ["id", "author_id"], + }); + + const userOwnedReplies = replyMessages.filter((msg) => msg.author_id === req.user_id); + const otherOwnedReplies = replyMessages.filter((msg) => msg.author_id !== req.user_id); + + const rights = await getRights(req.user_id); + + if (userOwnedReplies.length > 0) { + rights.hasThrow("SELF_DELETE_MESSAGES"); + } + + if (otherOwnedReplies.length > 0) { + if (!rights.has("MANAGE_MESSAGES")) { + permissions.hasThrow("MANAGE_MESSAGES"); + } + } + + await Message.delete({ id: In(parentMessage.reply_ids) }); + + await emitEvent({ + event: "MESSAGE_DELETE_BULK", + channel_id, + data: { + ids: parentMessage.reply_ids, + channel_id, + guild_id: channel.guild_id, + }, + } as MessageDeleteBulkEvent); + + parentMessage.reply_ids = []; + await Message.update({ id: message_id }, { reply_ids: [] }); + } + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts new file mode 100644 index 000000000..ba7a29698 --- /dev/null +++ b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts @@ -0,0 +1,104 @@ +import { Router, Request, Response } from "express"; +import { route } from "@spacebar/api"; +import { resolveLimit, getGuildLimits, Channel, Message, ChannelTypes, getPermission, Snowflake, DiscordApiErrors, ThreadMember } from "@spacebar/util"; +import { resolveMessageInChannel } from "../../../../../util/helpers/MessageProjection"; + +const router = Router({ mergeParams: true }); + +router.post( + "/", + route({ + permission: "USE_PUBLIC_THREADS", + responses: { 201: { body: "Channel" } }, + }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params as { + channel_id: string; + message_id: string; + }; + + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + const existingThread = await Channel.findOne({ + where: { parent_id: channel_id, last_message_id: message_id }, + }); + + if (existingThread) { + throw DiscordApiErrors.THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE; + } + + const { name, auto_archive_duration } = req.body as { + name: string; + auto_archive_duration?: number; + }; + + const thread = await Channel.create({ + id: Snowflake.generate(), + type: ChannelTypes.GUILD_PUBLIC_THREAD, + name: name || `Thread from ${message.author?.username || "Unknown"}`, + parent_id: channel_id, + guild_id: message.guild_id, + owner_id: req.user_id, + last_message_id: message_id, + default_auto_archive_duration: auto_archive_duration || 1440, + created_at: new Date(), + }).save(); + + await ThreadMember.create({ + thread_id: thread.id, + user_id: req.user_id!, + created_at: new Date(), + flags: 0, + }).save(); + + res.status(201).json(thread); + }, +); + +router.get( + "/", + route({ + permission: "VIEW_CHANNEL", + responses: { 200: { body: "Object" } }, + query: { + before: { type: "string", required: false }, + after: { type: "string", required: false }, + limit: { type: "number", required: false }, + }, + }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params as { + channel_id: string; + message_id: string; + }; + const qLimit = (req.query as { limit?: number }).limit; + const guildId = (req as Request & { guild_id?: string }).guild_id; + const limits = getGuildLimits(guildId).threads; + const limit = resolveLimit(qLimit, limits.maxThreadPageSize, limits.defaultThreadPageSize, limits.maxThreadPageSize); + + const threads = await Channel.find({ + where: { parent_id: channel_id, last_message_id: message_id }, + select: ["id", "name", "type", "created_at", "default_auto_archive_duration", "last_message_id"], + take: limit, + }); + + const threadsWithRemaining = threads.map((thread) => { + const lastActivityAt = thread.last_message_id ? new Date() : thread.created_at; + const inactivityMs = Date.now() - lastActivityAt.getTime(); + const durationMs = (thread.default_auto_archive_duration || 1440) * 60 * 1000; + const remainingAutoArchive = Math.max(0, durationMs - inactivityMs); + + return { + ...thread, + remainingAutoArchive: Math.floor(remainingAutoArchive / 1000), + }; + }); + + res.status(200).json({ + threads: threadsWithRemaining, + has_more: threads.length >= limit, + }); + }, +); + +export default router; diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts index dafdde7e3..f9221e17c 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts @@ -18,276 +18,242 @@ import { handleMessage, postHandleMessage, route } from "@spacebar/api"; import { - Attachment, - Channel, - Config, - DmChannelDTO, - DiscordApiErrors, - FieldErrors, - Member, - Message, - MessageCreateEvent, - MessageCreateSchema, - Reaction, - ReadState, - Rights, - Snowflake, - User, - emitEvent, - getPermission, - isTextChannel, - getUrlSignature, - uploadFile, - NewUrlSignatureData, - NewUrlUserSignatureData, + ApiError, + Attachment, + AutomodRule, + AutomodTriggerTypes, + Channel, + Config, + DiscordApiErrors, + DmChannelDTO, + emitEvent, + FieldErrors, + getPermission, + getUrlSignature, + Member, + Message, + MessageCreateEvent, + NewUrlSignatureData, + NewUrlUserSignatureData, + ReadState, + Relationship, + Rights, + Snowflake, + uploadFile, + User, } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; import multer from "multer"; -import { - FindManyOptions, - FindOperator, - LessThan, - MoreThan, - MoreThanOrEqual, -} from "typeorm"; +import { FindManyOptions, FindOperator, LessThan, MoreThan, MoreThanOrEqual } from "typeorm"; import { URL } from "url"; - -const router: Router = Router(); +import { + AutomodCustomWordsRule, + AutomodRuleActionType, + AutomodRuleEventType, + isTextChannel, + MessageCreateAttachment, + MessageCreateCloudAttachment, + MessageCreateSchema, + Reaction, + RelationshipType, +} from "@spacebar/schemas"; +import { getProjectedMessagesForChannel, getIntimacyBroadcastRuleForChannel, filterReactionsForIntimacyBroadcast } from "../../../../util/helpers/MessageProjection"; + +const router: Router = Router({ mergeParams: true }); async function populateForwardLinks(messages: Message[]): Promise { - for (const message of messages) { - if (!message.reply_ids) { - const replies = await Message.find({ - where: { - channel_id: message.channel_id, - message_reference: { - message_id: message.id, - }, - }, - select: ["id"], - }); - - if (replies.length > 0) { - message.reply_ids = replies.map((r) => r.id); - await Message.update( - { id: message.id }, - { reply_ids: message.reply_ids }, - ); - } else { - message.reply_ids = []; - await Message.update({ id: message.id }, { reply_ids: [] }); - } - } - } + for (const message of messages) { + if (!message.reply_ids) { + const replies = await Message.find({ + where: { + channel_id: message.channel_id, + message_reference: { + message_id: message.id, + }, + }, + select: ["id"], + }); + + if (replies.length > 0) { + message.reply_ids = replies.map((r) => r.id); + await Message.update({ id: message.id }, { reply_ids: message.reply_ids }); + } else { + message.reply_ids = []; + await Message.update({ id: message.id }, { reply_ids: [] }); + } + } + } } // https://discord.com/developers/docs/resources/channel#create-message // get messages router.get( - "/", - route({ - query: { - around: { - type: "string", - }, - before: { - type: "string", - }, - after: { - type: "string", - }, - limit: { - type: "number", - description: - "max number of messages to return (1-100). defaults to 50", - }, - }, - responses: { - 200: { - body: "APIMessageArray", - }, - 400: { - body: "APIErrorResponse", - }, - 403: {}, - 404: {}, - }, - }), - async (req: Request, res: Response) => { - const channel_id = req.params.channel_id; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - if (!channel) throw new HTTPError("Channel not found", 404); - - isTextChannel(channel.type); - const around = req.query.around ? `${req.query.around}` : undefined; - const before = req.query.before ? `${req.query.before}` : undefined; - const after = req.query.after ? `${req.query.after}` : undefined; - const limit = Number(req.query.limit) || 50; - if (limit < 1 || limit > 100) - throw new HTTPError("limit must be between 1 and 100", 422); - - const permissions = await getPermission( - req.user_id, - channel.guild_id, - channel_id, - ); - permissions.hasThrow("VIEW_CHANNEL"); - if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]); - - const query: FindManyOptions & { - where: { id?: FindOperator | FindOperator[] }; - } = { - order: { timestamp: "DESC" }, - take: limit, - where: { channel_id }, - relations: [ - "author", - "webhook", - "application", - "mentions", - "mention_roles", - "mention_channels", - "sticker_items", - "attachments", - ], - }; - - let messages: Message[]; - - if (around) { - query.take = Math.floor(limit / 2); - if (query.take != 0) { - const [right, left] = await Promise.all([ - Message.find({ - ...query, - where: { channel_id, id: LessThan(around) }, - }), - Message.find({ - ...query, - where: { channel_id, id: MoreThanOrEqual(around) }, - order: { timestamp: "ASC" }, - }), - ]); - left.push(...right); - messages = left.sort( - (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), - ); - } else { - query.take = 1; - const message = await Message.findOne({ - ...query, - where: { channel_id, id: around }, - }); - messages = message ? [message] : []; - } - } else { - if (after) { - if (BigInt(after) > BigInt(Snowflake.generate())) - throw new HTTPError( - "after parameter must not be greater than current time", - 422, - ); - - query.where.id = MoreThan(after); - query.order = { timestamp: "ASC" }; - } else if (before) { - if (BigInt(before) > BigInt(Snowflake.generate())) - throw new HTTPError( - "before parameter must not be greater than current time", - 422, - ); - - query.where.id = LessThan(before); - } - - messages = await Message.find(query); - } - - const endpoint = Config.get().cdn.endpointPublic; - - await populateForwardLinks(messages); - - const ret = messages.map((x: Message) => { - x = x.toJSON(); - - (x.reactions || []).forEach((y: Partial) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - if ((y.user_ids || []).includes(req.user_id)) y.me = true; - delete y.user_ids; - }); - if (!x.author) - x.author = User.create({ - id: "4", - discriminator: "0000", - username: "Spacebar Ghost", - public_flags: 0, - }); - x.attachments?.forEach((y: Attachment) => { - // dynamically set attachment proxy_url in case the endpoint changed - const uri = y.proxy_url.startsWith("http") - ? y.proxy_url - : `https://example.org${y.proxy_url}`; - - const url = new URL(uri); - if (endpoint) { - const newBase = new URL(endpoint); - url.protocol = newBase.protocol; - url.hostname = newBase.hostname; - url.port = newBase.port; - } - - y.proxy_url = url.toString(); - - y.proxy_url = getUrlSignature( - new NewUrlSignatureData({ - url: y.proxy_url, - userAgent: req.headers["user-agent"], - ip: req.ip, - }), - ) - .applyToUrl(y.proxy_url) - .toString(); - - y.url = getUrlSignature( - new NewUrlSignatureData({ - url: y.url, - userAgent: req.headers["user-agent"], - ip: req.ip, - }), - ) - .applyToUrl(y.url) - .toString(); - }); - - /** + "/", + route({ + query: { + around: { + type: "string", + required: false, + }, + before: { + type: "string", + required: false, + }, + after: { + type: "string", + required: false, + }, + limit: { + type: "number", + required: false, + description: "max number of messages to return (1-100). defaults to 50", + }, + }, + responses: { + 200: { + body: "APIMessageArray", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const channel_id = req.params.channel_id; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + if (!channel) throw new HTTPError("Channel not found", 404); + + isTextChannel(channel.type); + const around = req.query.around ? `${req.query.around}` : undefined; + const before = req.query.before ? `${req.query.before}` : undefined; + const after = req.query.after ? `${req.query.after}` : undefined; + const limit = Number(req.query.limit) || 50; + if (limit < 1 || limit > 100) throw new HTTPError("limit must be between 1 and 100", 422); + + const permissions = await getPermission(req.user_id, channel.guild_id, channel_id); + permissions.hasThrow("VIEW_CHANNEL"); + if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]); + + const messages = await getProjectedMessagesForChannel(channel_id, req.user_id, { + around, + before, + after, + limit, + }); + + const endpoint = Config.get().cdn.endpointPublic; + + await populateForwardLinks(messages); + + const intimacyRule = await getIntimacyBroadcastRuleForChannel(channel_id); + const isIntimacyBroadcast = intimacyRule !== null; + + const ret = messages.map((x: Message) => { + const projected = x.toProjectedJSON(channel_id); + + let reactions = projected.reactions || []; + if (isIntimacyBroadcast) { + reactions = filterReactionsForIntimacyBroadcast(reactions, req.user_id, x.author_id); + } + + reactions.forEach((y: Partial) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + if ((y.user_ids || []).includes(req.user_id)) y.me = true; + delete y.user_ids; + }); + projected.reactions = reactions; + if (!projected.author) + projected.author = User.create({ + id: "4", + discriminator: "0000", + username: "Spacebar Ghost", + public_flags: 0, + }); + projected.attachments?.forEach((y: Attachment) => { + // dynamically set attachment proxy_url in case the endpoint changed + const uri = y.proxy_url.startsWith("http") ? y.proxy_url : `https://example.org${y.proxy_url}`; + + const url = new URL(uri); + if (endpoint) { + const newBase = new URL(endpoint); + url.protocol = newBase.protocol; + url.hostname = newBase.hostname; + url.port = newBase.port; + } + + y.proxy_url = url.toString(); + + y.proxy_url = getUrlSignature( + new NewUrlSignatureData({ + url: y.proxy_url, + userAgent: req.headers["user-agent"], + ip: req.ip, + }), + ) + .applyToUrl(y.proxy_url) + .toString(); + + y.url = getUrlSignature( + new NewUrlSignatureData({ + url: y.url, + userAgent: req.headers["user-agent"], + ip: req.ip, + }), + ) + .applyToUrl(y.url) + .toString(); + }); + + /** Some clients ( discord.js ) only check if a property exists within the response, which causes errors when, say, the `application` property is `null`. **/ - // for (var curr in x) { - // if (x[curr] === null) - // delete x[curr]; - // } - - return x; - }); - - return res.json(ret); - }, + // for (var curr in projected) { + // if (projected[curr] === null) + // delete projected[curr]; + // } + + return projected; + }); + + await ret + .filter((x: MessageCreateSchema) => x.interaction_metadata && !x.interaction_metadata.user) + .forEachAsync(async (x: MessageCreateSchema) => { + x.interaction_metadata!.user = x.interaction!.user = await User.findOneOrFail({ where: { id: (x as Message).interaction_metadata!.user_id } }); + }); + + // polyfill message references for old messages + await ret + .filter((msg) => msg.message_reference && !msg.referenced_message?.id) + .forEachAsync(async (msg) => { + const whereOptions: { id: string; guild_id?: string; channel_id?: string } = { + id: msg.message_reference!.message_id, + }; + if (msg.message_reference!.guild_id) whereOptions.guild_id = msg.message_reference!.guild_id; + if (msg.message_reference!.channel_id) whereOptions.channel_id = msg.message_reference!.channel_id; + + msg.referenced_message = await Message.findOne({ where: whereOptions, relations: ["author", "mentions", "mention_roles", "mention_channels"] }); + }); + + return res.json(ret); + }, ); // TODO: config max upload size const messageUpload = multer({ - limits: { - fileSize: Config.get().limits.message.maxAttachmentSize, - fields: 10, - // files: 1 - }, - storage: multer.memoryStorage(), + limits: { + fileSize: Config.get().limits.message.maxAttachmentSize, + fields: 10, + // files: 1 + }, + storage: multer.memoryStorage(), }); // max upload 50 mb /** TODO: dynamically change limit of MessageCreateSchema with config @@ -299,215 +265,263 @@ const messageUpload = multer({ **/ // Send message router.post( - "/", - messageUpload.any(), - (req, res, next) => { - if (req.body.payload_json) { - req.body = JSON.parse(req.body.payload_json); - } - - next(); - }, - route({ - requestBody: "MessageCreateSchema", - permission: "SEND_MESSAGES", - right: "SEND_MESSAGES", - responses: { - 200: { - body: "Message", - }, - 400: { - body: "APIErrorResponse", - }, - 403: {}, - 404: {}, - }, - }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - const body = req.body as MessageCreateSchema; - const attachments: Attachment[] = []; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: ["recipients", "recipients.user"], - }); - if (!channel.isWritable()) { - throw new HTTPError( - `Cannot send messages to channel of type ${channel.type}`, - 400, - ); - } - - if (body.nonce) { - const existing = await Message.findOne({ - where: { - nonce: body.nonce, - channel_id: channel.id, - author_id: req.user_id, - }, - }); - if (existing) { - return res.json(existing); - } - } - - if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) { - const limits = Config.get().limits; - if (limits.absoluteRate.register.enabled) { - const count = await Message.count({ - where: { - channel_id, - timestamp: MoreThan( - new Date( - Date.now() - - limits.absoluteRate.sendMessage.window, - ), - ), - }, - }); - - if (count >= limits.absoluteRate.sendMessage.limit) - throw FieldErrors({ - channel_id: { - code: "TOO_MANY_MESSAGES", - message: req.t("common:toomany.MESSAGE"), - }, - }); - } - - if (channel.rate_limit_per_user && channel.rate_limit_per_user > 0) { - const lastMessage = await Message.findOne({ - where: { - channel_id, - author_id: req.user_id, - }, - order: { timestamp: "DESC" }, - select: ["timestamp"], - }); - - if (lastMessage) { - const timeSinceLastMessage = Date.now() - lastMessage.timestamp.getTime(); - const slowmodeMs = channel.rate_limit_per_user * 1000; - - if (timeSinceLastMessage < slowmodeMs) { - throw DiscordApiErrors.SLOWMODE_RATE_LIMIT; - } - } - } - } - - const files = (req.files as Express.Multer.File[]) ?? []; - for (const currFile of files) { - try { - const file = await uploadFile( - `/attachments/${channel.id}`, - currFile, - ); - attachments.push( - Attachment.create({ ...file, proxy_url: file.url }), - ); - } catch (error) { - return res.status(400).json({ message: error?.toString() }); - } - } - - const embeds = body.embeds || []; - if (body.embed) embeds.push(body.embed); - const message = await handleMessage({ - ...body, - type: 0, - pinned: false, - author_id: req.user_id, - embeds, - channel_id, - attachments, - timestamp: new Date(), - }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore dont care2 - message.edited_timestamp = null; - - channel.last_message_id = message.id; - - if (channel.isDm()) { - const channel_dto = await DmChannelDTO.from(channel); - - // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed - await Promise.all( - channel.recipients?.map((recipient) => { - if (recipient.closed) { - recipient.closed = false; - return Promise.all([ - recipient.save(), - emitEvent({ - event: "CHANNEL_CREATE", - data: channel_dto.excludedRecipients([ - recipient.user_id, - ]), - user_id: recipient.user_id, - }), - ]); - } - }) || [], - ); - } - - if (message.guild_id) { - // handleMessage will fetch the Member, but only if they are not guild owner. - // have to fetch ourselves otherwise. - if (!message.member) { - message.member = await Member.findOneOrFail({ - where: { id: req.user_id, guild_id: message.guild_id }, - relations: ["roles"], - }); - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - message.member.roles = message.member.roles - .filter((x) => x.id != x.guild_id) - .map((x) => x.id); - } - - let read_state = await ReadState.findOne({ - where: { user_id: req.user_id, channel_id }, - }); - if (!read_state) - read_state = ReadState.create({ user_id: req.user_id, channel_id }); - read_state.last_message_id = message.id; - - await Promise.all([ - read_state.save(), - message.save(), - emitEvent({ - event: "MESSAGE_CREATE", - channel_id: channel_id, - data: message, - } as MessageCreateEvent), - message.guild_id - ? Member.update( - { id: req.user_id, guild_id: message.guild_id }, - { last_message_id: message.id }, - ) - : null, - channel.save(), - ]); - - // no await as it shouldnt block the message send function and silently catch error - postHandleMessage(message).catch((e) => - console.error("[Message] post-message handler failed", e), - ); - - return res.json( - message.withSignedAttachments( - new NewUrlUserSignatureData({ - ip: req.ip, - userAgent: req.headers["user-agent"] as string, - }), - ), - ); - }, + "/", + messageUpload.any(), + (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + requestBody: "MessageCreateSchema", + permission: "SEND_MESSAGES", + right: "SEND_MESSAGES", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + const body = req.body as MessageCreateSchema; + const attachments: (Attachment | MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.attachments ?? []; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients", "recipients.user"], + }); + if (!channel.isWritable()) { + throw new HTTPError(`Cannot send messages to channel of type ${channel.type}`, 400); + } + + // handle blocked users in dms + if (channel.recipients?.length == 2) { + const otherUser = channel.recipients.find((r) => r.user_id != req.user_id)?.user; + if (otherUser) { + const relationship = await Relationship.findOne({ + where: [ + { from_id: req.user_id, to_id: otherUser.id }, + { from_id: otherUser.id, to_id: req.user_id }, + ], + }); + + if (relationship?.type === RelationshipType.blocked) { + throw DiscordApiErrors.CANNOT_MESSAGE_USER; + } + } + } + + if (body.nonce) { + const existing = await Message.findOne({ + where: { + nonce: body.nonce, + channel_id: channel.id, + author_id: req.user_id, + }, + }); + if (existing) { + return res.json(existing); + } + } + + if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) { + const limits = Config.get().limits; + if (limits.absoluteRate.sendMessage.enabled) { + const count = await Message.count({ + where: { + channel_id, + timestamp: MoreThan(new Date(Date.now() - limits.absoluteRate.sendMessage.window)), + }, + }); + + if (count >= limits.absoluteRate.sendMessage.limit) + throw FieldErrors({ + channel_id: { + code: "TOO_MANY_MESSAGES", + message: req.t("common:toomany.MESSAGE"), + }, + }); + } + + if (channel.rate_limit_per_user && channel.rate_limit_per_user > 0) { + const lastMessage = await Message.findOne({ + where: { + channel_id, + author_id: req.user_id, + }, + order: { timestamp: "DESC" }, + select: ["timestamp"], + }); + + if (lastMessage) { + const timeSinceLastMessage = Date.now() - lastMessage.timestamp.getTime(); + const slowmodeMs = channel.rate_limit_per_user * 1000; + + if (timeSinceLastMessage < slowmodeMs) { + throw DiscordApiErrors.SLOWMODE_RATE_LIMIT; + } + } + } + } + + const files = (req.files as Express.Multer.File[]) ?? []; + for (const currFile of files) { + try { + const file = await uploadFile(`/attachments/${channel.id}`, currFile); + attachments.push(Attachment.create({ ...file, proxy_url: file.url })); + } catch (error) { + return res.status(400).json({ message: error?.toString() }); + } + } + + const embeds = body.embeds || []; + if (body.embed) embeds.push(body.embed); + console.log("messages/index.ts: attachments:", attachments); + const message = await handleMessage({ + ...body, + type: 0, + pinned: false, + author_id: req.user_id, + embeds, + channel_id, + attachments, + timestamp: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore dont care2 + message.edited_timestamp = null; + + channel.last_message_id = message.id; + + if (channel.isDm()) { + const channel_dto = await DmChannelDTO.from(channel); + + // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed + await Promise.all( + channel.recipients?.map((recipient) => { + if (recipient.closed) { + recipient.closed = false; + return Promise.all([ + recipient.save(), + emitEvent({ + event: "CHANNEL_CREATE", + data: channel_dto.excludedRecipients([recipient.user_id]), + user_id: recipient.user_id, + }), + ]); + } + }) || [], + ); + } + + if (message.guild_id) { + // handleMessage will fetch the Member, but only if they are not guild owner. + // have to fetch ourselves otherwise. + if (!message.member) { + message.member = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: message.guild_id }, + relations: ["roles"], + }); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + message.member.roles = message.member.roles.filter((x) => x.id != x.guild_id).map((x) => x.id); + + if (message.content) + try { + const matchingRules = await AutomodRule.find({ + where: { guild_id: message.guild_id, enabled: true, event_type: AutomodRuleEventType.MESSAGE_SEND }, + order: { position: "ASC" }, + }); + for (const rule of matchingRules) { + if (rule.exempt_channels.includes(channel_id)) continue; + if (message.member.roles.some((x) => rule.exempt_roles.includes(x.id))) continue; + + if (rule.trigger_type == AutomodTriggerTypes.CUSTOM_WORDS) { + const triggerMeta = rule.trigger_metadata as AutomodCustomWordsRule; + const regexes = triggerMeta.regex_patterns.map((x) => new RegExp(x, "i")).concat(triggerMeta.keyword_filter.map((k) => k.globToRegexp("i"))); + const allowedRegexes = triggerMeta.allow_list.map((k) => k.globToRegexp("i")); + + const matches = regexes + .map((r) => message.content!.match(r)) + .filter((x) => x !== null && x.length > 0) + .filter((x) => !allowedRegexes.some((ar) => ar.test(x![0]))); + + if (matches.length > 0) { + console.log("Automod triggered by message:", message.id, "matches:", matches); + if (rule.actions.some((x) => x.type == AutomodRuleActionType.SEND_ALERT_MESSAGE && x.metadata.channel_id)) { + const alertActions = rule.actions.filter((x) => x.type == AutomodRuleActionType.SEND_ALERT_MESSAGE); + for (const action of alertActions) { + const alertChannel = await Channel.findOne({ where: { id: action.metadata.channel_id } }); + if (!alertChannel) continue; + const msg = await Message.createWithDefaults({ + content: `Automod Alert: Message ${message.id} by <@${message.author_id}> in <#${channel.id}> triggered automod rule "${rule.name}".\nMatched terms: ${matches + .map((x) => `\`${x![0]}\``) + .join(", ")}`, + author: message.author, + channel_id: alertChannel.id, + guild_id: message.guild_id, + member_id: message.member_id, + author_id: message.author_id, + }); + + await message.save(); + // await Promise.all([ + await emitEvent({ + event: "MESSAGE_CREATE", + channel_id: msg.channel_id, + data: msg.toJSON(), + } as MessageCreateEvent); + // ]); + } + } + } + } + } + } catch (e) { + console.log("[Automod] failed to process message:", e); + } + } + + let read_state = await ReadState.findOne({ + where: { user_id: req.user_id, channel_id }, + }); + if (!read_state) read_state = ReadState.create({ user_id: req.user_id, channel_id }); + read_state.last_message_id = message.id; + + await Promise.all([ + read_state.save(), + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: channel_id, + data: message, + } as MessageCreateEvent), + message.guild_id ? Member.update({ id: req.user_id, guild_id: message.guild_id }, { last_message_id: message.id }) : null, + channel.save(), + ]); + + // no await as it shouldnt block the message send function and silently catch error + postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); + + return res.json( + message.withSignedAttachments( + new NewUrlUserSignatureData({ + ip: req.ip, + userAgent: req.headers["user-agent"] as string, + }), + ), + ); + }, ); export default router; diff --git a/src/api/routes/channels/#channel_id/messages/pins/index.ts b/src/api/routes/channels/#channel_id/messages/pins/index.ts index 3316d1564..291d7c8a9 100644 --- a/src/api/routes/channels/#channel_id/messages/pins/index.ts +++ b/src/api/routes/channels/#channel_id/messages/pins/index.ts @@ -20,173 +20,182 @@ import { route } from "@spacebar/api"; import { ChannelPinsUpdateEvent, Config, DiscordApiErrors, emitEvent, Message, MessageCreateEvent, MessageUpdateEvent, User } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { IsNull, Not } from "typeorm"; +import { resolveMessageInChannel, computeProjectionsForMessage } from "../../../../../util/helpers/MessageProjection"; const router: Router = Router({ mergeParams: true }); router.put( - "/:message_id", - route({ - permission: "VIEW_CHANNEL", - responses: { - 204: {}, - 403: {}, - 404: {}, - 400: { - body: "APIErrorResponse", - }, - }, - }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - - const message = await Message.findOneOrFail({ - where: { id: message_id }, - relations: { author: true }, - }); - - // * in dm channels anyone can pin messages -> only check for guilds - if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES"); - - const pinned_count = await Message.count({ - where: { channel: { id: channel_id }, pinned_at: Not(IsNull()) }, - }); - - const { maxPins } = Config.get().limits.channel; - if (pinned_count >= maxPins) throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins); - - message.pinned_at = new Date(); - - const author = await User.getPublicUser(req.user_id); - - const systemPinMessage = Message.create({ - timestamp: new Date(), - type: 6, - guild_id: message.guild_id, - channel_id: message.channel_id, - author, - message_reference: { - message_id: message.id, - channel_id: message.channel_id, - guild_id: message.guild_id, - }, - reactions: [], - attachments: [], - embeds: [], - sticker_items: [], - edited_timestamp: undefined, - mentions: [], - mention_channels: [], - mention_roles: [], - mention_everyone: false, - }); - - await Promise.all([ - message.save(), - emitEvent({ - event: "MESSAGE_UPDATE", - channel_id, - data: message, - } as MessageUpdateEvent), - emitEvent({ - event: "CHANNEL_PINS_UPDATE", - channel_id, - data: { - channel_id, - guild_id: message.guild_id, - last_pin_timestamp: undefined, - }, - } as ChannelPinsUpdateEvent), - systemPinMessage.save(), - emitEvent({ - event: "MESSAGE_CREATE", - channel_id: message.channel_id, - data: systemPinMessage, - } as MessageCreateEvent), - ]); - - res.sendStatus(204); - }, + "/:message_id", + route({ + permission: "VIEW_CHANNEL", + responses: { + 204: {}, + 403: {}, + 404: {}, + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + // * in dm channels anyone can pin messages -> only check for guilds + if (message.guild_id) req.permission?.hasThrow("PIN_MESSAGES"); + + const pinned_count = await Message.count({ + where: { channel: { id: channel_id }, pinned_at: Not(IsNull()) }, + }); + + const { maxPins } = Config.get().limits.channel; + if (pinned_count >= maxPins) throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins); + + message.pinned_at = new Date(); + + const author = await User.getPublicUser(req.user_id); + + const systemPinMessage = Message.create({ + timestamp: new Date(), + type: 6, + guild_id: message.guild_id, + channel_id: message.channel_id, + author, + message_reference: { + message_id: message.id, + channel_id: message.channel_id, + guild_id: message.guild_id, + }, + reactions: [], + attachments: [], + embeds: [], + sticker_items: [], + edited_timestamp: undefined, + mentions: [], + mention_channels: [], + mention_roles: [], + mention_everyone: false, + }); + + await message.save(); + await systemPinMessage.save(); + + const projections = await computeProjectionsForMessage(message); + + await Promise.all([ + ...projections.map((projection) => + emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: projection.channelId, + data: message.toProjectedJSON(projection.channelId), + } as MessageUpdateEvent), + ), + ...projections.map((projection) => + emitEvent({ + event: "CHANNEL_PINS_UPDATE", + channel_id: projection.channelId, + data: { + channel_id: projection.channelId, + guild_id: message.guild_id, + last_pin_timestamp: undefined, + }, + } as ChannelPinsUpdateEvent), + ), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: message.channel_id, + data: systemPinMessage, + } as MessageCreateEvent), + ]); + + res.sendStatus(204); + }, ); router.delete( - "/:message_id", - route({ - permission: "VIEW_CHANNEL", - responses: { - 204: {}, - 403: {}, - 404: {}, - 400: { - body: "APIErrorResponse", - }, - }, - }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - - const message = await Message.findOneOrFail({ - where: { id: message_id }, - relations: { author: true }, - }); - - if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES"); - - message.pinned_at = null; - - await Promise.all([ - message.save(), - emitEvent({ - event: "MESSAGE_UPDATE", - channel_id, - data: message, - } as MessageUpdateEvent), - emitEvent({ - event: "CHANNEL_PINS_UPDATE", - channel_id, - data: { - channel_id, - guild_id: message.guild_id, - last_pin_timestamp: undefined, - }, - } as ChannelPinsUpdateEvent), - ]); - - res.sendStatus(204); - }, + "/:message_id", + route({ + permission: "VIEW_CHANNEL", + responses: { + 204: {}, + 403: {}, + 404: {}, + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + + const message = await resolveMessageInChannel(message_id, channel_id, req.user_id); + + if (message.guild_id) req.permission?.hasThrow("PIN_MESSAGES"); + + message.pinned_at = null; + + await message.save(); + + const projections = await computeProjectionsForMessage(message); + + await Promise.all([ + ...projections.map((projection) => + emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: projection.channelId, + data: message.toProjectedJSON(projection.channelId), + } as MessageUpdateEvent), + ), + ...projections.map((projection) => + emitEvent({ + event: "CHANNEL_PINS_UPDATE", + channel_id: projection.channelId, + data: { + channel_id: projection.channelId, + guild_id: message.guild_id, + last_pin_timestamp: undefined, + }, + } as ChannelPinsUpdateEvent), + ), + ]); + + res.sendStatus(204); + }, ); router.get( - "/", - route({ - permission: ["READ_MESSAGE_HISTORY"], - responses: { - 200: { - body: "APIMessageArray", - }, - 400: { - body: "APIErrorResponse", - }, - }, - }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - - const pins = await Message.find({ - where: { channel_id: channel_id, pinned_at: Not(IsNull()) }, - relations: { author: true }, - order: { pinned_at: "DESC" }, - }); - - const items = pins.map((message: Message) => ({ - message, - pinned_at: message.pinned_at, - })); - - res.send({ - items, - has_more: false, - }); - }, + "/", + route({ + permission: ["READ_MESSAGE_HISTORY"], + responses: { + 200: { + body: "APIMessageArray", + }, + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + + const pins = await Message.find({ + where: { channel_id: channel_id, pinned_at: Not(IsNull()) }, + relations: ["author"], + order: { pinned_at: "DESC" }, + }); + + const items = pins.map((message: Message) => ({ + message, + pinned_at: message.pinned_at, + })); + + res.send({ + items, + has_more: false, + }); + }, ); export default router; diff --git a/src/api/routes/routing-rules.ts b/src/api/routes/routing-rules.ts new file mode 100644 index 000000000..cf1dd75d9 --- /dev/null +++ b/src/api/routes/routing-rules.ts @@ -0,0 +1,242 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { route } from "@spacebar/api"; +import { RoutingRule, Snowflake, Channel, getPermission, getRights } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; + +const router: Router = Router(); + +router.get( + "/", + route({ + responses: { + 200: { + body: "RoutingRule[]", + }, + }, + }), + async (req: Request, res: Response) => { + const user_id = req.user_id; + if (!user_id) throw new HTTPError("Unauthorized", 401); + + const rights = await getRights(user_id); + const hasManageRoutingRight = rights.has("MANAGE_ROUTING"); + + let rules: RoutingRule[]; + + if (hasManageRoutingRight) { + rules = await RoutingRule.find({ + relations: ["source_channel", "storage_channel", "sink_channel"], + }); + } else { + const allRules = await RoutingRule.find({ + relations: ["source_channel", "storage_channel", "sink_channel"], + }); + + rules = []; + for (const rule of allRules) { + try { + if (rule.source_channel?.guild_id) { + const permission = await getPermission(user_id, rule.source_channel.guild_id, rule.source_channel_id); + if (permission.has("VIEW_CHANNEL")) { + let canViewStorage = true; + let canViewSink = true; + + if (rule.storage_channel?.guild_id) { + try { + const storagePermission = await getPermission(user_id, rule.storage_channel.guild_id, rule.storage_channel_id); + canViewStorage = storagePermission.has("VIEW_CHANNEL"); + } catch (e) { + canViewStorage = false; + } + } + + if (rule.sink_channel?.guild_id) { + try { + const sinkPermission = await getPermission(user_id, rule.sink_channel.guild_id, rule.sink_channel_id); + canViewSink = sinkPermission.has("VIEW_CHANNEL"); + } catch (e) { + canViewSink = false; + } + } + + if (!canViewStorage) { + rule.storage_channel = undefined; + } + if (!canViewSink) { + rule.sink_channel = undefined; + } + + rules.push(rule); + } + } + } catch (e) { + continue; + } + } + } + + return res.json(rules); + }, +); + +router.post( + "/", + route({ + right: "MANAGE_ROUTING", + responses: { + 201: { + body: "RoutingRule", + }, + 400: { + body: "APIErrorResponse", + }, + 403: { + body: "APIErrorResponse", + }, + 404: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { source_channel_id, storage_channel_id, sink_channel_id, source_users, target_users, valid_since, valid_until } = req.body; + + if (!source_channel_id || !storage_channel_id || !sink_channel_id || !source_users || !target_users || !valid_since || !valid_until) { + throw new HTTPError("Missing required fields", 400); + } + + const sourceChannel = await Channel.findOne({ + where: { id: source_channel_id }, + }); + const storageChannel = await Channel.findOne({ + where: { id: storage_channel_id }, + }); + const sinkChannel = await Channel.findOne({ + where: { id: sink_channel_id }, + }); + + if (!sourceChannel || !storageChannel || !sinkChannel) { + throw new HTTPError("One or more channels not found", 404); + } + + const rule = RoutingRule.create({ + registration_timestamp: Snowflake.generate(), + source_channel_id, + storage_channel_id, + sink_channel_id, + source_users: Array.isArray(source_users) ? source_users : [source_users], + target_users: Array.isArray(target_users) ? target_users : [target_users], + valid_since, + valid_until, + }); + + await RoutingRule.validateInvariants(rule); + + await rule.save(); + + return res.status(201).json(rule); + }, +); + +router.patch( + "/:rule_id", + route({ + right: "MANAGE_ROUTING", + responses: { + 200: { + body: "RoutingRule", + }, + 400: { + body: "APIErrorResponse", + }, + 403: { + body: "APIErrorResponse", + }, + 404: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { rule_id } = req.params; + const { valid_until } = req.body; + + if (!valid_until) { + throw new HTTPError("Only valid_until can be updated on routing rules", 400); + } + + const rule = await RoutingRule.findOne({ + where: { id: rule_id }, + }); + + if (!rule) { + throw new HTTPError("Routing rule not found", 404); + } + + const now = Snowflake.generate(); + if (BigInt(rule.valid_since) > BigInt(now) || BigInt(now) > BigInt(rule.valid_until)) { + throw new HTTPError("Cannot update routing rule that is not currently in effect", 400); + } + + if (BigInt(valid_until) < BigInt(rule.valid_since)) { + throw new HTTPError("valid_until cannot be earlier than valid_since", 400); + } + + rule.valid_until = valid_until; + await rule.save(); + + return res.json(rule); + }, +); + +router.delete( + "/:rule_id", + route({ + right: "MANAGE_ROUTING", + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 403: { + body: "APIErrorResponse", + }, + 404: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { rule_id } = req.params; + + const canDelete = await RoutingRule.canDelete(rule_id); + + if (!canDelete) { + throw new HTTPError("Cannot delete routing rule: rule is in effect or has affected messages", 400); + } + + await RoutingRule.delete({ id: rule_id }); + + return res.status(204).send(); + }, +); + +export default router; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index 6adbf9ff5..fe2d54d17 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -1,24 +1,22 @@ /* - Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ -import * as Sentry from "@sentry/node"; import { AutomodEvaluator, AutomodActionExecutor } from "@spacebar/util"; - import { EmbedHandlers } from "@spacebar/api"; import { Application, @@ -45,16 +43,15 @@ import { handleFile, Permissions, normalizeUrl, - DiscordApiErrors, - CloudAttachment, - ReadState, - Member, - Session, - MessageFlags, + RoutingRule, + Snowflake, } from "@spacebar/util"; import { HTTPError } from "lambert-server"; -import { In, Or, Equal, IsNull } from "typeorm"; -import { ChannelType, Embed, EmbedType, MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageType, Reaction } from "@spacebar/schemas"; +import { In, LessThanOrEqual, MoreThanOrEqual } from "typeorm"; +import fetch from "node-fetch-commonjs"; +import { CloudAttachment } from "../../../util/entities/CloudAttachment"; +import { Embed, MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageType, Reaction } from "@spacebar/schemas"; +import { computeProjectionsForMessage } from "../helpers/MessageProjection"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images @@ -64,25 +61,10 @@ const LINK_REGEX = / { const channel = await Channel.findOneOrFail({ where: { id: opts.channel_id }, - relations: { recipients: true }, + relations: ["recipients"], }); if (!channel || !opts.channel_id) throw new HTTPError("Channel not found", 404); - let permission: undefined | Permissions; - const limit = channel.rate_limit_per_user; - - if (limit) { - const lastMsgTime = (await Message.findOne({ where: { channel_id: channel.id, author_id: opts.author_id }, select: { timestamp: true }, order: { timestamp: "DESC" } })) - ?.timestamp; - if (lastMsgTime && Date.now() - limit * 1000 < +lastMsgTime) { - permission ||= await getPermission(opts.author_id, channel.guild_id, channel); - //FIXME MANAGE_MESSAGES and MANAGE_CHANNELS will need to be removed once they're gone as checks - if (!permission.has("MANAGE_MESSAGES") && !permission.has("MANAGE_CHANNELS") && !permission.has("BYPASS_SLOWMODE")) { - throw DiscordApiErrors.SLOWMODE_RATE_LIMIT; - } - } - } - const stickers = opts.sticker_ids ? await Sticker.find({ where: { id: In(opts.sticker_ids) } }) : undefined; // cloud attachments with indexes const cloudAttachments = opts.attachments?.reduce( @@ -108,7 +90,6 @@ export async function handleMessage(opts: MessageOptions): Promise { mentions: [], components: opts.components ?? undefined, // Fix Discord-Go? }); - const ephermal = (message.flags & (1 << 6)) !== 0; if (cloudAttachments && cloudAttachments.length > 0) { console.log("[Message] Processing attachments for message", message.id, ":", message.attachments); @@ -153,17 +134,16 @@ export async function handleMessage(opts: MessageOptions): Promise { for (const att of uploadedAttachments) { message.attachments![att.index] = att.attachment; } - } - // else console.log("[Message] No cloud attachments to process for message", message.id, ":", message.attachments); + } else console.log("[Message] No cloud attachments to process for message", message.id, ":", message.attachments); + + console.log("opts:", opts.attachments, "\nmessage:", message.attachments); if (message.content && message.content.length > Config.get().limits.message.maxCharacters) { throw new HTTPError("Content length over max character limit"); } if (opts.author_id) { - message.author = await User.findOneOrFail({ - where: { id: opts.author_id }, - }); + message.author = await User.getPublicUser(opts.author_id); const rights = await getRights(opts.author_id); rights.hasThrow("SEND_MESSAGES"); } @@ -173,6 +153,7 @@ export async function handleMessage(opts: MessageOptions): Promise { }); } + let permission: undefined | Permissions; if (opts.webhook_id) { message.webhook = await Webhook.findOneOrFail({ where: { id: opts.webhook_id }, @@ -210,7 +191,7 @@ export async function handleMessage(opts: MessageOptions): Promise { } if (opts.avatar_url) { const avatarData = await fetch(opts.avatar_url); - const base64 = await avatarData.arrayBuffer().then((x) => Buffer.from(x).toString("base64")); + const base64 = await avatarData.buffer().then((x) => x.toString("base64")); const dataUri = "data:" + avatarData.headers.get("content-type") + ";base64," + base64; @@ -218,7 +199,7 @@ export async function handleMessage(opts: MessageOptions): Promise { message.author.avatar = message.avatar; } } else { - permission ||= await getPermission(opts.author_id, channel.guild_id, channel); + permission = await getPermission(opts.author_id, channel.guild_id, opts.channel_id); permission.hasThrow("SEND_MESSAGES"); if (permission.cache.member) { message.member = permission.cache.member; @@ -235,7 +216,7 @@ export async function handleMessage(opts: MessageOptions): Promise { if (!opts.message_reference.guild_id) opts.message_reference.guild_id = channel.guild_id; if (!opts.message_reference.channel_id) opts.message_reference.channel_id = opts.channel_id; - if (opts.message_reference.type != 1) { + if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) { if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild"); if (opts.message_reference.channel_id !== opts.channel_id) throw new HTTPError("You can only reference messages from this channel"); } @@ -245,16 +226,7 @@ export async function handleMessage(opts: MessageOptions): Promise { where: { id: opts.message_reference.message_id, }, - relations: { - author: true, - webhook: true, - application: true, - mentions: true, - mention_roles: true, - mention_channels: true, - sticker_items: true, - attachments: true, - }, + relations: ["author", "webhook", "application", "mentions", "mention_roles", "mention_channels", "sticker_items", "attachments"], }); if (message.referenced_message.channel_id && message.referenced_message.channel_id !== opts.message_reference.channel_id) @@ -263,22 +235,13 @@ export async function handleMessage(opts: MessageOptions): Promise { throw new HTTPError("Referenced message not found in the specified channel", 404); } /** Q: should be checked if the referenced message exists? ANSWER: NO - otherwise backfilling won't work **/ + otherwise backfilling won't work **/ message.type = MessageType.REPLY; } } // TODO: stickers/activity - if ( - !allow_empty && - !opts.content && - !opts.embeds?.length && - !opts.attachments?.length && - !opts.sticker_ids?.length && - !opts.poll && - !opts.components?.length && - opts.message_reference?.type != 1 - ) { + if (!allow_empty && !opts.content && !opts.embeds?.length && !opts.attachments?.length && !opts.sticker_ids?.length && !opts.poll && !opts.components?.length) { console.log("[Message] Rejecting empty message:", opts, message); throw new HTTPError("Empty messages are not allowed", 50006); } @@ -297,9 +260,9 @@ export async function handleMessage(opts: MessageOptions): Promise { content = content.replace(/ *`[^)]*` */g, ""); // remove codeblocks // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients. /*for (const [, mention] of content.matchAll(CHANNEL_MENTION)) { - if (!mention_channel_ids.includes(mention)) - mention_channel_ids.push(mention); - }*/ + if (!mention_channel_ids.includes(mention)) + mention_channel_ids.push(mention); + }*/ for (const [, mention] of content.matchAll(USER_MENTION)) { if (!mention_user_ids.includes(mention)) mention_user_ids.push(mention); @@ -336,152 +299,48 @@ export async function handleMessage(opts: MessageOptions): Promise { ); } - // FORWARD - if (message.message_reference.type === 1) { - message.type = MessageType.DEFAULT; - - if (message.referenced_message) { - const mention_roles: string[] = []; - const mentions: string[] = []; - - // TODO: mention_roles and mentions arrays - not needed it seems, but discord still returns that - - message.message_snapshots = [ - { - message: { - attachments: message.referenced_message.attachments, - components: message.referenced_message.components, - content: message.referenced_message.content!, - edited_timestamp: message.referenced_message.edited_timestamp, - embeds: message.referenced_message.embeds, - flags: message.referenced_message.flags, - mention_roles, - mentions, - timestamp: message.referenced_message.timestamp, - type: message.referenced_message.type, - }, - }, - ]; + if (referencedMessage) { + if (!referencedMessage.reply_ids) { + referencedMessage.reply_ids = []; + } + if (!referencedMessage.reply_ids.includes(message.id)) { + referencedMessage.reply_ids.push(message.id); + await Message.update({ id: referencedMessage.id }, { reply_ids: referencedMessage.reply_ids }); } } } // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients. /*message.mention_channels = mention_channel_ids.map((x) => - Channel.create({ id: x }), - );*/ - message.mention_roles = ( - await Promise.all( - mention_role_ids.map((x) => { - return Role.findOne({ where: { id: x } }); - }), - ) - ).filter((role) => role !== null); - - message.mentions = [ - ...message.mentions, - ...( - await Promise.all( - mention_user_ids.map((x) => { - return User.findOne({ where: { id: x } }); - }), - ) - ).filter((user) => user !== null), - ]; + Channel.create({ id: x }), + );*/ + message.mention_roles = mention_role_ids.map((x) => Role.create({ id: x })); + message.mentions = [...message.mentions, ...mention_user_ids.map((x) => User.create({ id: x }))]; message.mention_everyone = mention_everyone; - async function fillInMissingIDs(ids: string[]) { - const states = await ReadState.findBy({ - user_id: Or(...ids.map((id) => Equal(id))), - channel_id: channel.id, - }); - const users = new Set(ids); - states.forEach((state) => users.delete(state.user_id)); - if (!users.size) { - return; - } - return Promise.all( - [...users].map((user_id) => { - return ReadState.create({ user_id, channel_id: channel.id }).save(); - }), - ); - } - if (ephermal) { - const id = message.interaction_metadata?.user_id; - if (id) { - let pinged = mention_everyone || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM; - if (!pinged) pinged = !!message.mentions.find((user) => user.id === id); - if (!pinged) pinged = !!(await Member.find({ where: { id, roles: Or(...message.mention_roles.map(({ id }) => Equal(id))) } })); - if (pinged) { - //stuff - } - } - } else if ((!!message.content?.match(EVERYONE_MENTION) && permission?.has("MENTION_EVERYONE")) || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) { - if (channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) { - if (channel.recipients) { - await fillInMissingIDs(channel.recipients.map(({ user_id }) => user_id)); - } - } else { - console.log(channel.guild_id); - await fillInMissingIDs((await Member.find({ where: { guild_id: channel.guild_id } })).map(({ id }) => id)); - } - const repository = ReadState.getRepository(); - const condition = { channel_id: channel.id }; - await repository.update({ ...condition, mention_count: IsNull() }, { mention_count: 0 }); - await repository.increment(condition, "mention_count", 1); - } else { - const users = new Set([ - ...(message.mention_roles.length - ? await Member.find({ - where: [ - ...message.mention_roles.map((role) => { - return { roles: { id: role.id } }; - }), - ], - }) - : [] - ).map((member) => member.id), - ...message.mentions.map((user) => user.id), - ]); - if (!!message.content?.match(HERE_MENTION) && permission?.has("MENTION_EVERYONE")) { - const ids = (await Member.find({ where: { guild_id: channel.guild_id } })).map(({ id }) => id); - (await Session.find({ where: { user_id: Or(...ids.map((id) => Equal(id))) } })).forEach(({ user_id }) => users.add(user_id)); - } - if (users.size) { - const repository = ReadState.getRepository(); - const condition = { user_id: Or(...[...users].map((id) => Equal(id))), channel_id: channel.id }; - - await fillInMissingIDs([...users]); - - await repository.update({ ...condition, mention_count: IsNull() }, { mention_count: 0 }); - await repository.increment(condition, "mention_count", 1); - } - } + // TODO: check and put it all in the body - // Automod enforcement - evaluate message against guild automod rules if (message.guild_id && message.content && message.author) { - const automodResult = await AutomodEvaluator.evaluateMessage({ - content: message.content, - channel, - author: message.author, - guild_id: message.guild_id, - member_roles: permission?.cache.member?.roles?.map((r) => r.id), - }); - - if (automodResult.triggered && automodResult.rule) { - await AutomodActionExecutor.executeActions(automodResult.actions, { - message, - channel, - member: permission?.cache.member, - rule_name: automodResult.rule.name, - matched_content: automodResult.matched_content, - keyword: automodResult.keyword, - }); - } - } + const automodResult = await AutomodEvaluator.evaluateMessage({ + content: message.content, + channel, + author: message.author, + guild_id: message.guild_id, + member_roles: permission?.cache.member?.roles?.map((r) => r.id), + }); - // TODO: check and put it all in the body + if (automodResult.triggered && automodResult.rule) { + await AutomodActionExecutor.executeActions(automodResult.actions, { + message, + channel, + member: permission?.cache.member, + rule_name: automodResult.rule.name, + matched_content: automodResult.matched_content, + keyword: automodResult.keyword, + }); + } + } return message; } @@ -508,11 +367,6 @@ export async function postHandleMessage(message: Message) { } } - data.embeds.forEach((embed) => { - if (!embed.type) { - embed.type = EmbedType.rich; - } - }); // Filter out embeds that could be links, start from scratch data.embeds = data.embeds.filter((embed) => embed.type === "rich"); @@ -539,7 +393,10 @@ export async function postHandleMessage(message: Message) { if (uniqueLinks.length === 0) { // No valid unique links found, update message to remove old embeds - data.embeds = data.embeds.filter((embed) => embed.type === "rich"); + data.embeds = data.embeds.filter((embed) => { + const hasUrl = !!embed.url; + return !hasUrl; + }); const author = data.author?.toPublicUser(); const event = { event: "MESSAGE_UPDATE", @@ -577,8 +434,8 @@ export async function postHandleMessage(message: Message) { } // bit gross, but whatever! - const endpointPublic = Config.get().cdn.endpointPublic; // lol - const handler = url.hostname === new URL(endpointPublic!).hostname ? EmbedHandlers["self"] : EmbedHandlers[url.hostname] || EmbedHandlers["default"]; + const endpointPublic = Config.get().cdn.endpointPublic || "http://127.0.0.1"; // lol + const handler = url.hostname === new URL(endpointPublic).hostname ? EmbedHandlers["self"] : EmbedHandlers[url.hostname] || EmbedHandlers["default"]; try { let res = await handler(url); @@ -611,20 +468,82 @@ export async function postHandleMessage(message: Message) { ]); } +async function evaluateRoutingForMessage( + sourceChannelId: string, + authorId: string, + messageId: string, +): Promise<{ storageChannelId: string; sourceChannelId: string; isIntimacyBroadcast: boolean }> { + const rules = await RoutingRule.find({ + where: { + source_channel_id: sourceChannelId, + }, + }); + + for (const rule of rules) { + const isValid = BigInt(rule.valid_since) <= BigInt(messageId) && BigInt(messageId) <= BigInt(rule.valid_until); + + if (!isValid) { + continue; + } + + if (rule.source_users.length > 0 && !rule.source_users.includes(authorId)) { + continue; + } + + const isIntimacyBroadcast = rule.isIntimacyBroadcast(); + + if (isIntimacyBroadcast) { + return { + storageChannelId: sourceChannelId, + sourceChannelId: sourceChannelId, + isIntimacyBroadcast: true, + }; + } + + return { + storageChannelId: rule.storage_channel_id, + sourceChannelId: sourceChannelId, + isIntimacyBroadcast: false, + }; + } + + return { + storageChannelId: sourceChannelId, + sourceChannelId: sourceChannelId, + isIntimacyBroadcast: false, + }; +} + export async function sendMessage(opts: MessageOptions) { - const message = await handleMessage({ ...opts, timestamp: new Date() }); + const messageId = opts.id || Snowflake.generate(); + const sourceChannelId = opts.channel_id!; + const authorId = opts.author_id!; - const ephemeral = (message.flags & Number(MessageFlags.FLAGS.EPHEMERAL)) !== 0; - await Promise.all([ - Message.insert(message), - emitEvent({ - event: "MESSAGE_CREATE", - ...(ephemeral ? { user_id: message.interaction_metadata?.user_id } : { channel_id: message.channel_id }), - data: message.toJSON(), - } as MessageCreateEvent), - ]); + const routing = await evaluateRoutingForMessage(sourceChannelId, authorId, messageId); + + const message = await handleMessage({ + ...opts, + id: messageId, + timestamp: new Date(), + }); + + message.channel_id = routing.storageChannelId; + message.source_channel_id = routing.sourceChannelId; + + await Message.insert(message); + + const projections = await computeProjectionsForMessage(message); + + await Promise.all( + projections.map((projection) => + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: projection.channelId, + data: message.toProjectedJSON(projection.channelId), + } as MessageCreateEvent), + ), + ); - // no await as it should catch error non-blockingly postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); return message; diff --git a/src/api/util/helpers/MessageProjection.ts b/src/api/util/helpers/MessageProjection.ts new file mode 100644 index 000000000..fb376e125 --- /dev/null +++ b/src/api/util/helpers/MessageProjection.ts @@ -0,0 +1,323 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Message, RoutingRule, Snowflake, Channel, getPermission } from "@spacebar/util"; +import { HTTPError } from "lambert-server"; +import { FindOperator, In, IsNull } from "typeorm"; +import { Reaction } from "@spacebar/schemas"; + +export interface MessageProjection { + channelId: string; + allowedUserIds?: string[]; + isIntimacyBroadcast?: boolean; + ruleId?: string; +} + +export interface ProjectedMessagesQuery { + around?: string; + before?: string; + after?: string; + limit: number; +} + +export async function computeProjectionsForMessage(message: Message): Promise { + const projections: MessageProjection[] = []; + const messageTimestamp = message.id; + const sourceChannelId = message.source_channel_id || message.channel_id; + + if (!sourceChannelId) { + return projections; + } + + projections.push({ + channelId: sourceChannelId, + allowedUserIds: undefined, + isIntimacyBroadcast: false, + }); + + const rules = await RoutingRule.find({ + where: { + source_channel_id: sourceChannelId, + }, + }); + + for (const rule of rules) { + const isValid = BigInt(rule.valid_since) <= BigInt(messageTimestamp) && BigInt(messageTimestamp) <= BigInt(rule.valid_until); + + if (!isValid) { + continue; + } + + if (rule.source_users.length > 0 && message.author_id && !rule.source_users.includes(message.author_id)) { + continue; + } + + const isIntimacyBroadcast = rule.isIntimacyBroadcast(); + + if (isIntimacyBroadcast) { + projections[0].isIntimacyBroadcast = true; + projections[0].ruleId = rule.id; + continue; + } + + if (rule.sink_channel_id) { + projections.push({ + channelId: rule.sink_channel_id, + allowedUserIds: rule.target_users.length > 0 ? rule.target_users : undefined, + isIntimacyBroadcast: false, + ruleId: rule.id, + }); + } + } + + return projections; +} + +export async function isMessageVisibleInChannelForUser(message: Message, channelId: string, userId: string): Promise { + const projections = await computeProjectionsForMessage(message); + + for (const projection of projections) { + if (projection.channelId === channelId) { + const channel = await Channel.findOne({ where: { id: channelId } }); + if (!channel) { + return false; + } + + try { + const permission = await getPermission(userId, channel.guild_id, channelId); + if (!permission.has("VIEW_CHANNEL")) { + return false; + } + } catch (e) { + return false; + } + + if (!projection.allowedUserIds) { + return true; + } + return projection.allowedUserIds.includes(userId); + } + } + + return false; +} + +export async function resolveMessageInChannel(messageId: string, channelId: string, userId: string): Promise { + const message = await Message.findOne({ + where: { id: messageId }, + relations: ["author", "webhook", "application", "attachments"], + }); + + if (!message) { + throw new HTTPError("Message not found", 404); + } + + const isVisible = await isMessageVisibleInChannelForUser(message, channelId, userId); + + if (!isVisible) { + throw new HTTPError("Message not found", 404); + } + + return message; +} + +export async function getProjectedMessagesForChannel(channelId: string, userId: string, query: ProjectedMessagesQuery): Promise { + const identityMessages = await Message.find({ + where: { + channel_id: channelId, + source_channel_id: channelId, + }, + order: { timestamp: "DESC" }, + take: query.limit * 2, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + "referenced_message", + "referenced_message.author", + ], + }); + + const legacyMessages = await Message.find({ + where: { + channel_id: channelId, + source_channel_id: IsNull(), + }, + order: { timestamp: "DESC" }, + take: query.limit * 2, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + "referenced_message", + "referenced_message.author", + ], + }); + + const sourceRules = await RoutingRule.find({ + where: { + source_channel_id: channelId, + }, + }); + + const sourceProjectedMessages: Message[] = []; + for (const rule of sourceRules) { + const messages = await Message.find({ + where: { + channel_id: rule.storage_channel_id, + source_channel_id: channelId, + }, + order: { timestamp: "DESC" }, + take: query.limit, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + "referenced_message", + "referenced_message.author", + ], + }); + + for (const message of messages) { + const isValid = BigInt(rule.valid_since) <= BigInt(message.id) && BigInt(message.id) <= BigInt(rule.valid_until); + + if (isValid) { + sourceProjectedMessages.push(message); + } + } + } + + const sinkRules = await RoutingRule.find({ + where: { + sink_channel_id: channelId, + }, + }); + + const sinkProjectedMessages: Message[] = []; + for (const rule of sinkRules) { + const messages = await Message.find({ + where: { + channel_id: rule.storage_channel_id, + source_channel_id: rule.source_channel_id, + }, + order: { timestamp: "DESC" }, + take: query.limit, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + "referenced_message", + "referenced_message.author", + ], + }); + + for (const message of messages) { + const isValid = BigInt(rule.valid_since) <= BigInt(message.id) && BigInt(message.id) <= BigInt(rule.valid_until); + + if (!isValid) { + continue; + } + + if (rule.source_users.length > 0 && message.author_id && !rule.source_users.includes(message.author_id)) { + continue; + } + + if (rule.target_users.length > 0 && !rule.target_users.includes(userId)) { + continue; + } + + sinkProjectedMessages.push(message); + } + } + + const allMessages = [...identityMessages, ...legacyMessages, ...sourceProjectedMessages, ...sinkProjectedMessages]; + + const messageMap = new Map(); + for (const message of allMessages) { + if (!messageMap.has(message.id)) { + messageMap.set(message.id, message); + } + } + + const dedupedMessages = Array.from(messageMap.values()); + + dedupedMessages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + + return dedupedMessages.slice(0, query.limit); +} + +export function filterReactionsForIntimacyBroadcast(reactions: Reaction[], viewingUserId: string, messageAuthorId?: string): Reaction[] { + if (!reactions || reactions.length === 0) { + return []; + } + + const isAuthor = viewingUserId === messageAuthorId; + if (isAuthor) { + return reactions; + } + + return reactions + .map((reaction) => { + const userReacted = reaction.user_ids?.includes(viewingUserId); + if (!userReacted) { + return null; + } + return { + ...reaction, + count: 1, + user_ids: [viewingUserId], + }; + }) + .filter((r): r is Reaction => r !== null); +} + +export async function getIntimacyBroadcastRuleForChannel(channelId: string): Promise { + const rules = await RoutingRule.find({ + where: { + source_channel_id: channelId, + }, + }); + + for (const rule of rules) { + if (rule.isIntimacyBroadcast() && rule.isActive()) { + return rule; + } + } + + return null; +} diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index 41028ee5e..4e688c99f 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -22,58 +22,17 @@ import { Role } from "./Role"; import { Channel } from "./Channel"; import { InteractionType } from "../interfaces/Interaction"; import { Application } from "./Application"; -import { - Column, - CreateDateColumn, - Entity, - Index, - JoinColumn, - JoinTable, - ManyToMany, - ManyToOne, - OneToMany, - RelationId, -} from "typeorm"; +import { Column, CreateDateColumn, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, RelationId } from "typeorm"; import { BaseClass } from "./BaseClass"; import { Guild } from "./Guild"; import { Webhook } from "./Webhook"; import { Sticker } from "./Sticker"; import { Attachment } from "./Attachment"; -import { dbEngine } from "../util/Database"; import { NewUrlUserSignatureData } from "../Signing"; - -export enum MessageType { - DEFAULT = 0, - RECIPIENT_ADD = 1, - RECIPIENT_REMOVE = 2, - CALL = 3, - CHANNEL_NAME_CHANGE = 4, - CHANNEL_ICON_CHANGE = 5, - CHANNEL_PINNED_MESSAGE = 6, - GUILD_MEMBER_JOIN = 7, - USER_PREMIUM_GUILD_SUBSCRIPTION = 8, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10, - USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11, - CHANNEL_FOLLOW_ADD = 12, - ACTION = 13, // /me messages - GUILD_DISCOVERY_DISQUALIFIED = 14, - GUILD_DISCOVERY_REQUALIFIED = 15, - ENCRYPTED = 16, - REPLY = 19, - APPLICATION_COMMAND = 20, // application command or self command invocation - AUTO_MODERATION_ACTION = 24, - ROUTE_ADDED = 41, // custom message routing: new route affecting that channel - ROUTE_DISABLED = 42, // custom message routing: given route no longer affecting that channel - SELF_COMMAND_SCRIPT = 43, // self command scripts - ENCRYPTION = 50, - CUSTOM_START = 63, - UNHANDLED = 255, -} +import { ActionRowComponent, ApplicationCommandType, Embed, MessageType, PartialMessage, Poll, Reaction } from "@spacebar/schemas"; @Entity({ name: "messages", - engine: dbEngine, }) @Index(["channel_id", "id"], { unique: true }) export class Message extends BaseClass { @@ -88,6 +47,10 @@ export class Message extends BaseClass { }) channel: Channel; + @Column({ nullable: true }) + @Index() + source_channel_id?: string; + @Column({ nullable: true }) @RelationId((message: Message) => message.guild) guild_id?: string; @@ -167,14 +130,10 @@ export class Message extends BaseClass { @ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" }) sticker_items?: Sticker[]; - @OneToMany( - () => Attachment, - (attachment: Attachment) => attachment.message, - { - cascade: true, - orphanedRowAction: "delete", - }, - ) + @OneToMany(() => Attachment, (attachment: Attachment) => attachment.message, { + cascade: true, + orphanedRowAction: "delete", + }) attachments?: Attachment[]; @Column({ type: "simple-json" }) @@ -186,8 +145,12 @@ export class Message extends BaseClass { @Column({ type: "text", nullable: true }) nonce?: string; - @Column({ nullable: true }) - pinned?: boolean; + @Column({ nullable: true, type: Date }) + pinned_at?: Date | null; + + get pinned(): boolean { + return this.pinned_at != null; + } @Column({ type: "int" }) type: MessageType; @@ -206,19 +169,28 @@ export class Message extends BaseClass { message_id: string; channel_id?: string; guild_id?: string; + type?: number; // 0 = DEFAULT, 1 = FORWARD }; @JoinColumn({ name: "message_reference_id" }) - @ManyToOne(() => Message) - referenced_message?: Message; + @ManyToOne(() => Message, { onDelete: "SET NULL" }) + referenced_message?: Message | null; @Column({ type: "simple-json", nullable: true }) interaction?: { id: string; type: InteractionType; name: string; - user_id: string; // the user who invoked the interaction - // user: User; // TODO: autopopulate user + }; + + @Column({ type: "simple-json", nullable: true }) + interaction_metadata?: { + id: string; + type: InteractionType; + user_id: string; + authorizing_integration_owners: object; + name: string; + command_type: ApplicationCommandType; }; @Column({ type: "simple-json", nullable: true }) @@ -243,12 +215,14 @@ export class Message extends BaseClass { member_id: undefined, webhook_id: this.webhook_id ?? undefined, application_id: undefined, + source_channel_id: undefined, nonce: this.nonce ?? undefined, tts: this.tts ?? false, guild: this.guild ?? undefined, webhook: this.webhook ?? undefined, interaction: this.interaction ?? undefined, + interaction_metadata: this.interaction_metadata ?? undefined, reactions: this.reactions ?? undefined, sticker_items: this.sticker_items ?? undefined, message_reference: this.message_reference ?? undefined, @@ -264,210 +238,86 @@ export class Message extends BaseClass { poll: this.poll ?? undefined, content: this.content ?? "", reply_ids: this.reply_ids ?? undefined, + pinned: this.pinned, + }; + } + + toPartialMessage(): PartialMessage { + return { + id: this.id, + // lobby_id: this.lobby_id, + channel_id: this.channel_id!, + type: this.type, + content: this.content!, + author: { ...this.author!, avatar: this.author?.avatar ?? null }, + flags: this.flags, + application_id: this.application_id, + //channel: this.channel, // TODO: ephemeral DM channels + // recipient_id: this.recipient_id, // TODO: ephemeral DM channels }; } withSignedAttachments(data: NewUrlUserSignatureData) { return { ...this, - attachments: this.attachments?.map((attachment: Attachment) => - Attachment.prototype.signUrls.call(attachment, data), - ), + attachments: this.attachments?.map((attachment: Attachment) => Attachment.prototype.signUrls.call(attachment, data)), }; } -} - -export interface MessageComponent { - type: MessageComponentType; -} - -export interface ActionRowComponent extends MessageComponent { - type: MessageComponentType.ActionRow; - components: ( - | ButtonComponent - | StringSelectMenuComponent - | SelectMenuComponent - | TextInputComponent - )[]; -} - -export interface ButtonComponent extends MessageComponent { - type: MessageComponentType.Button; - style: ButtonStyle; - label?: string; - emoji?: PartialEmoji; - custom_id?: string; - sku_id?: string; - url?: string; - disabled?: boolean; -} - -export enum ButtonStyle { - Primary = 1, - Secondary = 2, - Success = 3, - Danger = 4, - Link = 5, - Premium = 6, -} - -export interface SelectMenuComponent extends MessageComponent { - type: - | MessageComponentType.StringSelect - | MessageComponentType.UserSelect - | MessageComponentType.RoleSelect - | MessageComponentType.MentionableSelect - | MessageComponentType.ChannelSelect; - custom_id: string; - channel_types?: number[]; - placeholder?: string; - default_values?: SelectMenuDefaultOption[]; // only for non-string selects - min_values?: number; - max_values?: number; - disabled?: boolean; -} - -export interface SelectMenuOption { - label: string; - value: string; - description?: string; - emoji?: PartialEmoji; - default?: boolean; -} - -export interface SelectMenuDefaultOption { - id: string; - type: "user" | "role" | "channel"; -} - -export interface StringSelectMenuComponent extends SelectMenuComponent { - type: MessageComponentType.StringSelect; - options: SelectMenuOption[]; -} - -export interface TextInputComponent extends MessageComponent { - type: MessageComponentType.TextInput; - custom_id: string; - style: TextInputStyle; - label: string; - min_length?: number; - max_length?: number; - required?: boolean; - value?: string; - placeholder?: string; -} - -export enum TextInputStyle { - Short = 1, - Paragraph = 2, -} - -export enum MessageComponentType { - Script = 0, // self command script - ActionRow = 1, - Button = 2, - StringSelect = 3, - TextInput = 4, - UserSelect = 5, - RoleSelect = 6, - MentionableSelect = 7, - ChannelSelect = 8, -} -export interface Embed { - title?: string; //title of embed - type?: EmbedType; // type of embed (always "rich" for webhook embeds) - description?: string; // description of embed - url?: string; // url of embed - timestamp?: Date; // timestamp of embed content - color?: number; // color code of the embed - footer?: { - text: string; - icon_url?: string; - proxy_icon_url?: string; - }; // footer object footer information - image?: EmbedImage; // image object image information - thumbnail?: EmbedImage; // thumbnail object thumbnail information - video?: EmbedImage; // video object video information - provider?: { - name?: string; - url?: string; - }; // provider object provider information - author?: { - name?: string; - url?: string; - icon_url?: string; - proxy_icon_url?: string; - }; // author object author information - fields?: { - name: string; - value: string; - inline?: boolean; - }[]; -} - -export enum EmbedType { - rich = "rich", - image = "image", - video = "video", - gifv = "gifv", - article = "article", - link = "link", - auto_moderation_message = "auto_moderation_message", -} - -export interface EmbedImage { - url?: string; - proxy_url?: string; - height?: number; - width?: number; -} - -export interface Reaction { - count: number; - //// not saved in the database // me: boolean; // whether the current user reacted using this emoji - emoji: PartialEmoji; - user_ids: string[]; -} - -export interface PartialEmoji { - id?: string; - name: string; - animated?: boolean; -} - -export interface AllowedMentions { - parse?: ("users" | "roles" | "everyone")[]; - roles?: string[]; - users?: string[]; - replied_user?: boolean; -} - -export interface Poll { - question: PollMedia; - answers: PollAnswer[]; - expiry: Date; - allow_multiselect: boolean; - results?: PollResult; -} - -export interface PollMedia { - text?: string; - emoji?: PartialEmoji; -} - -export interface PollAnswer { - answer_id?: string; - poll_media: PollMedia; -} - -export interface PollResult { - is_finalized: boolean; - answer_counts: PollAnswerCount[]; -} + toProjectedJSON(projectionChannelId: string) { + const json = this.toJSON(); + return { + ...json, + channel_id: projectionChannelId, + }; + } -export interface PollAnswerCount { - id: string; - count: number; - me_voted: boolean; + static async createWithDefaults(opts: Partial): Promise { + const message = Message.create(); + + if (!opts.author) { + if (!opts.author_id) throw new Error("Either author or author_id must be provided to create a Message"); + opts.author = await User.findOneOrFail({ where: { id: opts.author_id! } }); + } + + if (!opts.channel) { + if (!opts.channel_id) throw new Error("Either channel or channel_id must be provided to create a Message"); + opts.channel = await Channel.findOneOrFail({ where: { id: opts.channel_id! } }); + opts.guild_id ??= opts.channel.guild_id; + } + + if (!opts.member_id) opts.member_id = message.author_id; + if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.member_id! } }); + + if (!opts.guild) { + if (opts.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.guild_id! } }); + else if (opts.channel?.guild?.id) opts.guild = opts.channel.guild; + else if (opts.channel?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.channel.guild_id! } }); + else if (opts.member?.guild?.id) opts.guild = opts.member.guild; + else if (opts.member?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.member.guild_id! } }); + else throw new Error("Either guild, guild_id, channel.guild, channel.guild_id, member.guild or member.guild_id must be provided to create a Message"); + } + + // try 2 now that we have a guild + if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.author!.id, guild_id: opts.guild!.id } }); + + // backpropagate ids + opts.channel_id = opts.channel.id; + opts.guild_id = opts.guild.id; + opts.author_id = opts.author.id; + opts.member_id = opts.member.id; + opts.webhook_id = opts.webhook?.id; + opts.application_id = opts.application?.id; + + Object.assign(message, { + tts: false, + embeds: [], + reactions: [], + flags: 0, + type: 0, + timestamp: new Date(), + ...opts, + }); + return message; + } } diff --git a/src/util/entities/RoutingRule.ts b/src/util/entities/RoutingRule.ts new file mode 100644 index 000000000..3105045a9 --- /dev/null +++ b/src/util/entities/RoutingRule.ts @@ -0,0 +1,169 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; +import { Role } from "./Role"; +import { Message } from "./Message"; +import { HTTPError } from "lambert-server"; +import { Snowflake } from "../util/Snowflake"; +import { getPermission } from "../util/Permissions"; +import { dbEngine } from "../util/Database"; + +@Entity({ + name: "routing_rules", + engine: dbEngine, +}) +export class RoutingRule extends BaseClass { + @Column() + registration_timestamp: string; + + @Column() + source_channel_id: string; + + @Column() + storage_channel_id: string; + + @Column({ nullable: true }) + sink_channel_id?: string; + + @Column({ nullable: true }) + target_role_id?: string; + + @Column({ nullable: true, type: "varchar" }) + target_role_guild_id?: string | null; + + @Column({ type: "simple-json" }) + source_users: string[]; + + @Column({ type: "simple-json" }) + target_users: string[]; + + @Column() + valid_since: string; + + @Column() + valid_until: string; + + @ManyToOne(() => Channel, { onDelete: "CASCADE" }) + @JoinColumn({ name: "source_channel_id" }) + source_channel?: Channel; + + @ManyToOne(() => Channel, { onDelete: "CASCADE" }) + @JoinColumn({ name: "storage_channel_id" }) + storage_channel?: Channel; + + @ManyToOne(() => Channel, { onDelete: "CASCADE" }) + @JoinColumn({ name: "sink_channel_id" }) + sink_channel?: Channel; + + @ManyToOne(() => Role, { onDelete: "CASCADE" }) + @JoinColumn({ name: "target_role_id" }) + target_role?: Role; + + isIntimacyBroadcast(): boolean { + return this.target_role_id !== undefined && this.target_role_id !== null && this.target_role_guild_id === null; + } + + static async validateInvariants(rule: Partial, isUpdate = false): Promise { + if (!rule.registration_timestamp && !isUpdate) { + rule.registration_timestamp = Snowflake.generate(); + } + + if (rule.valid_since && rule.registration_timestamp && BigInt(rule.valid_since) < BigInt(rule.registration_timestamp)) { + throw new HTTPError("valid_since cannot be earlier than registration_timestamp", 400); + } + + if (rule.valid_until && rule.valid_since && BigInt(rule.valid_until) < BigInt(rule.valid_since)) { + throw new HTTPError("valid_until cannot be earlier than valid_since", 400); + } + + if (!isUpdate && rule.source_channel_id && rule.source_users) { + const sourceChannel = await Channel.findOne({ + where: { id: rule.source_channel_id }, + }); + if (!sourceChannel) { + throw new HTTPError("Source channel not found", 404); + } + + for (const userId of rule.source_users) { + try { + const permission = await getPermission(userId, sourceChannel.guild_id, rule.source_channel_id); + permission.hasThrow("SEND_MESSAGES"); + } catch (e) { + throw new HTTPError(`User ${userId} does not have SEND_MESSAGES permission in source channel`, 403); + } + } + } + + if (!isUpdate && rule.source_channel_id && rule.target_users) { + const sourceChannel = await Channel.findOne({ + where: { id: rule.source_channel_id }, + }); + if (!sourceChannel) { + throw new HTTPError("Source channel not found", 404); + } + + for (const userId of rule.target_users) { + try { + const permission = await getPermission(userId, sourceChannel.guild_id, rule.source_channel_id); + permission.hasThrow("READ_MESSAGE_HISTORY"); + } catch (e) { + throw new HTTPError(`User ${userId} does not have READ_MESSAGE_HISTORY permission in source channel`, 403); + } + } + } + } + + static async canDelete(rule_id: string): Promise { + const rule = await RoutingRule.findOne({ + where: { id: rule_id }, + relations: ["source_channel", "storage_channel", "sink_channel"], + }); + + if (!rule) { + return true; + } + + if (!rule.source_channel || !rule.storage_channel || !rule.sink_channel) { + return true; + } + + const now = Snowflake.generate(); + const isInEffect = BigInt(rule.valid_since) <= BigInt(now) && BigInt(now) <= BigInt(rule.valid_until); + + if (isInEffect) { + return false; + } + + const affectedMessages = await Message.count({ + where: { + channel_id: rule.storage_channel_id, + source_channel_id: rule.source_channel_id, + }, + }); + + return affectedMessages === 0; + } + + isActive(): boolean { + const now = Snowflake.generate(); + return BigInt(this.valid_since) <= BigInt(now) && BigInt(now) <= BigInt(this.valid_until); + } +} diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index 63e20a3a8..4c099d22d 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -47,6 +47,7 @@ export * from "./ReadState"; export * from "./Recipient"; export * from "./Relationship"; export * from "./Role"; +export * from "./RoutingRule"; export * from "./SecurityKey"; export * from "./Session"; export * from "./Sticker"; diff --git a/src/util/migration/mariadb/1763660098-messageSourceChannel.ts b/src/util/migration/mariadb/1763660098-messageSourceChannel.ts new file mode 100644 index 000000000..ddf88fd9a --- /dev/null +++ b/src/util/migration/mariadb/1763660098-messageSourceChannel.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MessageSourceChannel1763660098 implements MigrationInterface { + name = "MessageSourceChannel1763660098"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE messages ADD source_channel_id varchar(255) NULL"); + await queryRunner.query("CREATE INDEX IDX_messages_source_channel_id ON messages (source_channel_id)"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("DROP INDEX IDX_messages_source_channel_id ON messages"); + await queryRunner.query("ALTER TABLE messages DROP COLUMN source_channel_id"); + } +} diff --git a/src/util/migration/mysql/1763660098-messageSourceChannel.ts b/src/util/migration/mysql/1763660098-messageSourceChannel.ts new file mode 100644 index 000000000..ddf88fd9a --- /dev/null +++ b/src/util/migration/mysql/1763660098-messageSourceChannel.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MessageSourceChannel1763660098 implements MigrationInterface { + name = "MessageSourceChannel1763660098"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE messages ADD source_channel_id varchar(255) NULL"); + await queryRunner.query("CREATE INDEX IDX_messages_source_channel_id ON messages (source_channel_id)"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("DROP INDEX IDX_messages_source_channel_id ON messages"); + await queryRunner.query("ALTER TABLE messages DROP COLUMN source_channel_id"); + } +} diff --git a/src/util/migration/postgres/1763660098-messageSourceChannel.ts b/src/util/migration/postgres/1763660098-messageSourceChannel.ts new file mode 100644 index 000000000..4b68f87a0 --- /dev/null +++ b/src/util/migration/postgres/1763660098-messageSourceChannel.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MessageSourceChannel1763660098 implements MigrationInterface { + name = "MessageSourceChannel1763660098"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE messages ADD source_channel_id varchar NULL"); + await queryRunner.query("CREATE INDEX IDX_messages_source_channel_id ON messages (source_channel_id)"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("DROP INDEX IDX_messages_source_channel_id"); + await queryRunner.query("ALTER TABLE messages DROP COLUMN source_channel_id"); + } +} diff --git a/src/util/util/Permissions.ts b/src/util/util/Permissions.ts index c6f810676..dda5f6d28 100644 --- a/src/util/util/Permissions.ts +++ b/src/util/util/Permissions.ts @@ -73,18 +73,14 @@ export class Permissions extends BitField { // TODO: what is permission 33? MANAGE_THREADS: BitFlag(34), USE_PUBLIC_THREADS: BitFlag(35), -<<<<<<< HEAD USE_PRIVATE_THREADS: BitFlag(36), USE_EXTERNAL_STICKERS: BitFlag(37), // TODO: what is permission 38? - // TODO: what are permissions 39-50? + MANAGE_ROUTING: BitFlag(39), + // TODO: what are permissions 40-50? PIN_MESSAGES: BitFlag(51), -======= - USE_PRIVATE_THREADS: BitFlag(36), - USE_EXTERNAL_STICKERS: BitFlag(37), - PIN_MESSAGES: BitFlag(38), - MANAGE_TICKETS: BitFlag(55), ->>>>>>> 2a9d7c9c (feat(tickets): add ticket routes, flags, initiator exposure; MANAGE_TICKETS; tracker thread creation) + // TODO: what are permissions 52-54? + MANAGE_TICKETS: BitFlag(55), /** * CUSTOM PERMISSIONS ideas: