summary refs log tree commit diff
path: root/src/api/routes/guilds
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/routes/guilds')
-rw-r--r--src/api/routes/guilds/#guild_id/audit-logs.ts17
-rw-r--r--src/api/routes/guilds/#guild_id/bans.ts167
-rw-r--r--src/api/routes/guilds/#guild_id/channels.ts56
-rw-r--r--src/api/routes/guilds/#guild_id/delete.ts29
-rw-r--r--src/api/routes/guilds/#guild_id/discovery-requirements.ts37
-rw-r--r--src/api/routes/guilds/#guild_id/emojis.ts119
-rw-r--r--src/api/routes/guilds/#guild_id/index.ts69
-rw-r--r--src/api/routes/guilds/#guild_id/integrations.ts9
-rw-r--r--src/api/routes/guilds/#guild_id/invites.ts15
-rw-r--r--src/api/routes/guilds/#guild_id/members/#member_id/index.ts107
-rw-r--r--src/api/routes/guilds/#guild_id/members/#member_id/nick.ts22
-rw-r--r--src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts21
-rw-r--r--src/api/routes/guilds/#guild_id/members/index.ts30
-rw-r--r--src/api/routes/guilds/#guild_id/premium.ts10
-rw-r--r--src/api/routes/guilds/#guild_id/prune.ts79
-rw-r--r--src/api/routes/guilds/#guild_id/regions.ts14
-rw-r--r--src/api/routes/guilds/#guild_id/roles/#role_id/index.ts76
-rw-r--r--src/api/routes/guilds/#guild_id/roles/index.ts95
-rw-r--r--src/api/routes/guilds/#guild_id/stickers.ts120
-rw-r--r--src/api/routes/guilds/#guild_id/templates.ts85
-rw-r--r--src/api/routes/guilds/#guild_id/vanity-url.ts57
-rw-r--r--src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts60
-rw-r--r--src/api/routes/guilds/#guild_id/webhooks.ts9
-rw-r--r--src/api/routes/guilds/#guild_id/welcome_screen.ts30
-rw-r--r--src/api/routes/guilds/#guild_id/widget.json.ts81
-rw-r--r--src/api/routes/guilds/#guild_id/widget.png.ts116
-rw-r--r--src/api/routes/guilds/#guild_id/widget.ts27
-rw-r--r--src/api/routes/guilds/index.ts32
-rw-r--r--src/api/routes/guilds/templates/index.ts82
29 files changed, 1671 insertions, 0 deletions
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..05b9982e
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/audit-logs.ts
@@ -0,0 +1,17 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+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..4600b4cb
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/bans.ts
@@ -0,0 +1,167 @@
+import { getIpAdress, route } from "@fosscord/api";
+import {
+	Ban,
+	BanModeratorSchema,
+	BanRegistrySchema,
+	DiscordApiErrors,
+	emitEvent,
+	GuildBanAddEvent,
+	GuildBanRemoveEvent,
+	HTTPError,
+	Member,
+	OrmUtils,
+	User
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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 } });
+	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, 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 = OrmUtils.mergeDeep(new Ban(), {
+		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 = OrmUtils.mergeDeep(new Ban(), {
+		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, 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..3563eb4c
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/channels.ts
@@ -0,0 +1,56 @@
+import { route } from "@fosscord/api";
+import { Channel, ChannelModifySchema, ChannelReorderSchema, ChannelUpdateEvent, emitEvent, HTTPError } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+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/guilds/#guild_id/delete.ts b/src/api/routes/guilds/#guild_id/delete.ts
new file mode 100644
index 00000000..e6a1a6b2
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/delete.ts
@@ -0,0 +1,29 @@
+import { route } from "@fosscord/api";
+import { emitEvent, Guild, GuildDeleteEvent, HTTPError } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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) => {
+	let { 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..c0260fe7
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/discovery-requirements.ts
@@ -0,0 +1,37 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+
+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..db5ae325
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/emojis.ts
@@ -0,0 +1,119 @@
+import { route } from "@fosscord/api";
+import {
+	Config,
+	DiscordApiErrors,
+	emitEvent,
+	Emoji,
+	EmojiCreateSchema,
+	EmojiModifySchema,
+	GuildEmojisUpdateEvent,
+	handleFile,
+	Member,
+	OrmUtils,
+	Snowflake,
+	User
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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 } });
+	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 OrmUtils.mergeDeep(new Emoji(), {
+		id: id,
+		guild_id: guild_id,
+		...body,
+		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 } })
+		}
+	} 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 OrmUtils.mergeDeep(new Emoji(), { ...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 } })
+			}
+		} 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 } })
+		}
+	} 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..af889982
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/index.ts
@@ -0,0 +1,69 @@
+import { route } from "@fosscord/api";
+import {
+	DiscordApiErrors,
+	emitEvent,
+	getPermission,
+	getRights,
+	Guild,
+	GuildUpdateEvent,
+	GuildUpdateSchema,
+	handleFile,
+	HTTPError,
+	Member,
+	OrmUtils
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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, 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);
+
+	let guild = await Guild.findOneOrFail({
+		where: { id: guild_id },
+		relations: ["emojis", "roles", "stickers"]
+	});
+	// TODO: check if body ids are valid
+	guild = OrmUtils.mergeDeep(guild, body);
+
+	//TODO: check this, removed toJSON call
+	const data = JSON.parse(JSON.stringify(guild));
+	// 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..6a5abec3
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/integrations.ts
@@ -0,0 +1,9 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+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..c663df72
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/invites.ts
@@ -0,0 +1,15 @@
+import { route } from "@fosscord/api";
+import { Invite, PublicInviteRelation } from "@fosscord/util";
+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/members/#member_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
new file mode 100644
index 00000000..57152f9a
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
@@ -0,0 +1,107 @@
+import { route } from "@fosscord/api";
+import {
+	emitEvent,
+	Emoji,
+	getPermission,
+	getRights,
+	Guild,
+	GuildMemberUpdateEvent,
+	Member,
+	MemberChangeSchema,
+	OrmUtils,
+	Role,
+	Sticker
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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;
+
+	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) => OrmUtils.mergeDeep(new Role(), { 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
+	}
+
+	let guild = await Guild.findOneOrFail({
+		where: { id: guild_id }
+	});
+
+	let emoji = await Emoji.find({
+		where: { guild_id: guild_id }
+	});
+
+	let roles = await Role.find({
+		where: { guild_id: guild_id }
+	});
+
+	let 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..26411f97
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/#member_id/nick.ts
@@ -0,0 +1,22 @@
+import { route } from "@fosscord/api";
+import { getPermission, Member, PermissionResolvable } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.patch("/", route({ body: "MemberNickChangeSchema" }), async (req: Request, res: Response) => {
+	let { guild_id, member_id } = req.params;
+	let 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..0aa7a4dc
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
@@ -0,0 +1,21 @@
+import { route } from "@fosscord/api";
+import { Member } from "@fosscord/util";
+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..08164626
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/members/index.ts
@@ -0,0 +1,30 @@
+import { route } from "@fosscord/api";
+import { HTTPError, Member, PublicMemberProjection } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { MoreThan } from "typeorm";
+
+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/premium.ts b/src/api/routes/guilds/#guild_id/premium.ts
new file mode 100644
index 00000000..b7716378
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/premium.ts
@@ -0,0 +1,10 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+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..3645721c
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/prune.ts
@@ -0,0 +1,79 @@
+import { route } from "@fosscord/api";
+import { Guild, Member, Snowflake } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { IsNull, LessThan } from "typeorm";
+const router = Router();
+
+//Returns all inactive members, respecting role hierarchy
+export const inactiveMembers = async (guild_id: string, user_id: string, days: number, roles: string[] = []) => {
+	let date = new Date();
+	date.setDate(date.getDate() - days);
+	//Snowflake should have `generateFromTime` method? Or similar?
+	let 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
+	**/
+	let 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);
+
+	let 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);
+
+	let 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..aa57ec65
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/regions.ts
@@ -0,0 +1,14 @@
+import { getIpAdress, getVoiceRegions, route } from "@fosscord/api";
+import { Guild } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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..7f9dbc6f
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/roles/#role_id/index.ts
@@ -0,0 +1,76 @@
+import { route } from "@fosscord/api";
+import {
+	emitEvent,
+	GuildRoleDeleteEvent,
+	GuildRoleUpdateEvent,
+	handleFile,
+	HTTPError,
+	Member,
+	OrmUtils,
+	Role,
+	RoleModifySchema
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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 = await handleFile(`/role-icons/${role_id}`, body.icon as string);
+
+	const role = OrmUtils.mergeDeep(new Role(), {
+		...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..9791f7a9
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/roles/index.ts
@@ -0,0 +1,95 @@
+import { route } from "@fosscord/api";
+import {
+	Config,
+	DiscordApiErrors,
+	emitEvent,
+	getPermission,
+	GuildRoleCreateEvent,
+	GuildRoleUpdateEvent,
+	Member,
+	OrmUtils,
+	Role,
+	RoleModifySchema,
+	RolePositionUpdateSchema
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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 } });
+
+	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);
+
+	let role: Role = OrmUtils.mergeDeep(new Role(), {
+		// 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: null,
+		unicode_emoji: null
+	});
+
+	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..15741780
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/stickers.ts
@@ -0,0 +1,120 @@
+import { route } from "@fosscord/api";
+import {
+	emitEvent,
+	GuildStickersUpdateEvent,
+	HTTPError,
+	Member,
+	ModifyGuildStickerSchema,
+	OrmUtils,
+	Snowflake,
+	Sticker,
+	StickerFormatType,
+	StickerType,
+	uploadFile
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import multer from "multer";
+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([
+			OrmUtils.mergeDeep(new Sticker(), {
+				...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 OrmUtils.mergeDeep(new Sticker(), { ...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 } })
+		}
+	} 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..448ee033
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/templates.ts
@@ -0,0 +1,85 @@
+import { generateCode, route } from "@fosscord/api";
+import { Guild, HTTPError, OrmUtils, Template } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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;
+
+	let 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 OrmUtils.mergeDeep(new Template(), {
+		...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 OrmUtils.mergeDeep(new Template(), { 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 OrmUtils.mergeDeep(new Template(), {
+		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..bf2db134
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/vanity-url.ts
@@ -0,0 +1,57 @@
+import { route } from "@fosscord/api";
+import { Channel, ChannelType, Guild, HTTPError, Invite, OrmUtils, VanityUrlSchema } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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 OrmUtils.mergeDeep(new Invite(), {
+		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({ where: { 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..797d348e
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts
@@ -0,0 +1,60 @@
+import { route } from "@fosscord/api";
+import {
+	Channel,
+	ChannelType,
+	DiscordApiErrors,
+	emitEvent,
+	getPermission,
+	OrmUtils,
+	VoiceState,
+	VoiceStateUpdateEvent,
+	VoiceStateUpdateSchema
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+router.patch("/", route({ body: "VoiceStateUpdateSchema" }), async (req: Request, res: Response) => {
+	const body = req.body as VoiceStateUpdateSchema;
+	let { 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");
+
+	let 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 = OrmUtils.mergeDeep(voice_state, body) as VoiceState;
+	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..80e6a59a
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/webhooks.ts
@@ -0,0 +1,9 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+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..85c22a19
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/welcome_screen.ts
@@ -0,0 +1,30 @@
+import { route } from "@fosscord/api";
+import { Guild, GuildUpdateWelcomeScreenSchema, HTTPError, Member } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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;
+
+	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..368fe46e
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/widget.json.ts
@@ -0,0 +1,81 @@
+import { random, route } from "@fosscord/api";
+import { Channel, Guild, HTTPError, Invite, Member, OrmUtils, Permissions } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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
+	let 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());
+		const body = {
+			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,
+			inviter_id: null
+		};
+
+		invite = await OrmUtils.mergeDeep(new Invite(), body).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 } });
+
+	// 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..1c4ef29b
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/widget.png.ts
@@ -0,0 +1,116 @@
+import { route } from "@fosscord/api";
+import { Guild, HTTPError } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import fs from "fs";
+import path from "path";
+
+// Setup canvas
+let createCanvas: any, loadImage: any;
+try {
+	createCanvas = require("canvas").createCanvas;
+	loadImage = require("canvas").loadImage;
+} catch {
+	console.log("Canvas not found, disabling widgets!");
+}
+const sizeOf = require("image-size");
+
+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) => {
+	if (!createCanvas) return res.status(404);
+	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);
+	}
+
+	// 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..d2369dd1
--- /dev/null
+++ b/src/api/routes/guilds/#guild_id/widget.ts
@@ -0,0 +1,27 @@
+import { route } from "@fosscord/api";
+import { Guild, WidgetModifySchema } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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/guilds/index.ts b/src/api/routes/guilds/index.ts
new file mode 100644
index 00000000..6946e2f7
--- /dev/null
+++ b/src/api/routes/guilds/index.ts
@@ -0,0 +1,32 @@
+import { route } from "@fosscord/api";
+import { Config, DiscordApiErrors, getRights, Guild, GuildCreateSchema, Member } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+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/guilds/templates/index.ts b/src/api/routes/guilds/templates/index.ts
new file mode 100644
index 00000000..467186a3
--- /dev/null
+++ b/src/api/routes/guilds/templates/index.ts
@@ -0,0 +1,82 @@
+import { route } from "@fosscord/api";
+import { Config, DiscordApiErrors, Guild, GuildTemplateCreateSchema, Member, OrmUtils, Role, Snowflake, Template } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+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 } });
+	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 } });
+
+	const guild_id = Snowflake.generate();
+
+	const [guild, role] = await Promise.all([
+		OrmUtils.mergeDeep(new Guild(), {
+			...body,
+			...template.serialized_source_guild,
+			id: guild_id,
+			owner_id: req.user_id
+		}).save(),
+		(
+			OrmUtils.mergeDeep(new Role(), {
+				id: guild_id,
+				guild_id: guild_id,
+				color: 0,
+				hoist: false,
+				managed: true,
+				mentionable: true,
+				name: "@everyone",
+				permissions: BigInt("2251804225"),
+				position: 0,
+				tags: null
+			}) as Role
+		).save()
+	]);
+
+	await Member.addToGuild(req.user_id, guild_id);
+
+	res.status(201).json({ id: guild.id });
+});
+
+export default router;