summary refs log tree commit diff
path: root/src/api/routes/v9/guilds
diff options
context:
space:
mode:
authorTheArcaneBrony <myrainbowdash949@gmail.com>2023-01-14 13:08:48 +0100
committerTheArcaneBrony <myrainbowdash949@gmail.com>2023-01-14 13:08:48 +0100
commit66df10d6b02cb1bed437665bc293dbcd5b9c73ff (patch)
treec3aa28e8a0f80a25260512c1dfb9c760c54f671c /src/api/routes/v9/guilds
parentUpdate openapi (diff)
downloadserver-ts-66df10d6b02cb1bed437665bc293dbcd5b9c73ff.tar.xz
Move endpoints to respective versions, split out non implemented routes
Signed-off-by: TheArcaneBrony <myrainbowdash949@gmail.com>
Diffstat (limited to 'src/api/routes/v9/guilds')
-rw-r--r--src/api/routes/v9/guilds/#guild_id/audit-logs.ts17
-rw-r--r--src/api/routes/v9/guilds/#guild_id/bans.ts200
-rw-r--r--src/api/routes/v9/guilds/#guild_id/channels.ts86
-rw-r--r--src/api/routes/v9/guilds/#guild_id/delete.ts44
-rw-r--r--src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts39
-rw-r--r--src/api/routes/v9/guilds/#guild_id/emojis.ts148
-rw-r--r--src/api/routes/v9/guilds/#guild_id/index.ts129
-rw-r--r--src/api/routes/v9/guilds/#guild_id/invites.ts22
-rw-r--r--src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts130
-rw-r--r--src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts26
-rw-r--r--src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts29
-rw-r--r--src/api/routes/v9/guilds/#guild_id/members/index.ts32
-rw-r--r--src/api/routes/v9/guilds/#guild_id/messages/search.ts137
-rw-r--r--src/api/routes/v9/guilds/#guild_id/profile/index.ts48
-rw-r--r--src/api/routes/v9/guilds/#guild_id/prune.ts106
-rw-r--r--src/api/routes/v9/guilds/#guild_id/regions.ts20
-rw-r--r--src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts92
-rw-r--r--src/api/routes/v9/guilds/#guild_id/roles/index.ts123
-rw-r--r--src/api/routes/v9/guilds/#guild_id/stickers.ts137
-rw-r--r--src/api/routes/v9/guilds/#guild_id/templates.ts116
-rw-r--r--src/api/routes/v9/guilds/#guild_id/vanity-url.ts82
-rw-r--r--src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts71
-rw-r--r--src/api/routes/v9/guilds/#guild_id/welcome-screen.ts43
-rw-r--r--src/api/routes/v9/guilds/#guild_id/widget.json.ts97
-rw-r--r--src/api/routes/v9/guilds/#guild_id/widget.png.ts179
-rw-r--r--src/api/routes/v9/guilds/#guild_id/widget.ts40
-rw-r--r--src/api/routes/v9/guilds/index.ts47
-rw-r--r--src/api/routes/v9/guilds/templates/index.ts132
28 files changed, 2372 insertions, 0 deletions
diff --git a/src/api/routes/v9/guilds/#guild_id/audit-logs.ts b/src/api/routes/v9/guilds/#guild_id/audit-logs.ts
new file mode 100644

