diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5859d46d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.sh -crlf +*.nix -crlf +.husky/pre-commit -crlf \ No newline at end of file diff --git a/.gitignore b/.gitignore index e62c03d6..0fcd6d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.DS_STORE +**/.DS_STORE db/ dist/ node_modules @@ -19,4 +19,4 @@ build *.tmp tmp/ dump/ -result +result \ No newline at end of file diff --git a/assets/openapi.json b/assets/openapi.json index 50d4fca1..68adb455 100644 --- a/assets/openapi.json +++ b/assets/openapi.json @@ -6262,7 +6262,21 @@ "type": "object", "properties": { "guild_id": { - "type": "string" + "anyOf": [ + { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "minItems": 1, + "maxItems": 1 + }, + { + "type": "string" + } + ] }, "query": { "type": "string" @@ -16619,6 +16633,86 @@ ] } }, + "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{burst}/{user_id}": { + "delete": { + "security": [ + { + "bearer": [] + } + ], + "responses": { + "204": { + "description": "No description available" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIErrorResponse" + } + } + } + }, + "403": { + "description": "No description available" + }, + "404": { + "description": "No description available" + } + }, + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "channel_id" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "message_id" + }, + { + "name": "emoji", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "emoji" + }, + { + "name": "burst", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "burst" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "user_id" + } + ], + "tags": [ + "channels" + ] + } + }, "/channels/{channel_id}/messages/{message_id}/": { "patch": { "x-right-required": "SEND_MESSAGES", diff --git a/assets/schemas.json b/assets/schemas.json index a3db68f8..a4da2538 100644 --- a/assets/schemas.json +++ b/assets/schemas.json @@ -211519,7 +211519,21 @@ "type": "object", "properties": { "guild_id": { - "type": "string" + "anyOf": [ + { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "minItems": 1, + "maxItems": 1 + }, + { + "type": "string" + } + ] }, "query": { "type": "string" diff --git a/hashes.json b/hashes.json index 2f3e1cfb..6883eafd 100644 --- a/hashes.json +++ b/hashes.json @@ -1,3 +1,3 @@ { - "npmDepsHash": "sha256-q1Q7rpSzfiRvrkoDPER9wjBOzZ5Bn5B+d41MFssM7nU=" + "npmDepsHash": "sha256-q1Q7rpSzfiRvrkoDPER9wjBOzZ5Bn5B+d41MFssM7nU=" } diff --git a/src/api/routes/auth/register.ts b/src/api/routes/auth/register.ts index de1cbd3d..62152440 100644 --- a/src/api/routes/auth/register.ts +++ b/src/api/routes/auth/register.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -287,6 +287,16 @@ router.post( }); } + const { maxUsername } = Config.get().limits.user; + if (body.username.length > maxUsername) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 2 and ${maxUsername} in length.`, + }, + }); + } + const user = await User.register({ ...body, req }); if (body.invite) { diff --git a/src/api/routes/channels/#channel_id/pins.ts b/src/api/routes/channels/#channel_id/pins.ts index 724ebffd..d43db6ec 100644 --- a/src/api/routes/channels/#channel_id/pins.ts +++ b/src/api/routes/channels/#channel_id/pins.ts @@ -1,30 +1,31 @@ /* 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 { - Channel, ChannelPinsUpdateEvent, Config, DiscordApiErrors, emitEvent, Message, + MessageCreateEvent, MessageUpdateEvent, + User, } from "@spacebar/util"; import { Request, Response, Router } from "express"; @@ -60,8 +61,34 @@ router.put( if (pinned_count >= maxPins) throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins); + message.pinned = true; + + 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.update({ id: message_id }, { pinned: true }), + message.save(), emitEvent({ event: "MESSAGE_UPDATE", channel_id, @@ -76,6 +103,12 @@ router.put( last_pin_timestamp: undefined, }, } as ChannelPinsUpdateEvent), + systemPinMessage.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: message.channel_id, + data: systemPinMessage, + } as MessageCreateEvent), ]); res.sendStatus(204); @@ -98,31 +131,27 @@ router.delete( async (req: Request, res: Response) => { const { channel_id, message_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - if (channel.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES"); - const message = await Message.findOneOrFail({ where: { id: message_id }, }); + + if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES"); + message.pinned = 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: channel.guild_id, + guild_id: message.guild_id, last_pin_timestamp: undefined, }, } as ChannelPinsUpdateEvent), diff --git a/src/api/routes/guilds/#guild_id/widget.json.ts b/src/api/routes/guilds/#guild_id/widget.json.ts index 39f49804..eb2dd102 100644 --- a/src/api/routes/guilds/#guild_id/widget.json.ts +++ b/src/api/routes/guilds/#guild_id/widget.json.ts @@ -1,25 +1,31 @@ /* 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 { random, route } from "@spacebar/api"; -import { Channel, Guild, Invite, Member, Permissions } from "@spacebar/util"; +import { + Channel, + DiscordApiErrors, + Guild, + Invite, + Member, + Permissions, +} from "@spacebar/util"; import { Request, Response, Router } from "express"; -import { HTTPError } from "lambert-server"; const router: Router = Router(); @@ -48,7 +54,7 @@ router.get( const { guild_id } = req.params; const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404); + if (!guild.widget_enabled) throw DiscordApiErrors.EMBED_DISABLED; // Fetch existing widget invite for widget channel let invite = await Invite.findOne({ diff --git a/src/api/routes/guilds/#guild_id/widget.png.ts b/src/api/routes/guilds/#guild_id/widget.png.ts index c9ba8afc..c42b33e0 100644 --- a/src/api/routes/guilds/#guild_id/widget.png.ts +++ b/src/api/routes/guilds/#guild_id/widget.png.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -19,11 +19,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { route } from "@spacebar/api"; -import { Guild } from "@spacebar/util"; +import { DiscordApiErrors, Guild } from "@spacebar/util"; import { Request, Response, Router } from "express"; import fs from "fs"; import { HTTPError } from "lambert-server"; import path from "path"; +import { storage } from "../../../../cdn/util/Storage"; const router: Router = Router(); @@ -48,10 +49,10 @@ router.get( const { guild_id } = req.params; const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404); + if (!guild.widget_enabled) throw DiscordApiErrors.EMBED_DISABLED; // Fetch guild information - const icon = guild.icon; + const icon = "avatars/" + guild_id + "/" + guild.icon; const name = guild.name; const presence = guild.presence_count + " ONLINE"; @@ -69,8 +70,7 @@ router.get( } // Setup canvas - const { createCanvas } = require("canvas"); - const { loadImage } = require("canvas"); + const { createCanvas, loadImage } = require("canvas"); const sizeOf = require("image-size"); // TODO: Widget style templates need Spacebar branding @@ -211,8 +211,8 @@ async function drawIcon( scale: number, icon: string, ) { - const img = new (require("canvas").Image)(); - img.src = icon; + const { loadImage } = require("canvas"); + const img = await loadImage(await storage.get(icon)); // Do some canvas clipping magic! canvas.save(); diff --git a/src/api/routes/users/@me/index.ts b/src/api/routes/users/@me/index.ts index 5caf0d11..9cd8bfda 100644 --- a/src/api/routes/users/@me/index.ts +++ b/src/api/routes/users/@me/index.ts @@ -155,8 +155,8 @@ router.patch( if (check_username.length > maxUsername) { throw FieldErrors({ username: { - code: "USERNAME_INVALID", - message: `Username must be less than ${maxUsername} in length`, + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 2 and ${maxUsername} in length.`, }, }); } diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts index e30a1ee0..94320eee 100644 --- a/src/gateway/opcodes/Identify.ts +++ b/src/gateway/opcodes/Identify.ts @@ -82,6 +82,7 @@ export async function onIdentify(this: WebSocket, data: Payload) { const identify: IdentifySchema = data.d; this.capabilities = new Capabilities(identify.capabilities || 0); + this.large_threshold = identify.large_threshold || 250; const user = await tryGetUserFromToken(identify.token, { relations: ["relationships", "relationships.to", "settings"], @@ -126,6 +127,7 @@ export async function onIdentify(this: WebSocket, data: Payload) { os: identify.properties?.os || identify.properties?.$os, version: 0, }, + client_status: {}, activities: identify.presence?.activities, // TODO: validation }); diff --git a/src/gateway/opcodes/RequestGuildMembers.ts b/src/gateway/opcodes/RequestGuildMembers.ts index d294f4d3..3381caed 100644 --- a/src/gateway/opcodes/RequestGuildMembers.ts +++ b/src/gateway/opcodes/RequestGuildMembers.ts @@ -17,6 +17,7 @@ */ import { + getDatabase, getPermission, GuildMembersChunkEvent, Member, @@ -29,19 +30,24 @@ import { check } from "./instanceOf"; import { FindManyOptions, In, Like } from "typeorm"; export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { - // TODO: check data + // Schema validation can only accept either string or array, so transforming it here to support both + if (!d.guild_id) throw new Error('"guild_id" is required'); + d.guild_id = Array.isArray(d.guild_id) ? d.guild_id[0] : d.guild_id; + + if (d.user_ids && !Array.isArray(d.user_ids)) d.user_ids = [d.user_ids]; + check.call(this, RequestGuildMembersSchema, d); - const { guild_id, query, presences, nonce } = - d as RequestGuildMembersSchema; - let { limit, user_ids } = d as RequestGuildMembersSchema; + const { query, presences, nonce } = d as RequestGuildMembersSchema; + let { limit, user_ids, guild_id } = d as RequestGuildMembersSchema; + + guild_id = guild_id as string; + user_ids = user_ids as string[] | undefined; if ("query" in d && (!limit || Number.isNaN(limit))) throw new Error('"query" requires "limit" to be set'); if ("query" in d && user_ids) throw new Error('"query" and "user_ids" are mutually exclusive'); - if (user_ids && !Array.isArray(user_ids)) user_ids = [user_ids]; - user_ids = user_ids as string[] | undefined; // TODO: Configurable limit? if ((query || (user_ids && user_ids.length > 0)) && (!limit || limit > 100)) @@ -50,24 +56,74 @@ export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { const permissions = await getPermission(this.user_id, guild_id); permissions.hasThrow("VIEW_CHANNEL"); - const whereQuery: FindManyOptions["where"] = {}; - if (query) { - whereQuery.user = { - username: Like(query + "%"), - }; - } else if (user_ids && user_ids.length > 0) { - whereQuery.id = In(user_ids); - } + const memberCount = await Member.count({ + where: { + guild_id, + }, + }); const memberFind: FindManyOptions = { where: { - ...whereQuery, guild_id, }, relations: ["user", "roles"], }; if (limit) memberFind.take = Math.abs(Number(limit || 100)); - const members = await Member.find(memberFind); + + let members: Member[] = []; + + if (memberCount > 75000) { + // since we dont have voice channels yet, just return the connecting users member object + members = await Member.find({ + ...memberFind, + where: { + ...memberFind.where, + user: { + id: this.user_id, + }, + }, + }); + } else if (memberCount > this.large_threshold) { + // find all members who are online, have a role, have a nickname, or are in a voice channel, as well as respecting the query and user_ids + const db = getDatabase(); + if (!db) throw new Error("Database not initialized"); + const repo = db.getRepository(Member); + const q = repo + .createQueryBuilder("member") + .where("member.guild_id = :guild_id", { guild_id }) + .leftJoinAndSelect("member.roles", "role") + .leftJoinAndSelect("member.user", "user") + .leftJoinAndSelect("user.sessions", "session") + .andWhere( + "',' || member.roles || ',' NOT LIKE :everyoneRoleIdList", + { everyoneRoleIdList: "%," + guild_id + ",%" }, + ) + .andWhere("session.status != 'offline'") + .addOrderBy("user.username", "ASC") + .limit(memberFind.take); + + if (query && query != "") { + q.andWhere(`user.username ILIKE :query`, { + query: `${query}%`, + }); + } else if (user_ids) { + q.andWhere(`user.id IN (:...user_ids)`, { user_ids }); + } + + members = await q.getMany(); + } else { + if (query) { + // @ts-expect-error memberFind.where is very much defined + memberFind.where.user = { + username: Like(query + "%"), + }; + } else if (user_ids && user_ids.length > 0) { + // @ts-expect-error memberFind.where is still very much defined + memberFind.where.id = In(user_ids); + } + + members = await Member.find(memberFind); + } const baseData = { guild_id, @@ -111,7 +167,17 @@ export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { }); } - if (notFound.length > 0) chunks[0].not_found = notFound; + if (notFound.length > 0) { + if (chunks.length == 0) + chunks.push({ + ...baseData, + members: [], + presences: presences ? [] : undefined, + chunk_index: 0, + chunk_count: 1, + }); + chunks[0].not_found = notFound; + } chunks.forEach((chunk) => { Send(this, { diff --git a/src/gateway/util/WebSocket.ts b/src/gateway/util/WebSocket.ts index 833756ff..8cfc5e08 100644 --- a/src/gateway/util/WebSocket.ts +++ b/src/gateway/util/WebSocket.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -43,4 +43,5 @@ export interface WebSocket extends WS { listen_options: ListenEventOpts; capabilities?: Capabilities; // client?: Client; + large_threshold: number; } diff --git a/src/util/schemas/RegisterSchema.ts b/src/util/schemas/RegisterSchema.ts index 7b7de9c7..cfee0f02 100644 --- a/src/util/schemas/RegisterSchema.ts +++ b/src/util/schemas/RegisterSchema.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -19,7 +19,6 @@ export interface RegisterSchema { /** * @minLength 2 - * @maxLength 32 */ username: string; /** diff --git a/src/util/schemas/RequestGuildMembersSchema.ts b/src/util/schemas/RequestGuildMembersSchema.ts index 01ba4f2e..9e60d26e 100644 --- a/src/util/schemas/RequestGuildMembersSchema.ts +++ b/src/util/schemas/RequestGuildMembersSchema.ts @@ -17,7 +17,7 @@ */ export interface RequestGuildMembersSchema { - guild_id: string; + guild_id: string | [string]; query?: string; limit?: number; presences?: boolean; @@ -26,7 +26,7 @@ export interface RequestGuildMembersSchema { } export const RequestGuildMembersSchema = { - guild_id: String, + guild_id: "" as string | string[], $query: String, $limit: Number, $presences: Boolean, diff --git a/src/util/schemas/UserModifySchema.ts b/src/util/schemas/UserModifySchema.ts index 4be6ad43..e4ed1071 100644 --- a/src/util/schemas/UserModifySchema.ts +++ b/src/util/schemas/UserModifySchema.ts @@ -18,8 +18,7 @@ export interface UserModifySchema { /** - * @minLength 1 - * @maxLength 100 + * @minLength 2 */ username?: string; avatar?: string | null; diff --git a/src/util/util/Constants.ts b/src/util/util/Constants.ts index a6caae00..34e925e5 100644 --- a/src/util/util/Constants.ts +++ b/src/util/util/Constants.ts @@ -812,7 +812,7 @@ export const DiscordApiErrors = { "Cannot execute action on a DM channel", 50003, ), - EMBED_DISABLED: new ApiError("Guild widget disabled", 50004), + EMBED_DISABLED: new ApiError("Widget Disabled", 50004), CANNOT_EDIT_MESSAGE_BY_OTHER: new ApiError( "Cannot edit a message authored by another user", 50005, diff --git a/src/webrtc/.DS_Store b/src/webrtc/.DS_Store deleted file mode 100644 index bfb0a416..00000000 Binary files a/src/webrtc/.DS_Store and /dev/null differ