diff --git a/src/api/routes/guilds/#guild_id/audit-logs.ts b/src/api/routes/guilds/#guild_id/audit-logs.ts
new file mode 100644
index 00000000..a4f2f800
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/audit-logs.ts
@@ -0,0 +1,20 @@
+import { Router, Response, Request } from "express";
+import { Channel, ChannelUpdateEvent, getPermission, emitEvent } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { ChannelModifySchema } from "../../channels/#channel_id";
+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/guilds/#guild_id/bans.ts b/src/api/routes/guilds/#guild_id/bans.ts
new file mode 100644
index 00000000..51d3ca67
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/bans.ts
@@ -0,0 +1,178 @@
+import { Request, Response, Router } from "express";
+import { DiscordApiErrors, emitEvent, GuildBanAddEvent, GuildBanRemoveEvent, Ban, User, Member } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { getIpAdress, route } from "@fosscord/api";
+
+export interface BanCreateSchema {
+ delete_message_days?: string;
+ reason?: string;
+};
+
+export interface BanRegistrySchema {
+ id: string;
+ user_id: string;
+ guild_id: string;
+ executor_id: string;
+ ip?: string;
+ reason?: string | undefined;
+};
+
+export interface BanModeratorSchema {
+ id: string;
+ user_id: string;
+ guild_id: string;
+ executor_id: string;
+ reason?: string | undefined;
+};
+
+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/guilds/#guild_id/channels.ts b/src/api/routes/guilds/#guild_id/channels.ts
new file mode 100644
index 00000000..11f727fc
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/channels.ts
@@ -0,0 +1,60 @@
+import { Router, Response, Request } from "express";
+import { Channel, ChannelUpdateEvent, getPermission, emitEvent } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { ChannelModifySchema } from "../../channels/#channel_id";
+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);
+});
+
+export type ChannelReorderSchema = { id: string; position?: number; lock_permissions?: boolean; parent_id?: string; }[];
+
+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/guilds/#guild_id/delete.ts b/src/api/routes/guilds/#guild_id/delete.ts
new file mode 100644
index 00000000..bd158c56
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/delete.ts
@@ -0,0 +1,30 @@
+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/guilds/#guild_id/discovery-requirements.ts b/src/api/routes/guilds/#guild_id/discovery-requirements.ts
new file mode 100644
index 00000000..ad20633f
--- /dev/null
+++ b/src/api/routes/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/guilds/#guild_id/emojis.ts b/src/api/routes/guilds/#guild_id/emojis.ts
new file mode 100644
index 00000000..75998e04
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/emojis.ts
@@ -0,0 +1,119 @@
+import { Router, Request, Response } from "express";
+import { Config, DiscordApiErrors, emitEvent, Emoji, GuildEmojisUpdateEvent, handleFile, Member, Snowflake, User } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+export interface EmojiCreateSchema {
+ name?: string;
+ image: string;
+ require_colons?: boolean | null;
+ roles?: string[];
+}
+
+export interface EmojiModifySchema {
+ name?: string;
+ roles?: string[];
+}
+
+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/guilds/#guild_id/index.ts b/src/api/routes/guilds/#guild_id/index.ts
new file mode 100644
index 00000000..45bbe348
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/index.ts
@@ -0,0 +1,76 @@
+import { Request, Response, Router } from "express";
+import { DiscordApiErrors, emitEvent, getPermission, getRights, Guild, GuildUpdateEvent, handleFile, Member } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import "missing-native-js-functions";
+import { GuildCreateSchema } from "../index";
+
+const router = Router();
+
+export interface GuildUpdateSchema extends Omit<GuildCreateSchema, "channels"> {
+ banner?: string | null;
+ splash?: string | null;
+ description?: string;
+ features?: string[];
+ verification_level?: number;
+ default_message_notifications?: number;
+ system_channel_flags?: number;
+ explicit_content_filter?: number;
+ public_updates_channel_id?: string;
+ afk_timeout?: number;
+ afk_channel_id?: string;
+ preferred_locale?: string;
+ premium_progress_bar_enabled?: boolean;
+}
+
+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_GUILD");
+
+ // TODO: guild update check image
+
+ if (body.icon) body.icon = await handleFile(`/icons/${guild_id}`, body.icon);
+ if (body.banner) body.banner = await handleFile(`/banners/${guild_id}`, body.banner);
+ if (body.splash) body.splash = await handleFile(`/splashes/${guild_id}`, body.splash);
+
+ var guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ relations: ["emojis", "roles", "stickers"]
+ });
+ // 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/guilds/#guild_id/integrations.ts b/src/api/routes/guilds/#guild_id/integrations.ts
new file mode 100644
index 00000000..abf997c9
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/integrations.ts
@@ -0,0 +1,12 @@
+import { Router, Response, Request } from "express";
+import { Channel, ChannelUpdateEvent, getPermission, emitEvent } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { ChannelModifySchema } from "../../channels/#channel_id";
+const router = Router();
+
+//TODO: implement integrations list
+router.get("/", route({}), async (req: Request, res: Response) => {
+ res.json([]);
+});
+export default router;
diff --git a/src/api/routes/guilds/#guild_id/invites.ts b/src/api/routes/guilds/#guild_id/invites.ts
new file mode 100644
index 00000000..b7534e31
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/invites.ts
@@ -0,0 +1,15 @@
+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/guilds/#guild_id/member-verification.ts b/src/api/routes/guilds/#guild_id/member-verification.ts
new file mode 100644
index 00000000..265a1b35
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/member-verification.ts
@@ -0,0 +1,14 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+const router = Router();
+
+router.get("/",route({}), async (req: Request, res: Response) => {
+ // TODO: member verification
+
+ res.status(404).json({
+ message: "Unknown Guild Member Verification Form",
+ code: 10068
+ });
+});
+
+export default router;
diff --git a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
new file mode 100644
index 00000000..d785eb00
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
@@ -0,0 +1,107 @@
+import { Request, Response, Router } from "express";
+import { Member, getPermission, getRights, Role, GuildMemberUpdateEvent, emitEvent, Sticker, Emoji, Rights, Guild } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+export interface MemberChangeSchema {
+ roles?: string[];
+ nick?: string;
+}
+
+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;
+
+ const 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.roles) {
+ permission.hasThrow("MANAGE_ROLES");
+
+ 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
+ }
+
+ if ('nick' in body) {
+ permission.hasThrow(req.user_id == member.user.id ? "CHANGE_NICKNAME" : "MANAGE_NICKNAMES");
+ member.nick = body.nick?.trim() || undefined;
+ }
+
+ 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 permission = await getPermission(req.user_id);
+ const rights = await getRights(req.user_id);
+ const { guild_id, member_id } = req.params;
+ 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/guilds/#guild_id/members/#member_id/nick.ts b/src/api/routes/guilds/#guild_id/members/#member_id/nick.ts
new file mode 100644
index 00000000..27f7f65d
--- /dev/null
+++ b/src/api/routes/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();
+
+export interface MemberNickChangeSchema {
+ nick: string;
+}
+
+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/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
new file mode 100644
index 00000000..8f5ca7ba
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
@@ -0,0 +1,21 @@
+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/guilds/#guild_id/members/index.ts b/src/api/routes/guilds/#guild_id/members/index.ts
new file mode 100644
index 00000000..b730a4e7
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/index.ts
@@ -0,0 +1,31 @@
+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/guilds/#guild_id/messages/search.ts b/src/api/routes/guilds/#guild_id/messages/search.ts
new file mode 100644
index 00000000..a7516ebd
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/messages/search.ts
@@ -0,0 +1,87 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import { getPermission, FieldErrors, Message } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { FindManyOptions, 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);
+ 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 };
+ //@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;
\ No newline at end of file
diff --git a/src/api/routes/guilds/#guild_id/premium.ts b/src/api/routes/guilds/#guild_id/premium.ts
new file mode 100644
index 00000000..75361ac6
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/premium.ts
@@ -0,0 +1,10 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+const router = Router();
+
+router.get("/subscriptions", route({}), async (req: Request, res: Response) => {
+ // TODO:
+ res.json([]);
+});
+
+export default router;
diff --git a/src/api/routes/guilds/#guild_id/prune.ts b/src/api/routes/guilds/#guild_id/prune.ts
new file mode 100644
index 00000000..cf3466f1
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/prune.ts
@@ -0,0 +1,86 @@
+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())
+ },
+ {
+ last_message_id: IsNull()
+ }
+ ],
+ relations: ["roles"]
+ });
+ console.log(members);
+ 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 });
+});
+
+export interface PruneSchema {
+ /**
+ * @min 0
+ */
+ days: number;
+}
+
+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/guilds/#guild_id/regions.ts b/src/api/routes/guilds/#guild_id/regions.ts
new file mode 100644
index 00000000..308d5ee5
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/regions.ts
@@ -0,0 +1,15 @@
+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/guilds/#guild_id/roles/#role_id/index.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/index.ts
new file mode 100644
index 00000000..a01068c0
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/roles/#role_id/index.ts
@@ -0,0 +1,69 @@
+import { Router, Request, Response } from "express";
+import { Role, Member, GuildRoleUpdateEvent, GuildRoleDeleteEvent, emitEvent, handleFile } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { HTTPError } from "lambert-server";
+import { RoleModifySchema } from "../";
+
+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 = Role.create({
+ ...body,
+ id: role_id,
+ guild_id,
+ 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/guilds/#guild_id/roles/index.ts b/src/api/routes/guilds/#guild_id/roles/index.ts
new file mode 100644
index 00000000..7e839f08
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/roles/index.ts
@@ -0,0 +1,111 @@
+import { Request, Response, Router } from "express";
+import {
+ Role,
+ getPermission,
+ Member,
+ GuildRoleCreateEvent,
+ GuildRoleUpdateEvent,
+ GuildRoleDeleteEvent,
+ emitEvent,
+ Config,
+ DiscordApiErrors,
+ handleFile
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+export interface RoleModifySchema {
+ name?: string;
+ permissions?: string;
+ color?: number;
+ hoist?: boolean; // whether the role should be displayed separately in the sidebar
+ mentionable?: boolean; // whether the role should be mentionable
+ position?: number;
+ icon?: string;
+ unicode_emoji?: string;
+}
+
+export type RolePositionUpdateSchema = {
+ id: string;
+ position: number;
+}[];
+
+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: 0,
+ 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
+ });
+
+ await Promise.all([
+ role.save(),
+ 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/guilds/#guild_id/stickers.ts b/src/api/routes/guilds/#guild_id/stickers.ts
new file mode 100644
index 00000000..7a4e71ee
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/stickers.ts
@@ -0,0 +1,135 @@
+import {
+ emitEvent,
+ GuildStickersUpdateEvent,
+ handleFile,
+ Member,
+ Snowflake,
+ Sticker,
+ StickerFormatType,
+ StickerType,
+ uploadFile
+} 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 } }));
+});
+
+export interface ModifyGuildStickerSchema {
+ /**
+ * @minLength 2
+ * @maxLength 30
+ */
+ name: string;
+ /**
+ * @maxLength 100
+ */
+ description?: string;
+ /**
+ * @maxLength 200
+ */
+ tags: string;
+}
+
+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/guilds/#guild_id/templates.ts b/src/api/routes/guilds/#guild_id/templates.ts
new file mode 100644
index 00000000..0444c402
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/templates.ts
@@ -0,0 +1,92 @@
+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"
+];
+
+export interface TemplateCreateSchema {
+ name: string;
+ description?: string;
+}
+
+export interface TemplateModifySchema {
+ name: string;
+ description?: string;
+}
+
+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/guilds/#guild_id/vanity-url.ts b/src/api/routes/guilds/#guild_id/vanity-url.ts
new file mode 100644
index 00000000..040bc1fd
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/vanity-url.ts
@@ -0,0 +1,66 @@
+import { Channel, ChannelType, getPermission, Guild, Invite, trimSpecial } 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 })));
+ }
+});
+
+export interface VanityUrlSchema {
+ /**
+ * @minLength 1
+ * @maxLength 20
+ */
+ code?: string;
+}
+
+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/guilds/#guild_id/voice-states/#user_id/index.ts b/src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts
new file mode 100644
index 00000000..b7fdfecd
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts
@@ -0,0 +1,62 @@
+import { Channel, ChannelType, DiscordApiErrors, emitEvent, getPermission, VoiceState, VoiceStateUpdateEvent } 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
+
+export interface VoiceStateUpdateSchema {
+ channel_id: string;
+ guild_id?: string;
+ suppress?: boolean;
+ request_to_speak_timestamp?: Date;
+ self_mute?: boolean;
+ self_deaf?: boolean;
+ self_video?: boolean;
+}
+
+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/guilds/#guild_id/webhooks.ts b/src/api/routes/guilds/#guild_id/webhooks.ts
new file mode 100644
index 00000000..8b2febea
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/webhooks.ts
@@ -0,0 +1,12 @@
+import { Router, Response, Request } from "express";
+import { Channel, ChannelUpdateEvent, getPermission, emitEvent } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { ChannelModifySchema } from "../../channels/#channel_id";
+const router = Router();
+
+//TODO: implement webhooks
+router.get("/", route({}), async (req: Request, res: Response) => {
+ res.json([]);
+});
+export default router;
diff --git a/src/api/routes/guilds/#guild_id/welcome-screen.ts b/src/api/routes/guilds/#guild_id/welcome-screen.ts
new file mode 100644
index 00000000..a57255f0
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/welcome-screen.ts
@@ -0,0 +1,44 @@
+import { Request, Response, Router } from "express";
+import { Guild, getPermission, Snowflake, Member } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+export interface GuildUpdateWelcomeScreenSchema {
+ welcome_channels?: {
+ channel_id: string;
+ description: string;
+ emoji_id?: string;
+ emoji_name?: string;
+ }[];
+ enabled?: boolean;
+ description?: string;
+}
+
+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/guilds/#guild_id/widget.json.ts b/src/api/routes/guilds/#guild_id/widget.json.ts
new file mode 100644
index 00000000..be5bf23f
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/widget.json.ts
@@ -0,0 +1,80 @@
+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/guilds/#guild_id/widget.png.ts b/src/api/routes/guilds/#guild_id/widget.png.ts
new file mode 100644
index 00000000..721b59fb
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/widget.png.ts
@@ -0,0 +1,111 @@
+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/guilds/#guild_id/widget.ts b/src/api/routes/guilds/#guild_id/widget.ts
new file mode 100644
index 00000000..103f84a3
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/widget.ts
@@ -0,0 +1,32 @@
+import { Request, Response, Router } from "express";
+import { Guild } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+export interface WidgetModifySchema {
+ enabled: boolean; // whether the widget is enabled
+ channel_id: string; // the widget channel id
+}
+
+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;
|