index 00000000..76a11f6b --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/audit-logs.ts
@@ -0,0 +1,17 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +//TODO: implement audit logs +router.get("/", route({}), async (req: Request, res: Response) => { + res.json({ + audit_log_entries: [], + users: [], + integrations: [], + webhooks: [], + guild_scheduled_events: [], + threads: [], + application_commands: [], + }); +}); +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/bans.ts b/src/api/routes/v9/guilds/#guild_id/bans.ts new file mode 100644
index 00000000..930985d7 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/bans.ts
@@ -0,0 +1,200 @@ +import { Request, Response, Router } from "express"; +import { + DiscordApiErrors, + emitEvent, + GuildBanAddEvent, + GuildBanRemoveEvent, + Ban, + User, + Member, + BanRegistrySchema, + BanModeratorSchema, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { getIpAdress, route } from "@fosscord/api"; + +const router: Router = Router(); + +/* TODO: Deleting the secrets is just a temporary go-around. Views should be implemented for both safety and better handling. */ + +router.get( + "/", + route({ permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + + let bans = await Ban.find({ where: { guild_id: guild_id } }); + let promisesToAwait: object[] = []; + const bansObj: object[] = []; + + bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing + + bans.forEach((ban) => { + promisesToAwait.push(User.getPublicUser(ban.user_id)); + }); + + const bannedUsers: object[] = await Promise.all(promisesToAwait); + + bans.forEach((ban, index) => { + const user = bannedUsers[index] as User; + bansObj.push({ + reason: ban.reason, + user: { + username: user.username, + discriminator: user.discriminator, + id: user.id, + avatar: user.avatar, + public_flags: user.public_flags, + }, + }); + }); + + return res.json(bansObj); + }, +); + +router.get( + "/:user", + route({ permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const user_id = req.params.ban; + + let ban = (await Ban.findOneOrFail({ + where: { guild_id: guild_id, user_id: user_id }, + })) as BanRegistrySchema; + + if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN; + // pretend self-bans don't exist to prevent victim chasing + + /* Filter secret from registry. */ + + ban = ban as BanModeratorSchema; + + delete ban.ip; + + return res.json(ban); + }, +); + +router.put( + "/:user_id", + route({ body: "BanCreateSchema", permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const banned_user_id = req.params.user_id; + + if ( + req.user_id === banned_user_id && + banned_user_id === req.permission!.cache.guild?.owner_id + ) + throw new HTTPError( + "You are the guild owner, hence can't ban yourself", + 403, + ); + + if (req.permission!.cache.guild?.owner_id === banned_user_id) + throw new HTTPError("You can't ban the owner", 400); + + const banned_user = await User.getPublicUser(banned_user_id); + + const ban = Ban.create({ + user_id: banned_user_id, + guild_id: guild_id, + ip: getIpAdress(req), + executor_id: req.user_id, + reason: req.body.reason, // || otherwise empty + }); + + await Promise.all([ + Member.removeFromGuild(banned_user_id, guild_id), + ban.save(), + emitEvent({ + event: "GUILD_BAN_ADD", + data: { + guild_id: guild_id, + user: banned_user, + }, + guild_id: guild_id, + } as GuildBanAddEvent), + ]); + + return res.json(ban); + }, +); + +router.put( + "/@me", + route({ body: "BanCreateSchema" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const banned_user = await User.getPublicUser(req.params.user_id); + + if (req.permission!.cache.guild?.owner_id === req.params.user_id) + throw new HTTPError( + "You are the guild owner, hence can't ban yourself", + 403, + ); + + const ban = Ban.create({ + user_id: req.params.user_id, + guild_id: guild_id, + ip: getIpAdress(req), + executor_id: req.params.user_id, + reason: req.body.reason, // || otherwise empty + }); + + await Promise.all([ + Member.removeFromGuild(req.user_id, guild_id), + ban.save(), + emitEvent({ + event: "GUILD_BAN_ADD", + data: { + guild_id: guild_id, + user: banned_user, + }, + guild_id: guild_id, + } as GuildBanAddEvent), + ]); + + return res.json(ban); + }, +); + +router.delete( + "/:user_id", + route({ permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id, user_id } = req.params; + + let ban = await Ban.findOneOrFail({ + where: { guild_id: guild_id, user_id: user_id }, + }); + + if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN; + // make self-bans irreversible and hide them from view to avoid victim chasing + + const banned_user = await User.getPublicUser(user_id); + + await Promise.all([ + Ban.delete({ + user_id: user_id, + guild_id, + }), + + emitEvent({ + event: "GUILD_BAN_REMOVE", + data: { + guild_id, + user: banned_user, + }, + guild_id, + } as GuildBanRemoveEvent), + ]); + + return res.status(204).send(); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/channels.ts b/src/api/routes/v9/guilds/#guild_id/channels.ts new file mode 100644
index 00000000..eae93607 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/channels.ts
@@ -0,0 +1,86 @@ +import { Router, Response, Request } from "express"; +import { + Channel, + ChannelUpdateEvent, + emitEvent, + ChannelModifySchema, + ChannelReorderSchema, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const channels = await Channel.find({ where: { guild_id } }); + + res.json(channels); +}); + +router.post( + "/", + route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + // creates a new guild channel https://discord.com/developers/docs/resources/guild#create-guild-channel + const { guild_id } = req.params; + const body = req.body as ChannelModifySchema; + + const channel = await Channel.createChannel( + { ...body, guild_id }, + req.user_id, + ); + + res.status(201).json(channel); + }, +); + +router.patch( + "/", + route({ body: "ChannelReorderSchema", permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + // changes guild channel position + const { guild_id } = req.params; + const body = req.body as ChannelReorderSchema; + + await Promise.all([ + body.map(async (x) => { + if (x.position == null && !x.parent_id) + throw new HTTPError( + `You need to at least specify position or parent_id`, + 400, + ); + + const opts: any = {}; + if (x.position != null) opts.position = x.position; + + if (x.parent_id) { + opts.parent_id = x.parent_id; + const parent_channel = await Channel.findOneOrFail({ + where: { id: x.parent_id, guild_id }, + select: ["permission_overwrites"], + }); + if (x.lock_permissions) { + opts.permission_overwrites = + parent_channel.permission_overwrites; + } + } + + await Channel.update({ guild_id, id: x.id }, opts); + const channel = await Channel.findOneOrFail({ + where: { guild_id, id: x.id }, + }); + + await emitEvent({ + event: "CHANNEL_UPDATE", + data: channel, + channel_id: x.id, + guild_id, + } as ChannelUpdateEvent); + }), + ]); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/delete.ts b/src/api/routes/v9/guilds/#guild_id/delete.ts new file mode 100644
index 00000000..b951e4f4 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/delete.ts
@@ -0,0 +1,44 @@ +import { + Channel, + emitEvent, + GuildDeleteEvent, + Guild, + Member, + Message, + Role, + Invite, + Emoji, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router = Router(); + +// discord prefixes this route with /delete instead of using the delete method +// docs are wrong https://discord.com/developers/docs/resources/guild#delete-guild +router.post("/", route({}), async (req: Request, res: Response) => { + var { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: ["owner_id"], + }); + if (guild.owner_id !== req.user_id) + throw new HTTPError("You are not the owner of this guild", 401); + + await Promise.all([ + Guild.delete({ id: guild_id }), // this will also delete all guild related data + emitEvent({ + event: "GUILD_DELETE", + data: { + id: guild_id, + }, + guild_id: guild_id, + } as GuildDeleteEvent), + ]); + + return res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts b/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts new file mode 100644
index 00000000..7e63c06b --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts
@@ -0,0 +1,39 @@ +import { Guild, Config } from "@fosscord/util"; + +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + // TODO: + // Load from database + // Admin control, but for now it allows anyone to be discoverable + + res.send({ + guild_id: guild_id, + safe_environment: true, + healthy: true, + health_score_pending: false, + size: true, + nsfw_properties: {}, + protected: true, + sufficient: true, + sufficient_without_grace_period: true, + valid_rules_channel: true, + retention_healthy: true, + engagement_healthy: true, + age: true, + minimum_age: 0, + health_score: { + avg_nonnew_participators: 0, + avg_nonnew_communicators: 0, + num_intentful_joiners: 0, + perc_ret_w1_intentful: 0, + }, + minimum_size: 0, + }); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/emojis.ts b/src/api/routes/v9/guilds/#guild_id/emojis.ts new file mode 100644
index 00000000..6e8570eb --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/emojis.ts
@@ -0,0 +1,148 @@ +import { Router, Request, Response } from "express"; +import { + Config, + DiscordApiErrors, + emitEvent, + Emoji, + GuildEmojisUpdateEvent, + handleFile, + Member, + Snowflake, + User, + EmojiCreateSchema, + EmojiModifySchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const emojis = await Emoji.find({ + where: { guild_id: guild_id }, + relations: ["user"], + }); + + return res.json(emojis); +}); + +router.get("/:emoji_id", route({}), async (req: Request, res: Response) => { + const { guild_id, emoji_id } = req.params; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const emoji = await Emoji.findOneOrFail({ + where: { guild_id: guild_id, id: emoji_id }, + relations: ["user"], + }); + + return res.json(emoji); +}); + +router.post( + "/", + route({ + body: "EmojiCreateSchema", + permission: "MANAGE_EMOJIS_AND_STICKERS", + }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const body = req.body as EmojiCreateSchema; + + const id = Snowflake.generate(); + const emoji_count = await Emoji.count({ + where: { guild_id: guild_id }, + }); + const { maxEmojis } = Config.get().limits.guild; + + if (emoji_count >= maxEmojis) + throw DiscordApiErrors.MAXIMUM_NUMBER_OF_EMOJIS_REACHED.withParams( + maxEmojis, + ); + if (body.require_colons == null) body.require_colons = true; + + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + body.image = (await handleFile(`/emojis/${id}`, body.image)) as string; + + const emoji = await Emoji.create({ + id: id, + guild_id: guild_id, + ...body, + require_colons: body.require_colons ?? undefined, // schema allows nulls, db does not + user: user, + managed: false, + animated: false, // TODO: Add support animated emojis + available: true, + roles: [], + }).save(); + + await emitEvent({ + event: "GUILD_EMOJIS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + emojis: await Emoji.find({ where: { guild_id: guild_id } }), + }, + } as GuildEmojisUpdateEvent); + + return res.status(201).json(emoji); + }, +); + +router.patch( + "/:emoji_id", + route({ + body: "EmojiModifySchema", + permission: "MANAGE_EMOJIS_AND_STICKERS", + }), + async (req: Request, res: Response) => { + const { emoji_id, guild_id } = req.params; + const body = req.body as EmojiModifySchema; + + const emoji = await Emoji.create({ + ...body, + id: emoji_id, + guild_id: guild_id, + }).save(); + + await emitEvent({ + event: "GUILD_EMOJIS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + emojis: await Emoji.find({ where: { guild_id: guild_id } }), + }, + } as GuildEmojisUpdateEvent); + + return res.json(emoji); + }, +); + +router.delete( + "/:emoji_id", + route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }), + async (req: Request, res: Response) => { + const { emoji_id, guild_id } = req.params; + + await Emoji.delete({ + id: emoji_id, + guild_id: guild_id, + }); + + await emitEvent({ + event: "GUILD_EMOJIS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + emojis: await Emoji.find({ where: { guild_id: guild_id } }), + }, + } as GuildEmojisUpdateEvent); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/index.ts b/src/api/routes/v9/guilds/#guild_id/index.ts new file mode 100644
index 00000000..79c20678 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/index.ts
@@ -0,0 +1,129 @@ +import { Request, Response, Router } from "express"; +import { + DiscordApiErrors, + emitEvent, + getPermission, + getRights, + Guild, + GuildUpdateEvent, + handleFile, + Member, + GuildUpdateSchema, + FosscordApiErrors, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const [guild, member] = await Promise.all([ + Guild.findOneOrFail({ where: { id: guild_id } }), + Member.findOne({ where: { guild_id: guild_id, id: req.user_id } }), + ]); + if (!member) + throw new HTTPError( + "You are not a member of the guild you are trying to access", + 401, + ); + + // @ts-ignore + guild.joined_at = member?.joined_at; + + return res.send(guild); +}); + +router.patch( + "/", + route({ body: "GuildUpdateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as GuildUpdateSchema; + const { guild_id } = req.params; + + const rights = await getRights(req.user_id); + const permission = await getPermission(req.user_id, guild_id); + + if (!rights.has("MANAGE_GUILDS") && !permission.has("MANAGE_GUILD")) + throw DiscordApiErrors.MISSING_PERMISSIONS.withParams( + "MANAGE_GUILDS", + ); + + var guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + relations: ["emojis", "roles", "stickers"], + }); + + // TODO: guild update check image + + if (body.icon && body.icon != guild.icon) + body.icon = await handleFile(`/icons/${guild_id}`, body.icon); + + if (body.banner && body.banner !== guild.banner) + body.banner = await handleFile(`/banners/${guild_id}`, body.banner); + + if (body.splash && body.splash !== guild.splash) + body.splash = await handleFile( + `/splashes/${guild_id}`, + body.splash, + ); + + if ( + body.discovery_splash && + body.discovery_splash !== guild.discovery_splash + ) + body.discovery_splash = await handleFile( + `/discovery-splashes/${guild_id}`, + body.discovery_splash, + ); + + if (body.features) { + const diff = guild.features + .filter((x) => !body.features?.includes(x)) + .concat( + body.features.filter((x) => !guild.features.includes(x)), + ); + + // TODO move these + const MUTABLE_FEATURES = [ + "COMMUNITY", + "INVITES_DISABLED", + "DISCOVERABLE", + ]; + + for (var feature of diff) { + if (MUTABLE_FEATURES.includes(feature)) continue; + + throw FosscordApiErrors.FEATURE_IS_IMMUTABLE.withParams( + feature, + ); + } + + // for some reason, they don't update in the assign. + guild.features = body.features; + } + + // TODO: check if body ids are valid + guild.assign(body); + + const data = guild.toJSON(); + // TODO: guild hashes + // TODO: fix vanity_url_code, template_id + delete data.vanity_url_code; + delete data.template_id; + + await Promise.all([ + guild.save(), + emitEvent({ + event: "GUILD_UPDATE", + data, + guild_id, + } as GuildUpdateEvent), + ]); + + return res.json(data); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/invites.ts b/src/api/routes/v9/guilds/#guild_id/invites.ts new file mode 100644
index 00000000..4d033e9c --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/invites.ts
@@ -0,0 +1,22 @@ +import { getPermission, Invite, PublicInviteRelation } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.get( + "/", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const invites = await Invite.find({ + where: { guild_id }, + relations: PublicInviteRelation, + }); + + return res.json(invites); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts new file mode 100644
index 00000000..0fcdd57c --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts
@@ -0,0 +1,130 @@ +import { Request, Response, Router } from "express"; +import { + Member, + getPermission, + getRights, + Role, + GuildMemberUpdateEvent, + emitEvent, + Sticker, + Emoji, + Guild, + handleFile, + MemberChangeSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id, member_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const member = await Member.findOneOrFail({ + where: { id: member_id, guild_id }, + }); + + return res.json(member); +}); + +router.patch( + "/", + route({ body: "MemberChangeSchema" }), + async (req: Request, res: Response) => { + let { guild_id, member_id } = req.params; + if (member_id === "@me") member_id = req.user_id; + const body = req.body as MemberChangeSchema; + + let member = await Member.findOneOrFail({ + where: { id: member_id, guild_id }, + relations: ["roles", "user"], + }); + const permission = await getPermission(req.user_id, guild_id); + const everyone = await Role.findOneOrFail({ + where: { guild_id: guild_id, name: "@everyone", position: 0 }, + }); + + if (body.avatar) + body.avatar = await handleFile( + `/guilds/${guild_id}/users/${member_id}/avatars`, + body.avatar as string, + ); + + member.assign(body); + + if ("roles" in body) { + permission.hasThrow("MANAGE_ROLES"); + + body.roles = body.roles || []; + body.roles.filter((x) => !!x); + + if (body.roles.indexOf(everyone.id) === -1) + body.roles.push(everyone.id); + member.roles = body.roles.map((x) => Role.create({ id: x })); // foreign key constraint will fail if role doesn't exist + } + + await member.save(); + + member.roles = member.roles.filter((x) => x.id !== everyone.id); + + // do not use promise.all as we have to first write to db before emitting the event to catch errors + await emitEvent({ + event: "GUILD_MEMBER_UPDATE", + guild_id, + data: { ...member, roles: member.roles.map((x) => x.id) }, + } as GuildMemberUpdateEvent); + + res.json(member); + }, +); + +router.put("/", route({}), async (req: Request, res: Response) => { + // TODO: Lurker mode + + const rights = await getRights(req.user_id); + + let { guild_id, member_id } = req.params; + if (member_id === "@me") { + member_id = req.user_id; + rights.hasThrow("JOIN_GUILDS"); + } else { + // TODO: join others by controller + } + + var guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + }); + + var emoji = await Emoji.find({ + where: { guild_id: guild_id }, + }); + + var roles = await Role.find({ + where: { guild_id: guild_id }, + }); + + var stickers = await Sticker.find({ + where: { guild_id: guild_id }, + }); + + await Member.addToGuild(member_id, guild_id); + res.send({ ...guild, emojis: emoji, roles: roles, stickers: stickers }); +}); + +router.delete("/", route({}), async (req: Request, res: Response) => { + const { guild_id, member_id } = req.params; + const permission = await getPermission(req.user_id, guild_id); + const rights = await getRights(req.user_id); + if (member_id === "@me" || member_id === req.user_id) { + // TODO: unless force-joined + rights.hasThrow("SELF_LEAVE_GROUPS"); + } else { + rights.hasThrow("KICK_BAN_MEMBERS"); + permission.hasThrow("KICK_MEMBERS"); + } + + await Member.removeFromGuild(member_id, guild_id); + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts new file mode 100644
index 00000000..20443821 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts
@@ -0,0 +1,26 @@ +import { getPermission, Member, PermissionResolvable } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.patch( + "/", + route({ body: "MemberNickChangeSchema" }), + async (req: Request, res: Response) => { + var { guild_id, member_id } = req.params; + var permissionString: PermissionResolvable = "MANAGE_NICKNAMES"; + if (member_id === "@me") { + member_id = req.user_id; + permissionString = "CHANGE_NICKNAME"; + } + + const perms = await getPermission(req.user_id, guild_id); + perms.hasThrow(permissionString); + + await Member.changeNickname(member_id, guild_id, req.body.nick); + res.status(200).send(); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts new file mode 100644
index 00000000..c0383912 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
@@ -0,0 +1,29 @@ +import { getPermission, Member } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.delete( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { guild_id, role_id, member_id } = req.params; + + await Member.removeRole(member_id, guild_id, role_id); + res.sendStatus(204); + }, +); + +router.put( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { guild_id, role_id, member_id } = req.params; + + await Member.addRole(member_id, guild_id, role_id); + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/index.ts b/src/api/routes/v9/guilds/#guild_id/members/index.ts new file mode 100644
index 00000000..b516b9e9 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/index.ts
@@ -0,0 +1,32 @@ +import { Request, Response, Router } from "express"; +import { Guild, Member, PublicMemberProjection } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { MoreThan } from "typeorm"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +// TODO: send over websocket +// TODO: check for GUILD_MEMBERS intent + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const limit = Number(req.query.limit) || 1; + if (limit > 1000 || limit < 1) + throw new HTTPError("Limit must be between 1 and 1000"); + const after = `${req.query.after}`; + const query = after ? { id: MoreThan(after) } : {}; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const members = await Member.find({ + where: { guild_id, ...query }, + select: PublicMemberProjection, + take: limit, + order: { id: "ASC" }, + }); + + return res.json(members); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/messages/search.ts b/src/api/routes/v9/guilds/#guild_id/messages/search.ts new file mode 100644
index 00000000..88488871 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/messages/search.ts
@@ -0,0 +1,137 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { getPermission, FieldErrors, Message, Channel } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { FindManyOptions, In, Like } from "typeorm"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { + channel_id, + content, + include_nsfw, // TODO + offset, + sort_order, + sort_by, // TODO: Handle 'relevance' + limit, + author_id, + } = req.query; + + const parsedLimit = Number(limit) || 50; + if (parsedLimit < 1 || parsedLimit > 100) + throw new HTTPError("limit must be between 1 and 100", 422); + + if (sort_order) { + if ( + typeof sort_order != "string" || + ["desc", "asc"].indexOf(sort_order) == -1 + ) + throw FieldErrors({ + sort_order: { + message: "Value must be one of ('desc', 'asc').", + code: "BASE_TYPE_CHOICES", + }, + }); // todo this is wrong + } + + const permissions = await getPermission( + req.user_id, + req.params.guild_id, + channel_id as string | undefined, + ); + permissions.hasThrow("VIEW_CHANNEL"); + if (!permissions.has("READ_MESSAGE_HISTORY")) + return res.json({ messages: [], total_results: 0 }); + + var query: FindManyOptions<Message> = { + order: { + timestamp: sort_order + ? (sort_order.toUpperCase() as "ASC" | "DESC") + : "DESC", + }, + take: parsedLimit || 0, + where: { + guild: { + id: req.params.guild_id, + }, + }, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + ], + skip: offset ? Number(offset) : 0, + }; + //@ts-ignore + if (channel_id) query.where!.channel = { id: channel_id }; + else { + // get all channel IDs that this user can access + const channels = await Channel.find({ + where: { guild_id: req.params.guild_id }, + select: ["id"], + }); + const ids = []; + + for (var channel of channels) { + const perm = await getPermission( + req.user_id, + req.params.guild_id, + channel.id, + ); + if (!perm.has("VIEW_CHANNEL") || !perm.has("READ_MESSAGE_HISTORY")) + continue; + ids.push(channel.id); + } + + //@ts-ignore + query.where!.channel = { id: In(ids) }; + } + //@ts-ignore + if (author_id) query.where!.author = { id: author_id }; + //@ts-ignore + if (content) query.where!.content = Like(`%${content}%`); + + const messages: Message[] = await Message.find(query); + + const messagesDto = messages.map((x) => [ + { + id: x.id, + type: x.type, + content: x.content, + channel_id: x.channel_id, + author: { + id: x.author?.id, + username: x.author?.username, + avatar: x.author?.avatar, + avatar_decoration: null, + discriminator: x.author?.discriminator, + public_flags: x.author?.public_flags, + }, + attachments: x.attachments, + embeds: x.embeds, + mentions: x.mentions, + mention_roles: x.mention_roles, + pinned: x.pinned, + mention_everyone: x.mention_everyone, + tts: x.tts, + timestamp: x.timestamp, + edited_timestamp: x.edited_timestamp, + flags: x.flags, + components: x.components, + hit: true, + }, + ]); + + return res.json({ + messages: messagesDto, + total_results: messages.length, + }); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/profile/index.ts b/src/api/routes/v9/guilds/#guild_id/profile/index.ts new file mode 100644
index 00000000..20a7fa95 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/profile/index.ts
@@ -0,0 +1,48 @@ +import { route } from "@fosscord/api"; +import { + emitEvent, + GuildMemberUpdateEvent, + handleFile, + Member, + MemberChangeProfileSchema, + OrmUtils, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.patch( + "/:member_id", + route({ body: "MemberChangeProfileSchema" }), + async (req: Request, res: Response) => { + let { guild_id, member_id } = req.params; + if (member_id === "@me") member_id = req.user_id; + const body = req.body as MemberChangeProfileSchema; + + let member = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id }, + relations: ["roles", "user"], + }); + + if (body.banner) + body.banner = await handleFile( + `/guilds/${guild_id}/users/${req.user_id}/avatars`, + body.banner as string, + ); + + member = await OrmUtils.mergeDeep(member, body); + + await member.save(); + + // do not use promise.all as we have to first write to db before emitting the event to catch errors + await emitEvent({ + event: "GUILD_MEMBER_UPDATE", + guild_id, + data: { ...member, roles: member.roles.map((x) => x.id) }, + } as GuildMemberUpdateEvent); + + res.json(member); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/prune.ts b/src/api/routes/v9/guilds/#guild_id/prune.ts new file mode 100644
index 00000000..8089ad84 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/prune.ts
@@ -0,0 +1,106 @@ +import { Router, Request, Response } from "express"; +import { Guild, Member, Snowflake } from "@fosscord/util"; +import { LessThan, IsNull } from "typeorm"; +import { route } from "@fosscord/api"; +const router = Router(); + +//Returns all inactive members, respecting role hierarchy +export const inactiveMembers = async ( + guild_id: string, + user_id: string, + days: number, + roles: string[] = [], +) => { + var date = new Date(); + date.setDate(date.getDate() - days); + //Snowflake should have `generateFromTime` method? Or similar? + var minId = BigInt(date.valueOf() - Snowflake.EPOCH) << BigInt(22); + + /** + idea: ability to customise the cutoff variable + possible candidates: public read receipt, last presence, last VC leave + **/ + var members = await Member.find({ + where: [ + { + guild_id, + last_message_id: LessThan(minId.toString()), + }, + { + guild_id, + last_message_id: IsNull(), + }, + ], + relations: ["roles"], + }); + if (!members.length) return []; + + //I'm sure I can do this in the above db query ( and it would probably be better to do so ), but oh well. + if (roles.length && members.length) + members = members.filter((user) => + user.roles?.some((role) => roles.includes(role.id)), + ); + + const me = await Member.findOneOrFail({ + where: { id: user_id, guild_id }, + relations: ["roles"], + }); + const myHighestRole = Math.max(...(me.roles?.map((x) => x.position) || [])); + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + members = members.filter( + (member) => + member.id !== guild.owner_id && //can't kick owner + member.roles?.some( + (role) => + role.position < myHighestRole || //roles higher than me can't be kicked + me.id === guild.owner_id, //owner can kick anyone + ), + ); + + return members; +}; + +router.get("/", route({}), async (req: Request, res: Response) => { + const days = parseInt(req.query.days as string); + + var roles = req.query.include_roles; + if (typeof roles === "string") roles = [roles]; //express will return array otherwise + + const members = await inactiveMembers( + req.params.guild_id, + req.user_id, + days, + roles as string[], + ); + + res.send({ pruned: members.length }); +}); + +router.post( + "/", + route({ permission: "KICK_MEMBERS", right: "KICK_BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const days = parseInt(req.body.days); + + var roles = req.query.include_roles; + if (typeof roles === "string") roles = [roles]; + + const { guild_id } = req.params; + const members = await inactiveMembers( + guild_id, + req.user_id, + days, + roles as string[], + ); + + await Promise.all( + members.map((x) => Member.removeFromGuild(x.id, guild_id)), + ); + + res.send({ purged: members.length }); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/regions.ts b/src/api/routes/v9/guilds/#guild_id/regions.ts new file mode 100644
index 00000000..0b275ea4 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/regions.ts
@@ -0,0 +1,20 @@ +import { Config, Guild, Member } from "@fosscord/util"; +import { Request, Response, Router } from "express"; +import { getVoiceRegions, route } from "@fosscord/api"; +import { getIpAdress } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + //TODO we should use an enum for guild's features and not hardcoded strings + return res.json( + await getVoiceRegions( + getIpAdress(req), + guild.features.includes("VIP_REGIONS"), + ), + ); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts b/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts new file mode 100644
index 00000000..84648703 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts
@@ -0,0 +1,92 @@ +import { Router, Request, Response } from "express"; +import { + Role, + Member, + GuildRoleUpdateEvent, + GuildRoleDeleteEvent, + emitEvent, + handleFile, + RoleModifySchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { HTTPError } from "lambert-server"; + +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 role = await Role.findOneOrFail({ where: { guild_id, id: role_id } }); + return res.json(role); +}); + +router.delete( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { guild_id, role_id } = req.params; + if (role_id === guild_id) + throw new HTTPError("You can't delete the @everyone role"); + + await Promise.all([ + Role.delete({ + id: role_id, + guild_id: guild_id, + }), + emitEvent({ + event: "GUILD_ROLE_DELETE", + guild_id, + data: { + guild_id, + role_id, + }, + } as GuildRoleDeleteEvent), + ]); + + res.sendStatus(204); + }, +); + +// TODO: check role hierarchy + +router.patch( + "/", + route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { role_id, guild_id } = req.params; + const body = req.body as RoleModifySchema; + + if (body.icon && body.icon.length) + body.icon = await handleFile( + `/role-icons/${role_id}`, + body.icon as string, + ); + else body.icon = undefined; + + const role = await Role.findOneOrFail({ + where: { id: role_id, guild: { id: guild_id } }, + }); + role.assign({ + ...body, + permissions: String( + req.permission!.bitfield & BigInt(body.permissions || "0"), + ), + }); + + await Promise.all([ + role.save(), + emitEvent({ + event: "GUILD_ROLE_UPDATE", + guild_id, + data: { + guild_id, + role, + }, + } as GuildRoleUpdateEvent), + ]); + + res.json(role); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/roles/index.ts b/src/api/routes/v9/guilds/#guild_id/roles/index.ts new file mode 100644
index 00000000..4cd47cf3 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/roles/index.ts
@@ -0,0 +1,123 @@ +import { Request, Response, Router } from "express"; +import { + Role, + getPermission, + Member, + GuildRoleCreateEvent, + GuildRoleUpdateEvent, + emitEvent, + Config, + DiscordApiErrors, + RoleModifySchema, + RolePositionUpdateSchema, + Snowflake, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Not } from "typeorm"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const roles = await Role.find({ where: { guild_id: guild_id } }); + + return res.json(roles); +}); + +router.post( + "/", + route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + const body = req.body as RoleModifySchema; + + const role_count = await Role.count({ where: { guild_id } }); + const { maxRoles } = Config.get().limits.guild; + + if (role_count > maxRoles) + throw DiscordApiErrors.MAXIMUM_ROLES.withParams(maxRoles); + + const role = Role.create({ + // values before ...body are default and can be overriden + position: 1, + hoist: false, + color: 0, + mentionable: false, + ...body, + guild_id: guild_id, + managed: false, + permissions: String( + req.permission!.bitfield & BigInt(body.permissions || "0"), + ), + tags: undefined, + icon: undefined, + unicode_emoji: undefined, + id: Snowflake.generate(), + }); + + await Promise.all([ + role.save(), + // Move all existing roles up one position, to accommodate the new role + Role.createQueryBuilder("roles") + .where({ + guild: { id: guild_id }, + name: Not("@everyone"), + id: Not(role.id), + }) + .update({ position: () => "position + 1" }) + .execute(), + emitEvent({ + event: "GUILD_ROLE_CREATE", + guild_id, + data: { + guild_id, + role: role, + }, + } as GuildRoleCreateEvent), + ]); + + res.json(role); + }, +); + +router.patch( + "/", + route({ body: "RolePositionUpdateSchema" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const body = req.body as RolePositionUpdateSchema; + + const perms = await getPermission(req.user_id, guild_id); + perms.hasThrow("MANAGE_ROLES"); + + await Promise.all( + body.map(async (x) => + Role.update({ guild_id, id: x.id }, { position: x.position }), + ), + ); + + const roles = await Role.find({ + where: body.map((x) => ({ id: x.id, guild_id })), + }); + + await Promise.all( + roles.map((x) => + emitEvent({ + event: "GUILD_ROLE_UPDATE", + guild_id, + data: { + guild_id, + role: x, + }, + } as GuildRoleUpdateEvent), + ), + ); + + res.json(roles); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/stickers.ts b/src/api/routes/v9/guilds/#guild_id/stickers.ts new file mode 100644
index 00000000..3b1f5f8e --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/stickers.ts
@@ -0,0 +1,137 @@ +import { + emitEvent, + GuildStickersUpdateEvent, + Member, + Snowflake, + Sticker, + StickerFormatType, + StickerType, + uploadFile, + ModifyGuildStickerSchema, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import multer from "multer"; +import { HTTPError } from "lambert-server"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + res.json(await Sticker.find({ where: { guild_id } })); +}); + +const bodyParser = multer({ + limits: { + fileSize: 1024 * 1024 * 100, + fields: 10, + files: 1, + }, + storage: multer.memoryStorage(), +}).single("file"); + +router.post( + "/", + bodyParser, + route({ + permission: "MANAGE_EMOJIS_AND_STICKERS", + body: "ModifyGuildStickerSchema", + }), + async (req: Request, res: Response) => { + if (!req.file) throw new HTTPError("missing file"); + + const { guild_id } = req.params; + const body = req.body as ModifyGuildStickerSchema; + const id = Snowflake.generate(); + + const [sticker] = await Promise.all([ + Sticker.create({ + ...body, + guild_id, + id, + type: StickerType.GUILD, + format_type: getStickerFormat(req.file.mimetype), + available: true, + }).save(), + uploadFile(`/stickers/${id}`, req.file), + ]); + + await sendStickerUpdateEvent(guild_id); + + res.json(sticker); + }, +); + +export function getStickerFormat(mime_type: string) { + switch (mime_type) { + case "image/apng": + return StickerFormatType.APNG; + case "application/json": + return StickerFormatType.LOTTIE; + case "image/png": + return StickerFormatType.PNG; + case "image/gif": + return StickerFormatType.GIF; + default: + throw new HTTPError( + "invalid sticker format: must be png, apng or lottie", + ); + } +} + +router.get("/:sticker_id", route({}), async (req: Request, res: Response) => { + const { guild_id, sticker_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + res.json( + await Sticker.findOneOrFail({ where: { guild_id, id: sticker_id } }), + ); +}); + +router.patch( + "/:sticker_id", + route({ + body: "ModifyGuildStickerSchema", + permission: "MANAGE_EMOJIS_AND_STICKERS", + }), + async (req: Request, res: Response) => { + const { guild_id, sticker_id } = req.params; + const body = req.body as ModifyGuildStickerSchema; + + const sticker = await Sticker.create({ + ...body, + guild_id, + id: sticker_id, + }).save(); + await sendStickerUpdateEvent(guild_id); + + return res.json(sticker); + }, +); + +async function sendStickerUpdateEvent(guild_id: string) { + return emitEvent({ + event: "GUILD_STICKERS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + stickers: await Sticker.find({ where: { guild_id: guild_id } }), + }, + } as GuildStickersUpdateEvent); +} + +router.delete( + "/:sticker_id", + route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }), + async (req: Request, res: Response) => { + const { guild_id, sticker_id } = req.params; + + await Sticker.delete({ guild_id, id: sticker_id }); + await sendStickerUpdateEvent(guild_id); + + return res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/templates.ts b/src/api/routes/v9/guilds/#guild_id/templates.ts new file mode 100644
index 00000000..3b5eddaa --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/templates.ts
@@ -0,0 +1,116 @@ +import { Request, Response, Router } from "express"; +import { Guild, Template } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +import { generateCode } from "@fosscord/api"; + +const router: Router = Router(); + +const TemplateGuildProjection: (keyof Guild)[] = [ + "name", + "description", + "region", + "verification_level", + "default_message_notifications", + "explicit_content_filter", + "preferred_locale", + "afk_timeout", + "roles", + // "channels", + "afk_channel_id", + "system_channel_id", + "system_channel_flags", + "icon", +]; + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + var templates = await Template.find({ + where: { source_guild_id: guild_id }, + }); + + return res.json(templates); +}); + +router.post( + "/", + route({ body: "TemplateCreateSchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: TemplateGuildProjection, + }); + const exists = await Template.findOneOrFail({ + where: { id: guild_id }, + }).catch((e) => {}); + if (exists) throw new HTTPError("Template already exists", 400); + + const template = await Template.create({ + ...req.body, + code: generateCode(), + creator_id: req.user_id, + created_at: new Date(), + updated_at: new Date(), + source_guild_id: guild_id, + serialized_source_guild: guild, + }).save(); + + res.json(template); + }, +); + +router.delete( + "/:code", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { code, guild_id } = req.params; + + const template = await Template.delete({ + code, + source_guild_id: guild_id, + }); + + res.json(template); + }, +); + +router.put( + "/:code", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { code, guild_id } = req.params; + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: TemplateGuildProjection, + }); + + const template = await Template.create({ + code, + serialized_source_guild: guild, + }).save(); + + res.json(template); + }, +); + +router.patch( + "/:code", + route({ body: "TemplateModifySchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { code, guild_id } = req.params; + const { name, description } = req.body; + + const template = await Template.create({ + code, + name: name, + description: description, + source_guild_id: guild_id, + }).save(); + + res.json(template); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/vanity-url.ts b/src/api/routes/v9/guilds/#guild_id/vanity-url.ts new file mode 100644
index 00000000..9a96b066 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/vanity-url.ts
@@ -0,0 +1,82 @@ +import { + Channel, + ChannelType, + Guild, + Invite, + VanityUrlSchema, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +const InviteRegex = /\W/g; + +router.get( + "/", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + if (!guild.features.includes("ALIASABLE_NAMES")) { + const invite = await Invite.findOne({ + where: { guild_id: guild_id, vanity_url: true }, + }); + if (!invite) return res.json({ code: null }); + + return res.json({ code: invite.code, uses: invite.uses }); + } else { + const invite = await Invite.find({ + where: { guild_id: guild_id, vanity_url: true }, + }); + if (!invite || invite.length == 0) return res.json({ code: null }); + + return res.json( + invite.map((x) => ({ code: x.code, uses: x.uses })), + ); + } + }, +); + +router.patch( + "/", + route({ body: "VanityUrlSchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const body = req.body as VanityUrlSchema; + const code = body.code?.replace(InviteRegex, ""); + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + if (!guild.features.includes("VANITY_URL")) + throw new HTTPError("Your guild doesn't support vanity urls"); + + if (!code || code.length === 0) + throw new HTTPError("Code cannot be null or empty"); + + const invite = await Invite.findOne({ where: { code } }); + if (invite) throw new HTTPError("Invite already exists"); + + const { id } = await Channel.findOneOrFail({ + where: { guild_id, type: ChannelType.GUILD_TEXT }, + }); + + await Invite.create({ + vanity_url: true, + code: code, + temporary: false, + uses: 0, + max_uses: 0, + max_age: 0, + created_at: new Date(), + expires_at: new Date(), + guild_id: guild_id, + channel_id: id, + }).save(); + + return res.json({ code: code }); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts b/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts new file mode 100644
index 00000000..af03a07e --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts
@@ -0,0 +1,71 @@ +import { + Channel, + ChannelType, + DiscordApiErrors, + emitEvent, + getPermission, + VoiceState, + VoiceStateUpdateEvent, + VoiceStateUpdateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); +//TODO need more testing when community guild and voice stage channel are working + +router.patch( + "/", + route({ body: "VoiceStateUpdateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as VoiceStateUpdateSchema; + var { guild_id, user_id } = req.params; + if (user_id === "@me") user_id = req.user_id; + + const perms = await getPermission( + req.user_id, + guild_id, + body.channel_id, + ); + + /* + From https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state + You must have the MUTE_MEMBERS permission to unsuppress others. You can always suppress yourself. + You must have the REQUEST_TO_SPEAK permission to request to speak. You can always clear your own request to speak. + */ + if (body.suppress && user_id !== req.user_id) { + perms.hasThrow("MUTE_MEMBERS"); + } + if (!body.suppress) body.request_to_speak_timestamp = new Date(); + if (body.request_to_speak_timestamp) perms.hasThrow("REQUEST_TO_SPEAK"); + + const voice_state = await VoiceState.findOne({ + where: { + guild_id, + channel_id: body.channel_id, + user_id, + }, + }); + if (!voice_state) throw DiscordApiErrors.UNKNOWN_VOICE_STATE; + + voice_state.assign(body); + const channel = await Channel.findOneOrFail({ + where: { guild_id, id: body.channel_id }, + }); + if (channel.type !== ChannelType.GUILD_STAGE_VOICE) { + throw DiscordApiErrors.CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE; + } + + await Promise.all([ + voice_state.save(), + emitEvent({ + event: "VOICE_STATE_UPDATE", + data: voice_state, + guild_id, + } as VoiceStateUpdateEvent), + ]); + return res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts b/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts new file mode 100644
index 00000000..80ab138b --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts
@@ -0,0 +1,43 @@ +import { Request, Response, Router } from "express"; +import { Guild, Member, GuildUpdateWelcomeScreenSchema } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + await Member.IsInGuildOrFail(req.user_id, guild_id); + + res.json(guild.welcome_screen); +}); + +router.patch( + "/", + route({ + body: "GuildUpdateWelcomeScreenSchema", + permission: "MANAGE_GUILD", + }), + async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + const body = req.body as GuildUpdateWelcomeScreenSchema; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + if (!guild.welcome_screen.enabled) + throw new HTTPError("Welcome screen disabled", 400); + if (body.welcome_channels) + guild.welcome_screen.welcome_channels = body.welcome_channels; // TODO: check if they exist and are valid + if (body.description) + guild.welcome_screen.description = body.description; + if (body.enabled != null) guild.welcome_screen.enabled = body.enabled; + + await guild.save(); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/widget.json.ts b/src/api/routes/v9/guilds/#guild_id/widget.json.ts new file mode 100644
index 00000000..2c3124a2 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/widget.json.ts
@@ -0,0 +1,97 @@ +import { Request, Response, Router } from "express"; +import { + Config, + Permissions, + Guild, + Invite, + Channel, + Member, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { random, route } from "@fosscord/api"; + +const router: Router = Router(); + +// Undocumented API notes: +// An invite is created for the widget_channel_id on request (only if an existing one created by the widget doesn't already exist) +// This invite created doesn't include an inviter object like user created ones and has a default expiry of 24 hours +// Missing user object information is intentional (https://github.com/discord/discord-api-docs/issues/1287) +// channels returns voice channel objects where @everyone has the CONNECT permission +// members (max 100 returned) is a sample of all members, and bots par invisible status, there exists some alphabetical distribution pattern between the members returned + +// https://discord.com/developers/docs/resources/guild#get-guild-widget +// TODO: Cache the response for a guild for 5 minutes regardless of response +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404); + + // Fetch existing widget invite for widget channel + var invite = await Invite.findOne({ + where: { channel_id: guild.widget_channel_id }, + }); + + if (guild.widget_channel_id && !invite) { + // Create invite for channel if none exists + // TODO: Refactor invite create code to a shared function + const max_age = 86400; // 24 hours + const expires_at = new Date(max_age * 1000 + Date.now()); + + invite = await Invite.create({ + code: random(), + temporary: false, + uses: 0, + max_uses: 0, + max_age: max_age, + expires_at, + created_at: new Date(), + guild_id, + channel_id: guild.widget_channel_id, + }).save(); + } + + // Fetch voice channels, and the @everyone permissions object + const channels = [] as any[]; + + ( + await Channel.find({ + where: { guild_id: guild_id, type: 2 }, + order: { position: "ASC" }, + }) + ).filter((doc) => { + // Only return channels where @everyone has the CONNECT permission + if ( + doc.permission_overwrites === undefined || + Permissions.channelPermission( + doc.permission_overwrites, + Permissions.FLAGS.CONNECT, + ) === Permissions.FLAGS.CONNECT + ) { + channels.push({ + id: doc.id, + name: doc.name, + position: doc.position, + }); + } + }); + + // Fetch members + // TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file) + let members = await Member.find({ where: { guild_id: guild_id } }); + + // Construct object to respond with + const data = { + id: guild_id, + name: guild.name, + instant_invite: invite?.code, + channels: channels, + members: members, + presence_count: guild.presence_count, + }; + + res.set("Cache-Control", "public, max-age=300"); + return res.json(data); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/widget.png.ts b/src/api/routes/v9/guilds/#guild_id/widget.png.ts new file mode 100644
index 00000000..eaec8f07 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/widget.png.ts
@@ -0,0 +1,179 @@ +import { Request, Response, Router } from "express"; +import { Guild } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +import fs from "fs"; +import path from "path"; + +const router: Router = Router(); + +// TODO: use svg templates instead of node-canvas for improved performance and to change it easily + +// https://discord.com/developers/docs/resources/guild#get-guild-widget-image +// TODO: Cache the response +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404); + + // Fetch guild information + const icon = guild.icon; + const name = guild.name; + const presence = guild.presence_count + " ONLINE"; + + // Fetch parameter + const style = req.query.style?.toString() || "shield"; + if ( + !["shield", "banner1", "banner2", "banner3", "banner4"].includes(style) + ) { + throw new HTTPError( + "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').", + 400, + ); + } + + // Setup canvas + const { createCanvas } = require("canvas"); + const { loadImage } = require("canvas"); + const sizeOf = require("image-size"); + + // TODO: Widget style templates need Fosscord branding + const source = path.join( + __dirname, + "..", + "..", + "..", + "..", + "..", + "assets", + "widget", + `${style}.png`, + ); + if (!fs.existsSync(source)) { + throw new HTTPError("Widget template does not exist.", 400); + } + + // Create base template image for parameter + const { width, height } = await sizeOf(source); + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + const template = await loadImage(source); + ctx.drawImage(template, 0, 0); + + // Add the guild specific information to the template asset image + switch (style) { + case "shield": + ctx.textAlign = "center"; + await drawText( + ctx, + 73, + 13, + "#FFFFFF", + "thin 10px Verdana", + presence, + ); + break; + case "banner1": + if (icon) await drawIcon(ctx, 20, 27, 50, icon); + await drawText(ctx, 83, 51, "#FFFFFF", "12px Verdana", name, 22); + await drawText( + ctx, + 83, + 66, + "#C9D2F0FF", + "thin 11px Verdana", + presence, + ); + break; + case "banner2": + if (icon) await drawIcon(ctx, 13, 19, 36, icon); + await drawText(ctx, 62, 34, "#FFFFFF", "12px Verdana", name, 15); + await drawText( + ctx, + 62, + 49, + "#C9D2F0FF", + "thin 11px Verdana", + presence, + ); + break; + case "banner3": + if (icon) await drawIcon(ctx, 20, 20, 50, icon); + await drawText(ctx, 83, 44, "#FFFFFF", "12px Verdana", name, 27); + await drawText( + ctx, + 83, + 58, + "#C9D2F0FF", + "thin 11px Verdana", + presence, + ); + break; + case "banner4": + if (icon) await drawIcon(ctx, 21, 136, 50, icon); + await drawText(ctx, 84, 156, "#FFFFFF", "13px Verdana", name, 27); + await drawText( + ctx, + 84, + 171, + "#C9D2F0FF", + "thin 12px Verdana", + presence, + ); + break; + default: + throw new HTTPError( + "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').", + 400, + ); + } + + // Return final image + const buffer = canvas.toBuffer("image/png"); + res.set("Content-Type", "image/png"); + res.set("Cache-Control", "public, max-age=3600"); + return res.send(buffer); +}); + +async function drawIcon( + canvas: any, + x: number, + y: number, + scale: number, + icon: string, +) { + // @ts-ignore + const img = new require("canvas").Image(); + img.src = icon; + + // Do some canvas clipping magic! + canvas.save(); + canvas.beginPath(); + + const r = scale / 2; // use scale to determine radius + canvas.arc(x + r, y + r, r, 0, 2 * Math.PI, false); // start circle at x, and y coords + radius to find center + + canvas.clip(); + canvas.drawImage(img, x, y, scale, scale); + + canvas.restore(); +} + +async function drawText( + canvas: any, + x: number, + y: number, + color: string, + font: string, + text: string, + maxcharacters?: number, +) { + canvas.fillStyle = color; + canvas.font = font; + if (text.length > (maxcharacters || 0) && maxcharacters) + text = text.slice(0, maxcharacters) + "..."; + canvas.fillText(text, x, y); +} + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/widget.ts b/src/api/routes/v9/guilds/#guild_id/widget.ts new file mode 100644
index 00000000..108339e1 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/widget.ts
@@ -0,0 +1,40 @@ +import { Request, Response, Router } from "express"; +import { Guild, WidgetModifySchema } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +// https://discord.com/developers/docs/resources/guild#get-guild-widget-settings +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + return res.json({ + enabled: guild.widget_enabled || false, + channel_id: guild.widget_channel_id || null, + }); +}); + +// https://discord.com/developers/docs/resources/guild#modify-guild-widget +router.patch( + "/", + route({ body: "WidgetModifySchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const body = req.body as WidgetModifySchema; + const { guild_id } = req.params; + + await Guild.update( + { id: guild_id }, + { + widget_enabled: body.enabled, + widget_channel_id: body.channel_id, + }, + ); + // Widget invite for the widget_channel_id gets created as part of the /guilds/{guild.id}/widget.json request + + return res.json(body); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/index.ts b/src/api/routes/v9/guilds/index.ts new file mode 100644
index 00000000..69575aea --- /dev/null +++ b/src/api/routes/v9/guilds/index.ts
@@ -0,0 +1,47 @@ +import { Router, Request, Response } from "express"; +import { + Role, + Guild, + Config, + getRights, + Member, + DiscordApiErrors, + GuildCreateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +//TODO: create default channel + +router.post( + "/", + route({ body: "GuildCreateSchema", right: "CREATE_GUILDS" }), + async (req: Request, res: Response) => { + const body = req.body as GuildCreateSchema; + + const { maxGuilds } = Config.get().limits.user; + const guild_count = await Member.count({ where: { id: req.user_id } }); + const rights = await getRights(req.user_id); + if (guild_count >= maxGuilds && !rights.has("MANAGE_GUILDS")) { + throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds); + } + + const guild = await Guild.createGuild({ + ...body, + owner_id: req.user_id, + }); + + const { autoJoin } = Config.get().guild; + if (autoJoin.enabled && !autoJoin.guilds?.length) { + // @ts-ignore + await Config.set({ guild: { autoJoin: { guilds: [guild.id] } } }); + } + + await Member.addToGuild(req.user_id, guild.id); + + res.status(201).json({ id: guild.id }); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/templates/index.ts b/src/api/routes/v9/guilds/templates/index.ts new file mode 100644
index 00000000..240bf074 --- /dev/null +++ b/src/api/routes/v9/guilds/templates/index.ts
@@ -0,0 +1,132 @@ +import { Request, Response, Router } from "express"; +import { + Template, + Guild, + Role, + Snowflake, + Config, + Member, + GuildTemplateCreateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { DiscordApiErrors } from "@fosscord/util"; +import fetch from "node-fetch"; +const router: Router = Router(); + +router.get("/:code", route({}), async (req: Request, res: Response) => { + const { allowDiscordTemplates, allowRaws, enabled } = + Config.get().templates; + if (!enabled) + res.json({ + code: 403, + message: "Template creation & usage is disabled on this instance.", + }).sendStatus(403); + + const { code } = req.params; + + if (code.startsWith("discord:")) { + if (!allowDiscordTemplates) + return res + .json({ + code: 403, + message: + "Discord templates cannot be used on this instance.", + }) + .sendStatus(403); + const discordTemplateID = code.split("discord:", 2)[1]; + + const discordTemplateData = await fetch( + `https://discord.com/api/v9/guilds/templates/${discordTemplateID}`, + { + method: "get", + headers: { "Content-Type": "application/json" }, + }, + ); + return res.json(await discordTemplateData.json()); + } + + if (code.startsWith("external:")) { + if (!allowRaws) + return res + .json({ + code: 403, + message: "Importing raws is disabled on this instance.", + }) + .sendStatus(403); + + return res.json(code.split("external:", 2)[1]); + } + + const template = await Template.findOneOrFail({ where: { code: code } }); + res.json(template); +}); + +router.post( + "/:code", + route({ body: "GuildTemplateCreateSchema" }), + async (req: Request, res: Response) => { + const { + enabled, + allowTemplateCreation, + allowDiscordTemplates, + allowRaws, + } = Config.get().templates; + if (!enabled) + return res + .json({ + code: 403, + message: + "Template creation & usage is disabled on this instance.", + }) + .sendStatus(403); + if (!allowTemplateCreation) + return res + .json({ + code: 403, + message: "Template creation is disabled on this instance.", + }) + .sendStatus(403); + + const { code } = req.params; + const body = req.body as GuildTemplateCreateSchema; + + const { maxGuilds } = Config.get().limits.user; + + const guild_count = await Member.count({ where: { id: req.user_id } }); + if (guild_count >= maxGuilds) { + throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds); + } + + const template = await Template.findOneOrFail({ + where: { code: code }, + }); + + const guild_id = Snowflake.generate(); + + const [guild, role] = await Promise.all([ + Guild.create({ + ...body, + ...template.serialized_source_guild, + id: guild_id, + owner_id: req.user_id, + }).save(), + Role.create({ + id: guild_id, + guild_id: guild_id, + color: 0, + hoist: false, + managed: true, + mentionable: true, + name: "@everyone", + permissions: BigInt("2251804225").toString(), // TODO: where did this come from? + position: 0, + }).save(), + ]); + + await Member.addToGuild(req.user_id, guild_id); + + res.status(201).json({ id: guild.id }); + }, +); + +export default router;