diff --git a/assets/openapi.json b/assets/openapi.json index a2ad8e8802..569ee63de8 100644 --- a/assets/openapi.json +++ b/assets/openapi.json @@ -11700,10 +11700,7 @@ "type": "string" } }, - "additionalProperties": false, - "required": [ - "text" - ] + "additionalProperties": false }, "image": { "$ref": "#/components/schemas/EmbedImage" diff --git a/assets/schemas.json b/assets/schemas.json index a324c963e7..9efdb51b6e 100644 --- a/assets/schemas.json +++ b/assets/schemas.json @@ -12433,10 +12433,7 @@ "type": "string" } }, - "additionalProperties": false, - "required": [ - "text" - ] + "additionalProperties": false }, "image": { "$ref": "#/definitions/EmbedImage" diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts index 2f5031f3cc..7cd77f0249 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts @@ -199,6 +199,10 @@ router.get( } messages = await Message.find(query); + + if (after) { + messages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + } } await Message.fillReplies(messages); diff --git a/src/api/routes/channels/#channel_id/typing.ts b/src/api/routes/channels/#channel_id/typing.ts index 3633164781..cefabcaf46 100644 --- a/src/api/routes/channels/#channel_id/typing.ts +++ b/src/api/routes/channels/#channel_id/typing.ts @@ -17,7 +17,7 @@ */ import { route } from "@spacebar/api"; -import { Channel, emitEvent, Member, TypingStartEvent } from "@spacebar/util"; +import { Channel, DiscordApiErrors, emitEvent, getPermission, Member, Message, TypingStartEvent } from "@spacebar/util"; import { Request, Response, Router } from "express"; const router: Router = Router({ mergeParams: true }); @@ -39,6 +39,18 @@ router.post( const channel = await Channel.findOneOrFail({ where: { id: channel_id }, }); + + if (channel.rate_limit_per_user) { + const lastMsgTime = (await Message.findOne({ where: { channel_id: channel.id, author_id: user_id }, select: { timestamp: true }, order: { timestamp: "DESC" } })) + ?.timestamp; + if (lastMsgTime && Date.now() - channel.rate_limit_per_user * 1000 < +lastMsgTime) { + const permission = await getPermission(user_id, channel.guild_id, channel_id); + if (!permission.has("MANAGE_MESSAGES") && !permission.has("MANAGE_CHANNELS") && !permission.has("BYPASS_SLOWMODE")) { + throw DiscordApiErrors.SLOWMODE_RATE_LIMIT; + } + } + } + const member = await Member.findOne({ where: { id: user_id, guild_id: channel.guild_id }, relations: { roles: true, user: true }, diff --git a/src/api/routes/channels/#channel_id/webhooks.ts b/src/api/routes/channels/#channel_id/webhooks.ts index 1e62cc6b9e..75d6f63296 100644 --- a/src/api/routes/channels/#channel_id/webhooks.ts +++ b/src/api/routes/channels/#channel_id/webhooks.ts @@ -85,9 +85,8 @@ router.post( let { avatar, name } = req.body as WebhookCreateSchema; name = trimSpecial(name); - // TODO: move this if (name) { - ValidateName(name); + ValidateName(name, "name", 80); } if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar); diff --git a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts index d20710dece..4b0532fa75 100644 --- a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts +++ b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts @@ -17,7 +17,21 @@ */ import { route } from "@spacebar/api"; -import { Config, DiscordApiErrors, emitEvent, Emoji, getPermission, getRights, Guild, GuildMemberUpdateEvent, handleFile, Member, Role, Sticker } from "@spacebar/util"; +import { + Config, + DiscordApiErrors, + emitEvent, + Emoji, + FieldErrors, + getPermission, + getRights, + Guild, + GuildMemberUpdateEvent, + handleFile, + Member, + Role, + Sticker, +} from "@spacebar/util"; import { Request, Response, Router } from "express"; import { MemberChangeSchema, PublicMemberProjection, PublicUserProjection } from "@spacebar/schemas"; @@ -102,6 +116,15 @@ router.patch( permission.hasThrow("CHANGE_NICKNAME"); } + if (body.nick && body.nick.length > 32) { + throw FieldErrors({ + nick: { + code: "BASE_TYPE_BAD_LENGTH", + message: "Must be between 1 and 32 in length.", + }, + }); + } + if (!body.nick) { delete body.nick; // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/src/api/routes/guilds/#guild_id/roles/index.ts b/src/api/routes/guilds/#guild_id/roles/index.ts index d7c493d3b2..6c9786701f 100644 --- a/src/api/routes/guilds/#guild_id/roles/index.ts +++ b/src/api/routes/guilds/#guild_id/roles/index.ts @@ -64,6 +64,7 @@ router.post( if (body.name && body.name.length > 255) throw new Error("Role name must not exceed 255 characters"); const everyoneRole = await Role.findOne({ where: { id: guild_id } }); + const defaultPermissions = everyoneRole?.permissions || "0"; const role = Role.create({ // values before ...body are default and can be overridden @@ -74,7 +75,9 @@ router.post( ...body, guild_id: guild_id, managed: false, - permissions: String((req.permission?.bitfield || 0n) & BigInt(body.permissions || everyoneRole?.permissions || 0)), + permissions: body.permissions + ? String((req.permission?.bitfield || 0n) & BigInt(body.permissions)) + : String((req.permission?.bitfield || 0n) & BigInt(defaultPermissions)), tags: undefined, icon: undefined, unicode_emoji: undefined, diff --git a/src/api/routes/users/#user_id/relationships.ts b/src/api/routes/users/#user_id/relationships.ts index 38bd64b683..1f0bbc1148 100644 --- a/src/api/routes/users/#user_id/relationships.ts +++ b/src/api/routes/users/#user_id/relationships.ts @@ -55,7 +55,7 @@ router.get( username: relation_user.username, avatar: relation_user.avatar, discriminator: relation_user.discriminator, - public_flags: relation_user.public_flags, + public_flags: Number(relation_user.public_flags) || 0, }); } } diff --git a/src/api/routes/webhooks/#webhook_id/#token/index.ts b/src/api/routes/webhooks/#webhook_id/#token/index.ts index 562b0e16da..5aa48ff5ad 100644 --- a/src/api/routes/webhooks/#webhook_id/#token/index.ts +++ b/src/api/routes/webhooks/#webhook_id/#token/index.ts @@ -163,7 +163,7 @@ router.patch( throw new HTTPError("Empty webhook updates are not allowed", 50006); } if (body.name) { - ValidateName(body.name); + ValidateName(body.name, "name", 80); } if (body.avatar) { body.avatar = await handleFile(`/avatars/${webhook_id}`, body.avatar as string); diff --git a/src/api/routes/webhooks/#webhook_id/index.ts b/src/api/routes/webhooks/#webhook_id/index.ts index f8f508643d..7230a4919b 100644 --- a/src/api/routes/webhooks/#webhook_id/index.ts +++ b/src/api/routes/webhooks/#webhook_id/index.ts @@ -145,7 +145,7 @@ router.patch( if (body.avatar) body.avatar = await handleFile(`/avatars/${webhook_id}`, body.avatar as string); if (body.name) { - ValidateName(body.name); + ValidateName(body.name, "name", 80); } const channel_id = body.channel_id || webhook.channel_id; diff --git a/src/api/util/utility/EmbedHandlers.ts b/src/api/util/utility/EmbedHandlers.ts index f242d68630..4deed8966c 100644 --- a/src/api/util/utility/EmbedHandlers.ts +++ b/src/api/util/utility/EmbedHandlers.ts @@ -653,35 +653,26 @@ export async function fillMessageUrlEmbeds(message: Message) { const linkMatches = getMessageContentUrls(message).filter((l) => !l.startsWith("<") && !l.endsWith(">")); // Filter out embeds that could be links, start from scratch - message.embeds = message.embeds.filter((embed) => embed.type === "rich"); + const richEmbeds = message.embeds.filter((embed) => embed.type === "rich"); + message.embeds = richEmbeds; if (linkMatches.length == 0) return message; const uniqueLinks: string[] = arrayDistinctBy(linkMatches, normalizeUrl); - if (uniqueLinks.length === 0) { - // No valid unique links found, update message to remove old embeds - message.embeds = message.embeds.filter((embed) => embed.type === "rich"); - await saveAndEmitMessageUpdate(message); - return message; - } + if (uniqueLinks.length === 0) return message; - // avoid a race condition updating the same row - let messageUpdateLock = saveAndEmitMessageUpdate(message); - await getOrUpdateEmbedCache(uniqueLinks, async (url, embeds) => { - if (url !== "cached" && message.embeds.length + embeds.length > Config.get().limits.message.maxEmbeds) return; + let embedsChanged = false; + await getOrUpdateEmbedCache(uniqueLinks, async (_url, embeds) => { + if (_url !== "cached" && message.embeds.length + embeds.length > Config.get().limits.message.maxEmbeds) return; message.embeds.push(...embeds); if (message.embeds.length > Config.get().limits.message.maxEmbeds) message.embeds = message.embeds.slice(0, Config.get().limits.message.maxEmbeds); - - try { - await messageUpdateLock; - } catch { - /* empty */ - } - messageUpdateLock = saveAndEmitMessageUpdate(message); + embedsChanged = true; }); - await saveAndEmitMessageUpdate(message); + if (embedsChanged) { + await saveAndEmitMessageUpdate(message); + } return message; } diff --git a/src/schemas/api/messages/Embeds.ts b/src/schemas/api/messages/Embeds.ts index 0f6019a65d..20f11e6429 100644 --- a/src/schemas/api/messages/Embeds.ts +++ b/src/schemas/api/messages/Embeds.ts @@ -24,7 +24,7 @@ export interface Embed { timestamp?: Date; // timestamp of embed content color?: number; // color code of the embed footer?: { - text: string; + text?: string; icon_url?: string; proxy_icon_url?: string; }; // footer object footer information diff --git a/src/util/dtos/UserDTO.ts b/src/util/dtos/UserDTO.ts index 2a79f8f104..a14c71e484 100644 --- a/src/util/dtos/UserDTO.ts +++ b/src/util/dtos/UserDTO.ts @@ -30,7 +30,7 @@ export class MinimalPublicUserDTO { this.avatar = user.avatar; this.discriminator = user.discriminator; this.id = user.id; - this.public_flags = user.public_flags; + this.public_flags = Number(user.public_flags) || 0; this.username = user.username; this.badge_ids = user.badge_ids; } diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts index 24d934304b..03396c293b 100644 --- a/src/util/entities/Channel.ts +++ b/src/util/entities/Channel.ts @@ -530,8 +530,7 @@ export class Channel extends BaseClass { }); } - if (recipients.length === 1) return channel_dto; - else return channel_dto.excludedRecipients([creator_user_id]); + return channel_dto.excludedRecipients([creator_user_id]); } static async checkServerDmReopenPrivacy(channel: Channel, creatorUserId: string) { diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts index df6468bbe3..40d68ef937 100644 --- a/src/util/entities/Member.ts +++ b/src/util/entities/Member.ts @@ -21,7 +21,7 @@ import { BeforeInsert, BeforeUpdate, Column, Entity, Index, JoinColumn, JoinTabl import { Ban, Channel, PublicGuildRelations } from "."; import { ReadyGuildDTO } from "../dtos"; import { GuildCreateEvent, GuildDeleteEvent, GuildMemberAddEvent, GuildMemberRemoveEvent, GuildMemberUpdateEvent, MessageCreateEvent } from "../interfaces"; -import { Config, emitEvent, DiscordApiErrors, Stopwatch } from "../util"; +import { Config, emitEvent, DiscordApiErrors, FieldErrors, Stopwatch } from "../util"; import { BaseClassWithoutId } from "./BaseClass"; import { Guild } from "./Guild"; import { Message } from "./Message"; @@ -277,6 +277,15 @@ export class Member extends BaseClassWithoutId { } static async changeNickname(user_id: string, guild_id: string, nickname: string) { + if (nickname && nickname.length > 32) { + throw FieldErrors({ + nick: { + code: "BASE_TYPE_BAD_LENGTH", + message: "Must be between 1 and 32 in length.", + }, + }); + } + const member = await Member.findOneOrFail({ where: { id: user_id, diff --git a/src/util/util/NameValidation.ts b/src/util/util/NameValidation.ts index 5536670ee1..9ba2b1d870 100755 --- a/src/util/util/NameValidation.ts +++ b/src/util/util/NameValidation.ts @@ -18,25 +18,24 @@ import { Config } from "./Config"; import { FieldErrors } from "./FieldError"; -import { HTTPError } from "lambert-server"; -export function ValidateName(name: string) { +export function ValidateName(name: string, field: string = "username", maxLength?: number) { const check_username = name.replace(/\s/g, ""); if (!check_username) { throw FieldErrors({ - username: { + [field]: { code: "BASE_TYPE_REQUIRED", message: "common:field.BASE_TYPE_REQUIRED", }, }); } const general = Config.get(); - const { maxUsername } = general.limits.user; - if (check_username.length > maxUsername || check_username.length < 2) { + const limit = maxLength ?? general.limits.user.maxUsername; + if (check_username.length > limit || check_username.length < 2) { throw FieldErrors({ - username: { + [field]: { code: "BASE_TYPE_BAD_LENGTH", - message: `Must be between 2 and ${maxUsername} in length.`, + message: `Must be between 2 and ${limit} in length.`, }, }); } @@ -44,13 +43,23 @@ export function ValidateName(name: string) { const { blockedContains, blockedEquals } = general.user; for (const word of blockedContains) { if (name.toLowerCase().includes(word)) { - throw new HTTPError(`Username cannot contain "${word}"`, 400); + throw FieldErrors({ + [field]: { + code: "NAME_BLOCKED", + message: `Name cannot contain "${word}"`, + }, + }); } } for (const word of blockedEquals) { if (name.toLowerCase() === word) { - throw new HTTPError(`Username cannot be "${word}"`, 400); + throw FieldErrors({ + [field]: { + code: "NAME_BLOCKED", + message: `Name cannot be "${word}"`, + }, + }); } } return name;