From 3c2d07d0b1a611f27188746e3d6d347fd5fb0145 Mon Sep 17 00:00:00 2001 From: ochen1 Date: Sun, 9 Apr 2023 12:34:45 -0600 Subject: [PATCH 1/9] Add PATCH /guilds/#guild_id/roles/#role_id/members API Discord uses this in the Edit Role menu, Manage members tab to update the list of members with the role. --- .../#guild_id/roles/#role_id/members.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/api/routes/guilds/#guild_id/roles/#role_id/members.ts diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts new file mode 100644 index 00000000..25ca7557 --- /dev/null +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts @@ -0,0 +1,53 @@ +/* + 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 { Router, Request, Response } from "express"; +import { Member } from "@spacebar/util"; +import { route } from "@spacebar/api"; + +const router = Router(); + +router.patch( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + // Payload is JSON containing a list of member_ids, the new list of members to have the role + const { guild_id, role_id } = req.params; + const { member_ids } = req.body; + await Member.IsInGuildOrFail(req.user_id, guild_id); + const members = await Member.find({ + where: { guild_id }, + relations: ["roles"], + }); + const members_to_add = members.filter((member) => { + return member_ids.includes(member.id) && !member.roles.map((role) => role.id).includes(role_id); + }); + const members_to_remove = members.filter((member) => { + return !member_ids.includes(member.id) && member.roles.map((role) => role.id).includes(role_id); + }); + for (const member of members_to_add) { + Member.addRole(member.id, guild_id, role_id); + } + for (const member of members_to_remove) { + Member.removeRole(member.id, guild_id, role_id); + } + res.sendStatus(204); + } +); + +export default router; From 4a7eb89a721254e30464b4865801de3d0b3ff7c7 Mon Sep 17 00:00:00 2001 From: ochen1 Date: Sun, 9 Apr 2023 12:36:31 -0600 Subject: [PATCH 2/9] Fix indentation --- src/api/routes/guilds/#guild_id/roles/#role_id/members.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts index 25ca7557..26359a69 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts @@ -46,7 +46,7 @@ router.patch( for (const member of members_to_remove) { Member.removeRole(member.id, guild_id, role_id); } - res.sendStatus(204); + res.sendStatus(204); } ); From f4e172eec26de624b6736b68679b63574c39fc7e Mon Sep 17 00:00:00 2001 From: ochen1 Date: Sun, 9 Apr 2023 12:38:13 -0600 Subject: [PATCH 3/9] Add PATCH /guilds/#guild_id/roles/#role_id/member-ids API --- .../#guild_id/roles/#role_id/member-ids.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts new file mode 100644 index 00000000..6353e1f6 --- /dev/null +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts @@ -0,0 +1,38 @@ +/* + 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 { Router, Request, Response } from "express"; +import { Member } from "@spacebar/util"; +import { route } from "@spacebar/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id, role_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + const members = await Member.find({ + select: ["id"], + relations: ["roles"], + }); + const member_ids = members.filter((member) => { + return member.roles.map((role) => role.id).includes(role_id); + }).map((member) => member.id); + return res.json(member_ids); +}); + +export default router; From b91fca6d745edbc438a468bcb1826372029869f5 Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 11 Apr 2023 12:28:16 +1000 Subject: [PATCH 4/9] prettier --- .../#guild_id/roles/#role_id/member-ids.ts | 22 ++++---- .../#guild_id/roles/#role_id/members.ts | 56 ++++++++++--------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts index 6353e1f6..e0e7bd20 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts @@ -23,16 +23,18 @@ import { route } from "@spacebar/api"; const router = Router(); router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id, role_id } = req.params; - await Member.IsInGuildOrFail(req.user_id, guild_id); - const members = await Member.find({ - select: ["id"], - relations: ["roles"], - }); - const member_ids = members.filter((member) => { - return member.roles.map((role) => role.id).includes(role_id); - }).map((member) => member.id); - return res.json(member_ids); + const { guild_id, role_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + const members = await Member.find({ + select: ["id"], + relations: ["roles"], + }); + const member_ids = members + .filter((member) => { + return member.roles.map((role) => role.id).includes(role_id); + }) + .map((member) => member.id); + return res.json(member_ids); }); export default router; diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts index 26359a69..a1238382 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts @@ -23,31 +23,37 @@ import { route } from "@spacebar/api"; const router = Router(); router.patch( - "/", - route({ permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - // Payload is JSON containing a list of member_ids, the new list of members to have the role - const { guild_id, role_id } = req.params; - const { member_ids } = req.body; - await Member.IsInGuildOrFail(req.user_id, guild_id); - const members = await Member.find({ - where: { guild_id }, - relations: ["roles"], - }); - const members_to_add = members.filter((member) => { - return member_ids.includes(member.id) && !member.roles.map((role) => role.id).includes(role_id); - }); - const members_to_remove = members.filter((member) => { - return !member_ids.includes(member.id) && member.roles.map((role) => role.id).includes(role_id); - }); - for (const member of members_to_add) { - Member.addRole(member.id, guild_id, role_id); - } - for (const member of members_to_remove) { - Member.removeRole(member.id, guild_id, role_id); - } - res.sendStatus(204); - } + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + // Payload is JSON containing a list of member_ids, the new list of members to have the role + const { guild_id, role_id } = req.params; + const { member_ids } = req.body; + await Member.IsInGuildOrFail(req.user_id, guild_id); + const members = await Member.find({ + where: { guild_id }, + relations: ["roles"], + }); + const members_to_add = members.filter((member) => { + return ( + member_ids.includes(member.id) && + !member.roles.map((role) => role.id).includes(role_id) + ); + }); + const members_to_remove = members.filter((member) => { + return ( + !member_ids.includes(member.id) && + member.roles.map((role) => role.id).includes(role_id) + ); + }); + for (const member of members_to_add) { + Member.addRole(member.id, guild_id, role_id); + } + for (const member of members_to_remove) { + Member.removeRole(member.id, guild_id, role_id); + } + res.sendStatus(204); + }, ); export default router; From 6a148898a511da630ccafd0d47a1854aab9f79a3 Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 11 Apr 2023 12:33:59 +1000 Subject: [PATCH 5/9] Remove Member.isInGuildOrFail, as it will always pass ( route permission check ) --- .../routes/guilds/#guild_id/roles/#role_id/members.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts index a1238382..886b0d1a 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts @@ -29,11 +29,12 @@ router.patch( // Payload is JSON containing a list of member_ids, the new list of members to have the role const { guild_id, role_id } = req.params; const { member_ids } = req.body; - await Member.IsInGuildOrFail(req.user_id, guild_id); + const members = await Member.find({ where: { guild_id }, relations: ["roles"], }); + const members_to_add = members.filter((member) => { return ( member_ids.includes(member.id) && @@ -46,12 +47,15 @@ router.patch( member.roles.map((role) => role.id).includes(role_id) ); }); + for (const member of members_to_add) { - Member.addRole(member.id, guild_id, role_id); + await Member.addRole(member.id, guild_id, role_id); } + for (const member of members_to_remove) { - Member.removeRole(member.id, guild_id, role_id); + await Member.removeRole(member.id, guild_id, role_id); } + res.sendStatus(204); }, ); From debfaea8667257b6179f83f460b4b76d9dec1b01 Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 11 Apr 2023 12:56:32 +1000 Subject: [PATCH 6/9] Use partition func instead + use Promise.all --- .../#guild_id/roles/#role_id/members.ts | 33 ++++++++----------- src/gateway/opcodes/LazyRequest.ts | 9 +---- src/util/util/Array.ts | 8 +++++ 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts index 886b0d1a..6b9fad9c 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts @@ -17,7 +17,7 @@ */ import { Router, Request, Response } from "express"; -import { Member } from "@spacebar/util"; +import { Member, partition } from "@spacebar/util"; import { route } from "@spacebar/api"; const router = Router(); @@ -35,26 +35,21 @@ router.patch( relations: ["roles"], }); - const members_to_add = members.filter((member) => { - return ( + const [add, remove] = partition( + members, + (member) => member_ids.includes(member.id) && - !member.roles.map((role) => role.id).includes(role_id) - ); - }); - const members_to_remove = members.filter((member) => { - return ( - !member_ids.includes(member.id) && - member.roles.map((role) => role.id).includes(role_id) - ); - }); + !member.roles.map((role) => role.id).includes(role_id), + ); - for (const member of members_to_add) { - await Member.addRole(member.id, guild_id, role_id); - } - - for (const member of members_to_remove) { - await Member.removeRole(member.id, guild_id, role_id); - } + await Promise.all([ + ...add.map((member) => + Member.addRole(member.id, guild_id, role_id), + ), + ...remove.map((member) => + Member.removeRole(member.id, guild_id, role_id), + ), + ]); res.sendStatus(204); }, diff --git a/src/gateway/opcodes/LazyRequest.ts b/src/gateway/opcodes/LazyRequest.ts index 3cc2b655..64e50d92 100644 --- a/src/gateway/opcodes/LazyRequest.ts +++ b/src/gateway/opcodes/LazyRequest.ts @@ -26,6 +26,7 @@ import { LazyRequestSchema, User, Presence, + partition, } from "@spacebar/util"; import { WebSocket, @@ -302,11 +303,3 @@ export async function onLazyRequest(this: WebSocket, { d }: Payload) { }, }); } - -/* https://stackoverflow.com/a/50636286 */ -function partition(array: T[], filter: (elem: T) => boolean) { - const pass: T[] = [], - fail: T[] = []; - array.forEach((e) => (filter(e) ? pass : fail).push(e)); - return [pass, fail]; -} diff --git a/src/util/util/Array.ts b/src/util/util/Array.ts index 8a141340..082ac307 100644 --- a/src/util/util/Array.ts +++ b/src/util/util/Array.ts @@ -21,3 +21,11 @@ export function containsAll(arr: unknown[], target: unknown[]) { return target.every((v) => arr.includes(v)); } + +/* https://stackoverflow.com/a/50636286 */ +export function partition(array: T[], filter: (elem: T) => boolean) { + const pass: T[] = [], + fail: T[] = []; + array.forEach((e) => (filter(e) ? pass : fail).push(e)); + return [pass, fail]; +} From e9ce4ca51b740122b2150727196ac20d3e01a5bc Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 11 Apr 2023 13:11:19 +1000 Subject: [PATCH 7/9] member-counts route --- .../guilds/#guild_id/roles/member-counts.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/api/routes/guilds/#guild_id/roles/member-counts.ts diff --git a/src/api/routes/guilds/#guild_id/roles/member-counts.ts b/src/api/routes/guilds/#guild_id/roles/member-counts.ts new file mode 100644 index 00000000..88243b42 --- /dev/null +++ b/src/api/routes/guilds/#guild_id/roles/member-counts.ts @@ -0,0 +1,39 @@ +/* + 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 { Request, Response, Router } from "express"; +import { Role, Member } from "@spacebar/util"; +import { route } from "@spacebar/api"; +import {} from "typeorm"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const role_ids = await Role.find({ where: { guild_id }, select: ["id"] }); + const counts: { [id: string]: number } = {}; + for (const { id } of role_ids) { + counts[id] = await Member.count({ where: { roles: { id }, guild_id } }); + } + + return res.json(counts); +}); + +export default router; From 7a2a41be3ec8af8b155a241cbbc2507082f40eae Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 11 Apr 2023 13:17:51 +1000 Subject: [PATCH 8/9] Don't fetch the entire role relation when counting member ids --- .../#guild_id/roles/#role_id/member-ids.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts index e0e7bd20..b086193e 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts @@ -24,17 +24,19 @@ const router = Router(); router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id, role_id } = req.params; - await Member.IsInGuildOrFail(req.user_id, guild_id); + + // TODO: Is this route really not paginated? const members = await Member.find({ select: ["id"], - relations: ["roles"], + where: { + roles: { + id: role_id, + }, + guild_id, + }, }); - const member_ids = members - .filter((member) => { - return member.roles.map((role) => role.id).includes(role_id); - }) - .map((member) => member.id); - return res.json(member_ids); + + return res.json(members.map((x) => x.id)); }); export default router; From d865528442769b3d5683b75d0872b01da2b5374f Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 11 Apr 2023 13:19:26 +1000 Subject: [PATCH 9/9] Add erkins note --- src/api/routes/guilds/#guild_id/roles/#role_id/members.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts index 6b9fad9c..705848aa 100644 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts +++ b/src/api/routes/guilds/#guild_id/roles/#role_id/members.ts @@ -42,6 +42,7 @@ router.patch( !member.roles.map((role) => role.id).includes(role_id), ); + // TODO (erkin): have a bulk add/remove function that adds the roles in a single txn await Promise.all([ ...add.map((member) => Member.addRole(member.id, guild_id, role_id),