diff --git a/src/api/routes/v9/-/healthz.ts b/src/api/routes/v9/-/healthz.ts
new file mode 100644
index 00000000..d9d1c026
--- /dev/null
+++ b/src/api/routes/v9/-/healthz.ts
@@ -0,0 +1,13 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import { getDatabase } from "@fosscord/util";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ if (!getDatabase()) return res.sendStatus(503);
+
+ return res.sendStatus(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/-/readyz.ts b/src/api/routes/v9/-/readyz.ts
new file mode 100644
index 00000000..d9d1c026
--- /dev/null
+++ b/src/api/routes/v9/-/readyz.ts
@@ -0,0 +1,13 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import { getDatabase } from "@fosscord/util";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ if (!getDatabase()) return res.sendStatus(503);
+
+ return res.sendStatus(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/applications/#id/bot/index.ts b/src/api/routes/v9/applications/#id/bot/index.ts
new file mode 100644
index 00000000..c4cfccd8
--- /dev/null
+++ b/src/api/routes/v9/applications/#id/bot/index.ts
@@ -0,0 +1,102 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import {
+ Application,
+ generateToken,
+ User,
+ BotModifySchema,
+ handleFile,
+ DiscordApiErrors,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { verifyToken } from "node-2fa";
+
+const router: Router = Router();
+
+router.post("/", route({}), async (req: Request, res: Response) => {
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["owner"],
+ });
+
+ if (app.owner.id != req.user_id)
+ throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ const user = await User.register({
+ username: app.name,
+ password: undefined,
+ id: app.id,
+ req,
+ });
+
+ user.id = app.id;
+ user.premium_since = new Date();
+ user.bot = true;
+
+ await user.save();
+
+ // flags is NaN here?
+ app.assign({ bot: user, flags: app.flags || 0 });
+
+ await app.save();
+
+ res.send({
+ token: await generateToken(user.id),
+ }).status(204);
+});
+
+router.post("/reset", route({}), async (req: Request, res: Response) => {
+ let bot = await User.findOneOrFail({ where: { id: req.params.id } });
+ let owner = await User.findOneOrFail({ where: { id: req.user_id } });
+
+ if (owner.id != req.user_id)
+ throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ if (
+ owner.totp_secret &&
+ (!req.body.code || verifyToken(owner.totp_secret, req.body.code))
+ )
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
+
+ bot.data = { hash: undefined, valid_tokens_since: new Date() };
+
+ await bot.save();
+
+ let token = await generateToken(bot.id);
+
+ res.json({ token }).status(200);
+});
+
+router.patch(
+ "/",
+ route({ body: "BotModifySchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as BotModifySchema;
+ if (!body.avatar?.trim()) delete body.avatar;
+
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["bot", "owner"],
+ });
+
+ if (!app.bot) throw DiscordApiErrors.BOT_ONLY_ENDPOINT;
+
+ if (app.owner.id != req.user_id)
+ throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ if (body.avatar)
+ body.avatar = await handleFile(
+ `/avatars/${app.id}`,
+ body.avatar as string,
+ );
+
+ app.bot.assign(body);
+
+ app.bot.save();
+
+ await app.save();
+ res.json(app).status(200);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/applications/#id/entitlements.ts b/src/api/routes/v9/applications/#id/entitlements.ts
new file mode 100644
index 00000000..cfcfe40f
--- /dev/null
+++ b/src/api/routes/v9/applications/#id/entitlements.ts
@@ -0,0 +1,12 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ // TODO:
+ //const { exclude_consumed } = req.query;
+ res.status(200).send([]);
+});
+
+export default router;
diff --git a/src/api/routes/v9/applications/#id/index.ts b/src/api/routes/v9/applications/#id/index.ts
new file mode 100644
index 00000000..11cd5a56
--- /dev/null
+++ b/src/api/routes/v9/applications/#id/index.ts
@@ -0,0 +1,81 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import {
+ Application,
+ OrmUtils,
+ DiscordApiErrors,
+ ApplicationModifySchema,
+ User,
+} from "@fosscord/util";
+import { verifyToken } from "node-2fa";
+import { HTTPError } from "lambert-server";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["owner", "bot"],
+ });
+ if (app.owner.id != req.user_id)
+ throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ return res.json(app);
+});
+
+router.patch(
+ "/",
+ route({ body: "ApplicationModifySchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as ApplicationModifySchema;
+
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["owner", "bot"],
+ });
+
+ if (app.owner.id != req.user_id)
+ throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ if (
+ app.owner.totp_secret &&
+ (!req.body.code ||
+ verifyToken(app.owner.totp_secret, req.body.code))
+ )
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
+
+ if (app.bot) {
+ app.bot.assign({ bio: body.description });
+ await app.bot.save();
+ }
+
+ app.assign(body);
+
+ await app.save();
+
+ return res.json(app);
+ },
+);
+
+router.post("/delete", route({}), async (req: Request, res: Response) => {
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["bot", "owner"],
+ });
+ if (app.owner.id != req.user_id)
+ throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ if (
+ app.owner.totp_secret &&
+ (!req.body.code || verifyToken(app.owner.totp_secret, req.body.code))
+ )
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
+
+ if (app.bot) await User.delete({ id: app.bot.id });
+
+ await Application.delete({ id: app.id });
+
+ res.send().status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/applications/index.ts b/src/api/routes/v9/applications/index.ts
new file mode 100644
index 00000000..a6b35bfa
--- /dev/null
+++ b/src/api/routes/v9/applications/index.ts
@@ -0,0 +1,42 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import {
+ Application,
+ ApplicationCreateSchema,
+ trimSpecial,
+ User,
+} from "@fosscord/util";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ let results = await Application.find({
+ where: { owner: { id: req.user_id } },
+ relations: ["owner", "bot"],
+ });
+ res.json(results).status(200);
+});
+
+router.post(
+ "/",
+ route({ body: "ApplicationCreateSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as ApplicationCreateSchema;
+ const user = await User.findOneOrFail({ where: { id: req.user_id } });
+
+ const app = Application.create({
+ name: trimSpecial(body.name),
+ description: "",
+ bot_public: true,
+ owner: user,
+ verify_key: "IMPLEMENTME",
+ flags: 0,
+ });
+
+ await app.save();
+
+ res.json(app);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/auth/generate-registration-tokens.ts b/src/api/routes/v9/auth/generate-registration-tokens.ts
new file mode 100644
index 00000000..ba40bd9a
--- /dev/null
+++ b/src/api/routes/v9/auth/generate-registration-tokens.ts
@@ -0,0 +1,49 @@
+import { route, random } from "@fosscord/api";
+import { Config, ValidRegistrationToken } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+const router: Router = Router();
+export default router;
+
+router.get(
+ "/",
+ route({ right: "OPERATOR" }),
+ async (req: Request, res: Response) => {
+ const count = req.query.count ? parseInt(req.query.count as string) : 1;
+ const length = req.query.length
+ ? parseInt(req.query.length as string)
+ : 255;
+
+ let tokens: ValidRegistrationToken[] = [];
+
+ for (let i = 0; i < count; i++) {
+ const token = ValidRegistrationToken.create({
+ token: random(length),
+ expires_at:
+ Date.now() +
+ Config.get().security.defaultRegistrationTokenExpiration,
+ });
+ tokens.push(token);
+ }
+
+ // Why are these options used, exactly?
+ await ValidRegistrationToken.save(tokens, {
+ chunk: 1000,
+ reload: false,
+ transaction: false,
+ });
+
+ const ret = req.query.include_url
+ ? tokens.map(
+ (x) =>
+ `${Config.get().general.frontPage}/register?token=${
+ x.token
+ }`,
+ )
+ : tokens.map((x) => x.token);
+
+ if (req.query.plain) return res.send(ret.join("\n"));
+
+ return res.json({ tokens: ret });
+ },
+);
diff --git a/src/api/routes/v9/auth/location-metadata.ts b/src/api/routes/v9/auth/location-metadata.ts
new file mode 100644
index 00000000..0ae946ed
--- /dev/null
+++ b/src/api/routes/v9/auth/location-metadata.ts
@@ -0,0 +1,17 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { getIpAdress, IPAnalysis } from "@fosscord/api";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ //TODO
+ //Note: It's most likely related to legal. At the moment Discord hasn't finished this too
+ const country_code = (await IPAnalysis(getIpAdress(req))).country_code;
+ res.json({
+ consent_required: false,
+ country_code: country_code,
+ promotional_email_opt_in: { required: true, pre_checked: false },
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/auth/login.ts b/src/api/routes/v9/auth/login.ts
new file mode 100644
index 00000000..7434fa35
--- /dev/null
+++ b/src/api/routes/v9/auth/login.ts
@@ -0,0 +1,138 @@
+import { Request, Response, Router } from "express";
+import { route, getIpAdress, verifyCaptcha } from "@fosscord/api";
+import bcrypt from "bcrypt";
+import {
+ Config,
+ User,
+ generateToken,
+ adjustEmail,
+ FieldErrors,
+ LoginSchema,
+} from "@fosscord/util";
+import crypto from "crypto";
+
+const router: Router = Router();
+export default router;
+
+router.post(
+ "/",
+ route({ body: "LoginSchema" }),
+ async (req: Request, res: Response) => {
+ const { login, password, captcha_key, undelete } =
+ req.body as LoginSchema;
+ const email = adjustEmail(login);
+
+ const config = Config.get();
+
+ if (config.login.requireCaptcha && config.security.captcha.enabled) {
+ const { sitekey, service } = config.security.captcha;
+ if (!captcha_key) {
+ return res.status(400).json({
+ captcha_key: ["captcha-required"],
+ captcha_sitekey: sitekey,
+ captcha_service: service,
+ });
+ }
+
+ const ip = getIpAdress(req);
+ const verify = await verifyCaptcha(captcha_key, ip);
+ if (!verify.success) {
+ return res.status(400).json({
+ captcha_key: verify["error-codes"],
+ captcha_sitekey: sitekey,
+ captcha_service: service,
+ });
+ }
+ }
+
+ const user = await User.findOneOrFail({
+ where: [{ phone: login }, { email: email }],
+ select: [
+ "data",
+ "id",
+ "disabled",
+ "deleted",
+ "settings",
+ "totp_secret",
+ "mfa_enabled",
+ ],
+ }).catch((e) => {
+ throw FieldErrors({
+ login: {
+ message: req.t("auth:login.INVALID_LOGIN"),
+ code: "INVALID_LOGIN",
+ },
+ });
+ });
+
+ if (undelete) {
+ // undelete refers to un'disable' here
+ if (user.disabled)
+ await User.update({ id: user.id }, { disabled: false });
+ if (user.deleted)
+ await User.update({ id: user.id }, { deleted: false });
+ } else {
+ if (user.deleted)
+ return res.status(400).json({
+ message: "This account is scheduled for deletion.",
+ code: 20011,
+ });
+ if (user.disabled)
+ return res.status(400).json({
+ message: req.t("auth:login.ACCOUNT_DISABLED"),
+ code: 20013,
+ });
+ }
+
+ // the salt is saved in the password refer to bcrypt docs
+ const same_password = await bcrypt.compare(
+ password,
+ user.data.hash || "",
+ );
+ if (!same_password) {
+ throw FieldErrors({
+ password: {
+ message: req.t("auth:login.INVALID_PASSWORD"),
+ code: "INVALID_PASSWORD",
+ },
+ });
+ }
+
+ if (user.mfa_enabled) {
+ // TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy
+ const ticket = crypto.randomBytes(40).toString("hex");
+
+ await User.update({ id: user.id }, { totp_last_ticket: ticket });
+
+ return res.json({
+ ticket: ticket,
+ mfa: true,
+ sms: false, // TODO
+ token: null,
+ });
+ }
+
+ const token = await generateToken(user.id);
+
+ // Notice this will have a different token structure, than discord
+ // Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package
+ // https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png
+
+ res.json({ token, settings: user.settings });
+ },
+);
+
+/**
+ * POST /auth/login
+ * @argument { login: "email@gmail.com", password: "cleartextpassword", undelete: false, captcha_key: null, login_source: null, gift_code_sku_id: null, }
+
+ * MFA required:
+ * @returns {"token": null, "mfa": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"}
+
+ * Captcha required:
+ * @returns {"captcha_key": ["captcha-required"], "captcha_sitekey": null, "captcha_service": "recaptcha"}
+
+ * Sucess:
+ * @returns {"token": "USERTOKEN", "settings": {"locale": "en", "theme": "dark"}}
+
+ */
diff --git a/src/api/routes/v9/auth/logout.ts b/src/api/routes/v9/auth/logout.ts
new file mode 100644
index 00000000..e1bdbea3
--- /dev/null
+++ b/src/api/routes/v9/auth/logout.ts
@@ -0,0 +1,17 @@
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+
+const router: Router = Router();
+export default router;
+
+router.post("/", route({}), async (req: Request, res: Response) => {
+ if (req.body.provider != null || req.body.voip_provider != null) {
+ console.log(`[LOGOUT]: provider or voip provider not null!`, req.body);
+ } else {
+ delete req.body.provider;
+ delete req.body.voip_provider;
+ if (Object.keys(req.body).length != 0)
+ console.log(`[LOGOUT]: Extra fields sent in logout!`, req.body);
+ }
+ res.status(204).send();
+});
diff --git a/src/api/routes/v9/auth/mfa/totp.ts b/src/api/routes/v9/auth/mfa/totp.ts
new file mode 100644
index 00000000..83cf7648
--- /dev/null
+++ b/src/api/routes/v9/auth/mfa/totp.ts
@@ -0,0 +1,52 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { BackupCode, generateToken, User, TotpSchema } from "@fosscord/util";
+import { verifyToken } from "node-2fa";
+import { HTTPError } from "lambert-server";
+const router = Router();
+
+router.post(
+ "/",
+ route({ body: "TotpSchema" }),
+ async (req: Request, res: Response) => {
+ const { code, ticket, gift_code_sku_id, login_source } =
+ req.body as TotpSchema;
+
+ const user = await User.findOneOrFail({
+ where: {
+ totp_last_ticket: ticket,
+ },
+ select: ["id", "totp_secret", "settings"],
+ });
+
+ const backup = await BackupCode.findOne({
+ where: {
+ code: code,
+ expired: false,
+ consumed: false,
+ user: { id: user.id },
+ },
+ });
+
+ if (!backup) {
+ const ret = verifyToken(user.totp_secret!, code);
+ if (!ret || ret.delta != 0)
+ throw new HTTPError(
+ req.t("auth:login.INVALID_TOTP_CODE"),
+ 60008,
+ );
+ } else {
+ backup.consumed = true;
+ await backup.save();
+ }
+
+ await User.update({ id: user.id }, { totp_last_ticket: "" });
+
+ return res.json({
+ token: await generateToken(user.id),
+ user_settings: user.settings,
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/auth/register.ts b/src/api/routes/v9/auth/register.ts
new file mode 100644
index 00000000..3d968114
--- /dev/null
+++ b/src/api/routes/v9/auth/register.ts
@@ -0,0 +1,278 @@
+import { Request, Response, Router } from "express";
+import {
+ Config,
+ generateToken,
+ Invite,
+ FieldErrors,
+ User,
+ adjustEmail,
+ RegisterSchema,
+ ValidRegistrationToken,
+} from "@fosscord/util";
+import {
+ route,
+ getIpAdress,
+ IPAnalysis,
+ isProxy,
+ verifyCaptcha,
+} from "@fosscord/api";
+import bcrypt from "bcrypt";
+import { HTTPError } from "lambert-server";
+import { LessThan, MoreThan } from "typeorm";
+
+const router: Router = Router();
+
+router.post(
+ "/",
+ route({ body: "RegisterSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as RegisterSchema;
+ const { register, security, limits } = Config.get();
+ const ip = getIpAdress(req);
+
+ // Reg tokens
+ // They're a one time use token that bypasses registration limits ( rates, disabled reg, etc )
+ let regTokenUsed = false;
+ if (req.get("Referrer") && req.get("Referrer")?.includes("token=")) {
+ // eg theyre on https://staging.fosscord.com/register?token=whatever
+ const token = req.get("Referrer")!.split("token=")[1].split("&")[0];
+ if (token) {
+ const regToken = await ValidRegistrationToken.findOne({
+ where: { token, expires_at: MoreThan(new Date()) },
+ });
+ await ValidRegistrationToken.delete({ token });
+ regTokenUsed = true;
+ console.log(
+ `[REGISTER] Registration token ${token} used for registration!`,
+ );
+ } else {
+ console.log(
+ `[REGISTER] Invalid registration token ${token} used for registration by ${ip}!`,
+ );
+ }
+ }
+
+ // email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick
+ let email = adjustEmail(body.email);
+
+ // check if registration is allowed
+ if (!regTokenUsed && !register.allowNewRegistration) {
+ throw FieldErrors({
+ email: {
+ code: "REGISTRATION_DISABLED",
+ message: req.t("auth:register.REGISTRATION_DISABLED"),
+ },
+ });
+ }
+
+ // check if the user agreed to the Terms of Service
+ if (!body.consent) {
+ throw FieldErrors({
+ consent: {
+ code: "CONSENT_REQUIRED",
+ message: req.t("auth:register.CONSENT_REQUIRED"),
+ },
+ });
+ }
+
+ if (!regTokenUsed && register.disabled) {
+ throw FieldErrors({
+ email: {
+ code: "DISABLED",
+ message: "registration is disabled on this instance",
+ },
+ });
+ }
+
+ if (
+ !regTokenUsed &&
+ register.requireCaptcha &&
+ security.captcha.enabled
+ ) {
+ const { sitekey, service } = security.captcha;
+ if (!body.captcha_key) {
+ return res?.status(400).json({
+ captcha_key: ["captcha-required"],
+ captcha_sitekey: sitekey,
+ captcha_service: service,
+ });
+ }
+
+ const verify = await verifyCaptcha(body.captcha_key, ip);
+ if (!verify.success) {
+ return res.status(400).json({
+ captcha_key: verify["error-codes"],
+ captcha_sitekey: sitekey,
+ captcha_service: service,
+ });
+ }
+ }
+
+ if (!regTokenUsed && !register.allowMultipleAccounts) {
+ // TODO: check if fingerprint was eligible generated
+ const exists = await User.findOne({
+ where: { fingerprints: body.fingerprint },
+ select: ["id"],
+ });
+
+ if (exists) {
+ throw FieldErrors({
+ email: {
+ code: "EMAIL_ALREADY_REGISTERED",
+ message: req.t(
+ "auth:register.EMAIL_ALREADY_REGISTERED",
+ ),
+ },
+ });
+ }
+ }
+
+ if (!regTokenUsed && register.blockProxies) {
+ if (isProxy(await IPAnalysis(ip))) {
+ console.log(`proxy ${ip} blocked from registration`);
+ throw new HTTPError("Your IP is blocked from registration");
+ }
+ }
+
+ // TODO: gift_code_sku_id?
+ // TODO: check password strength
+
+ if (email) {
+ // replace all dots and chars after +, if its a gmail.com email
+ if (!email) {
+ throw FieldErrors({
+ email: {
+ code: "INVALID_EMAIL",
+ message: req?.t("auth:register.INVALID_EMAIL"),
+ },
+ });
+ }
+
+ // check if there is already an account with this email
+ const exists = await User.findOne({ where: { email: email } });
+
+ if (exists) {
+ throw FieldErrors({
+ email: {
+ code: "EMAIL_ALREADY_REGISTERED",
+ message: req.t(
+ "auth:register.EMAIL_ALREADY_REGISTERED",
+ ),
+ },
+ });
+ }
+ } else if (register.email.required) {
+ throw FieldErrors({
+ email: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+ }
+
+ if (register.dateOfBirth.required && !body.date_of_birth) {
+ throw FieldErrors({
+ date_of_birth: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+ } else if (
+ register.dateOfBirth.required &&
+ register.dateOfBirth.minimum
+ ) {
+ const minimum = new Date();
+ minimum.setFullYear(
+ minimum.getFullYear() - register.dateOfBirth.minimum,
+ );
+ body.date_of_birth = new Date(body.date_of_birth as Date);
+
+ // higher is younger
+ if (body.date_of_birth > minimum) {
+ throw FieldErrors({
+ date_of_birth: {
+ code: "DATE_OF_BIRTH_UNDERAGE",
+ message: req.t("auth:register.DATE_OF_BIRTH_UNDERAGE", {
+ years: register.dateOfBirth.minimum,
+ }),
+ },
+ });
+ }
+ }
+
+ if (body.password) {
+ // the salt is saved in the password refer to bcrypt docs
+ body.password = await bcrypt.hash(body.password, 12);
+ } else if (register.password.required) {
+ throw FieldErrors({
+ password: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+ }
+
+ if (
+ !regTokenUsed &&
+ !body.invite &&
+ (register.requireInvite ||
+ (register.guestsRequireInvite && !register.email))
+ ) {
+ // require invite to register -> e.g. for organizations to send invites to their employees
+ throw FieldErrors({
+ email: {
+ code: "INVITE_ONLY",
+ message: req.t("auth:register.INVITE_ONLY"),
+ },
+ });
+ }
+
+ if (
+ !regTokenUsed &&
+ limits.absoluteRate.register.enabled &&
+ (await User.count({
+ where: {
+ created_at: MoreThan(
+ new Date(
+ Date.now() - limits.absoluteRate.register.window,
+ ),
+ ),
+ },
+ })) >= limits.absoluteRate.register.limit
+ ) {
+ console.log(
+ `Global register ratelimit exceeded for ${getIpAdress(req)}, ${
+ req.body.username
+ }, ${req.body.invite || "No invite given"}`,
+ );
+ throw FieldErrors({
+ email: {
+ code: "TOO_MANY_REGISTRATIONS",
+ message: req.t("auth:register.TOO_MANY_REGISTRATIONS"),
+ },
+ });
+ }
+
+ const user = await User.register({ ...body, req });
+
+ if (body.invite) {
+ // await to fail if the invite doesn't exist (necessary for requireInvite to work properly) (username only signups are possible)
+ await Invite.joinGuild(user.id, body.invite);
+ }
+
+ return res.json({ token: await generateToken(user.id) });
+ },
+);
+
+export default router;
+
+/**
+ * POST /auth/register
+ * @argument { "fingerprint":"805826570869932034.wR8vi8lGlFBJerErO9LG5NViJFw", "email":"qo8etzvaf@gmail.com", "username":"qp39gr98", "password":"wtp9gep9gw", "invite":null, "consent":true, "date_of_birth":"2000-04-04", "gift_code_sku_id":null, "captcha_key":null}
+ *
+ * Field Error
+ * @returns { "code": 50035, "errors": { "consent": { "_errors": [{ "code": "CONSENT_REQUIRED", "message": "You must agree to Discord's Terms of Service and Privacy Policy." }]}}, "message": "Invalid Form Body"}
+ *
+ * Success 200:
+ * @returns {token: "OMITTED"}
+ */
diff --git a/src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts b/src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts
new file mode 100644
index 00000000..65f0a57c
--- /dev/null
+++ b/src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts
@@ -0,0 +1,34 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { FieldErrors, User, BackupCodesChallengeSchema } from "@fosscord/util";
+import bcrypt from "bcrypt";
+const router = Router();
+
+router.post(
+ "/",
+ route({ body: "BackupCodesChallengeSchema" }),
+ async (req: Request, res: Response) => {
+ const { password } = req.body as BackupCodesChallengeSchema;
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: ["data"],
+ });
+
+ if (!(await bcrypt.compare(password, user.data.hash || ""))) {
+ throw FieldErrors({
+ password: {
+ message: req.t("auth:login.INVALID_PASSWORD"),
+ code: "INVALID_PASSWORD",
+ },
+ });
+ }
+
+ return res.json({
+ nonce: "NoncePlaceholder",
+ regenerate_nonce: "RegenNoncePlaceholder",
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/followers.ts b/src/api/routes/v9/channels/#channel_id/followers.ts
new file mode 100644
index 00000000..641af4f8
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/followers.ts
@@ -0,0 +1,14 @@
+import { Router, Response, Request } from "express";
+const router: Router = Router();
+// TODO:
+
+export default router;
+
+/**
+ *
+ * @param {"webhook_channel_id":"754001514330062952"}
+ *
+ * Creates a WebHook in the channel and returns the id of it
+ *
+ * @returns {"channel_id": "816382962056560690", "webhook_id": "834910735095037962"}
+ */
diff --git a/src/api/routes/v9/channels/#channel_id/index.ts b/src/api/routes/v9/channels/#channel_id/index.ts
new file mode 100644
index 00000000..a164fff6
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/index.ts
@@ -0,0 +1,103 @@
+import {
+ Channel,
+ ChannelDeleteEvent,
+ ChannelType,
+ ChannelUpdateEvent,
+ emitEvent,
+ Recipient,
+ handleFile,
+ ChannelModifySchema,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+// TODO: delete channel
+// TODO: Get channel
+
+router.get(
+ "/",
+ route({ permission: "VIEW_CHANNEL" }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+
+ return res.send(channel);
+ },
+);
+
+router.delete(
+ "/",
+ route({ permission: "MANAGE_CHANNELS" }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients"],
+ });
+
+ if (channel.type === ChannelType.DM) {
+ const recipient = await Recipient.findOneOrFail({
+ where: { channel_id: channel_id, user_id: req.user_id },
+ });
+ recipient.closed = true;
+ await Promise.all([
+ recipient.save(),
+ emitEvent({
+ event: "CHANNEL_DELETE",
+ data: channel,
+ user_id: req.user_id,
+ } as ChannelDeleteEvent),
+ ]);
+ } else if (channel.type === ChannelType.GROUP_DM) {
+ await Channel.removeRecipientFromChannel(channel, req.user_id);
+ } else {
+ await Promise.all([
+ Channel.delete({ id: channel_id }),
+ emitEvent({
+ event: "CHANNEL_DELETE",
+ data: channel,
+ channel_id,
+ } as ChannelDeleteEvent),
+ ]);
+ }
+
+ res.send(channel);
+ },
+);
+
+router.patch(
+ "/",
+ route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }),
+ async (req: Request, res: Response) => {
+ var payload = req.body as ChannelModifySchema;
+ const { channel_id } = req.params;
+ if (payload.icon)
+ payload.icon = await handleFile(
+ `/channel-icons/${channel_id}`,
+ payload.icon,
+ );
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ channel.assign(payload);
+
+ await Promise.all([
+ channel.save(),
+ emitEvent({
+ event: "CHANNEL_UPDATE",
+ data: channel,
+ channel_id,
+ } as ChannelUpdateEvent),
+ ]);
+
+ res.send(channel);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/invites.ts b/src/api/routes/v9/channels/#channel_id/invites.ts
new file mode 100644
index 00000000..afa5201b
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/invites.ts
@@ -0,0 +1,91 @@
+import { Router, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { random } from "@fosscord/api";
+import {
+ Channel,
+ Invite,
+ InviteCreateEvent,
+ emitEvent,
+ User,
+ Guild,
+ PublicInviteRelation,
+} from "@fosscord/util";
+import { isTextChannel } from "../../../v0/channels/#channel_id/messages";
+
+const router: Router = Router();
+
+router.post(
+ "/",
+ route({
+ body: "InviteCreateSchema",
+ permission: "CREATE_INSTANT_INVITE",
+ right: "CREATE_INVITES",
+ }),
+ async (req: Request, res: Response) => {
+ const { user_id } = req;
+ const { channel_id } = req.params;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ select: ["id", "name", "type", "guild_id"],
+ });
+ isTextChannel(channel.type);
+
+ if (!channel.guild_id) {
+ throw new HTTPError("This channel doesn't exist", 404);
+ }
+ const { guild_id } = channel;
+
+ const expires_at = new Date(req.body.max_age * 1000 + Date.now());
+
+ const invite = await Invite.create({
+ code: random(),
+ temporary: req.body.temporary || true,
+ uses: 0,
+ max_uses: req.body.max_uses,
+ max_age: req.body.max_age,
+ expires_at,
+ created_at: new Date(),
+ guild_id,
+ channel_id: channel_id,
+ inviter_id: user_id,
+ }).save();
+ const data = invite.toJSON();
+ data.inviter = await User.getPublicUser(req.user_id);
+ data.guild = await Guild.findOne({ where: { id: guild_id } });
+ data.channel = channel;
+
+ await emitEvent({
+ event: "INVITE_CREATE",
+ data,
+ guild_id,
+ } as InviteCreateEvent);
+ res.status(201).send(data);
+ },
+);
+
+router.get(
+ "/",
+ route({ permission: "MANAGE_CHANNELS" }),
+ async (req: Request, res: Response) => {
+ const { user_id } = req;
+ const { channel_id } = req.params;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+
+ if (!channel.guild_id) {
+ throw new HTTPError("This channel doesn't exist", 404);
+ }
+ const { guild_id } = channel;
+
+ const invites = await Invite.find({
+ where: { guild_id },
+ relations: PublicInviteRelation,
+ });
+
+ res.status(200).send(invites);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts
new file mode 100644
index 00000000..1a30143f
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts
@@ -0,0 +1,52 @@
+import {
+ emitEvent,
+ getPermission,
+ MessageAckEvent,
+ ReadState,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+// TODO: public read receipts & privacy scoping
+// TODO: send read state event to all channel members
+// TODO: advance-only notification cursor
+
+router.post(
+ "/",
+ route({ body: "MessageAcknowledgeSchema" }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+
+ const permission = await getPermission(
+ req.user_id,
+ undefined,
+ channel_id,
+ );
+ permission.hasThrow("VIEW_CHANNEL");
+
+ let read_state = await ReadState.findOne({
+ where: { user_id: req.user_id, channel_id },
+ });
+ if (!read_state)
+ read_state = ReadState.create({ user_id: req.user_id, channel_id });
+ read_state.last_message_id = message_id;
+
+ await read_state.save();
+
+ await emitEvent({
+ event: "MESSAGE_ACK",
+ user_id: req.user_id,
+ data: {
+ channel_id,
+ message_id,
+ version: 3763,
+ },
+ } as MessageAckEvent);
+
+ res.json({ token: null });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts
new file mode 100644
index 00000000..d8b55ccd
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts
@@ -0,0 +1,38 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.post(
+ "/",
+ route({ permission: "MANAGE_MESSAGES" }),
+ (req: Request, res: Response) => {
+ // TODO:
+ res.json({
+ id: "",
+ type: 0,
+ content: "",
+ channel_id: "",
+ author: {
+ id: "",
+ username: "",
+ avatar: "",
+ discriminator: "",
+ public_flags: 64,
+ },
+ attachments: [],
+ embeds: [],
+ mentions: [],
+ mention_roles: [],
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ timestamp: "",
+ edited_timestamp: null,
+ flags: 1,
+ components: [],
+ }).status(200);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts
new file mode 100644
index 00000000..d57d9a1b
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts
@@ -0,0 +1,246 @@
+import {
+ Attachment,
+ Channel,
+ emitEvent,
+ FosscordApiErrors,
+ getPermission,
+ getRights,
+ Message,
+ MessageCreateEvent,
+ MessageDeleteEvent,
+ MessageUpdateEvent,
+ Snowflake,
+ uploadFile,
+ MessageCreateSchema,
+ DiscordApiErrors,
+} from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import multer from "multer";
+import { route } from "@fosscord/api";
+import { handleMessage, postHandleMessage } from "@fosscord/api";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+// TODO: message content/embed string length limit
+
+const messageUpload = multer({
+ limits: {
+ fileSize: 1024 * 1024 * 100,
+ fields: 10,
+ files: 1,
+ },
+ storage: multer.memoryStorage(),
+}); // max upload 50 mb
+
+router.patch(
+ "/",
+ route({
+ body: "MessageCreateSchema",
+ permission: "SEND_MESSAGES",
+ right: "SEND_MESSAGES",
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+ var body = req.body as MessageCreateSchema;
+
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ relations: ["attachments"],
+ });
+
+ const permissions = await getPermission(
+ req.user_id,
+ undefined,
+ channel_id,
+ );
+
+ const rights = await getRights(req.user_id);
+
+ if (req.user_id !== message.author_id) {
+ if (!rights.has("MANAGE_MESSAGES")) {
+ permissions.hasThrow("MANAGE_MESSAGES");
+ body = { flags: body.flags };
+ // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins
+ }
+ } else rights.hasThrow("SELF_EDIT_MESSAGES");
+
+ const new_message = await handleMessage({
+ ...message,
+ // TODO: should message_reference be overridable?
+ // @ts-ignore
+ message_reference: message.message_reference,
+ ...body,
+ author_id: message.author_id,
+ channel_id,
+ id: message_id,
+ edited_timestamp: new Date(),
+ });
+
+ await Promise.all([
+ new_message.save(),
+ await emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id,
+ data: { ...new_message, nonce: undefined },
+ } as MessageUpdateEvent),
+ ]);
+
+ postHandleMessage(new_message);
+
+ return res.json(new_message);
+ },
+);
+
+// Backfill message with specific timestamp
+router.put(
+ "/",
+ messageUpload.single("file"),
+ async (req, res, next) => {
+ if (req.body.payload_json) {
+ req.body = JSON.parse(req.body.payload_json);
+ }
+
+ next();
+ },
+ route({
+ body: "MessageCreateSchema",
+ permission: "SEND_MESSAGES",
+ right: "SEND_BACKDATED_EVENTS",
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+ var body = req.body as MessageCreateSchema;
+ const attachments: Attachment[] = [];
+
+ const rights = await getRights(req.user_id);
+ rights.hasThrow("SEND_MESSAGES");
+
+ // regex to check if message contains anything other than numerals ( also no decimals )
+ if (!message_id.match(/^\+?\d+$/)) {
+ throw new HTTPError("Message IDs must be positive integers", 400);
+ }
+
+ const snowflake = Snowflake.deconstruct(message_id);
+ if (Date.now() < snowflake.timestamp) {
+ // message is in the future
+ throw FosscordApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE;
+ }
+
+ const exists = await Message.findOne({
+ where: { id: message_id, channel_id: channel_id },
+ });
+ if (exists) {
+ throw FosscordApiErrors.CANNOT_REPLACE_BY_BACKFILL;
+ }
+
+ if (req.file) {
+ try {
+ const file = await uploadFile(
+ `/attachments/${req.params.channel_id}`,
+ req.file,
+ );
+ attachments.push(
+ Attachment.create({ ...file, proxy_url: file.url }),
+ );
+ } catch (error) {
+ return res.status(400).json(error);
+ }
+ }
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients", "recipients.user"],
+ });
+
+ const embeds = body.embeds || [];
+ if (body.embed) embeds.push(body.embed);
+ let message = await handleMessage({
+ ...body,
+ type: 0,
+ pinned: false,
+ author_id: req.user_id,
+ id: message_id,
+ embeds,
+ channel_id,
+ attachments,
+ edited_timestamp: undefined,
+ timestamp: new Date(snowflake.timestamp),
+ });
+
+ //Fix for the client bug
+ delete message.member;
+
+ await Promise.all([
+ message.save(),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: channel_id,
+ data: message,
+ } as MessageCreateEvent),
+ channel.save(),
+ ]);
+
+ postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error
+
+ return res.json(message);
+ },
+);
+
+router.get(
+ "/",
+ route({ permission: "VIEW_CHANNEL" }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ relations: ["attachments"],
+ });
+
+ const permissions = await getPermission(
+ req.user_id,
+ undefined,
+ channel_id,
+ );
+
+ if (message.author_id !== req.user_id)
+ permissions.hasThrow("READ_MESSAGE_HISTORY");
+
+ return res.json(message);
+ },
+);
+
+router.delete("/", route({}), async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({ where: { id: channel_id } });
+ const message = await Message.findOneOrFail({ where: { id: message_id } });
+
+ const rights = await getRights(req.user_id);
+
+ if (message.author_id !== req.user_id) {
+ if (!rights.has("MANAGE_MESSAGES")) {
+ const permission = await getPermission(
+ req.user_id,
+ channel.guild_id,
+ channel_id,
+ );
+ permission.hasThrow("MANAGE_MESSAGES");
+ }
+ } else rights.hasThrow("SELF_DELETE_MESSAGES");
+
+ await Message.delete({ id: message_id });
+
+ await emitEvent({
+ event: "MESSAGE_DELETE",
+ channel_id,
+ data: {
+ id: message_id,
+ channel_id,
+ guild_id: channel.guild_id,
+ },
+ } as MessageDeleteEvent);
+
+ res.sendStatus(204);
+});
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts
new file mode 100644
index 00000000..9f774682
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts
@@ -0,0 +1,250 @@
+import {
+ Channel,
+ emitEvent,
+ Emoji,
+ getPermission,
+ Member,
+ Message,
+ MessageReactionAddEvent,
+ MessageReactionRemoveAllEvent,
+ MessageReactionRemoveEmojiEvent,
+ MessageReactionRemoveEvent,
+ PartialEmoji,
+ PublicUserProjection,
+ User,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Router, Response, Request } from "express";
+import { HTTPError } from "lambert-server";
+import { In } from "typeorm";
+
+const router = Router();
+// TODO: check if emoji is really an unicode emoji or a prperly encoded external emoji
+
+function getEmoji(emoji: string): PartialEmoji {
+ emoji = decodeURIComponent(emoji);
+ const parts = emoji.includes(":") && emoji.split(":");
+ if (parts)
+ return {
+ name: parts[0],
+ id: parts[1],
+ };
+
+ return {
+ id: undefined,
+ name: emoji,
+ };
+}
+
+router.delete(
+ "/",
+ route({ permission: "MANAGE_MESSAGES" }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+
+ await Message.update({ id: message_id, channel_id }, { reactions: [] });
+
+ await emitEvent({
+ event: "MESSAGE_REACTION_REMOVE_ALL",
+ channel_id,
+ data: {
+ channel_id,
+ message_id,
+ guild_id: channel.guild_id,
+ },
+ } as MessageReactionRemoveAllEvent);
+
+ res.sendStatus(204);
+ },
+);
+
+router.delete(
+ "/:emoji",
+ route({ permission: "MANAGE_MESSAGES" }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+ const emoji = getEmoji(req.params.emoji);
+
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ });
+
+ const already_added = message.reactions.find(
+ (x) =>
+ (x.emoji.id === emoji.id && emoji.id) ||
+ x.emoji.name === emoji.name,
+ );
+ if (!already_added) throw new HTTPError("Reaction not found", 404);
+ message.reactions.remove(already_added);
+
+ await Promise.all([
+ message.save(),
+ emitEvent({
+ event: "MESSAGE_REACTION_REMOVE_EMOJI",
+ channel_id,
+ data: {
+ channel_id,
+ message_id,
+ guild_id: message.guild_id,
+ emoji,
+ },
+ } as MessageReactionRemoveEmojiEvent),
+ ]);
+
+ res.sendStatus(204);
+ },
+);
+
+router.get(
+ "/:emoji",
+ route({ permission: "VIEW_CHANNEL" }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+ const emoji = getEmoji(req.params.emoji);
+
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ });
+ const reaction = message.reactions.find(
+ (x) =>
+ (x.emoji.id === emoji.id && emoji.id) ||
+ x.emoji.name === emoji.name,
+ );
+ if (!reaction) throw new HTTPError("Reaction not found", 404);
+
+ const users = await User.find({
+ where: {
+ id: In(reaction.user_ids),
+ },
+ select: PublicUserProjection,
+ });
+
+ res.json(users);
+ },
+);
+
+router.put(
+ "/:emoji/:user_id",
+ route({ permission: "READ_MESSAGE_HISTORY", right: "SELF_ADD_REACTIONS" }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id, user_id } = req.params;
+ if (user_id !== "@me") throw new HTTPError("Invalid user");
+ const emoji = getEmoji(req.params.emoji);
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ });
+ const already_added = message.reactions.find(
+ (x) =>
+ (x.emoji.id === emoji.id && emoji.id) ||
+ x.emoji.name === emoji.name,
+ );
+
+ if (!already_added) req.permission!.hasThrow("ADD_REACTIONS");
+
+ if (emoji.id) {
+ const external_emoji = await Emoji.findOneOrFail({
+ where: { id: emoji.id },
+ });
+ if (!already_added) req.permission!.hasThrow("USE_EXTERNAL_EMOJIS");
+ emoji.animated = external_emoji.animated;
+ emoji.name = external_emoji.name;
+ }
+
+ if (already_added) {
+ if (already_added.user_ids.includes(req.user_id))
+ return res.sendStatus(204); // Do not throw an error ¯\_(ツ)_/¯ as discord also doesn't throw any error
+ already_added.count++;
+ } else
+ message.reactions.push({
+ count: 1,
+ emoji,
+ user_ids: [req.user_id],
+ });
+
+ await message.save();
+
+ const member =
+ channel.guild_id &&
+ (await Member.findOneOrFail({ where: { id: req.user_id } }));
+
+ await emitEvent({
+ event: "MESSAGE_REACTION_ADD",
+ channel_id,
+ data: {
+ user_id: req.user_id,
+ channel_id,
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ member,
+ },
+ } as MessageReactionAddEvent);
+
+ res.sendStatus(204);
+ },
+);
+
+router.delete(
+ "/:emoji/:user_id",
+ route({}),
+ async (req: Request, res: Response) => {
+ var { message_id, channel_id, user_id } = req.params;
+
+ const emoji = getEmoji(req.params.emoji);
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const message = await Message.findOneOrFail({
+ where: { id: message_id, channel_id },
+ });
+
+ if (user_id === "@me") user_id = req.user_id;
+ else {
+ const permissions = await getPermission(
+ req.user_id,
+ undefined,
+ channel_id,
+ );
+ permissions.hasThrow("MANAGE_MESSAGES");
+ }
+
+ const already_added = message.reactions.find(
+ (x) =>
+ (x.emoji.id === emoji.id && emoji.id) ||
+ x.emoji.name === emoji.name,
+ );
+ if (!already_added || !already_added.user_ids.includes(user_id))
+ throw new HTTPError("Reaction not found", 404);
+
+ already_added.count--;
+
+ if (already_added.count <= 0) message.reactions.remove(already_added);
+
+ await message.save();
+
+ await emitEvent({
+ event: "MESSAGE_REACTION_REMOVE",
+ channel_id,
+ data: {
+ user_id: req.user_id,
+ channel_id,
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ },
+ } as MessageReactionRemoveEvent);
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts b/src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts
new file mode 100644
index 00000000..553ab17e
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts
@@ -0,0 +1,65 @@
+import { Router, Response, Request } from "express";
+import {
+ Channel,
+ Config,
+ emitEvent,
+ getPermission,
+ getRights,
+ MessageDeleteBulkEvent,
+ Message,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+export default router;
+
+// should users be able to bulk delete messages or only bots? ANSWER: all users
+// should this request fail, if you provide messages older than 14 days/invalid ids? ANSWER: NO
+// https://discord.com/developers/docs/resources/channel#bulk-delete-messages
+router.post(
+ "/",
+ route({ body: "BulkDeleteSchema" }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ if (!channel.guild_id)
+ throw new HTTPError("Can't bulk delete dm channel messages", 400);
+
+ const rights = await getRights(req.user_id);
+ rights.hasThrow("SELF_DELETE_MESSAGES");
+
+ let superuser = rights.has("MANAGE_MESSAGES");
+ const permission = await getPermission(
+ req.user_id,
+ channel?.guild_id,
+ channel_id,
+ );
+
+ const { maxBulkDelete } = Config.get().limits.message;
+
+ const { messages } = req.body as { messages: string[] };
+ if (messages.length === 0)
+ throw new HTTPError("You must specify messages to bulk delete");
+ if (!superuser) {
+ permission.hasThrow("MANAGE_MESSAGES");
+ if (messages.length > maxBulkDelete)
+ throw new HTTPError(
+ `You cannot delete more than ${maxBulkDelete} messages`,
+ );
+ }
+
+ await Message.delete(messages);
+
+ await emitEvent({
+ event: "MESSAGE_DELETE_BULK",
+ channel_id,
+ data: { ids: messages, channel_id, guild_id: channel.guild_id },
+ } as MessageDeleteBulkEvent);
+
+ res.sendStatus(204);
+ },
+);
diff --git a/src/api/routes/v9/channels/#channel_id/messages/index.ts b/src/api/routes/v9/channels/#channel_id/messages/index.ts
new file mode 100644
index 00000000..2968437d
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/messages/index.ts
@@ -0,0 +1,351 @@
+import { Router, Response, Request } from "express";
+import {
+ Attachment,
+ Channel,
+ ChannelType,
+ Config,
+ DmChannelDTO,
+ emitEvent,
+ FieldErrors,
+ getPermission,
+ Message,
+ MessageCreateEvent,
+ Snowflake,
+ uploadFile,
+ Member,
+ Role,
+ MessageCreateSchema,
+ ReadState,
+ DiscordApiErrors,
+ getRights,
+ Rights,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import {
+ handleMessage,
+ postHandleMessage,
+ route,
+ getIpAdress,
+} from "@fosscord/api";
+import multer from "multer";
+import { yellow } from "picocolors";
+import { FindManyOptions, LessThan, MoreThan } from "typeorm";
+import { URL } from "url";
+
+const router: Router = Router();
+
+export default router;
+
+export function isTextChannel(type: ChannelType): boolean {
+ switch (type) {
+ case ChannelType.GUILD_STORE:
+ case ChannelType.GUILD_VOICE:
+ case ChannelType.GUILD_STAGE_VOICE:
+ case ChannelType.GUILD_CATEGORY:
+ case ChannelType.GUILD_FORUM:
+ case ChannelType.DIRECTORY:
+ throw new HTTPError("not a text channel", 400);
+ case ChannelType.DM:
+ case ChannelType.GROUP_DM:
+ case ChannelType.GUILD_NEWS:
+ case ChannelType.GUILD_NEWS_THREAD:
+ case ChannelType.GUILD_PUBLIC_THREAD:
+ case ChannelType.GUILD_PRIVATE_THREAD:
+ case ChannelType.GUILD_TEXT:
+ case ChannelType.ENCRYPTED:
+ case ChannelType.ENCRYPTED_THREAD:
+ return true;
+ default:
+ throw new HTTPError("unimplemented", 400);
+ }
+}
+
+// https://discord.com/developers/docs/resources/channel#create-message
+// get messages
+router.get("/", async (req: Request, res: Response) => {
+ const channel_id = req.params.channel_id;
+ const channel = await Channel.findOneOrFail({ where: { id: channel_id } });
+ if (!channel) throw new HTTPError("Channel not found", 404);
+
+ isTextChannel(channel.type);
+ const around = req.query.around ? `${req.query.around}` : undefined;
+ const before = req.query.before ? `${req.query.before}` : undefined;
+ const after = req.query.after ? `${req.query.after}` : undefined;
+ const limit = Number(req.query.limit) || 50;
+ if (limit < 1 || limit > 100)
+ throw new HTTPError("limit must be between 1 and 100", 422);
+
+ var halfLimit = Math.floor(limit / 2);
+
+ const permissions = await getPermission(
+ req.user_id,
+ channel.guild_id,
+ channel_id,
+ );
+ permissions.hasThrow("VIEW_CHANNEL");
+ if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]);
+
+ var query: FindManyOptions<Message> & { where: { id?: any } } = {
+ order: { timestamp: "DESC" },
+ take: limit,
+ where: { channel_id },
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ ],
+ };
+
+ if (after) {
+ if (BigInt(after) > BigInt(Snowflake.generate()))
+ return res.status(422);
+ query.where.id = MoreThan(after);
+ } else if (before) {
+ if (BigInt(before) < BigInt(req.params.channel_id))
+ return res.status(422);
+ query.where.id = LessThan(before);
+ } else if (around) {
+ query.where.id = [
+ MoreThan((BigInt(around) - BigInt(halfLimit)).toString()),
+ LessThan((BigInt(around) + BigInt(halfLimit)).toString()),
+ ];
+
+ return res.json([]); // TODO: fix around
+ }
+
+ const messages = await Message.find(query);
+ const endpoint = Config.get().cdn.endpointPublic;
+
+ return res.json(
+ messages.map((x: any) => {
+ (x.reactions || []).forEach((x: any) => {
+ // @ts-ignore
+ if ((x.user_ids || []).includes(req.user_id)) x.me = true;
+ // @ts-ignore
+ delete x.user_ids;
+ });
+ // @ts-ignore
+ if (!x.author)
+ x.author = {
+ id: "4",
+ discriminator: "0000",
+ username: "Fosscord Ghost",
+ public_flags: "0",
+ avatar: null,
+ };
+ x.attachments?.forEach((y: any) => {
+ // dynamically set attachment proxy_url in case the endpoint changed
+ const uri = y.proxy_url.startsWith("http")
+ ? y.proxy_url
+ : `https://example.org${y.proxy_url}`;
+ y.proxy_url = `${endpoint == null ? "" : endpoint}${
+ new URL(uri).pathname
+ }`;
+ });
+
+ /**
+ Some clients ( discord.js ) only check if a property exists within the response,
+ which causes erorrs when, say, the `application` property is `null`.
+ **/
+
+ // for (var curr in x) {
+ // if (x[curr] === null)
+ // delete x[curr];
+ // }
+
+ return x;
+ }),
+ );
+});
+
+// TODO: config max upload size
+const messageUpload = multer({
+ limits: {
+ fileSize: Config.get().limits.message.maxAttachmentSize,
+ fields: 10,
+ // files: 1
+ },
+ storage: multer.memoryStorage(),
+}); // max upload 50 mb
+/**
+ TODO: dynamically change limit of MessageCreateSchema with config
+
+ https://discord.com/developers/docs/resources/channel#create-message
+ TODO: text channel slowdown (per-user and across-users)
+ Q: trim and replace message content and every embed field A: NO, given this cannot be implemented in E2EE channels
+ TODO: only dispatch notifications for mentions denoted in allowed_mentions
+**/
+// Send message
+router.post(
+ "/",
+ messageUpload.any(),
+ (req, res, next) => {
+ if (req.body.payload_json) {
+ req.body = JSON.parse(req.body.payload_json);
+ }
+
+ next();
+ },
+ route({
+ body: "MessageCreateSchema",
+ permission: "SEND_MESSAGES",
+ right: "SEND_MESSAGES",
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+ var body = req.body as MessageCreateSchema;
+ const attachments: Attachment[] = [];
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients", "recipients.user"],
+ });
+ if (!channel.isWritable()) {
+ throw new HTTPError(
+ `Cannot send messages to channel of type ${channel.type}`,
+ 400,
+ );
+ }
+
+ if (body.nonce) {
+ const existing = await Message.findOne({
+ where: {
+ nonce: body.nonce,
+ channel_id: channel.id,
+ author_id: req.user_id,
+ },
+ });
+ if (existing) {
+ return res.json(existing);
+ }
+ }
+
+ if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) {
+ var limits = Config.get().limits;
+ if (limits.absoluteRate.register.enabled) {
+ const count = await Message.count({
+ where: {
+ channel_id,
+ timestamp: MoreThan(
+ new Date(
+ Date.now() -
+ limits.absoluteRate.sendMessage.window,
+ ),
+ ),
+ },
+ });
+
+ if (count >= limits.absoluteRate.sendMessage.limit)
+ throw FieldErrors({
+ channel_id: {
+ code: "TOO_MANY_MESSAGES",
+ message: req.t("common:toomany.MESSAGE"),
+ },
+ });
+ }
+ }
+
+ const files = (req.files as Express.Multer.File[]) ?? [];
+ for (var currFile of files) {
+ try {
+ const file = await uploadFile(
+ `/attachments/${channel.id}`,
+ currFile,
+ );
+ attachments.push(
+ Attachment.create({ ...file, proxy_url: file.url }),
+ );
+ } catch (error) {
+ return res.status(400).json({ message: error!.toString() });
+ }
+ }
+
+ const embeds = body.embeds || [];
+ if (body.embed) embeds.push(body.embed);
+ let message = await handleMessage({
+ ...body,
+ type: 0,
+ pinned: false,
+ author_id: req.user_id,
+ embeds,
+ channel_id,
+ attachments,
+ edited_timestamp: undefined,
+ timestamp: new Date(),
+ });
+
+ channel.last_message_id = message.id;
+
+ if (channel.isDm()) {
+ const channel_dto = await DmChannelDTO.from(channel);
+
+ // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed
+ await Promise.all(
+ channel.recipients!.map((recipient) => {
+ if (recipient.closed) {
+ recipient.closed = false;
+ return Promise.all([
+ recipient.save(),
+ emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel_dto.excludedRecipients([
+ recipient.user_id,
+ ]),
+ user_id: recipient.user_id,
+ }),
+ ]);
+ }
+ }),
+ );
+ }
+
+ if (message.guild_id) {
+ // handleMessage will fetch the Member, but only if they are not guild owner.
+ // have to fetch ourselves otherwise.
+ if (!message.member) {
+ message.member = await Member.findOneOrFail({
+ where: { id: req.user_id, guild_id: message.guild_id },
+ relations: ["roles"],
+ });
+ }
+
+ //@ts-ignore
+ message.member.roles = message.member.roles
+ .filter((x) => x.id != x.guild_id)
+ .map((x) => x.id);
+ }
+
+ let read_state = await ReadState.findOne({
+ where: { user_id: req.user_id, channel_id },
+ });
+ if (!read_state)
+ read_state = ReadState.create({ user_id: req.user_id, channel_id });
+ read_state.last_message_id = message.id;
+
+ await Promise.all([
+ read_state.save(),
+ message.save(),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: channel_id,
+ data: message,
+ } as MessageCreateEvent),
+ message.guild_id
+ ? Member.update(
+ { id: req.user_id, guild_id: message.guild_id },
+ { last_message_id: message.id },
+ )
+ : null,
+ channel.save(),
+ ]);
+
+ postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error
+
+ return res.json(message);
+ },
+);
diff --git a/src/api/routes/v9/channels/#channel_id/permissions.ts b/src/api/routes/v9/channels/#channel_id/permissions.ts
new file mode 100644
index 00000000..b08cd0c8
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/permissions.ts
@@ -0,0 +1,101 @@
+import {
+ Channel,
+ ChannelPermissionOverwrite,
+ ChannelUpdateEvent,
+ emitEvent,
+ Member,
+ Role,
+ ChannelPermissionOverwriteSchema,
+} from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import { HTTPError } from "lambert-server";
+
+import { route } from "@fosscord/api";
+const router: Router = Router();
+
+// TODO: Only permissions your bot has in the guild or channel can be allowed/denied (unless your bot has a MANAGE_ROLES overwrite in the channel)
+
+router.put(
+ "/:overwrite_id",
+ route({
+ body: "ChannelPermissionOverwriteSchema",
+ permission: "MANAGE_ROLES",
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, overwrite_id } = req.params;
+ const body = req.body as ChannelPermissionOverwriteSchema;
+
+ var channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ if (!channel.guild_id) throw new HTTPError("Channel not found", 404);
+
+ if (body.type === 0) {
+ if (!(await Role.count({ where: { id: overwrite_id } })))
+ throw new HTTPError("role not found", 404);
+ } else if (body.type === 1) {
+ if (!(await Member.count({ where: { id: overwrite_id } })))
+ throw new HTTPError("user not found", 404);
+ } else throw new HTTPError("type not supported", 501);
+
+ //@ts-ignore
+ var overwrite: ChannelPermissionOverwrite =
+ channel.permission_overwrites?.find((x) => x.id === overwrite_id);
+ if (!overwrite) {
+ // @ts-ignore
+ overwrite = {
+ id: overwrite_id,
+ type: body.type,
+ };
+ channel.permission_overwrites!.push(overwrite);
+ }
+ overwrite.allow = String(
+ req.permission!.bitfield & (BigInt(body.allow) || BigInt("0")),
+ );
+ overwrite.deny = String(
+ req.permission!.bitfield & (BigInt(body.deny) || BigInt("0")),
+ );
+
+ await Promise.all([
+ channel.save(),
+ emitEvent({
+ event: "CHANNEL_UPDATE",
+ channel_id,
+ data: channel,
+ } as ChannelUpdateEvent),
+ ]);
+
+ return res.sendStatus(204);
+ },
+);
+
+// TODO: check permission hierarchy
+router.delete(
+ "/:overwrite_id",
+ route({ permission: "MANAGE_ROLES" }),
+ async (req: Request, res: Response) => {
+ const { channel_id, overwrite_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ if (!channel.guild_id) throw new HTTPError("Channel not found", 404);
+
+ channel.permission_overwrites = channel.permission_overwrites!.filter(
+ (x) => x.id === overwrite_id,
+ );
+
+ await Promise.all([
+ channel.save(),
+ emitEvent({
+ event: "CHANNEL_UPDATE",
+ channel_id,
+ data: channel,
+ } as ChannelUpdateEvent),
+ ]);
+
+ return res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/pins.ts b/src/api/routes/v9/channels/#channel_id/pins.ts
new file mode 100644
index 00000000..d3f6960a
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/pins.ts
@@ -0,0 +1,113 @@
+import {
+ Channel,
+ ChannelPinsUpdateEvent,
+ Config,
+ emitEvent,
+ getPermission,
+ Message,
+ MessageUpdateEvent,
+ DiscordApiErrors,
+} from "@fosscord/util";
+import { Router, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.put(
+ "/:message_id",
+ route({ permission: "VIEW_CHANNEL" }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+
+ const message = await Message.findOneOrFail({
+ where: { id: message_id },
+ });
+
+ // * in dm channels anyone can pin messages -> only check for guilds
+ if (message.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES");
+
+ const pinned_count = await Message.count({
+ where: { channel: { id: channel_id }, pinned: true },
+ });
+ const { maxPins } = Config.get().limits.channel;
+ if (pinned_count >= maxPins)
+ throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins);
+
+ await Promise.all([
+ Message.update({ id: message_id }, { pinned: true }),
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id,
+ data: message,
+ } as MessageUpdateEvent),
+ emitEvent({
+ event: "CHANNEL_PINS_UPDATE",
+ channel_id,
+ data: {
+ channel_id,
+ guild_id: message.guild_id,
+ last_pin_timestamp: undefined,
+ },
+ } as ChannelPinsUpdateEvent),
+ ]);
+
+ res.sendStatus(204);
+ },
+);
+
+router.delete(
+ "/:message_id",
+ route({ permission: "VIEW_CHANNEL" }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ if (channel.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES");
+
+ const message = await Message.findOneOrFail({
+ where: { id: message_id },
+ });
+ message.pinned = false;
+
+ await Promise.all([
+ message.save(),
+
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id,
+ data: message,
+ } as MessageUpdateEvent),
+
+ emitEvent({
+ event: "CHANNEL_PINS_UPDATE",
+ channel_id,
+ data: {
+ channel_id,
+ guild_id: channel.guild_id,
+ last_pin_timestamp: undefined,
+ },
+ } as ChannelPinsUpdateEvent),
+ ]);
+
+ res.sendStatus(204);
+ },
+);
+
+router.get(
+ "/",
+ route({ permission: ["READ_MESSAGE_HISTORY"] }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+
+ let pins = await Message.find({
+ where: { channel_id: channel_id, pinned: true },
+ });
+
+ res.send(pins);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/purge.ts b/src/api/routes/v9/channels/#channel_id/purge.ts
new file mode 100644
index 00000000..0be9ab7c
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/purge.ts
@@ -0,0 +1,99 @@
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { isTextChannel } from "../../../v0/channels/#channel_id/messages";
+import { FindManyOptions, Between, Not } from "typeorm";
+import {
+ Channel,
+ Config,
+ emitEvent,
+ getPermission,
+ getRights,
+ Message,
+ MessageDeleteBulkEvent,
+ PurgeSchema,
+} from "@fosscord/util";
+import { Router, Response, Request } from "express";
+
+const router: Router = Router();
+
+export default router;
+
+/**
+TODO: apply the delete bit by bit to prevent client and database stress
+**/
+router.post(
+ "/",
+ route({
+ /*body: "PurgeSchema",*/
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+
+ if (!channel.guild_id)
+ throw new HTTPError("Can't purge dm channels", 400);
+ isTextChannel(channel.type);
+
+ const rights = await getRights(req.user_id);
+ if (!rights.has("MANAGE_MESSAGES")) {
+ const permissions = await getPermission(
+ req.user_id,
+ channel.guild_id,
+ channel_id,
+ );
+ permissions.hasThrow("MANAGE_MESSAGES");
+ permissions.hasThrow("MANAGE_CHANNELS");
+ }
+
+ const { before, after } = req.body as PurgeSchema;
+
+ // TODO: send the deletion event bite-by-bite to prevent client stress
+
+ var query: FindManyOptions<Message> & { where: { id?: any } } = {
+ order: { id: "ASC" },
+ // take: limit,
+ where: {
+ channel_id,
+ id: Between(after, before), // the right way around
+ author_id: rights.has("SELF_DELETE_MESSAGES")
+ ? undefined
+ : Not(req.user_id),
+ // if you lack the right of self-deletion, you can't delete your own messages, even in purges
+ },
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ ],
+ };
+
+ const messages = await Message.find(query);
+ const endpoint = Config.get().cdn.endpointPublic;
+
+ if (messages.length == 0) {
+ res.sendStatus(304);
+ return;
+ }
+
+ await Message.delete(messages.map((x) => x.id));
+
+ await emitEvent({
+ event: "MESSAGE_DELETE_BULK",
+ channel_id,
+ data: {
+ ids: messages.map((x) => x.id),
+ channel_id,
+ guild_id: channel.guild_id,
+ },
+ } as MessageDeleteBulkEvent);
+
+ res.sendStatus(204);
+ },
+);
diff --git a/src/api/routes/v9/channels/#channel_id/recipients.ts b/src/api/routes/v9/channels/#channel_id/recipients.ts
new file mode 100644
index 00000000..cc7e5756
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/recipients.ts
@@ -0,0 +1,89 @@
+import { Request, Response, Router } from "express";
+import {
+ Channel,
+ ChannelRecipientAddEvent,
+ ChannelType,
+ DiscordApiErrors,
+ DmChannelDTO,
+ emitEvent,
+ PublicUserProjection,
+ Recipient,
+ User,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.put("/:user_id", route({}), async (req: Request, res: Response) => {
+ const { channel_id, user_id } = req.params;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients"],
+ });
+
+ if (channel.type !== ChannelType.GROUP_DM) {
+ const recipients = [
+ ...channel.recipients!.map((r) => r.user_id),
+ user_id,
+ ].unique();
+
+ const new_channel = await Channel.createDMChannel(
+ recipients,
+ req.user_id,
+ );
+ return res.status(201).json(new_channel);
+ } else {
+ if (channel.recipients!.map((r) => r.user_id).includes(user_id)) {
+ throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error?
+ }
+
+ channel.recipients!.push(
+ Recipient.create({ channel_id: channel_id, user_id: user_id }),
+ );
+ await channel.save();
+
+ await emitEvent({
+ event: "CHANNEL_CREATE",
+ data: await DmChannelDTO.from(channel, [user_id]),
+ user_id: user_id,
+ });
+
+ await emitEvent({
+ event: "CHANNEL_RECIPIENT_ADD",
+ data: {
+ channel_id: channel_id,
+ user: await User.findOneOrFail({
+ where: { id: user_id },
+ select: PublicUserProjection,
+ }),
+ },
+ channel_id: channel_id,
+ } as ChannelRecipientAddEvent);
+ return res.sendStatus(204);
+ }
+});
+
+router.delete("/:user_id", route({}), async (req: Request, res: Response) => {
+ const { channel_id, user_id } = req.params;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients"],
+ });
+ if (
+ !(
+ channel.type === ChannelType.GROUP_DM &&
+ (channel.owner_id === req.user_id || user_id === req.user_id)
+ )
+ )
+ throw DiscordApiErrors.MISSING_PERMISSIONS;
+
+ if (!channel.recipients!.map((r) => r.user_id).includes(user_id)) {
+ throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error?
+ }
+
+ await Channel.removeRecipientFromChannel(channel, user_id);
+
+ return res.sendStatus(204);
+});
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/typing.ts b/src/api/routes/v9/channels/#channel_id/typing.ts
new file mode 100644
index 00000000..03f76205
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/typing.ts
@@ -0,0 +1,45 @@
+import { Channel, emitEvent, Member, TypingStartEvent } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Router, Request, Response } from "express";
+
+const router: Router = Router();
+
+router.post(
+ "/",
+ route({ permission: "SEND_MESSAGES" }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+ const user_id = req.user_id;
+ const timestamp = Date.now();
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const member = await Member.findOne({
+ where: { id: user_id, guild_id: channel.guild_id },
+ relations: ["roles", "user"],
+ });
+
+ await emitEvent({
+ event: "TYPING_START",
+ channel_id: channel_id,
+ data: {
+ ...(member
+ ? {
+ member: {
+ ...member,
+ roles: member?.roles?.map((x) => x.id),
+ },
+ }
+ : null),
+ channel_id,
+ timestamp,
+ user_id,
+ guild_id: channel.guild_id,
+ },
+ } as TypingStartEvent);
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/channels/#channel_id/webhooks.ts b/src/api/routes/v9/channels/#channel_id/webhooks.ts
new file mode 100644
index 00000000..f2923f95
--- /dev/null
+++ b/src/api/routes/v9/channels/#channel_id/webhooks.ts
@@ -0,0 +1,66 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import {
+ Channel,
+ Config,
+ handleFile,
+ trimSpecial,
+ User,
+ Webhook,
+ WebhookCreateSchema,
+ WebhookType,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { isTextChannel } from "../../../v0/channels/#channel_id/messages/index";
+import { DiscordApiErrors } from "@fosscord/util";
+import crypto from "crypto";
+
+const router: Router = Router();
+
+// TODO: use Image Data Type for avatar instead of String
+router.post(
+ "/",
+ route({ body: "WebhookCreateSchema", permission: "MANAGE_WEBHOOKS" }),
+ async (req: Request, res: Response) => {
+ const channel_id = req.params.channel_id;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+
+ isTextChannel(channel.type);
+ if (!channel.guild_id) throw new HTTPError("Not a guild channel", 400);
+
+ const webhook_count = await Webhook.count({ where: { channel_id } });
+ const { maxWebhooks } = Config.get().limits.channel;
+ if (webhook_count > maxWebhooks)
+ throw DiscordApiErrors.MAXIMUM_WEBHOOKS.withParams(maxWebhooks);
+
+ var { avatar, name } = req.body as WebhookCreateSchema;
+ name = trimSpecial(name);
+
+ // TODO: move this
+ if (name === "clyde") throw new HTTPError("Invalid name", 400);
+ if (name === "Fosscord Ghost") throw new HTTPError("Invalid name", 400);
+
+ if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar);
+
+ const hook = Webhook.create({
+ type: WebhookType.Incoming,
+ name,
+ avatar,
+ guild_id: channel.guild_id,
+ channel_id: channel.id,
+ user_id: req.user_id,
+ token: crypto.randomBytes(24).toString("base64"),
+ });
+
+ const user = await User.getPublicUser(req.user_id);
+
+ return res.json({
+ ...hook,
+ user: user,
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/discoverable-guilds.ts b/src/api/routes/v9/discoverable-guilds.ts
new file mode 100644
index 00000000..428ca605
--- /dev/null
+++ b/src/api/routes/v9/discoverable-guilds.ts
@@ -0,0 +1,46 @@
+import { Guild, Config } from "@fosscord/util";
+
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { Like } from "typeorm";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { offset, limit, categories } = req.query;
+ var showAllGuilds = Config.get().guild.discovery.showAllGuilds;
+ var configLimit = Config.get().guild.discovery.limit;
+ let guilds;
+ if (categories == undefined) {
+ guilds = showAllGuilds
+ ? await Guild.find({ take: Math.abs(Number(limit || configLimit)) })
+ : await Guild.find({
+ where: { features: Like(`%DISCOVERABLE%`) },
+ take: Math.abs(Number(limit || configLimit)),
+ });
+ } else {
+ guilds = showAllGuilds
+ ? await Guild.find({
+ where: { primary_category_id: categories.toString() },
+ take: Math.abs(Number(limit || configLimit)),
+ })
+ : await Guild.find({
+ where: {
+ primary_category_id: categories.toString(),
+ features: Like("%DISCOVERABLE%"),
+ },
+ take: Math.abs(Number(limit || configLimit)),
+ });
+ }
+
+ const total = guilds ? guilds.length : undefined;
+
+ res.send({
+ total: total,
+ guilds: guilds,
+ offset: Number(offset || Config.get().guild.discovery.offset),
+ limit: Number(limit || configLimit),
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/discovery.ts b/src/api/routes/v9/discovery.ts
new file mode 100644
index 00000000..90450035
--- /dev/null
+++ b/src/api/routes/v9/discovery.ts
@@ -0,0 +1,20 @@
+import { Categories } from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/categories", route({}), async (req: Request, res: Response) => {
+ // TODO:
+ // Get locale instead
+
+ const { locale, primary_only } = req.query;
+
+ const out = primary_only
+ ? await Categories.find()
+ : await Categories.find({ where: { is_primary: true } });
+
+ res.send(out);
+});
+
+export default router;
diff --git a/src/api/routes/v9/download/index.ts b/src/api/routes/v9/download/index.ts
new file mode 100644
index 00000000..1c135f25
--- /dev/null
+++ b/src/api/routes/v9/download/index.ts
@@ -0,0 +1,34 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import { FieldErrors, Release } from "@fosscord/util";
+
+const router = Router();
+
+/*
+ TODO: Putting the download route in /routes/download.ts doesn't register the route, for some reason
+ But putting it here *does*
+*/
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { platform } = req.query;
+
+ if (!platform)
+ throw FieldErrors({
+ platform: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+
+ const release = await Release.findOneOrFail({
+ where: {
+ enabled: true,
+ platform: platform as string,
+ },
+ order: { pub_date: "DESC" },
+ });
+
+ res.redirect(release.url);
+});
+
+export default router;
diff --git a/src/api/routes/v9/gateway/bot.ts b/src/api/routes/v9/gateway/bot.ts
new file mode 100644
index 00000000..2e26d019
--- /dev/null
+++ b/src/api/routes/v9/gateway/bot.ts
@@ -0,0 +1,40 @@
+import { Config } from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import { route, RouteOptions } from "@fosscord/api";
+
+const router = Router();
+
+export interface GatewayBotResponse {
+ url: string;
+ shards: number;
+ session_start_limit: {
+ total: number;
+ remaining: number;
+ reset_after: number;
+ max_concurrency: number;
+ };
+}
+
+const options: RouteOptions = {
+ test: {
+ response: {
+ body: "GatewayBotResponse",
+ },
+ },
+};
+
+router.get("/", route(options), (req: Request, res: Response) => {
+ const { endpointPublic } = Config.get().gateway;
+ res.json({
+ url: endpointPublic || process.env.GATEWAY || "ws://localhost:3002",
+ shards: 1,
+ session_start_limit: {
+ total: 1000,
+ remaining: 999,
+ reset_after: 14400000,
+ max_concurrency: 1,
+ },
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/gateway/index.ts b/src/api/routes/v9/gateway/index.ts
new file mode 100644
index 00000000..a6ed9dc4
--- /dev/null
+++ b/src/api/routes/v9/gateway/index.ts
@@ -0,0 +1,26 @@
+import { Config } from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import { route, RouteOptions } from "@fosscord/api";
+
+const router = Router();
+
+export interface GatewayResponse {
+ url: string;
+}
+
+const options: RouteOptions = {
+ test: {
+ response: {
+ body: "GatewayResponse",
+ },
+ },
+};
+
+router.get("/", route(options), (req: Request, res: Response) => {
+ const { endpointPublic } = Config.get().gateway;
+ res.json({
+ url: endpointPublic || process.env.GATEWAY || "ws://localhost:3002",
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/gifs/search.ts b/src/api/routes/v9/gifs/search.ts
new file mode 100644
index 00000000..54352215
--- /dev/null
+++ b/src/api/routes/v9/gifs/search.ts
@@ -0,0 +1,31 @@
+import { Router, Response, Request } from "express";
+import fetch from "node-fetch";
+import ProxyAgent from "proxy-agent";
+import { route } from "@fosscord/api";
+import { getGifApiKey, parseGifResult } from "./trending";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ // TODO: Custom providers
+ const { q, media_format, locale } = req.query;
+
+ const apiKey = getGifApiKey();
+
+ const agent = new ProxyAgent();
+
+ const response = await fetch(
+ `https://g.tenor.com/v1/search?q=${q}&media_format=${media_format}&locale=${locale}&key=${apiKey}`,
+ {
+ agent,
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ },
+ );
+
+ const { results } = (await response.json()) as any; // TODO: types
+
+ res.json(results.map(parseGifResult)).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/gifs/trending-gifs.ts b/src/api/routes/v9/gifs/trending-gifs.ts
new file mode 100644
index 00000000..e4b28e24
--- /dev/null
+++ b/src/api/routes/v9/gifs/trending-gifs.ts
@@ -0,0 +1,31 @@
+import { Router, Response, Request } from "express";
+import fetch from "node-fetch";
+import ProxyAgent from "proxy-agent";
+import { route } from "@fosscord/api";
+import { getGifApiKey, parseGifResult } from "./trending";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ // TODO: Custom providers
+ const { media_format, locale } = req.query;
+
+ const apiKey = getGifApiKey();
+
+ const agent = new ProxyAgent();
+
+ const response = await fetch(
+ `https://g.tenor.com/v1/trending?media_format=${media_format}&locale=${locale}&key=${apiKey}`,
+ {
+ agent,
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ },
+ );
+
+ const { results } = (await response.json()) as any; // TODO: types
+
+ res.json(results.map(parseGifResult)).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/gifs/trending.ts b/src/api/routes/v9/gifs/trending.ts
new file mode 100644
index 00000000..58044ea5
--- /dev/null
+++ b/src/api/routes/v9/gifs/trending.ts
@@ -0,0 +1,72 @@
+import { Router, Response, Request } from "express";
+import fetch from "node-fetch";
+import ProxyAgent from "proxy-agent";
+import { route } from "@fosscord/api";
+import { Config } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+
+export function parseGifResult(result: any) {
+ return {
+ id: result.id,
+ title: result.title,
+ url: result.itemurl,
+ src: result.media[0].mp4.url,
+ gif_src: result.media[0].gif.url,
+ width: result.media[0].mp4.dims[0],
+ height: result.media[0].mp4.dims[1],
+ preview: result.media[0].mp4.preview,
+ };
+}
+
+export function getGifApiKey() {
+ const { enabled, provider, apiKey } = Config.get().gif;
+ if (!enabled) throw new HTTPError(`Gifs are disabled`);
+ if (provider !== "tenor" || !apiKey)
+ throw new HTTPError(`${provider} gif provider not supported`);
+
+ return apiKey;
+}
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ // TODO: Custom providers
+ // TODO: return gifs as mp4
+ const { media_format, locale } = req.query;
+
+ const apiKey = getGifApiKey();
+
+ const agent = new ProxyAgent();
+
+ const [responseSource, trendGifSource] = await Promise.all([
+ fetch(
+ `https://g.tenor.com/v1/categories?locale=${locale}&key=${apiKey}`,
+ {
+ agent,
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ },
+ ),
+ fetch(
+ `https://g.tenor.com/v1/trending?locale=${locale}&key=${apiKey}`,
+ {
+ agent,
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ },
+ ),
+ ]);
+
+ const { tags } = (await responseSource.json()) as any; // TODO: types
+ const { results } = (await trendGifSource.json()) as any; //TODO: types;
+
+ res.json({
+ categories: tags.map((x: any) => ({
+ name: x.searchterm,
+ src: x.image,
+ })),
+ gifs: [parseGifResult(results[0])],
+ }).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/guild-recommendations.ts b/src/api/routes/v9/guild-recommendations.ts
new file mode 100644
index 00000000..8bf1e508
--- /dev/null
+++ b/src/api/routes/v9/guild-recommendations.ts
@@ -0,0 +1,30 @@
+import { Guild, Config } from "@fosscord/util";
+
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { Like } from "typeorm";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { limit, personalization_disabled } = req.query;
+ var showAllGuilds = Config.get().guild.discovery.showAllGuilds;
+
+ const genLoadId = (size: Number) =>
+ [...Array(size)]
+ .map(() => Math.floor(Math.random() * 16).toString(16))
+ .join("");
+
+ const guilds = showAllGuilds
+ ? await Guild.find({ take: Math.abs(Number(limit || 24)) })
+ : await Guild.find({
+ where: { features: Like("%DISCOVERABLE%") },
+ take: Math.abs(Number(limit || 24)),
+ });
+ res.send({
+ recommended_guilds: guilds,
+ load_id: `server_recs/${genLoadId(32)}`,
+ }).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/audit-logs.ts b/src/api/routes/v9/guilds/#guild_id/audit-logs.ts
new file mode 100644
index 00000000..76a11f6b
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/audit-logs.ts
@@ -0,0 +1,17 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+const router = Router();
+
+//TODO: implement audit logs
+router.get("/", route({}), async (req: Request, res: Response) => {
+ res.json({
+ audit_log_entries: [],
+ users: [],
+ integrations: [],
+ webhooks: [],
+ guild_scheduled_events: [],
+ threads: [],
+ application_commands: [],
+ });
+});
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/bans.ts b/src/api/routes/v9/guilds/#guild_id/bans.ts
new file mode 100644
index 00000000..930985d7
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/bans.ts
@@ -0,0 +1,200 @@
+import { Request, Response, Router } from "express";
+import {
+ DiscordApiErrors,
+ emitEvent,
+ GuildBanAddEvent,
+ GuildBanRemoveEvent,
+ Ban,
+ User,
+ Member,
+ BanRegistrySchema,
+ BanModeratorSchema,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { getIpAdress, route } from "@fosscord/api";
+
+const router: Router = Router();
+
+/* TODO: Deleting the secrets is just a temporary go-around. Views should be implemented for both safety and better handling. */
+
+router.get(
+ "/",
+ route({ permission: "BAN_MEMBERS" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ let bans = await Ban.find({ where: { guild_id: guild_id } });
+ let promisesToAwait: object[] = [];
+ const bansObj: object[] = [];
+
+ bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing
+
+ bans.forEach((ban) => {
+ promisesToAwait.push(User.getPublicUser(ban.user_id));
+ });
+
+ const bannedUsers: object[] = await Promise.all(promisesToAwait);
+
+ bans.forEach((ban, index) => {
+ const user = bannedUsers[index] as User;
+ bansObj.push({
+ reason: ban.reason,
+ user: {
+ username: user.username,
+ discriminator: user.discriminator,
+ id: user.id,
+ avatar: user.avatar,
+ public_flags: user.public_flags,
+ },
+ });
+ });
+
+ return res.json(bansObj);
+ },
+);
+
+router.get(
+ "/:user",
+ route({ permission: "BAN_MEMBERS" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const user_id = req.params.ban;
+
+ let ban = (await Ban.findOneOrFail({
+ where: { guild_id: guild_id, user_id: user_id },
+ })) as BanRegistrySchema;
+
+ if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN;
+ // pretend self-bans don't exist to prevent victim chasing
+
+ /* Filter secret from registry. */
+
+ ban = ban as BanModeratorSchema;
+
+ delete ban.ip;
+
+ return res.json(ban);
+ },
+);
+
+router.put(
+ "/:user_id",
+ route({ body: "BanCreateSchema", permission: "BAN_MEMBERS" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const banned_user_id = req.params.user_id;
+
+ if (
+ req.user_id === banned_user_id &&
+ banned_user_id === req.permission!.cache.guild?.owner_id
+ )
+ throw new HTTPError(
+ "You are the guild owner, hence can't ban yourself",
+ 403,
+ );
+
+ if (req.permission!.cache.guild?.owner_id === banned_user_id)
+ throw new HTTPError("You can't ban the owner", 400);
+
+ const banned_user = await User.getPublicUser(banned_user_id);
+
+ const ban = Ban.create({
+ user_id: banned_user_id,
+ guild_id: guild_id,
+ ip: getIpAdress(req),
+ executor_id: req.user_id,
+ reason: req.body.reason, // || otherwise empty
+ });
+
+ await Promise.all([
+ Member.removeFromGuild(banned_user_id, guild_id),
+ ban.save(),
+ emitEvent({
+ event: "GUILD_BAN_ADD",
+ data: {
+ guild_id: guild_id,
+ user: banned_user,
+ },
+ guild_id: guild_id,
+ } as GuildBanAddEvent),
+ ]);
+
+ return res.json(ban);
+ },
+);
+
+router.put(
+ "/@me",
+ route({ body: "BanCreateSchema" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ const banned_user = await User.getPublicUser(req.params.user_id);
+
+ if (req.permission!.cache.guild?.owner_id === req.params.user_id)
+ throw new HTTPError(
+ "You are the guild owner, hence can't ban yourself",
+ 403,
+ );
+
+ const ban = Ban.create({
+ user_id: req.params.user_id,
+ guild_id: guild_id,
+ ip: getIpAdress(req),
+ executor_id: req.params.user_id,
+ reason: req.body.reason, // || otherwise empty
+ });
+
+ await Promise.all([
+ Member.removeFromGuild(req.user_id, guild_id),
+ ban.save(),
+ emitEvent({
+ event: "GUILD_BAN_ADD",
+ data: {
+ guild_id: guild_id,
+ user: banned_user,
+ },
+ guild_id: guild_id,
+ } as GuildBanAddEvent),
+ ]);
+
+ return res.json(ban);
+ },
+);
+
+router.delete(
+ "/:user_id",
+ route({ permission: "BAN_MEMBERS" }),
+ async (req: Request, res: Response) => {
+ const { guild_id, user_id } = req.params;
+
+ let ban = await Ban.findOneOrFail({
+ where: { guild_id: guild_id, user_id: user_id },
+ });
+
+ if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN;
+ // make self-bans irreversible and hide them from view to avoid victim chasing
+
+ const banned_user = await User.getPublicUser(user_id);
+
+ await Promise.all([
+ Ban.delete({
+ user_id: user_id,
+ guild_id,
+ }),
+
+ emitEvent({
+ event: "GUILD_BAN_REMOVE",
+ data: {
+ guild_id,
+ user: banned_user,
+ },
+ guild_id,
+ } as GuildBanRemoveEvent),
+ ]);
+
+ return res.status(204).send();
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/channels.ts b/src/api/routes/v9/guilds/#guild_id/channels.ts
new file mode 100644
index 00000000..eae93607
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/channels.ts
@@ -0,0 +1,86 @@
+import { Router, Response, Request } from "express";
+import {
+ Channel,
+ ChannelUpdateEvent,
+ emitEvent,
+ ChannelModifySchema,
+ ChannelReorderSchema,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const channels = await Channel.find({ where: { guild_id } });
+
+ res.json(channels);
+});
+
+router.post(
+ "/",
+ route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }),
+ async (req: Request, res: Response) => {
+ // creates a new guild channel https://discord.com/developers/docs/resources/guild#create-guild-channel
+ const { guild_id } = req.params;
+ const body = req.body as ChannelModifySchema;
+
+ const channel = await Channel.createChannel(
+ { ...body, guild_id },
+ req.user_id,
+ );
+
+ res.status(201).json(channel);
+ },
+);
+
+router.patch(
+ "/",
+ route({ body: "ChannelReorderSchema", permission: "MANAGE_CHANNELS" }),
+ async (req: Request, res: Response) => {
+ // changes guild channel position
+ const { guild_id } = req.params;
+ const body = req.body as ChannelReorderSchema;
+
+ await Promise.all([
+ body.map(async (x) => {
+ if (x.position == null && !x.parent_id)
+ throw new HTTPError(
+ `You need to at least specify position or parent_id`,
+ 400,
+ );
+
+ const opts: any = {};
+ if (x.position != null) opts.position = x.position;
+
+ if (x.parent_id) {
+ opts.parent_id = x.parent_id;
+ const parent_channel = await Channel.findOneOrFail({
+ where: { id: x.parent_id, guild_id },
+ select: ["permission_overwrites"],
+ });
+ if (x.lock_permissions) {
+ opts.permission_overwrites =
+ parent_channel.permission_overwrites;
+ }
+ }
+
+ await Channel.update({ guild_id, id: x.id }, opts);
+ const channel = await Channel.findOneOrFail({
+ where: { guild_id, id: x.id },
+ });
+
+ await emitEvent({
+ event: "CHANNEL_UPDATE",
+ data: channel,
+ channel_id: x.id,
+ guild_id,
+ } as ChannelUpdateEvent);
+ }),
+ ]);
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/delete.ts b/src/api/routes/v9/guilds/#guild_id/delete.ts
new file mode 100644
index 00000000..b951e4f4
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/delete.ts
@@ -0,0 +1,44 @@
+import {
+ Channel,
+ emitEvent,
+ GuildDeleteEvent,
+ Guild,
+ Member,
+ Message,
+ Role,
+ Invite,
+ Emoji,
+} from "@fosscord/util";
+import { Router, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+// discord prefixes this route with /delete instead of using the delete method
+// docs are wrong https://discord.com/developers/docs/resources/guild#delete-guild
+router.post("/", route({}), async (req: Request, res: Response) => {
+ var { guild_id } = req.params;
+
+ const guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: ["owner_id"],
+ });
+ if (guild.owner_id !== req.user_id)
+ throw new HTTPError("You are not the owner of this guild", 401);
+
+ await Promise.all([
+ Guild.delete({ id: guild_id }), // this will also delete all guild related data
+ emitEvent({
+ event: "GUILD_DELETE",
+ data: {
+ id: guild_id,
+ },
+ guild_id: guild_id,
+ } as GuildDeleteEvent),
+ ]);
+
+ return res.sendStatus(204);
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts b/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts
new file mode 100644
index 00000000..7e63c06b
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts
@@ -0,0 +1,39 @@
+import { Guild, Config } from "@fosscord/util";
+
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ // TODO:
+ // Load from database
+ // Admin control, but for now it allows anyone to be discoverable
+
+ res.send({
+ guild_id: guild_id,
+ safe_environment: true,
+ healthy: true,
+ health_score_pending: false,
+ size: true,
+ nsfw_properties: {},
+ protected: true,
+ sufficient: true,
+ sufficient_without_grace_period: true,
+ valid_rules_channel: true,
+ retention_healthy: true,
+ engagement_healthy: true,
+ age: true,
+ minimum_age: 0,
+ health_score: {
+ avg_nonnew_participators: 0,
+ avg_nonnew_communicators: 0,
+ num_intentful_joiners: 0,
+ perc_ret_w1_intentful: 0,
+ },
+ minimum_size: 0,
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/emojis.ts b/src/api/routes/v9/guilds/#guild_id/emojis.ts
new file mode 100644
index 00000000..6e8570eb
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/emojis.ts
@@ -0,0 +1,148 @@
+import { Router, Request, Response } from "express";
+import {
+ Config,
+ DiscordApiErrors,
+ emitEvent,
+ Emoji,
+ GuildEmojisUpdateEvent,
+ handleFile,
+ Member,
+ Snowflake,
+ User,
+ EmojiCreateSchema,
+ EmojiModifySchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ const emojis = await Emoji.find({
+ where: { guild_id: guild_id },
+ relations: ["user"],
+ });
+
+ return res.json(emojis);
+});
+
+router.get("/:emoji_id", route({}), async (req: Request, res: Response) => {
+ const { guild_id, emoji_id } = req.params;
+
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ const emoji = await Emoji.findOneOrFail({
+ where: { guild_id: guild_id, id: emoji_id },
+ relations: ["user"],
+ });
+
+ return res.json(emoji);
+});
+
+router.post(
+ "/",
+ route({
+ body: "EmojiCreateSchema",
+ permission: "MANAGE_EMOJIS_AND_STICKERS",
+ }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const body = req.body as EmojiCreateSchema;
+
+ const id = Snowflake.generate();
+ const emoji_count = await Emoji.count({
+ where: { guild_id: guild_id },
+ });
+ const { maxEmojis } = Config.get().limits.guild;
+
+ if (emoji_count >= maxEmojis)
+ throw DiscordApiErrors.MAXIMUM_NUMBER_OF_EMOJIS_REACHED.withParams(
+ maxEmojis,
+ );
+ if (body.require_colons == null) body.require_colons = true;
+
+ const user = await User.findOneOrFail({ where: { id: req.user_id } });
+ body.image = (await handleFile(`/emojis/${id}`, body.image)) as string;
+
+ const emoji = await Emoji.create({
+ id: id,
+ guild_id: guild_id,
+ ...body,
+ require_colons: body.require_colons ?? undefined, // schema allows nulls, db does not
+ user: user,
+ managed: false,
+ animated: false, // TODO: Add support animated emojis
+ available: true,
+ roles: [],
+ }).save();
+
+ await emitEvent({
+ event: "GUILD_EMOJIS_UPDATE",
+ guild_id: guild_id,
+ data: {
+ guild_id: guild_id,
+ emojis: await Emoji.find({ where: { guild_id: guild_id } }),
+ },
+ } as GuildEmojisUpdateEvent);
+
+ return res.status(201).json(emoji);
+ },
+);
+
+router.patch(
+ "/:emoji_id",
+ route({
+ body: "EmojiModifySchema",
+ permission: "MANAGE_EMOJIS_AND_STICKERS",
+ }),
+ async (req: Request, res: Response) => {
+ const { emoji_id, guild_id } = req.params;
+ const body = req.body as EmojiModifySchema;
+
+ const emoji = await Emoji.create({
+ ...body,
+ id: emoji_id,
+ guild_id: guild_id,
+ }).save();
+
+ await emitEvent({
+ event: "GUILD_EMOJIS_UPDATE",
+ guild_id: guild_id,
+ data: {
+ guild_id: guild_id,
+ emojis: await Emoji.find({ where: { guild_id: guild_id } }),
+ },
+ } as GuildEmojisUpdateEvent);
+
+ return res.json(emoji);
+ },
+);
+
+router.delete(
+ "/:emoji_id",
+ route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }),
+ async (req: Request, res: Response) => {
+ const { emoji_id, guild_id } = req.params;
+
+ await Emoji.delete({
+ id: emoji_id,
+ guild_id: guild_id,
+ });
+
+ await emitEvent({
+ event: "GUILD_EMOJIS_UPDATE",
+ guild_id: guild_id,
+ data: {
+ guild_id: guild_id,
+ emojis: await Emoji.find({ where: { guild_id: guild_id } }),
+ },
+ } as GuildEmojisUpdateEvent);
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/index.ts b/src/api/routes/v9/guilds/#guild_id/index.ts
new file mode 100644
index 00000000..79c20678
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/index.ts
@@ -0,0 +1,129 @@
+import { Request, Response, Router } from "express";
+import {
+ DiscordApiErrors,
+ emitEvent,
+ getPermission,
+ getRights,
+ Guild,
+ GuildUpdateEvent,
+ handleFile,
+ Member,
+ GuildUpdateSchema,
+ FosscordApiErrors,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ const [guild, member] = await Promise.all([
+ Guild.findOneOrFail({ where: { id: guild_id } }),
+ Member.findOne({ where: { guild_id: guild_id, id: req.user_id } }),
+ ]);
+ if (!member)
+ throw new HTTPError(
+ "You are not a member of the guild you are trying to access",
+ 401,
+ );
+
+ // @ts-ignore
+ guild.joined_at = member?.joined_at;
+
+ return res.send(guild);
+});
+
+router.patch(
+ "/",
+ route({ body: "GuildUpdateSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as GuildUpdateSchema;
+ const { guild_id } = req.params;
+
+ const rights = await getRights(req.user_id);
+ const permission = await getPermission(req.user_id, guild_id);
+
+ if (!rights.has("MANAGE_GUILDS") && !permission.has("MANAGE_GUILD"))
+ throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(
+ "MANAGE_GUILDS",
+ );
+
+ var guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ relations: ["emojis", "roles", "stickers"],
+ });
+
+ // TODO: guild update check image
+
+ if (body.icon && body.icon != guild.icon)
+ body.icon = await handleFile(`/icons/${guild_id}`, body.icon);
+
+ if (body.banner && body.banner !== guild.banner)
+ body.banner = await handleFile(`/banners/${guild_id}`, body.banner);
+
+ if (body.splash && body.splash !== guild.splash)
+ body.splash = await handleFile(
+ `/splashes/${guild_id}`,
+ body.splash,
+ );
+
+ if (
+ body.discovery_splash &&
+ body.discovery_splash !== guild.discovery_splash
+ )
+ body.discovery_splash = await handleFile(
+ `/discovery-splashes/${guild_id}`,
+ body.discovery_splash,
+ );
+
+ if (body.features) {
+ const diff = guild.features
+ .filter((x) => !body.features?.includes(x))
+ .concat(
+ body.features.filter((x) => !guild.features.includes(x)),
+ );
+
+ // TODO move these
+ const MUTABLE_FEATURES = [
+ "COMMUNITY",
+ "INVITES_DISABLED",
+ "DISCOVERABLE",
+ ];
+
+ for (var feature of diff) {
+ if (MUTABLE_FEATURES.includes(feature)) continue;
+
+ throw FosscordApiErrors.FEATURE_IS_IMMUTABLE.withParams(
+ feature,
+ );
+ }
+
+ // for some reason, they don't update in the assign.
+ guild.features = body.features;
+ }
+
+ // TODO: check if body ids are valid
+ guild.assign(body);
+
+ const data = guild.toJSON();
+ // TODO: guild hashes
+ // TODO: fix vanity_url_code, template_id
+ delete data.vanity_url_code;
+ delete data.template_id;
+
+ await Promise.all([
+ guild.save(),
+ emitEvent({
+ event: "GUILD_UPDATE",
+ data,
+ guild_id,
+ } as GuildUpdateEvent),
+ ]);
+
+ return res.json(data);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/invites.ts b/src/api/routes/v9/guilds/#guild_id/invites.ts
new file mode 100644
index 00000000..4d033e9c
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/invites.ts
@@ -0,0 +1,22 @@
+import { getPermission, Invite, PublicInviteRelation } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({ permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ const invites = await Invite.find({
+ where: { guild_id },
+ relations: PublicInviteRelation,
+ });
+
+ return res.json(invites);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts
new file mode 100644
index 00000000..0fcdd57c
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts
@@ -0,0 +1,130 @@
+import { Request, Response, Router } from "express";
+import {
+ Member,
+ getPermission,
+ getRights,
+ Role,
+ GuildMemberUpdateEvent,
+ emitEvent,
+ Sticker,
+ Emoji,
+ Guild,
+ handleFile,
+ MemberChangeSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id, member_id } = req.params;
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ const member = await Member.findOneOrFail({
+ where: { id: member_id, guild_id },
+ });
+
+ return res.json(member);
+});
+
+router.patch(
+ "/",
+ route({ body: "MemberChangeSchema" }),
+ async (req: Request, res: Response) => {
+ let { guild_id, member_id } = req.params;
+ if (member_id === "@me") member_id = req.user_id;
+ const body = req.body as MemberChangeSchema;
+
+ let member = await Member.findOneOrFail({
+ where: { id: member_id, guild_id },
+ relations: ["roles", "user"],
+ });
+ const permission = await getPermission(req.user_id, guild_id);
+ const everyone = await Role.findOneOrFail({
+ where: { guild_id: guild_id, name: "@everyone", position: 0 },
+ });
+
+ if (body.avatar)
+ body.avatar = await handleFile(
+ `/guilds/${guild_id}/users/${member_id}/avatars`,
+ body.avatar as string,
+ );
+
+ member.assign(body);
+
+ if ("roles" in body) {
+ permission.hasThrow("MANAGE_ROLES");
+
+ body.roles = body.roles || [];
+ body.roles.filter((x) => !!x);
+
+ if (body.roles.indexOf(everyone.id) === -1)
+ body.roles.push(everyone.id);
+ member.roles = body.roles.map((x) => Role.create({ id: x })); // foreign key constraint will fail if role doesn't exist
+ }
+
+ await member.save();
+
+ member.roles = member.roles.filter((x) => x.id !== everyone.id);
+
+ // do not use promise.all as we have to first write to db before emitting the event to catch errors
+ await emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ guild_id,
+ data: { ...member, roles: member.roles.map((x) => x.id) },
+ } as GuildMemberUpdateEvent);
+
+ res.json(member);
+ },
+);
+
+router.put("/", route({}), async (req: Request, res: Response) => {
+ // TODO: Lurker mode
+
+ const rights = await getRights(req.user_id);
+
+ let { guild_id, member_id } = req.params;
+ if (member_id === "@me") {
+ member_id = req.user_id;
+ rights.hasThrow("JOIN_GUILDS");
+ } else {
+ // TODO: join others by controller
+ }
+
+ var guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ });
+
+ var emoji = await Emoji.find({
+ where: { guild_id: guild_id },
+ });
+
+ var roles = await Role.find({
+ where: { guild_id: guild_id },
+ });
+
+ var stickers = await Sticker.find({
+ where: { guild_id: guild_id },
+ });
+
+ await Member.addToGuild(member_id, guild_id);
+ res.send({ ...guild, emojis: emoji, roles: roles, stickers: stickers });
+});
+
+router.delete("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id, member_id } = req.params;
+ const permission = await getPermission(req.user_id, guild_id);
+ const rights = await getRights(req.user_id);
+ if (member_id === "@me" || member_id === req.user_id) {
+ // TODO: unless force-joined
+ rights.hasThrow("SELF_LEAVE_GROUPS");
+ } else {
+ rights.hasThrow("KICK_BAN_MEMBERS");
+ permission.hasThrow("KICK_MEMBERS");
+ }
+
+ await Member.removeFromGuild(member_id, guild_id);
+ res.sendStatus(204);
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts
new file mode 100644
index 00000000..20443821
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts
@@ -0,0 +1,26 @@
+import { getPermission, Member, PermissionResolvable } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.patch(
+ "/",
+ route({ body: "MemberNickChangeSchema" }),
+ async (req: Request, res: Response) => {
+ var { guild_id, member_id } = req.params;
+ var permissionString: PermissionResolvable = "MANAGE_NICKNAMES";
+ if (member_id === "@me") {
+ member_id = req.user_id;
+ permissionString = "CHANGE_NICKNAME";
+ }
+
+ const perms = await getPermission(req.user_id, guild_id);
+ perms.hasThrow(permissionString);
+
+ await Member.changeNickname(member_id, guild_id, req.body.nick);
+ res.status(200).send();
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
new file mode 100644
index 00000000..c0383912
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
@@ -0,0 +1,29 @@
+import { getPermission, Member } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.delete(
+ "/",
+ route({ permission: "MANAGE_ROLES" }),
+ async (req: Request, res: Response) => {
+ const { guild_id, role_id, member_id } = req.params;
+
+ await Member.removeRole(member_id, guild_id, role_id);
+ res.sendStatus(204);
+ },
+);
+
+router.put(
+ "/",
+ route({ permission: "MANAGE_ROLES" }),
+ async (req: Request, res: Response) => {
+ const { guild_id, role_id, member_id } = req.params;
+
+ await Member.addRole(member_id, guild_id, role_id);
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/members/index.ts b/src/api/routes/v9/guilds/#guild_id/members/index.ts
new file mode 100644
index 00000000..b516b9e9
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/members/index.ts
@@ -0,0 +1,32 @@
+import { Request, Response, Router } from "express";
+import { Guild, Member, PublicMemberProjection } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { MoreThan } from "typeorm";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+
+// TODO: send over websocket
+// TODO: check for GUILD_MEMBERS intent
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const limit = Number(req.query.limit) || 1;
+ if (limit > 1000 || limit < 1)
+ throw new HTTPError("Limit must be between 1 and 1000");
+ const after = `${req.query.after}`;
+ const query = after ? { id: MoreThan(after) } : {};
+
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ const members = await Member.find({
+ where: { guild_id, ...query },
+ select: PublicMemberProjection,
+ take: limit,
+ order: { id: "ASC" },
+ });
+
+ return res.json(members);
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/messages/search.ts b/src/api/routes/v9/guilds/#guild_id/messages/search.ts
new file mode 100644
index 00000000..88488871
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/messages/search.ts
@@ -0,0 +1,137 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import { getPermission, FieldErrors, Message, Channel } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { FindManyOptions, In, Like } from "typeorm";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const {
+ channel_id,
+ content,
+ include_nsfw, // TODO
+ offset,
+ sort_order,
+ sort_by, // TODO: Handle 'relevance'
+ limit,
+ author_id,
+ } = req.query;
+
+ const parsedLimit = Number(limit) || 50;
+ if (parsedLimit < 1 || parsedLimit > 100)
+ throw new HTTPError("limit must be between 1 and 100", 422);
+
+ if (sort_order) {
+ if (
+ typeof sort_order != "string" ||
+ ["desc", "asc"].indexOf(sort_order) == -1
+ )
+ throw FieldErrors({
+ sort_order: {
+ message: "Value must be one of ('desc', 'asc').",
+ code: "BASE_TYPE_CHOICES",
+ },
+ }); // todo this is wrong
+ }
+
+ const permissions = await getPermission(
+ req.user_id,
+ req.params.guild_id,
+ channel_id as string | undefined,
+ );
+ permissions.hasThrow("VIEW_CHANNEL");
+ if (!permissions.has("READ_MESSAGE_HISTORY"))
+ return res.json({ messages: [], total_results: 0 });
+
+ var query: FindManyOptions<Message> = {
+ order: {
+ timestamp: sort_order
+ ? (sort_order.toUpperCase() as "ASC" | "DESC")
+ : "DESC",
+ },
+ take: parsedLimit || 0,
+ where: {
+ guild: {
+ id: req.params.guild_id,
+ },
+ },
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ ],
+ skip: offset ? Number(offset) : 0,
+ };
+ //@ts-ignore
+ if (channel_id) query.where!.channel = { id: channel_id };
+ else {
+ // get all channel IDs that this user can access
+ const channels = await Channel.find({
+ where: { guild_id: req.params.guild_id },
+ select: ["id"],
+ });
+ const ids = [];
+
+ for (var channel of channels) {
+ const perm = await getPermission(
+ req.user_id,
+ req.params.guild_id,
+ channel.id,
+ );
+ if (!perm.has("VIEW_CHANNEL") || !perm.has("READ_MESSAGE_HISTORY"))
+ continue;
+ ids.push(channel.id);
+ }
+
+ //@ts-ignore
+ query.where!.channel = { id: In(ids) };
+ }
+ //@ts-ignore
+ if (author_id) query.where!.author = { id: author_id };
+ //@ts-ignore
+ if (content) query.where!.content = Like(`%${content}%`);
+
+ const messages: Message[] = await Message.find(query);
+
+ const messagesDto = messages.map((x) => [
+ {
+ id: x.id,
+ type: x.type,
+ content: x.content,
+ channel_id: x.channel_id,
+ author: {
+ id: x.author?.id,
+ username: x.author?.username,
+ avatar: x.author?.avatar,
+ avatar_decoration: null,
+ discriminator: x.author?.discriminator,
+ public_flags: x.author?.public_flags,
+ },
+ attachments: x.attachments,
+ embeds: x.embeds,
+ mentions: x.mentions,
+ mention_roles: x.mention_roles,
+ pinned: x.pinned,
+ mention_everyone: x.mention_everyone,
+ tts: x.tts,
+ timestamp: x.timestamp,
+ edited_timestamp: x.edited_timestamp,
+ flags: x.flags,
+ components: x.components,
+ hit: true,
+ },
+ ]);
+
+ return res.json({
+ messages: messagesDto,
+ total_results: messages.length,
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/profile/index.ts b/src/api/routes/v9/guilds/#guild_id/profile/index.ts
new file mode 100644
index 00000000..20a7fa95
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/profile/index.ts
@@ -0,0 +1,48 @@
+import { route } from "@fosscord/api";
+import {
+ emitEvent,
+ GuildMemberUpdateEvent,
+ handleFile,
+ Member,
+ MemberChangeProfileSchema,
+ OrmUtils,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.patch(
+ "/:member_id",
+ route({ body: "MemberChangeProfileSchema" }),
+ async (req: Request, res: Response) => {
+ let { guild_id, member_id } = req.params;
+ if (member_id === "@me") member_id = req.user_id;
+ const body = req.body as MemberChangeProfileSchema;
+
+ let member = await Member.findOneOrFail({
+ where: { id: req.user_id, guild_id },
+ relations: ["roles", "user"],
+ });
+
+ if (body.banner)
+ body.banner = await handleFile(
+ `/guilds/${guild_id}/users/${req.user_id}/avatars`,
+ body.banner as string,
+ );
+
+ member = await OrmUtils.mergeDeep(member, body);
+
+ await member.save();
+
+ // do not use promise.all as we have to first write to db before emitting the event to catch errors
+ await emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ guild_id,
+ data: { ...member, roles: member.roles.map((x) => x.id) },
+ } as GuildMemberUpdateEvent);
+
+ res.json(member);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/prune.ts b/src/api/routes/v9/guilds/#guild_id/prune.ts
new file mode 100644
index 00000000..8089ad84
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/prune.ts
@@ -0,0 +1,106 @@
+import { Router, Request, Response } from "express";
+import { Guild, Member, Snowflake } from "@fosscord/util";
+import { LessThan, IsNull } from "typeorm";
+import { route } from "@fosscord/api";
+const router = Router();
+
+//Returns all inactive members, respecting role hierarchy
+export const inactiveMembers = async (
+ guild_id: string,
+ user_id: string,
+ days: number,
+ roles: string[] = [],
+) => {
+ var date = new Date();
+ date.setDate(date.getDate() - days);
+ //Snowflake should have `generateFromTime` method? Or similar?
+ var minId = BigInt(date.valueOf() - Snowflake.EPOCH) << BigInt(22);
+
+ /**
+ idea: ability to customise the cutoff variable
+ possible candidates: public read receipt, last presence, last VC leave
+ **/
+ var members = await Member.find({
+ where: [
+ {
+ guild_id,
+ last_message_id: LessThan(minId.toString()),
+ },
+ {
+ guild_id,
+ last_message_id: IsNull(),
+ },
+ ],
+ relations: ["roles"],
+ });
+ if (!members.length) return [];
+
+ //I'm sure I can do this in the above db query ( and it would probably be better to do so ), but oh well.
+ if (roles.length && members.length)
+ members = members.filter((user) =>
+ user.roles?.some((role) => roles.includes(role.id)),
+ );
+
+ const me = await Member.findOneOrFail({
+ where: { id: user_id, guild_id },
+ relations: ["roles"],
+ });
+ const myHighestRole = Math.max(...(me.roles?.map((x) => x.position) || []));
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+
+ members = members.filter(
+ (member) =>
+ member.id !== guild.owner_id && //can't kick owner
+ member.roles?.some(
+ (role) =>
+ role.position < myHighestRole || //roles higher than me can't be kicked
+ me.id === guild.owner_id, //owner can kick anyone
+ ),
+ );
+
+ return members;
+};
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const days = parseInt(req.query.days as string);
+
+ var roles = req.query.include_roles;
+ if (typeof roles === "string") roles = [roles]; //express will return array otherwise
+
+ const members = await inactiveMembers(
+ req.params.guild_id,
+ req.user_id,
+ days,
+ roles as string[],
+ );
+
+ res.send({ pruned: members.length });
+});
+
+router.post(
+ "/",
+ route({ permission: "KICK_MEMBERS", right: "KICK_BAN_MEMBERS" }),
+ async (req: Request, res: Response) => {
+ const days = parseInt(req.body.days);
+
+ var roles = req.query.include_roles;
+ if (typeof roles === "string") roles = [roles];
+
+ const { guild_id } = req.params;
+ const members = await inactiveMembers(
+ guild_id,
+ req.user_id,
+ days,
+ roles as string[],
+ );
+
+ await Promise.all(
+ members.map((x) => Member.removeFromGuild(x.id, guild_id)),
+ );
+
+ res.send({ purged: members.length });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/regions.ts b/src/api/routes/v9/guilds/#guild_id/regions.ts
new file mode 100644
index 00000000..0b275ea4
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/regions.ts
@@ -0,0 +1,20 @@
+import { Config, Guild, Member } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { getVoiceRegions, route } from "@fosscord/api";
+import { getIpAdress } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+ //TODO we should use an enum for guild's features and not hardcoded strings
+ return res.json(
+ await getVoiceRegions(
+ getIpAdress(req),
+ guild.features.includes("VIP_REGIONS"),
+ ),
+ );
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts b/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts
new file mode 100644
index 00000000..84648703
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts
@@ -0,0 +1,92 @@
+import { Router, Request, Response } from "express";
+import {
+ Role,
+ Member,
+ GuildRoleUpdateEvent,
+ GuildRoleDeleteEvent,
+ emitEvent,
+ handleFile,
+ RoleModifySchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id, role_id } = req.params;
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+ const role = await Role.findOneOrFail({ where: { guild_id, id: role_id } });
+ return res.json(role);
+});
+
+router.delete(
+ "/",
+ route({ permission: "MANAGE_ROLES" }),
+ async (req: Request, res: Response) => {
+ const { guild_id, role_id } = req.params;
+ if (role_id === guild_id)
+ throw new HTTPError("You can't delete the @everyone role");
+
+ await Promise.all([
+ Role.delete({
+ id: role_id,
+ guild_id: guild_id,
+ }),
+ emitEvent({
+ event: "GUILD_ROLE_DELETE",
+ guild_id,
+ data: {
+ guild_id,
+ role_id,
+ },
+ } as GuildRoleDeleteEvent),
+ ]);
+
+ res.sendStatus(204);
+ },
+);
+
+// TODO: check role hierarchy
+
+router.patch(
+ "/",
+ route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }),
+ async (req: Request, res: Response) => {
+ const { role_id, guild_id } = req.params;
+ const body = req.body as RoleModifySchema;
+
+ if (body.icon && body.icon.length)
+ body.icon = await handleFile(
+ `/role-icons/${role_id}`,
+ body.icon as string,
+ );
+ else body.icon = undefined;
+
+ const role = await Role.findOneOrFail({
+ where: { id: role_id, guild: { id: guild_id } },
+ });
+ role.assign({
+ ...body,
+ permissions: String(
+ req.permission!.bitfield & BigInt(body.permissions || "0"),
+ ),
+ });
+
+ await Promise.all([
+ role.save(),
+ emitEvent({
+ event: "GUILD_ROLE_UPDATE",
+ guild_id,
+ data: {
+ guild_id,
+ role,
+ },
+ } as GuildRoleUpdateEvent),
+ ]);
+
+ res.json(role);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/roles/index.ts b/src/api/routes/v9/guilds/#guild_id/roles/index.ts
new file mode 100644
index 00000000..4cd47cf3
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/roles/index.ts
@@ -0,0 +1,123 @@
+import { Request, Response, Router } from "express";
+import {
+ Role,
+ getPermission,
+ Member,
+ GuildRoleCreateEvent,
+ GuildRoleUpdateEvent,
+ emitEvent,
+ Config,
+ DiscordApiErrors,
+ RoleModifySchema,
+ RolePositionUpdateSchema,
+ Snowflake,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Not } from "typeorm";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const guild_id = req.params.guild_id;
+
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ const roles = await Role.find({ where: { guild_id: guild_id } });
+
+ return res.json(roles);
+});
+
+router.post(
+ "/",
+ route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }),
+ async (req: Request, res: Response) => {
+ const guild_id = req.params.guild_id;
+ const body = req.body as RoleModifySchema;
+
+ const role_count = await Role.count({ where: { guild_id } });
+ const { maxRoles } = Config.get().limits.guild;
+
+ if (role_count > maxRoles)
+ throw DiscordApiErrors.MAXIMUM_ROLES.withParams(maxRoles);
+
+ const role = Role.create({
+ // values before ...body are default and can be overriden
+ position: 1,
+ hoist: false,
+ color: 0,
+ mentionable: false,
+ ...body,
+ guild_id: guild_id,
+ managed: false,
+ permissions: String(
+ req.permission!.bitfield & BigInt(body.permissions || "0"),
+ ),
+ tags: undefined,
+ icon: undefined,
+ unicode_emoji: undefined,
+ id: Snowflake.generate(),
+ });
+
+ await Promise.all([
+ role.save(),
+ // Move all existing roles up one position, to accommodate the new role
+ Role.createQueryBuilder("roles")
+ .where({
+ guild: { id: guild_id },
+ name: Not("@everyone"),
+ id: Not(role.id),
+ })
+ .update({ position: () => "position + 1" })
+ .execute(),
+ emitEvent({
+ event: "GUILD_ROLE_CREATE",
+ guild_id,
+ data: {
+ guild_id,
+ role: role,
+ },
+ } as GuildRoleCreateEvent),
+ ]);
+
+ res.json(role);
+ },
+);
+
+router.patch(
+ "/",
+ route({ body: "RolePositionUpdateSchema" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const body = req.body as RolePositionUpdateSchema;
+
+ const perms = await getPermission(req.user_id, guild_id);
+ perms.hasThrow("MANAGE_ROLES");
+
+ await Promise.all(
+ body.map(async (x) =>
+ Role.update({ guild_id, id: x.id }, { position: x.position }),
+ ),
+ );
+
+ const roles = await Role.find({
+ where: body.map((x) => ({ id: x.id, guild_id })),
+ });
+
+ await Promise.all(
+ roles.map((x) =>
+ emitEvent({
+ event: "GUILD_ROLE_UPDATE",
+ guild_id,
+ data: {
+ guild_id,
+ role: x,
+ },
+ } as GuildRoleUpdateEvent),
+ ),
+ );
+
+ res.json(roles);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/stickers.ts b/src/api/routes/v9/guilds/#guild_id/stickers.ts
new file mode 100644
index 00000000..3b1f5f8e
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/stickers.ts
@@ -0,0 +1,137 @@
+import {
+ emitEvent,
+ GuildStickersUpdateEvent,
+ Member,
+ Snowflake,
+ Sticker,
+ StickerFormatType,
+ StickerType,
+ uploadFile,
+ ModifyGuildStickerSchema,
+} from "@fosscord/util";
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import multer from "multer";
+import { HTTPError } from "lambert-server";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ res.json(await Sticker.find({ where: { guild_id } }));
+});
+
+const bodyParser = multer({
+ limits: {
+ fileSize: 1024 * 1024 * 100,
+ fields: 10,
+ files: 1,
+ },
+ storage: multer.memoryStorage(),
+}).single("file");
+
+router.post(
+ "/",
+ bodyParser,
+ route({
+ permission: "MANAGE_EMOJIS_AND_STICKERS",
+ body: "ModifyGuildStickerSchema",
+ }),
+ async (req: Request, res: Response) => {
+ if (!req.file) throw new HTTPError("missing file");
+
+ const { guild_id } = req.params;
+ const body = req.body as ModifyGuildStickerSchema;
+ const id = Snowflake.generate();
+
+ const [sticker] = await Promise.all([
+ Sticker.create({
+ ...body,
+ guild_id,
+ id,
+ type: StickerType.GUILD,
+ format_type: getStickerFormat(req.file.mimetype),
+ available: true,
+ }).save(),
+ uploadFile(`/stickers/${id}`, req.file),
+ ]);
+
+ await sendStickerUpdateEvent(guild_id);
+
+ res.json(sticker);
+ },
+);
+
+export function getStickerFormat(mime_type: string) {
+ switch (mime_type) {
+ case "image/apng":
+ return StickerFormatType.APNG;
+ case "application/json":
+ return StickerFormatType.LOTTIE;
+ case "image/png":
+ return StickerFormatType.PNG;
+ case "image/gif":
+ return StickerFormatType.GIF;
+ default:
+ throw new HTTPError(
+ "invalid sticker format: must be png, apng or lottie",
+ );
+ }
+}
+
+router.get("/:sticker_id", route({}), async (req: Request, res: Response) => {
+ const { guild_id, sticker_id } = req.params;
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ res.json(
+ await Sticker.findOneOrFail({ where: { guild_id, id: sticker_id } }),
+ );
+});
+
+router.patch(
+ "/:sticker_id",
+ route({
+ body: "ModifyGuildStickerSchema",
+ permission: "MANAGE_EMOJIS_AND_STICKERS",
+ }),
+ async (req: Request, res: Response) => {
+ const { guild_id, sticker_id } = req.params;
+ const body = req.body as ModifyGuildStickerSchema;
+
+ const sticker = await Sticker.create({
+ ...body,
+ guild_id,
+ id: sticker_id,
+ }).save();
+ await sendStickerUpdateEvent(guild_id);
+
+ return res.json(sticker);
+ },
+);
+
+async function sendStickerUpdateEvent(guild_id: string) {
+ return emitEvent({
+ event: "GUILD_STICKERS_UPDATE",
+ guild_id: guild_id,
+ data: {
+ guild_id: guild_id,
+ stickers: await Sticker.find({ where: { guild_id: guild_id } }),
+ },
+ } as GuildStickersUpdateEvent);
+}
+
+router.delete(
+ "/:sticker_id",
+ route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }),
+ async (req: Request, res: Response) => {
+ const { guild_id, sticker_id } = req.params;
+
+ await Sticker.delete({ guild_id, id: sticker_id });
+ await sendStickerUpdateEvent(guild_id);
+
+ return res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/templates.ts b/src/api/routes/v9/guilds/#guild_id/templates.ts
new file mode 100644
index 00000000..3b5eddaa
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/templates.ts
@@ -0,0 +1,116 @@
+import { Request, Response, Router } from "express";
+import { Guild, Template } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import { generateCode } from "@fosscord/api";
+
+const router: Router = Router();
+
+const TemplateGuildProjection: (keyof Guild)[] = [
+ "name",
+ "description",
+ "region",
+ "verification_level",
+ "default_message_notifications",
+ "explicit_content_filter",
+ "preferred_locale",
+ "afk_timeout",
+ "roles",
+ // "channels",
+ "afk_channel_id",
+ "system_channel_id",
+ "system_channel_flags",
+ "icon",
+];
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ var templates = await Template.find({
+ where: { source_guild_id: guild_id },
+ });
+
+ return res.json(templates);
+});
+
+router.post(
+ "/",
+ route({ body: "TemplateCreateSchema", permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: TemplateGuildProjection,
+ });
+ const exists = await Template.findOneOrFail({
+ where: { id: guild_id },
+ }).catch((e) => {});
+ if (exists) throw new HTTPError("Template already exists", 400);
+
+ const template = await Template.create({
+ ...req.body,
+ code: generateCode(),
+ creator_id: req.user_id,
+ created_at: new Date(),
+ updated_at: new Date(),
+ source_guild_id: guild_id,
+ serialized_source_guild: guild,
+ }).save();
+
+ res.json(template);
+ },
+);
+
+router.delete(
+ "/:code",
+ route({ permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { code, guild_id } = req.params;
+
+ const template = await Template.delete({
+ code,
+ source_guild_id: guild_id,
+ });
+
+ res.json(template);
+ },
+);
+
+router.put(
+ "/:code",
+ route({ permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { code, guild_id } = req.params;
+ const guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: TemplateGuildProjection,
+ });
+
+ const template = await Template.create({
+ code,
+ serialized_source_guild: guild,
+ }).save();
+
+ res.json(template);
+ },
+);
+
+router.patch(
+ "/:code",
+ route({ body: "TemplateModifySchema", permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { code, guild_id } = req.params;
+ const { name, description } = req.body;
+
+ const template = await Template.create({
+ code,
+ name: name,
+ description: description,
+ source_guild_id: guild_id,
+ }).save();
+
+ res.json(template);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/vanity-url.ts b/src/api/routes/v9/guilds/#guild_id/vanity-url.ts
new file mode 100644
index 00000000..9a96b066
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/vanity-url.ts
@@ -0,0 +1,82 @@
+import {
+ Channel,
+ ChannelType,
+ Guild,
+ Invite,
+ VanityUrlSchema,
+} from "@fosscord/util";
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+
+const InviteRegex = /\W/g;
+
+router.get(
+ "/",
+ route({ permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+
+ if (!guild.features.includes("ALIASABLE_NAMES")) {
+ const invite = await Invite.findOne({
+ where: { guild_id: guild_id, vanity_url: true },
+ });
+ if (!invite) return res.json({ code: null });
+
+ return res.json({ code: invite.code, uses: invite.uses });
+ } else {
+ const invite = await Invite.find({
+ where: { guild_id: guild_id, vanity_url: true },
+ });
+ if (!invite || invite.length == 0) return res.json({ code: null });
+
+ return res.json(
+ invite.map((x) => ({ code: x.code, uses: x.uses })),
+ );
+ }
+ },
+);
+
+router.patch(
+ "/",
+ route({ body: "VanityUrlSchema", permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ const body = req.body as VanityUrlSchema;
+ const code = body.code?.replace(InviteRegex, "");
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+ if (!guild.features.includes("VANITY_URL"))
+ throw new HTTPError("Your guild doesn't support vanity urls");
+
+ if (!code || code.length === 0)
+ throw new HTTPError("Code cannot be null or empty");
+
+ const invite = await Invite.findOne({ where: { code } });
+ if (invite) throw new HTTPError("Invite already exists");
+
+ const { id } = await Channel.findOneOrFail({
+ where: { guild_id, type: ChannelType.GUILD_TEXT },
+ });
+
+ await Invite.create({
+ vanity_url: true,
+ code: code,
+ temporary: false,
+ uses: 0,
+ max_uses: 0,
+ max_age: 0,
+ created_at: new Date(),
+ expires_at: new Date(),
+ guild_id: guild_id,
+ channel_id: id,
+ }).save();
+
+ return res.json({ code: code });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts b/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts
new file mode 100644
index 00000000..af03a07e
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts
@@ -0,0 +1,71 @@
+import {
+ Channel,
+ ChannelType,
+ DiscordApiErrors,
+ emitEvent,
+ getPermission,
+ VoiceState,
+ VoiceStateUpdateEvent,
+ VoiceStateUpdateSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+//TODO need more testing when community guild and voice stage channel are working
+
+router.patch(
+ "/",
+ route({ body: "VoiceStateUpdateSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as VoiceStateUpdateSchema;
+ var { guild_id, user_id } = req.params;
+ if (user_id === "@me") user_id = req.user_id;
+
+ const perms = await getPermission(
+ req.user_id,
+ guild_id,
+ body.channel_id,
+ );
+
+ /*
+ From https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state
+ You must have the MUTE_MEMBERS permission to unsuppress others. You can always suppress yourself.
+ You must have the REQUEST_TO_SPEAK permission to request to speak. You can always clear your own request to speak.
+ */
+ if (body.suppress && user_id !== req.user_id) {
+ perms.hasThrow("MUTE_MEMBERS");
+ }
+ if (!body.suppress) body.request_to_speak_timestamp = new Date();
+ if (body.request_to_speak_timestamp) perms.hasThrow("REQUEST_TO_SPEAK");
+
+ const voice_state = await VoiceState.findOne({
+ where: {
+ guild_id,
+ channel_id: body.channel_id,
+ user_id,
+ },
+ });
+ if (!voice_state) throw DiscordApiErrors.UNKNOWN_VOICE_STATE;
+
+ voice_state.assign(body);
+ const channel = await Channel.findOneOrFail({
+ where: { guild_id, id: body.channel_id },
+ });
+ if (channel.type !== ChannelType.GUILD_STAGE_VOICE) {
+ throw DiscordApiErrors.CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE;
+ }
+
+ await Promise.all([
+ voice_state.save(),
+ emitEvent({
+ event: "VOICE_STATE_UPDATE",
+ data: voice_state,
+ guild_id,
+ } as VoiceStateUpdateEvent),
+ ]);
+ return res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts b/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts
new file mode 100644
index 00000000..80ab138b
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts
@@ -0,0 +1,43 @@
+import { Request, Response, Router } from "express";
+import { Guild, Member, GuildUpdateWelcomeScreenSchema } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const guild_id = req.params.guild_id;
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+ await Member.IsInGuildOrFail(req.user_id, guild_id);
+
+ res.json(guild.welcome_screen);
+});
+
+router.patch(
+ "/",
+ route({
+ body: "GuildUpdateWelcomeScreenSchema",
+ permission: "MANAGE_GUILD",
+ }),
+ async (req: Request, res: Response) => {
+ const guild_id = req.params.guild_id;
+ const body = req.body as GuildUpdateWelcomeScreenSchema;
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+
+ if (!guild.welcome_screen.enabled)
+ throw new HTTPError("Welcome screen disabled", 400);
+ if (body.welcome_channels)
+ guild.welcome_screen.welcome_channels = body.welcome_channels; // TODO: check if they exist and are valid
+ if (body.description)
+ guild.welcome_screen.description = body.description;
+ if (body.enabled != null) guild.welcome_screen.enabled = body.enabled;
+
+ await guild.save();
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/widget.json.ts b/src/api/routes/v9/guilds/#guild_id/widget.json.ts
new file mode 100644
index 00000000..2c3124a2
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/widget.json.ts
@@ -0,0 +1,97 @@
+import { Request, Response, Router } from "express";
+import {
+ Config,
+ Permissions,
+ Guild,
+ Invite,
+ Channel,
+ Member,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { random, route } from "@fosscord/api";
+
+const router: Router = Router();
+
+// Undocumented API notes:
+// An invite is created for the widget_channel_id on request (only if an existing one created by the widget doesn't already exist)
+// This invite created doesn't include an inviter object like user created ones and has a default expiry of 24 hours
+// Missing user object information is intentional (https://github.com/discord/discord-api-docs/issues/1287)
+// channels returns voice channel objects where @everyone has the CONNECT permission
+// members (max 100 returned) is a sample of all members, and bots par invisible status, there exists some alphabetical distribution pattern between the members returned
+
+// https://discord.com/developers/docs/resources/guild#get-guild-widget
+// TODO: Cache the response for a guild for 5 minutes regardless of response
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+ if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404);
+
+ // Fetch existing widget invite for widget channel
+ var invite = await Invite.findOne({
+ where: { channel_id: guild.widget_channel_id },
+ });
+
+ if (guild.widget_channel_id && !invite) {
+ // Create invite for channel if none exists
+ // TODO: Refactor invite create code to a shared function
+ const max_age = 86400; // 24 hours
+ const expires_at = new Date(max_age * 1000 + Date.now());
+
+ invite = await Invite.create({
+ code: random(),
+ temporary: false,
+ uses: 0,
+ max_uses: 0,
+ max_age: max_age,
+ expires_at,
+ created_at: new Date(),
+ guild_id,
+ channel_id: guild.widget_channel_id,
+ }).save();
+ }
+
+ // Fetch voice channels, and the @everyone permissions object
+ const channels = [] as any[];
+
+ (
+ await Channel.find({
+ where: { guild_id: guild_id, type: 2 },
+ order: { position: "ASC" },
+ })
+ ).filter((doc) => {
+ // Only return channels where @everyone has the CONNECT permission
+ if (
+ doc.permission_overwrites === undefined ||
+ Permissions.channelPermission(
+ doc.permission_overwrites,
+ Permissions.FLAGS.CONNECT,
+ ) === Permissions.FLAGS.CONNECT
+ ) {
+ channels.push({
+ id: doc.id,
+ name: doc.name,
+ position: doc.position,
+ });
+ }
+ });
+
+ // Fetch members
+ // TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file)
+ let members = await Member.find({ where: { guild_id: guild_id } });
+
+ // Construct object to respond with
+ const data = {
+ id: guild_id,
+ name: guild.name,
+ instant_invite: invite?.code,
+ channels: channels,
+ members: members,
+ presence_count: guild.presence_count,
+ };
+
+ res.set("Cache-Control", "public, max-age=300");
+ return res.json(data);
+});
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/widget.png.ts b/src/api/routes/v9/guilds/#guild_id/widget.png.ts
new file mode 100644
index 00000000..eaec8f07
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/widget.png.ts
@@ -0,0 +1,179 @@
+import { Request, Response, Router } from "express";
+import { Guild } from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+import fs from "fs";
+import path from "path";
+
+const router: Router = Router();
+
+// TODO: use svg templates instead of node-canvas for improved performance and to change it easily
+
+// https://discord.com/developers/docs/resources/guild#get-guild-widget-image
+// TODO: Cache the response
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+ if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404);
+
+ // Fetch guild information
+ const icon = guild.icon;
+ const name = guild.name;
+ const presence = guild.presence_count + " ONLINE";
+
+ // Fetch parameter
+ const style = req.query.style?.toString() || "shield";
+ if (
+ !["shield", "banner1", "banner2", "banner3", "banner4"].includes(style)
+ ) {
+ throw new HTTPError(
+ "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').",
+ 400,
+ );
+ }
+
+ // Setup canvas
+ const { createCanvas } = require("canvas");
+ const { loadImage } = require("canvas");
+ const sizeOf = require("image-size");
+
+ // TODO: Widget style templates need Fosscord branding
+ const source = path.join(
+ __dirname,
+ "..",
+ "..",
+ "..",
+ "..",
+ "..",
+ "assets",
+ "widget",
+ `${style}.png`,
+ );
+ if (!fs.existsSync(source)) {
+ throw new HTTPError("Widget template does not exist.", 400);
+ }
+
+ // Create base template image for parameter
+ const { width, height } = await sizeOf(source);
+ const canvas = createCanvas(width, height);
+ const ctx = canvas.getContext("2d");
+ const template = await loadImage(source);
+ ctx.drawImage(template, 0, 0);
+
+ // Add the guild specific information to the template asset image
+ switch (style) {
+ case "shield":
+ ctx.textAlign = "center";
+ await drawText(
+ ctx,
+ 73,
+ 13,
+ "#FFFFFF",
+ "thin 10px Verdana",
+ presence,
+ );
+ break;
+ case "banner1":
+ if (icon) await drawIcon(ctx, 20, 27, 50, icon);
+ await drawText(ctx, 83, 51, "#FFFFFF", "12px Verdana", name, 22);
+ await drawText(
+ ctx,
+ 83,
+ 66,
+ "#C9D2F0FF",
+ "thin 11px Verdana",
+ presence,
+ );
+ break;
+ case "banner2":
+ if (icon) await drawIcon(ctx, 13, 19, 36, icon);
+ await drawText(ctx, 62, 34, "#FFFFFF", "12px Verdana", name, 15);
+ await drawText(
+ ctx,
+ 62,
+ 49,
+ "#C9D2F0FF",
+ "thin 11px Verdana",
+ presence,
+ );
+ break;
+ case "banner3":
+ if (icon) await drawIcon(ctx, 20, 20, 50, icon);
+ await drawText(ctx, 83, 44, "#FFFFFF", "12px Verdana", name, 27);
+ await drawText(
+ ctx,
+ 83,
+ 58,
+ "#C9D2F0FF",
+ "thin 11px Verdana",
+ presence,
+ );
+ break;
+ case "banner4":
+ if (icon) await drawIcon(ctx, 21, 136, 50, icon);
+ await drawText(ctx, 84, 156, "#FFFFFF", "13px Verdana", name, 27);
+ await drawText(
+ ctx,
+ 84,
+ 171,
+ "#C9D2F0FF",
+ "thin 12px Verdana",
+ presence,
+ );
+ break;
+ default:
+ throw new HTTPError(
+ "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').",
+ 400,
+ );
+ }
+
+ // Return final image
+ const buffer = canvas.toBuffer("image/png");
+ res.set("Content-Type", "image/png");
+ res.set("Cache-Control", "public, max-age=3600");
+ return res.send(buffer);
+});
+
+async function drawIcon(
+ canvas: any,
+ x: number,
+ y: number,
+ scale: number,
+ icon: string,
+) {
+ // @ts-ignore
+ const img = new require("canvas").Image();
+ img.src = icon;
+
+ // Do some canvas clipping magic!
+ canvas.save();
+ canvas.beginPath();
+
+ const r = scale / 2; // use scale to determine radius
+ canvas.arc(x + r, y + r, r, 0, 2 * Math.PI, false); // start circle at x, and y coords + radius to find center
+
+ canvas.clip();
+ canvas.drawImage(img, x, y, scale, scale);
+
+ canvas.restore();
+}
+
+async function drawText(
+ canvas: any,
+ x: number,
+ y: number,
+ color: string,
+ font: string,
+ text: string,
+ maxcharacters?: number,
+) {
+ canvas.fillStyle = color;
+ canvas.font = font;
+ if (text.length > (maxcharacters || 0) && maxcharacters)
+ text = text.slice(0, maxcharacters) + "...";
+ canvas.fillText(text, x, y);
+}
+
+export default router;
diff --git a/src/api/routes/v9/guilds/#guild_id/widget.ts b/src/api/routes/v9/guilds/#guild_id/widget.ts
new file mode 100644
index 00000000..108339e1
--- /dev/null
+++ b/src/api/routes/v9/guilds/#guild_id/widget.ts
@@ -0,0 +1,40 @@
+import { Request, Response, Router } from "express";
+import { Guild, WidgetModifySchema } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+// https://discord.com/developers/docs/resources/guild#get-guild-widget-settings
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+
+ const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
+
+ return res.json({
+ enabled: guild.widget_enabled || false,
+ channel_id: guild.widget_channel_id || null,
+ });
+});
+
+// https://discord.com/developers/docs/resources/guild#modify-guild-widget
+router.patch(
+ "/",
+ route({ body: "WidgetModifySchema", permission: "MANAGE_GUILD" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as WidgetModifySchema;
+ const { guild_id } = req.params;
+
+ await Guild.update(
+ { id: guild_id },
+ {
+ widget_enabled: body.enabled,
+ widget_channel_id: body.channel_id,
+ },
+ );
+ // Widget invite for the widget_channel_id gets created as part of the /guilds/{guild.id}/widget.json request
+
+ return res.json(body);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/index.ts b/src/api/routes/v9/guilds/index.ts
new file mode 100644
index 00000000..69575aea
--- /dev/null
+++ b/src/api/routes/v9/guilds/index.ts
@@ -0,0 +1,47 @@
+import { Router, Request, Response } from "express";
+import {
+ Role,
+ Guild,
+ Config,
+ getRights,
+ Member,
+ DiscordApiErrors,
+ GuildCreateSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+//TODO: create default channel
+
+router.post(
+ "/",
+ route({ body: "GuildCreateSchema", right: "CREATE_GUILDS" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as GuildCreateSchema;
+
+ const { maxGuilds } = Config.get().limits.user;
+ const guild_count = await Member.count({ where: { id: req.user_id } });
+ const rights = await getRights(req.user_id);
+ if (guild_count >= maxGuilds && !rights.has("MANAGE_GUILDS")) {
+ throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds);
+ }
+
+ const guild = await Guild.createGuild({
+ ...body,
+ owner_id: req.user_id,
+ });
+
+ const { autoJoin } = Config.get().guild;
+ if (autoJoin.enabled && !autoJoin.guilds?.length) {
+ // @ts-ignore
+ await Config.set({ guild: { autoJoin: { guilds: [guild.id] } } });
+ }
+
+ await Member.addToGuild(req.user_id, guild.id);
+
+ res.status(201).json({ id: guild.id });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/guilds/templates/index.ts b/src/api/routes/v9/guilds/templates/index.ts
new file mode 100644
index 00000000..240bf074
--- /dev/null
+++ b/src/api/routes/v9/guilds/templates/index.ts
@@ -0,0 +1,132 @@
+import { Request, Response, Router } from "express";
+import {
+ Template,
+ Guild,
+ Role,
+ Snowflake,
+ Config,
+ Member,
+ GuildTemplateCreateSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { DiscordApiErrors } from "@fosscord/util";
+import fetch from "node-fetch";
+const router: Router = Router();
+
+router.get("/:code", route({}), async (req: Request, res: Response) => {
+ const { allowDiscordTemplates, allowRaws, enabled } =
+ Config.get().templates;
+ if (!enabled)
+ res.json({
+ code: 403,
+ message: "Template creation & usage is disabled on this instance.",
+ }).sendStatus(403);
+
+ const { code } = req.params;
+
+ if (code.startsWith("discord:")) {
+ if (!allowDiscordTemplates)
+ return res
+ .json({
+ code: 403,
+ message:
+ "Discord templates cannot be used on this instance.",
+ })
+ .sendStatus(403);
+ const discordTemplateID = code.split("discord:", 2)[1];
+
+ const discordTemplateData = await fetch(
+ `https://discord.com/api/v9/guilds/templates/${discordTemplateID}`,
+ {
+ method: "get",
+ headers: { "Content-Type": "application/json" },
+ },
+ );
+ return res.json(await discordTemplateData.json());
+ }
+
+ if (code.startsWith("external:")) {
+ if (!allowRaws)
+ return res
+ .json({
+ code: 403,
+ message: "Importing raws is disabled on this instance.",
+ })
+ .sendStatus(403);
+
+ return res.json(code.split("external:", 2)[1]);
+ }
+
+ const template = await Template.findOneOrFail({ where: { code: code } });
+ res.json(template);
+});
+
+router.post(
+ "/:code",
+ route({ body: "GuildTemplateCreateSchema" }),
+ async (req: Request, res: Response) => {
+ const {
+ enabled,
+ allowTemplateCreation,
+ allowDiscordTemplates,
+ allowRaws,
+ } = Config.get().templates;
+ if (!enabled)
+ return res
+ .json({
+ code: 403,
+ message:
+ "Template creation & usage is disabled on this instance.",
+ })
+ .sendStatus(403);
+ if (!allowTemplateCreation)
+ return res
+ .json({
+ code: 403,
+ message: "Template creation is disabled on this instance.",
+ })
+ .sendStatus(403);
+
+ const { code } = req.params;
+ const body = req.body as GuildTemplateCreateSchema;
+
+ const { maxGuilds } = Config.get().limits.user;
+
+ const guild_count = await Member.count({ where: { id: req.user_id } });
+ if (guild_count >= maxGuilds) {
+ throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds);
+ }
+
+ const template = await Template.findOneOrFail({
+ where: { code: code },
+ });
+
+ const guild_id = Snowflake.generate();
+
+ const [guild, role] = await Promise.all([
+ Guild.create({
+ ...body,
+ ...template.serialized_source_guild,
+ id: guild_id,
+ owner_id: req.user_id,
+ }).save(),
+ Role.create({
+ id: guild_id,
+ guild_id: guild_id,
+ color: 0,
+ hoist: false,
+ managed: true,
+ mentionable: true,
+ name: "@everyone",
+ permissions: BigInt("2251804225").toString(), // TODO: where did this come from?
+ position: 0,
+ }).save(),
+ ]);
+
+ await Member.addToGuild(req.user_id, guild_id);
+
+ res.status(201).json({ id: guild.id });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/invites/index.ts b/src/api/routes/v9/invites/index.ts
new file mode 100644
index 00000000..ce0ba982
--- /dev/null
+++ b/src/api/routes/v9/invites/index.ts
@@ -0,0 +1,89 @@
+import { Router, Request, Response } from "express";
+import {
+ emitEvent,
+ getPermission,
+ Guild,
+ Invite,
+ InviteDeleteEvent,
+ User,
+ PublicInviteRelation,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import { HTTPError } from "lambert-server";
+
+const router: Router = Router();
+
+router.get("/:code", route({}), async (req: Request, res: Response) => {
+ const { code } = req.params;
+
+ const invite = await Invite.findOneOrFail({
+ where: { code },
+ relations: PublicInviteRelation,
+ });
+
+ res.status(200).send(invite);
+});
+
+router.post(
+ "/:code",
+ route({ right: "USE_MASS_INVITES" }),
+ async (req: Request, res: Response) => {
+ const { code } = req.params;
+ const { guild_id } = await Invite.findOneOrFail({
+ where: { code: code },
+ });
+ const { features } = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ });
+ const { public_flags } = await User.findOneOrFail({
+ where: { id: req.user_id },
+ });
+
+ if (
+ features.includes("INTERNAL_EMPLOYEE_ONLY") &&
+ (public_flags & 1) !== 1
+ )
+ throw new HTTPError(
+ "Only intended for the staff of this server.",
+ 401,
+ );
+ if (features.includes("INVITES_CLOSED"))
+ throw new HTTPError("Sorry, this guild has joins closed.", 403);
+
+ const invite = await Invite.joinGuild(req.user_id, code);
+
+ res.json(invite);
+ },
+);
+
+// * cant use permission of route() function because path doesn't have guild_id/channel_id
+router.delete("/:code", route({}), async (req: Request, res: Response) => {
+ const { code } = req.params;
+ const invite = await Invite.findOneOrFail({ where: { code } });
+ const { guild_id, channel_id } = invite;
+
+ const permission = await getPermission(req.user_id, guild_id, channel_id);
+
+ if (!permission.has("MANAGE_GUILD") && !permission.has("MANAGE_CHANNELS"))
+ throw new HTTPError(
+ "You missing the MANAGE_GUILD or MANAGE_CHANNELS permission",
+ 401,
+ );
+
+ await Promise.all([
+ Invite.delete({ code }),
+ emitEvent({
+ event: "INVITE_DELETE",
+ guild_id: guild_id,
+ data: {
+ channel_id: channel_id,
+ guild_id: guild_id,
+ code: code,
+ },
+ } as InviteDeleteEvent),
+ ]);
+
+ res.json({ invite: invite });
+});
+
+export default router;
diff --git a/src/api/routes/v9/oauth2/authorize.ts b/src/api/routes/v9/oauth2/authorize.ts
new file mode 100644
index 00000000..6374972e
--- /dev/null
+++ b/src/api/routes/v9/oauth2/authorize.ts
@@ -0,0 +1,168 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import {
+ ApiError,
+ Application,
+ ApplicationAuthorizeSchema,
+ getPermission,
+ DiscordApiErrors,
+ Member,
+ Permissions,
+ User,
+ getRights,
+ Rights,
+ MemberPrivateProjection,
+} from "@fosscord/util";
+const router = Router();
+
+// TODO: scopes, other oauth types
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { client_id, scope, response_type, redirect_url } = req.query;
+
+ const app = await Application.findOne({
+ where: {
+ id: client_id as string,
+ },
+ relations: ["bot"],
+ });
+
+ // TODO: use DiscordApiErrors
+ // findOneOrFail throws code 404
+ if (!app) throw DiscordApiErrors.UNKNOWN_APPLICATION;
+ if (!app.bot) throw DiscordApiErrors.OAUTH2_APPLICATION_BOT_ABSENT;
+
+ const bot = app.bot;
+ delete app.bot;
+
+ const user = await User.findOneOrFail({
+ where: {
+ id: req.user_id,
+ bot: false,
+ },
+ select: ["id", "username", "avatar", "discriminator", "public_flags"],
+ });
+
+ const guilds = await Member.find({
+ where: {
+ user: {
+ id: req.user_id,
+ },
+ },
+ relations: ["guild", "roles"],
+ //@ts-ignore
+ // prettier-ignore
+ select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id"],
+ });
+
+ const guildsWithPermissions = guilds.map((x) => {
+ const perms =
+ x.guild.owner_id === user.id
+ ? new Permissions(Permissions.FLAGS.ADMINISTRATOR)
+ : Permissions.finalPermission({
+ user: {
+ id: user.id,
+ roles: x.roles?.map((x) => x.id) || [],
+ },
+ guild: {
+ roles: x?.roles || [],
+ },
+ });
+
+ return {
+ id: x.guild.id,
+ name: x.guild.name,
+ icon: x.guild.icon,
+ mfa_level: x.guild.mfa_level,
+ permissions: perms.bitfield.toString(),
+ };
+ });
+
+ return res.json({
+ guilds: guildsWithPermissions,
+ user: {
+ id: user.id,
+ username: user.username,
+ avatar: user.avatar,
+ avatar_decoration: null, // TODO
+ discriminator: user.discriminator,
+ public_flags: user.public_flags,
+ },
+ application: {
+ id: app.id,
+ name: app.name,
+ icon: app.icon,
+ description: app.description,
+ summary: app.summary,
+ type: app.type,
+ hook: app.hook,
+ guild_id: null, // TODO support guilds
+ bot_public: app.bot_public,
+ bot_require_code_grant: app.bot_require_code_grant,
+ verify_key: app.verify_key,
+ flags: app.flags,
+ },
+ bot: {
+ id: bot.id,
+ username: bot.username,
+ avatar: bot.avatar,
+ avatar_decoration: null, // TODO
+ discriminator: bot.discriminator,
+ public_flags: bot.public_flags,
+ bot: true,
+ approximated_guild_count: 0, // TODO
+ },
+ authorized: false,
+ });
+});
+
+router.post(
+ "/",
+ route({ body: "ApplicationAuthorizeSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as ApplicationAuthorizeSchema;
+ const { client_id, scope, response_type, redirect_url } = req.query;
+
+ // TODO: captcha verification
+ // TODO: MFA verification
+
+ const perms = await getPermission(
+ req.user_id,
+ body.guild_id,
+ undefined,
+ { member_relations: ["user"] },
+ );
+ // getPermission cache won't exist if we're owner
+ if (
+ Object.keys(perms.cache || {}).length > 0 &&
+ perms.cache.member!.user.bot
+ )
+ throw DiscordApiErrors.UNAUTHORIZED;
+ perms.hasThrow("MANAGE_GUILD");
+
+ const app = await Application.findOne({
+ where: {
+ id: client_id as string,
+ },
+ relations: ["bot"],
+ });
+
+ // TODO: use DiscordApiErrors
+ // findOneOrFail throws code 404
+ if (!app) throw new ApiError("Unknown Application", 10002, 404);
+ if (!app.bot)
+ throw new ApiError(
+ "OAuth2 application does not have a bot",
+ 50010,
+ 400,
+ );
+
+ await Member.addToGuild(app.id, body.guild_id);
+
+ return res.json({
+ location: "/oauth2/authorized", // redirect URL
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/partners/#guild_id/requirements.ts b/src/api/routes/v9/partners/#guild_id/requirements.ts
new file mode 100644
index 00000000..7e63c06b
--- /dev/null
+++ b/src/api/routes/v9/partners/#guild_id/requirements.ts
@@ -0,0 +1,39 @@
+import { Guild, Config } from "@fosscord/util";
+
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { guild_id } = req.params;
+ // TODO:
+ // Load from database
+ // Admin control, but for now it allows anyone to be discoverable
+
+ res.send({
+ guild_id: guild_id,
+ safe_environment: true,
+ healthy: true,
+ health_score_pending: false,
+ size: true,
+ nsfw_properties: {},
+ protected: true,
+ sufficient: true,
+ sufficient_without_grace_period: true,
+ valid_rules_channel: true,
+ retention_healthy: true,
+ engagement_healthy: true,
+ age: true,
+ minimum_age: 0,
+ health_score: {
+ avg_nonnew_participators: 0,
+ avg_nonnew_communicators: 0,
+ num_intentful_joiners: 0,
+ perc_ret_w1_intentful: 0,
+ },
+ minimum_size: 0,
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/ping.ts b/src/api/routes/v9/ping.ts
new file mode 100644
index 00000000..3c1da2c3
--- /dev/null
+++ b/src/api/routes/v9/ping.ts
@@ -0,0 +1,26 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import { Config } from "@fosscord/util";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/policies/instance/domains.ts b/src/api/routes/v9/policies/instance/domains.ts
new file mode 100644
index 00000000..f22eac17
--- /dev/null
+++ b/src/api/routes/v9/policies/instance/domains.ts
@@ -0,0 +1,21 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { Config } from "@fosscord/util";
+import { config } from "dotenv";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { cdn, gateway } = Config.get();
+
+ const IdentityForm = {
+ cdn: cdn.endpointPublic || process.env.CDN || "http://localhost:3001",
+ gateway:
+ gateway.endpointPublic ||
+ process.env.GATEWAY ||
+ "ws://localhost:3002",
+ };
+
+ res.json(IdentityForm);
+});
+
+export default router;
diff --git a/src/api/routes/v9/policies/instance/index.ts b/src/api/routes/v9/policies/instance/index.ts
new file mode 100644
index 00000000..1c1afa09
--- /dev/null
+++ b/src/api/routes/v9/policies/instance/index.ts
@@ -0,0 +1,11 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { Config } from "@fosscord/util";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.json(general);
+});
+
+export default router;
diff --git a/src/api/routes/v9/policies/instance/limits.ts b/src/api/routes/v9/policies/instance/limits.ts
new file mode 100644
index 00000000..06f14f83
--- /dev/null
+++ b/src/api/routes/v9/policies/instance/limits.ts
@@ -0,0 +1,11 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { Config } from "@fosscord/util";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { limits } = Config.get();
+ res.json(limits);
+});
+
+export default router;
diff --git a/src/api/routes/v9/policies/stats.ts b/src/api/routes/v9/policies/stats.ts
new file mode 100644
index 00000000..dc4652fc
--- /dev/null
+++ b/src/api/routes/v9/policies/stats.ts
@@ -0,0 +1,29 @@
+import { route } from "@fosscord/api";
+import {
+ Config,
+ getRights,
+ Guild,
+ Member,
+ Message,
+ User,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ if (!Config.get().security.statsWorldReadable) {
+ const rights = await getRights(req.user_id);
+ rights.hasThrow("VIEW_SERVER_STATS");
+ }
+
+ res.json({
+ counts: {
+ user: await User.count(),
+ guild: await Guild.count(),
+ message: await Message.count(),
+ members: await Member.count(),
+ },
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/scheduled-maintenances/upcoming_json.ts b/src/api/routes/v9/scheduled-maintenances/upcoming_json.ts
new file mode 100644
index 00000000..e42723a1
--- /dev/null
+++ b/src/api/routes/v9/scheduled-maintenances/upcoming_json.ts
@@ -0,0 +1,16 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+const router = Router();
+
+router.get(
+ "/scheduled-maintenances/upcoming.json",
+ route({}),
+ async (req: Request, res: Response) => {
+ res.json({
+ page: {},
+ scheduled_maintenances: {},
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/sticker-packs/index.ts b/src/api/routes/v9/sticker-packs/index.ts
new file mode 100644
index 00000000..e6560d12
--- /dev/null
+++ b/src/api/routes/v9/sticker-packs/index.ts
@@ -0,0 +1,13 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import { StickerPack } from "@fosscord/util";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const sticker_packs = await StickerPack.find({ relations: ["stickers"] });
+
+ res.json({ sticker_packs });
+});
+
+export default router;
diff --git a/src/api/routes/v9/stickers/#sticker_id/index.ts b/src/api/routes/v9/stickers/#sticker_id/index.ts
new file mode 100644
index 00000000..b484a7a1
--- /dev/null
+++ b/src/api/routes/v9/stickers/#sticker_id/index.ts
@@ -0,0 +1,12 @@
+import { Sticker } 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 { sticker_id } = req.params;
+
+ res.json(await Sticker.find({ where: { id: sticker_id } }));
+});
+
+export default router;
diff --git a/src/api/routes/v9/stop.ts b/src/api/routes/v9/stop.ts
new file mode 100644
index 00000000..3f49b360
--- /dev/null
+++ b/src/api/routes/v9/stop.ts
@@ -0,0 +1,16 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.post(
+ "/",
+ route({ right: "OPERATOR" }),
+ async (req: Request, res: Response) => {
+ console.log(`/stop was called by ${req.user_id} at ${new Date()}`);
+ res.sendStatus(200);
+ process.kill(process.pid, "SIGTERM");
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/store/published-listings/applications.ts b/src/api/routes/v9/store/published-listings/applications.ts
new file mode 100644
index 00000000..6156f43e
--- /dev/null
+++ b/src/api/routes/v9/store/published-listings/applications.ts
@@ -0,0 +1,79 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/:id", route({}), async (req: Request, res: Response) => {
+ //TODO
+ const id = req.params.id;
+ res.json({
+ id: "",
+ summary: "",
+ sku: {
+ id: "",
+ type: 1,
+ dependent_sku_id: null,
+ application_id: "",
+ manifets_labels: [],
+ access_type: 2,
+ name: "",
+ features: [],
+ release_date: "",
+ premium: false,
+ slug: "",
+ flags: 4,
+ genres: [],
+ legal_notice: "",
+ application: {
+ id: "",
+ name: "",
+ icon: "",
+ description: "",
+ summary: "",
+ cover_image: "",
+ primary_sku_id: "",
+ hook: true,
+ slug: "",
+ guild_id: "",
+ bot_public: "",
+ bot_require_code_grant: false,
+ verify_key: "",
+ publishers: [
+ {
+ id: "",
+ name: "",
+ },
+ ],
+ developers: [
+ {
+ id: "",
+ name: "",
+ },
+ ],
+ system_requirements: {},
+ show_age_gate: false,
+ price: {
+ amount: 0,
+ currency: "EUR",
+ },
+ locales: [],
+ },
+ tagline: "",
+ description: "",
+ carousel_items: [
+ {
+ asset_id: "",
+ },
+ ],
+ header_logo_dark_theme: {}, //{id: "", size: 4665, mime_type: "image/gif", width 160, height: 160}
+ header_logo_light_theme: {},
+ box_art: {},
+ thumbnail: {},
+ header_background: {},
+ hero_background: {},
+ assets: [],
+ },
+ }).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts b/src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts
new file mode 100644
index 00000000..845cdfe7
--- /dev/null
+++ b/src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts
@@ -0,0 +1,25 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ //TODO
+ res.json([
+ {
+ id: "",
+ name: "",
+ interval: 1,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "",
+ fallback_price: 499,
+ fallback_currency: "eur",
+ currency: "eur",
+ price: 4199,
+ price_tier: null,
+ },
+ ]).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/store/published-listings/skus.ts b/src/api/routes/v9/store/published-listings/skus.ts
new file mode 100644
index 00000000..6156f43e
--- /dev/null
+++ b/src/api/routes/v9/store/published-listings/skus.ts
@@ -0,0 +1,79 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/:id", route({}), async (req: Request, res: Response) => {
+ //TODO
+ const id = req.params.id;
+ res.json({
+ id: "",
+ summary: "",
+ sku: {
+ id: "",
+ type: 1,
+ dependent_sku_id: null,
+ application_id: "",
+ manifets_labels: [],
+ access_type: 2,
+ name: "",
+ features: [],
+ release_date: "",
+ premium: false,
+ slug: "",
+ flags: 4,
+ genres: [],
+ legal_notice: "",
+ application: {
+ id: "",
+ name: "",
+ icon: "",
+ description: "",
+ summary: "",
+ cover_image: "",
+ primary_sku_id: "",
+ hook: true,
+ slug: "",
+ guild_id: "",
+ bot_public: "",
+ bot_require_code_grant: false,
+ verify_key: "",
+ publishers: [
+ {
+ id: "",
+ name: "",
+ },
+ ],
+ developers: [
+ {
+ id: "",
+ name: "",
+ },
+ ],
+ system_requirements: {},
+ show_age_gate: false,
+ price: {
+ amount: 0,
+ currency: "EUR",
+ },
+ locales: [],
+ },
+ tagline: "",
+ description: "",
+ carousel_items: [
+ {
+ asset_id: "",
+ },
+ ],
+ header_logo_dark_theme: {}, //{id: "", size: 4665, mime_type: "image/gif", width 160, height: 160}
+ header_logo_light_theme: {},
+ box_art: {},
+ thumbnail: {},
+ header_background: {},
+ hero_background: {},
+ assets: [],
+ },
+ }).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts b/src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts
new file mode 100644
index 00000000..6b49e959
--- /dev/null
+++ b/src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts
@@ -0,0 +1,313 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+const skus = new Map([
+ [
+ "521842865731534868",
+ [
+ {
+ id: "511651856145973248",
+ name: "Individual Premium Tier 3 Monthly (Legacy)",
+ interval: 1,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521842865731534868",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ {
+ id: "511651860671627264",
+ name: "Individiual Premium Tier 3 Yearly (Legacy)",
+ interval: 2,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521842865731534868",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ ],
+ ],
+ [
+ "521846918637420545",
+ [
+ {
+ id: "511651871736201216",
+ name: "Individual Premium Tier 2 Monthly",
+ interval: 1,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521846918637420545",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ {
+ id: "511651876987469824",
+ name: "Individual Premum Tier 2 Yearly",
+ interval: 2,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521846918637420545",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ {
+ id: "978380684370378761",
+ name: "Individual Premum Tier 1",
+ interval: 2,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521846918637420545",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ ],
+ ],
+ [
+ "521847234246082599",
+ [
+ {
+ id: "642251038925127690",
+ name: "Individual Premium Tier 3 Quarterly",
+ interval: 1,
+ interval_count: 3,
+ tax_inclusive: true,
+ sku_id: "521847234246082599",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ {
+ id: "511651880837840896",
+ name: "Individual Premium Tier 3 Monthly",
+ interval: 1,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521847234246082599",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ {
+ id: "511651885459963904",
+ name: "Individual Premium Tier 3 Yearly",
+ interval: 2,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "521847234246082599",
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ ],
+ ],
+ [
+ "590663762298667008",
+ [
+ {
+ id: "590665532894740483",
+ name: "Crowd Premium Monthly",
+ interval: 1,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "590663762298667008",
+ discount_price: 0,
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ {
+ id: "590665538238152709",
+ name: "Crowd Premium Yearly",
+ interval: 2,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "590663762298667008",
+ discount_price: 0,
+ currency: "eur",
+ price: 0,
+ price_tier: null,
+ },
+ ],
+ ],
+ [
+ "978380684370378762",
+ [
+ [
+ {
+ id: "978380692553465866",
+ name: "Premium Tier 0 Monthly",
+ interval: 1,
+ interval_count: 1,
+ tax_inclusive: true,
+ sku_id: "978380684370378762",
+ currency: "usd",
+ price: 299,
+ price_tier: null,
+ prices: {
+ "0": {
+ country_prices: {
+ country_code: "US",
+ prices: [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ payment_source_prices: {
+ "775487223059316758": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "736345864146255982": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "683074999590060249": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ },
+ "3": {
+ country_prices: {
+ country_code: "US",
+ prices: [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ payment_source_prices: {
+ "775487223059316758": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "736345864146255982": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "683074999590060249": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ },
+ "4": {
+ country_prices: {
+ country_code: "US",
+ prices: [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ payment_source_prices: {
+ "775487223059316758": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "736345864146255982": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "683074999590060249": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ },
+ "1": {
+ country_prices: {
+ country_code: "US",
+ prices: [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ payment_source_prices: {
+ "775487223059316758": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "736345864146255982": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ "683074999590060249": [
+ {
+ currency: "usd",
+ amount: 0,
+ exponent: 2,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ ],
+ ],
+]);
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ // TODO: add the ability to add custom
+ const { sku_id } = req.params;
+
+ if (!skus.has(sku_id)) {
+ console.log(`Request for invalid SKU ${sku_id}! Please report this!`);
+ res.sendStatus(404);
+ } else {
+ res.json(skus.get(sku_id)).status(200);
+ }
+});
+
+export default router;
diff --git a/src/api/routes/v9/updates.ts b/src/api/routes/v9/updates.ts
new file mode 100644
index 00000000..7e9128f4
--- /dev/null
+++ b/src/api/routes/v9/updates.ts
@@ -0,0 +1,35 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import { Config, FieldErrors, Release } from "@fosscord/util";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { client } = Config.get();
+ const platform = req.query.platform;
+
+ if (!platform)
+ throw FieldErrors({
+ platform: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+
+ const release = await Release.findOneOrFail({
+ where: {
+ enabled: true,
+ platform: platform as string,
+ },
+ order: { pub_date: "DESC" },
+ });
+
+ res.json({
+ name: release.name,
+ pub_date: release.pub_date,
+ url: release.url,
+ notes: release.notes,
+ });
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/#id/delete.ts b/src/api/routes/v9/users/#id/delete.ts
new file mode 100644
index 00000000..6112e943
--- /dev/null
+++ b/src/api/routes/v9/users/#id/delete.ts
@@ -0,0 +1,38 @@
+import { route } from "@fosscord/api";
+import {
+ emitEvent,
+ Member,
+ PrivateUserProjection,
+ User,
+ UserDeleteEvent,
+ UserDeleteSchema,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.post(
+ "/",
+ route({ right: "MANAGE_USERS" }),
+ async (req: Request, res: Response) => {
+ let user = await User.findOneOrFail({
+ where: { id: req.params.id },
+ select: [...PrivateUserProjection, "data"],
+ });
+ await Promise.all([
+ Member.delete({ id: req.params.id }),
+ User.delete({ id: req.params.id }),
+ ]);
+
+ // TODO: respect intents as USER_DELETE has potential to cause privacy issues
+ await emitEvent({
+ event: "USER_DELETE",
+ user_id: req.user_id,
+ data: { user_id: req.params.id },
+ } as UserDeleteEvent);
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/#id/index.ts b/src/api/routes/v9/users/#id/index.ts
new file mode 100644
index 00000000..bdb1060f
--- /dev/null
+++ b/src/api/routes/v9/users/#id/index.ts
@@ -0,0 +1,13 @@
+import { Router, Request, Response } from "express";
+import { User } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const { id } = req.params;
+
+ res.json(await User.getPublicUser(id));
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/#id/profile.ts b/src/api/routes/v9/users/#id/profile.ts
new file mode 100644
index 00000000..5c649056
--- /dev/null
+++ b/src/api/routes/v9/users/#id/profile.ts
@@ -0,0 +1,182 @@
+import { Router, Request, Response } from "express";
+import {
+ PublicConnectedAccount,
+ PublicUser,
+ User,
+ UserPublic,
+ Member,
+ Guild,
+ UserProfileModifySchema,
+ handleFile,
+ PrivateUserProjection,
+ emitEvent,
+ UserUpdateEvent,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+export interface UserProfileResponse {
+ user: UserPublic;
+ connected_accounts: PublicConnectedAccount;
+ premium_guild_since?: Date;
+ premium_since?: Date;
+}
+
+router.get(
+ "/",
+ route({ test: { response: { body: "UserProfileResponse" } } }),
+ async (req: Request, res: Response) => {
+ if (req.params.id === "@me") req.params.id = req.user_id;
+
+ const { guild_id, with_mutual_guilds } = req.query;
+
+ const user = await User.getPublicUser(req.params.id, {
+ relations: ["connected_accounts"],
+ });
+
+ var mutual_guilds: object[] = [];
+ var premium_guild_since;
+
+ if (with_mutual_guilds == "true") {
+ const requested_member = await Member.find({
+ where: { id: req.params.id },
+ });
+ const self_member = await Member.find({
+ where: { id: req.user_id },
+ });
+
+ for (const rmem of requested_member) {
+ if (rmem.premium_since) {
+ if (premium_guild_since) {
+ if (premium_guild_since > rmem.premium_since) {
+ premium_guild_since = rmem.premium_since;
+ }
+ } else {
+ premium_guild_since = rmem.premium_since;
+ }
+ }
+ for (const smem of self_member) {
+ if (smem.guild_id === rmem.guild_id) {
+ mutual_guilds.push({
+ id: rmem.guild_id,
+ nick: rmem.nick,
+ });
+ }
+ }
+ }
+ }
+
+ const guild_member =
+ guild_id && typeof guild_id == "string"
+ ? await Member.findOneOrFail({
+ where: { id: req.params.id, guild_id: guild_id },
+ relations: ["roles"],
+ })
+ : undefined;
+
+ // TODO: make proper DTO's in util?
+
+ const userDto = {
+ username: user.username,
+ discriminator: user.discriminator,
+ id: user.id,
+ public_flags: user.public_flags,
+ avatar: user.avatar,
+ accent_color: user.accent_color,
+ banner: user.banner,
+ bio: req.user_bot ? null : user.bio,
+ bot: user.bot,
+ };
+
+ const userProfile = {
+ bio: req.user_bot ? null : user.bio,
+ accent_color: user.accent_color,
+ banner: user.banner,
+ pronouns: user.pronouns,
+ theme_colors: user.theme_colors,
+ };
+
+ const guildMemberDto = guild_member
+ ? {
+ avatar: guild_member.avatar,
+ banner: guild_member.banner,
+ bio: req.user_bot ? null : guild_member.bio,
+ communication_disabled_until:
+ guild_member.communication_disabled_until,
+ deaf: guild_member.deaf,
+ flags: user.flags,
+ is_pending: guild_member.pending,
+ pending: guild_member.pending, // why is this here twice, discord?
+ joined_at: guild_member.joined_at,
+ mute: guild_member.mute,
+ nick: guild_member.nick,
+ premium_since: guild_member.premium_since,
+ roles: guild_member.roles
+ .map((x) => x.id)
+ .filter((id) => id != guild_id),
+ user: userDto,
+ }
+ : undefined;
+
+ const guildMemberProfile = {
+ accent_color: null,
+ banner: guild_member?.banner || null,
+ bio: guild_member?.bio || "",
+ guild_id,
+ };
+ res.json({
+ connected_accounts: user.connected_accounts,
+ premium_guild_since: premium_guild_since, // TODO
+ premium_since: user.premium_since, // TODO
+ mutual_guilds: mutual_guilds, // TODO {id: "", nick: null} when ?with_mutual_guilds=true
+ user: userDto,
+ premium_type: user.premium_type,
+ profile_themes_experiment_bucket: 4, // TODO: This doesn't make it available, for some reason?
+ user_profile: userProfile,
+ guild_member: guild_id && guildMemberDto,
+ guild_member_profile: guild_id && guildMemberProfile,
+ });
+ },
+);
+
+router.patch(
+ "/",
+ route({ body: "UserProfileModifySchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as UserProfileModifySchema;
+
+ if (body.banner)
+ body.banner = await handleFile(
+ `/banners/${req.user_id}`,
+ body.banner as string,
+ );
+ let user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: [...PrivateUserProjection, "data"],
+ });
+
+ user.assign(body);
+ await user.save();
+
+ // @ts-ignore
+ delete user.data;
+
+ // TODO: send update member list event in gateway
+ await emitEvent({
+ event: "USER_UPDATE",
+ user_id: req.user_id,
+ data: user,
+ } as UserUpdateEvent);
+
+ res.json({
+ accent_color: user.accent_color,
+ bio: user.bio,
+ banner: user.banner,
+ theme_colors: user.theme_colors,
+ pronouns: user.pronouns,
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/#id/relationships.ts b/src/api/routes/v9/users/#id/relationships.ts
new file mode 100644
index 00000000..c6480567
--- /dev/null
+++ b/src/api/routes/v9/users/#id/relationships.ts
@@ -0,0 +1,54 @@
+import { Router, Request, Response } from "express";
+import { User } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+export interface UserRelationsResponse {
+ object: {
+ id?: string;
+ username?: string;
+ avatar?: string;
+ discriminator?: string;
+ public_flags?: number;
+ };
+}
+
+router.get(
+ "/",
+ route({ test: { response: { body: "UserRelationsResponse" } } }),
+ async (req: Request, res: Response) => {
+ var mutual_relations: object[] = [];
+ const requested_relations = await User.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["relationships"],
+ });
+ const self_relations = await User.findOneOrFail({
+ where: { id: req.user_id },
+ relations: ["relationships"],
+ });
+
+ for (const rmem of requested_relations.relationships) {
+ for (const smem of self_relations.relationships)
+ if (
+ rmem.to_id === smem.to_id &&
+ rmem.type === 1 &&
+ rmem.to_id !== req.user_id
+ ) {
+ var relation_user = await User.getPublicUser(rmem.to_id);
+
+ mutual_relations.push({
+ id: relation_user.id,
+ username: relation_user.username,
+ avatar: relation_user.avatar,
+ discriminator: relation_user.discriminator,
+ public_flags: relation_user.public_flags,
+ });
+ }
+ }
+
+ res.json(mutual_relations);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/channels.ts b/src/api/routes/v9/users/@me/channels.ts
new file mode 100644
index 00000000..237be102
--- /dev/null
+++ b/src/api/routes/v9/users/@me/channels.ts
@@ -0,0 +1,39 @@
+import { Request, Response, Router } from "express";
+import {
+ Recipient,
+ DmChannelDTO,
+ Channel,
+ DmChannelCreateSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const recipients = await Recipient.find({
+ where: { user_id: req.user_id, closed: false },
+ relations: ["channel", "channel.recipients"],
+ });
+ res.json(
+ await Promise.all(
+ recipients.map((r) => DmChannelDTO.from(r.channel, [req.user_id])),
+ ),
+ );
+});
+
+router.post(
+ "/",
+ route({ body: "DmChannelCreateSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as DmChannelCreateSchema;
+ res.json(
+ await Channel.createDMChannel(
+ body.recipients,
+ req.user_id,
+ body.name,
+ ),
+ );
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/delete.ts b/src/api/routes/v9/users/@me/delete.ts
new file mode 100644
index 00000000..a9f8167c
--- /dev/null
+++ b/src/api/routes/v9/users/@me/delete.ts
@@ -0,0 +1,38 @@
+import { Router, Request, Response } from "express";
+import { Guild, Member, User } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import bcrypt from "bcrypt";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+
+router.post("/", route({}), async (req: Request, res: Response) => {
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: ["data"],
+ }); //User object
+ let correctpass = true;
+
+ if (user.data.hash) {
+ // guest accounts can delete accounts without password
+ correctpass = await bcrypt.compare(req.body.password, user.data.hash);
+ if (!correctpass) {
+ throw new HTTPError(req.t("auth:login.INVALID_PASSWORD"));
+ }
+ }
+
+ // TODO: decrement guild member count
+
+ if (correctpass) {
+ await Promise.all([
+ User.delete({ id: req.user_id }),
+ Member.delete({ id: req.user_id }),
+ ]);
+
+ res.sendStatus(204);
+ } else {
+ res.sendStatus(401);
+ }
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/disable.ts b/src/api/routes/v9/users/@me/disable.ts
new file mode 100644
index 00000000..313a888f
--- /dev/null
+++ b/src/api/routes/v9/users/@me/disable.ts
@@ -0,0 +1,32 @@
+import { User } from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+import bcrypt from "bcrypt";
+
+const router = Router();
+
+router.post("/", route({}), async (req: Request, res: Response) => {
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: ["data"],
+ }); //User object
+ let correctpass = true;
+
+ if (user.data.hash) {
+ // guest accounts can delete accounts without password
+ correctpass = await bcrypt.compare(req.body.password, user.data.hash); //Not sure if user typed right password :/
+ }
+
+ if (correctpass) {
+ await User.update({ id: req.user_id }, { disabled: true });
+
+ res.sendStatus(204);
+ } else {
+ res.status(400).json({
+ message: "Password does not match",
+ code: 50018,
+ });
+ }
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/email-settings.ts b/src/api/routes/v9/users/@me/email-settings.ts
new file mode 100644
index 00000000..a2834b89
--- /dev/null
+++ b/src/api/routes/v9/users/@me/email-settings.ts
@@ -0,0 +1,20 @@
+import { Router, Response, Request } from "express";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ // TODO:
+ res.json({
+ categories: {
+ social: true,
+ communication: true,
+ tips: false,
+ updates_and_announcements: false,
+ recommendations_and_events: false,
+ },
+ initialized: false,
+ }).status(200);
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/guilds.ts b/src/api/routes/v9/users/@me/guilds.ts
new file mode 100644
index 00000000..e12bf258
--- /dev/null
+++ b/src/api/routes/v9/users/@me/guilds.ts
@@ -0,0 +1,76 @@
+import { Router, Request, Response } from "express";
+import {
+ Guild,
+ Member,
+ User,
+ GuildDeleteEvent,
+ GuildMemberRemoveEvent,
+ emitEvent,
+ Config,
+} from "@fosscord/util";
+import { HTTPError } from "lambert-server";
+import { route } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const members = await Member.find({
+ relations: ["guild"],
+ where: { id: req.user_id },
+ });
+
+ let guild = members.map((x) => x.guild);
+
+ if ("with_counts" in req.query && req.query.with_counts == "true") {
+ guild = []; // TODO: Load guilds with user role permissions number
+ }
+
+ res.json(guild);
+});
+
+// user send to leave a certain guild
+router.delete("/:guild_id", route({}), async (req: Request, res: Response) => {
+ const { autoJoin } = Config.get().guild;
+ const { guild_id } = req.params;
+ const guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: ["owner_id"],
+ });
+
+ if (!guild) throw new HTTPError("Guild doesn't exist", 404);
+ if (guild.owner_id === req.user_id)
+ throw new HTTPError("You can't leave your own guild", 400);
+ if (
+ autoJoin.enabled &&
+ autoJoin.guilds.includes(guild_id) &&
+ !autoJoin.canLeave
+ ) {
+ throw new HTTPError("You can't leave instance auto join guilds", 400);
+ }
+
+ await Promise.all([
+ Member.delete({ id: req.user_id, guild_id: guild_id }),
+ emitEvent({
+ event: "GUILD_DELETE",
+ data: {
+ id: guild_id,
+ },
+ user_id: req.user_id,
+ } as GuildDeleteEvent),
+ ]);
+
+ const user = await User.getPublicUser(req.user_id);
+
+ await emitEvent({
+ event: "GUILD_MEMBER_REMOVE",
+ data: {
+ guild_id: guild_id,
+ user: user,
+ },
+ guild_id: guild_id,
+ } as GuildMemberRemoveEvent);
+
+ return res.sendStatus(204);
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts b/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts
new file mode 100644
index 00000000..436261d4
--- /dev/null
+++ b/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts
@@ -0,0 +1,44 @@
+import { Router, Response, Request } from "express";
+import {
+ Channel,
+ Member,
+ OrmUtils,
+ UserGuildSettingsSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+// GET doesn't exist on discord.com
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const user = await Member.findOneOrFail({
+ where: { id: req.user_id, guild_id: req.params.guild_id },
+ select: ["settings"],
+ });
+ return res.json(user.settings);
+});
+
+router.patch(
+ "/",
+ route({ body: "UserGuildSettingsSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as UserGuildSettingsSchema;
+
+ if (body.channel_overrides) {
+ for (var channel in body.channel_overrides) {
+ Channel.findOneOrFail({ where: { id: channel } });
+ }
+ }
+
+ const user = await Member.findOneOrFail({
+ where: { id: req.user_id, guild_id: req.params.guild_id },
+ select: ["settings"],
+ });
+ OrmUtils.mergeDeep(user.settings || {}, body);
+ Member.update({ id: req.user_id, guild_id: req.params.guild_id }, user);
+
+ res.json(user.settings);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/index.ts b/src/api/routes/v9/users/@me/index.ts
new file mode 100644
index 00000000..37356d9d
--- /dev/null
+++ b/src/api/routes/v9/users/@me/index.ts
@@ -0,0 +1,156 @@
+import { Router, Request, Response } from "express";
+import {
+ User,
+ PrivateUserProjection,
+ emitEvent,
+ UserUpdateEvent,
+ handleFile,
+ FieldErrors,
+ adjustEmail,
+ Config,
+ UserModifySchema,
+ generateToken,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import bcrypt from "bcrypt";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ res.json(
+ await User.findOne({
+ select: PrivateUserProjection,
+ where: { id: req.user_id },
+ }),
+ );
+});
+
+router.patch(
+ "/",
+ route({ body: "UserModifySchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as UserModifySchema;
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: [...PrivateUserProjection, "data"],
+ });
+
+ // Populated on password change
+ var newToken: string | undefined;
+
+ if (body.avatar)
+ body.avatar = await handleFile(
+ `/avatars/${req.user_id}`,
+ body.avatar as string,
+ );
+ if (body.banner)
+ body.banner = await handleFile(
+ `/banners/${req.user_id}`,
+ body.banner as string,
+ );
+
+ if (body.password) {
+ if (user.data?.hash) {
+ const same_password = await bcrypt.compare(
+ body.password,
+ user.data.hash || "",
+ );
+ if (!same_password) {
+ throw FieldErrors({
+ password: {
+ message: req.t("auth:login.INVALID_PASSWORD"),
+ code: "INVALID_PASSWORD",
+ },
+ });
+ }
+ } else {
+ user.data.hash = await bcrypt.hash(body.password, 12);
+ }
+ }
+
+ if (body.email) {
+ body.email = adjustEmail(body.email);
+ if (!body.email && Config.get().register.email.required)
+ throw FieldErrors({
+ email: {
+ message: req.t("auth:register.EMAIL_INVALID"),
+ code: "EMAIL_INVALID",
+ },
+ });
+ if (!body.password)
+ throw FieldErrors({
+ password: {
+ message: req.t("auth:register.INVALID_PASSWORD"),
+ code: "INVALID_PASSWORD",
+ },
+ });
+ }
+
+ if (body.new_password) {
+ if (!body.password && !user.email) {
+ throw FieldErrors({
+ password: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+ }
+ user.data.hash = await bcrypt.hash(body.new_password, 12);
+ user.data.valid_tokens_since = new Date();
+ newToken = (await generateToken(user.id)) as string;
+ }
+
+ if (body.username) {
+ var check_username = body?.username?.replace(/\s/g, "");
+ if (!check_username) {
+ throw FieldErrors({
+ username: {
+ code: "BASE_TYPE_REQUIRED",
+ message: req.t("common:field.BASE_TYPE_REQUIRED"),
+ },
+ });
+ }
+ }
+
+ if (body.discriminator) {
+ if (
+ await User.findOne({
+ where: {
+ discriminator: body.discriminator,
+ username: body.username || user.username,
+ },
+ })
+ ) {
+ throw FieldErrors({
+ discriminator: {
+ code: "INVALID_DISCRIMINATOR",
+ message: "This discriminator is already in use.",
+ },
+ });
+ }
+ }
+
+ user.assign(body);
+ user.validate();
+ await user.save();
+
+ // @ts-ignore
+ delete user.data;
+
+ // TODO: send update member list event in gateway
+ await emitEvent({
+ event: "USER_UPDATE",
+ user_id: req.user_id,
+ data: user,
+ } as UserUpdateEvent);
+
+ res.json({
+ ...user,
+ newToken,
+ });
+ },
+);
+
+export default router;
+// {"message": "Invalid two-factor code", "code": 60008}
diff --git a/src/api/routes/v9/users/@me/mfa/codes-verification.ts b/src/api/routes/v9/users/@me/mfa/codes-verification.ts
new file mode 100644
index 00000000..3411605b
--- /dev/null
+++ b/src/api/routes/v9/users/@me/mfa/codes-verification.ts
@@ -0,0 +1,49 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import {
+ BackupCode,
+ generateMfaBackupCodes,
+ User,
+ CodesVerificationSchema,
+} from "@fosscord/util";
+
+const router = Router();
+
+router.post(
+ "/",
+ route({ body: "CodesVerificationSchema" }),
+ async (req: Request, res: Response) => {
+ const { key, nonce, regenerate } = req.body as CodesVerificationSchema;
+
+ // TODO: We don't have email/etc etc, so can't send a verification code.
+ // Once that's done, this route can verify `key`
+
+ const user = await User.findOneOrFail({ where: { id: req.user_id } });
+
+ var codes: BackupCode[];
+ if (regenerate) {
+ await BackupCode.update(
+ { user: { id: req.user_id } },
+ { expired: true },
+ );
+
+ codes = generateMfaBackupCodes(req.user_id);
+ await Promise.all(codes.map((x) => x.save()));
+ } else {
+ codes = await BackupCode.find({
+ where: {
+ user: {
+ id: req.user_id,
+ },
+ expired: false,
+ },
+ });
+ }
+
+ return res.json({
+ backup_codes: codes.map((x) => ({ ...x, expired: undefined })),
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/mfa/codes.ts b/src/api/routes/v9/users/@me/mfa/codes.ts
new file mode 100644
index 00000000..33053028
--- /dev/null
+++ b/src/api/routes/v9/users/@me/mfa/codes.ts
@@ -0,0 +1,62 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import {
+ BackupCode,
+ FieldErrors,
+ generateMfaBackupCodes,
+ User,
+ MfaCodesSchema,
+} from "@fosscord/util";
+import bcrypt from "bcrypt";
+
+const router = Router();
+
+// TODO: This route is replaced with users/@me/mfa/codes-verification in newer clients
+
+router.post(
+ "/",
+ route({ body: "MfaCodesSchema" }),
+ async (req: Request, res: Response) => {
+ const { password, regenerate } = req.body as MfaCodesSchema;
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: ["data"],
+ });
+
+ if (!(await bcrypt.compare(password, user.data.hash || ""))) {
+ throw FieldErrors({
+ password: {
+ message: req.t("auth:login.INVALID_PASSWORD"),
+ code: "INVALID_PASSWORD",
+ },
+ });
+ }
+
+ var codes: BackupCode[];
+ if (regenerate) {
+ await BackupCode.update(
+ { user: { id: req.user_id } },
+ { expired: true },
+ );
+
+ codes = generateMfaBackupCodes(req.user_id);
+ await Promise.all(codes.map((x) => x.save()));
+ } else {
+ codes = await BackupCode.find({
+ where: {
+ user: {
+ id: req.user_id,
+ },
+ expired: false,
+ },
+ });
+ }
+
+ return res.json({
+ backup_codes: codes.map((x) => ({ ...x, expired: undefined })),
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/mfa/totp/disable.ts b/src/api/routes/v9/users/@me/mfa/totp/disable.ts
new file mode 100644
index 00000000..7916e598
--- /dev/null
+++ b/src/api/routes/v9/users/@me/mfa/totp/disable.ts
@@ -0,0 +1,56 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { verifyToken } from "node-2fa";
+import { HTTPError } from "lambert-server";
+import {
+ User,
+ generateToken,
+ BackupCode,
+ TotpDisableSchema,
+} from "@fosscord/util";
+
+const router = Router();
+
+router.post(
+ "/",
+ route({ body: "TotpDisableSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as TotpDisableSchema;
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: ["totp_secret"],
+ });
+
+ const backup = await BackupCode.findOne({ where: { code: body.code } });
+ if (!backup) {
+ const ret = verifyToken(user.totp_secret!, body.code);
+ if (!ret || ret.delta != 0)
+ throw new HTTPError(
+ req.t("auth:login.INVALID_TOTP_CODE"),
+ 60008,
+ );
+ }
+
+ await User.update(
+ { id: req.user_id },
+ {
+ mfa_enabled: false,
+ totp_secret: "",
+ },
+ );
+
+ await BackupCode.update(
+ { user: { id: req.user_id } },
+ {
+ expired: true,
+ },
+ );
+
+ return res.json({
+ token: await generateToken(user.id),
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/mfa/totp/enable.ts b/src/api/routes/v9/users/@me/mfa/totp/enable.ts
new file mode 100644
index 00000000..2c7044da
--- /dev/null
+++ b/src/api/routes/v9/users/@me/mfa/totp/enable.ts
@@ -0,0 +1,59 @@
+import { Router, Request, Response } from "express";
+import {
+ User,
+ generateToken,
+ generateMfaBackupCodes,
+ TotpEnableSchema,
+} from "@fosscord/util";
+import { route } from "@fosscord/api";
+import bcrypt from "bcrypt";
+import { HTTPError } from "lambert-server";
+import { verifyToken } from "node-2fa";
+
+const router = Router();
+
+router.post(
+ "/",
+ route({ body: "TotpEnableSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as TotpEnableSchema;
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: ["data", "email"],
+ });
+
+ // TODO: Are guests allowed to enable 2fa?
+ if (user.data.hash) {
+ if (!(await bcrypt.compare(body.password, user.data.hash))) {
+ throw new HTTPError(req.t("auth:login.INVALID_PASSWORD"));
+ }
+ }
+
+ if (!body.secret)
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_SECRET"), 60005);
+
+ if (!body.code)
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
+
+ if (verifyToken(body.secret, body.code)?.delta != 0)
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
+
+ let backup_codes = generateMfaBackupCodes(req.user_id);
+ await Promise.all(backup_codes.map((x) => x.save()));
+ await User.update(
+ { id: req.user_id },
+ { mfa_enabled: true, totp_secret: body.secret },
+ );
+
+ res.send({
+ token: await generateToken(user.id),
+ backup_codes: backup_codes.map((x) => ({
+ ...x,
+ expired: undefined,
+ })),
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/notes.ts b/src/api/routes/v9/users/@me/notes.ts
new file mode 100644
index 00000000..e54eb897
--- /dev/null
+++ b/src/api/routes/v9/users/@me/notes.ts
@@ -0,0 +1,68 @@
+import { Request, Response, Router } from "express";
+import { route } from "@fosscord/api";
+import { User, Note, emitEvent, Snowflake } from "@fosscord/util";
+
+const router: Router = Router();
+
+router.get("/:id", route({}), async (req: Request, res: Response) => {
+ const { id } = req.params;
+
+ const note = await Note.findOneOrFail({
+ where: {
+ owner: { id: req.user_id },
+ target: { id: id },
+ },
+ });
+
+ return res.json({
+ note: note?.content,
+ note_user_id: id,
+ user_id: req.user_id,
+ });
+});
+
+router.put("/:id", route({}), async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const owner = await User.findOneOrFail({ where: { id: req.user_id } });
+ const target = await User.findOneOrFail({ where: { id: id } }); //if noted user does not exist throw
+ const { note } = req.body;
+
+ if (note && note.length) {
+ // upsert a note
+ if (
+ await Note.findOne({
+ where: { owner: { id: owner.id }, target: { id: target.id } },
+ })
+ ) {
+ Note.update(
+ { owner: { id: owner.id }, target: { id: target.id } },
+ { owner, target, content: note },
+ );
+ } else {
+ Note.insert({
+ id: Snowflake.generate(),
+ owner,
+ target,
+ content: note,
+ });
+ }
+ } else {
+ await Note.delete({
+ owner: { id: owner.id },
+ target: { id: target.id },
+ });
+ }
+
+ await emitEvent({
+ event: "USER_NOTE_UPDATE",
+ data: {
+ note: note,
+ id: target.id,
+ },
+ user_id: owner.id,
+ });
+
+ return res.status(204);
+});
+
+export default router;
diff --git a/src/api/routes/v9/users/@me/relationships.ts b/src/api/routes/v9/users/@me/relationships.ts
new file mode 100644
index 00000000..3eec704b
--- /dev/null
+++ b/src/api/routes/v9/users/@me/relationships.ts
@@ -0,0 +1,259 @@
+import {
+ RelationshipAddEvent,
+ User,
+ PublicUserProjection,
+ RelationshipType,
+ RelationshipRemoveEvent,
+ emitEvent,
+ Relationship,
+ Config,
+} from "@fosscord/util";
+import { Router, Response, Request } from "express";
+import { HTTPError } from "lambert-server";
+import { DiscordApiErrors } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+const userProjection: (keyof User)[] = [
+ "relationships",
+ ...PublicUserProjection,
+];
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ relations: ["relationships", "relationships.to"],
+ select: ["id", "relationships"],
+ });
+
+ //TODO DTO
+ const related_users = user.relationships.map((r) => {
+ return {
+ id: r.to.id,
+ type: r.type,
+ nickname: null,
+ user: r.to.toPublicUser(),
+ };
+ });
+
+ return res.json(related_users);
+});
+
+router.put(
+ "/:id",
+ route({ body: "RelationshipPutSchema" }),
+ async (req: Request, res: Response) => {
+ return await updateRelationship(
+ req,
+ res,
+ await User.findOneOrFail({
+ where: { id: req.params.id },
+ relations: ["relationships", "relationships.to"],
+ select: userProjection,
+ }),
+ req.body.type ?? RelationshipType.friends,
+ );
+ },
+);
+
+router.post(
+ "/",
+ route({ body: "RelationshipPostSchema" }),
+ async (req: Request, res: Response) => {
+ return await updateRelationship(
+ req,
+ res,
+ await User.findOneOrFail({
+ relations: ["relationships", "relationships.to"],
+ select: userProjection,
+ where: {
+ discriminator: String(req.body.discriminator).padStart(
+ 4,
+ "0",
+ ), //Discord send the discriminator as integer, we need to add leading zeroes
+ username: req.body.username,
+ },
+ }),
+ req.body.type,
+ );
+ },
+);
+
+router.delete("/:id", route({}), async (req: Request, res: Response) => {
+ const { id } = req.params;
+ if (id === req.user_id)
+ throw new HTTPError("You can't remove yourself as a friend");
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ select: userProjection,
+ relations: ["relationships"],
+ });
+ const friend = await User.findOneOrFail({
+ where: { id: id },
+ select: userProjection,
+ relations: ["relationships"],
+ });
+
+ const relationship = user.relationships.find((x) => x.to_id === id);
+ const friendRequest = friend.relationships.find(
+ (x) => x.to_id === req.user_id,
+ );
+
+ if (!relationship)
+ throw new HTTPError("You are not friends with the user", 404);
+ if (relationship?.type === RelationshipType.blocked) {
+ // unblock user
+
+ await Promise.all([
+ Relationship.delete({ id: relationship.id }),
+ emitEvent({
+ event: "RELATIONSHIP_REMOVE",
+ user_id: req.user_id,
+ data: relationship.toPublicRelationship(),
+ } as RelationshipRemoveEvent),
+ ]);
+ return res.sendStatus(204);
+ }
+ if (friendRequest && friendRequest.type !== RelationshipType.blocked) {
+ await Promise.all([
+ Relationship.delete({ id: friendRequest.id }),
+ await emitEvent({
+ event: "RELATIONSHIP_REMOVE",
+ data: friendRequest.toPublicRelationship(),
+ user_id: id,
+ } as RelationshipRemoveEvent),
+ ]);
+ }
+
+ await Promise.all([
+ Relationship.delete({ id: relationship.id }),
+ emitEvent({
+ event: "RELATIONSHIP_REMOVE",
+ data: relationship.toPublicRelationship(),
+ user_id: req.user_id,
+ } as RelationshipRemoveEvent),
+ ]);
+
+ return res.sendStatus(204);
+});
+
+export default router;
+
+async function updateRelationship(
+ req: Request,
+ res: Response,
+ friend: User,
+ type: RelationshipType,
+) {
+ const id = friend.id;
+ if (id === req.user_id)
+ throw new HTTPError("You can't add yourself as a friend");
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ relations: ["relationships", "relationships.to"],
+ select: userProjection,
+ });
+
+ var relationship = user.relationships.find((x) => x.to_id === id);
+ const friendRequest = friend.relationships.find(
+ (x) => x.to_id === req.user_id,
+ );
+
+ // TODO: you can add infinitely many blocked users (should this be prevented?)
+ if (type === RelationshipType.blocked) {
+ if (relationship) {
+ if (relationship.type === RelationshipType.blocked)
+ throw new HTTPError("You already blocked the user");
+ relationship.type = RelationshipType.blocked;
+ await relationship.save();
+ } else {
+ relationship = await Relationship.create({
+ to_id: id,
+ type: RelationshipType.blocked,
+ from_id: req.user_id,
+ }).save();
+ }
+
+ if (friendRequest && friendRequest.type !== RelationshipType.blocked) {
+ await Promise.all([
+ Relationship.delete({ id: friendRequest.id }),
+ emitEvent({
+ event: "RELATIONSHIP_REMOVE",
+ data: friendRequest.toPublicRelationship(),
+ user_id: id,
+ } as RelationshipRemoveEvent),
+ ]);
+ }
+
+ await emitEvent({
+ event: "RELATIONSHIP_ADD",
+ data: relationship.toPublicRelationship(),
+ user_id: req.user_id,
+ } as RelationshipAddEvent);
+
+ return res.sendStatus(204);
+ }
+
+ const { maxFriends } = Config.get().limits.user;
+ if (user.relationships.length >= maxFriends)
+ throw DiscordApiErrors.MAXIMUM_FRIENDS.withParams(maxFriends);
+
+ var incoming_relationship = Relationship.create({
+ nickname: undefined,
+ type: RelationshipType.incoming,
+ to: user,
+ from: friend,
+ });
+ var outgoing_relationship = Relationship.create({
+ nickname: undefined,
+ type: RelationshipType.outgoing,
+ to: friend,
+ from: user,
+ });
+
+ if (friendRequest) {
+ if (friendRequest.type === RelationshipType.blocked)
+ throw new HTTPError("The user blocked you");
+ if (friendRequest.type === RelationshipType.friends)
+ throw new HTTPError("You are already friends with the user");
+ // accept friend request
+ incoming_relationship = friendRequest;
+ incoming_relationship.type = RelationshipType.friends;
+ }
+
+ if (relationship) {
+ if (relationship.type === RelationshipType.outgoing)
+ throw new HTTPError("You already sent a friend request");
+ if (relationship.type === RelationshipType.blocked)
+ throw new HTTPError(
+ "Unblock the user before sending a friend request",
+ );
+ if (relationship.type === RelationshipType.friends)
+ throw new HTTPError("You are already friends with the user");
+ outgoing_relationship = relationship;
+ outgoing_relationship.type = RelationshipType.friends;
+ }
+
+ await Promise.all([
+ incoming_relationship.save(),
+ outgoing_relationship.save(),
+ emitEvent({
+ event: "RELATIONSHIP_ADD",
+ data: outgoing_relationship.toPublicRelationship(),
+ user_id: req.user_id,
+ } as RelationshipAddEvent),
+ emitEvent({
+ event: "RELATIONSHIP_ADD",
+ data: {
+ ...incoming_relationship.toPublicRelationship(),
+ should_notify: true,
+ },
+ user_id: id,
+ } as RelationshipAddEvent),
+ ]);
+
+ return res.sendStatus(204);
+}
diff --git a/src/api/routes/v9/users/@me/settings.ts b/src/api/routes/v9/users/@me/settings.ts
new file mode 100644
index 00000000..cce366ac
--- /dev/null
+++ b/src/api/routes/v9/users/@me/settings.ts
@@ -0,0 +1,35 @@
+import { Router, Response, Request } from "express";
+import { OrmUtils, User, UserSettingsSchema } from "@fosscord/util";
+import { route } from "@fosscord/api";
+
+const router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ relations: ["settings"],
+ });
+ return res.json(user.settings);
+});
+
+router.patch(
+ "/",
+ route({ body: "UserSettingsSchema" }),
+ async (req: Request, res: Response) => {
+ const body = req.body as UserSettingsSchema;
+ if (body.locale === "en") body.locale = "en-US"; // fix discord client crash on unkown locale
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id, bot: false },
+ relations: ["settings"],
+ });
+
+ user.settings.assign(body);
+
+ user.settings.save();
+
+ res.json(user.settings);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/v9/voice/regions.ts b/src/api/routes/v9/voice/regions.ts
new file mode 100644
index 00000000..4de304ee
--- /dev/null
+++ b/src/api/routes/v9/voice/regions.ts
@@ -0,0 +1,11 @@
+import { Router, Request, Response } from "express";
+import { getIpAdress, route } from "@fosscord/api";
+import { getVoiceRegions } from "@fosscord/api";
+
+const router: Router = Router();
+
+router.get("/", route({}), async (req: Request, res: Response) => {
+ res.json(await getVoiceRegions(getIpAdress(req), true)); //vip true?
+});
+
+export default router;
|