summary refs log tree commit diff
path: root/api/src
diff options
context:
space:
mode:
authorFlam3rboy <34555296+Flam3rboy@users.noreply.github.com>2021-09-24 10:43:43 +0200
committerFlam3rboy <34555296+Flam3rboy@users.noreply.github.com>2021-09-24 10:43:43 +0200
commit9abb04d2e6fcc2567ddafc2dc753cbb0c79ca930 (patch)
treea9ce1cd54d5dd50023e1a1c5d0a494802f37b5e6 /api/src
parent:sparkles: added User flags (diff)
parent:art: remove start from setup script (diff)
downloadserver-9abb04d2e6fcc2567ddafc2dc753cbb0c79ca930.tar.xz
Merge branch 'master' of https://github.com/fosscord/fosscord-server
Diffstat (limited to 'api/src')
-rw-r--r--api/src/Server.ts30
-rw-r--r--api/src/index.ts11
-rw-r--r--api/src/middlewares/BodyParser.ts2
-rw-r--r--api/src/middlewares/ErrorHandler.ts9
-rw-r--r--api/src/middlewares/RateLimit.ts5
-rw-r--r--api/src/middlewares/TestClient.ts2
-rw-r--r--api/src/routes/applications/detectable.ts11
-rw-r--r--api/src/routes/auth/login.ts131
-rw-r--r--api/src/routes/auth/register.ts382
-rw-r--r--api/src/routes/channels/#channel_id/index.ts86
-rw-r--r--api/src/routes/channels/#channel_id/invites.ts42
-rw-r--r--api/src/routes/channels/#channel_id/messages/#message_id/ack.ts12
-rw-r--r--api/src/routes/channels/#channel_id/messages/#message_id/index.ts22
-rw-r--r--api/src/routes/channels/#channel_id/messages/#message_id/reactions.ts33
-rw-r--r--api/src/routes/channels/#channel_id/messages/bulk-delete.ts9
-rw-r--r--api/src/routes/channels/#channel_id/messages/index.ts163
-rw-r--r--api/src/routes/channels/#channel_id/permissions.ts84
-rw-r--r--api/src/routes/channels/#channel_id/pins.ts33
-rw-r--r--api/src/routes/channels/#channel_id/recipients.ts57
-rw-r--r--api/src/routes/channels/#channel_id/typing.ts10
-rw-r--r--api/src/routes/channels/#channel_id/webhooks.ts16
-rw-r--r--api/src/routes/discoverable-guilds.ts4
-rw-r--r--api/src/routes/experiments.ts3
-rw-r--r--api/src/routes/gateway.ts17
-rw-r--r--api/src/routes/guilds/#guild_id/bans.ts32
-rw-r--r--api/src/routes/guilds/#guild_id/channels.ts76
-rw-r--r--api/src/routes/guilds/#guild_id/delete.ts15
-rw-r--r--api/src/routes/guilds/#guild_id/index.ts37
-rw-r--r--api/src/routes/guilds/#guild_id/invites.ts6
-rw-r--r--api/src/routes/guilds/#guild_id/members/#member_id/index.ts36
-rw-r--r--api/src/routes/guilds/#guild_id/members/#member_id/nick.ts9
-rw-r--r--api/src/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts11
-rw-r--r--api/src/routes/guilds/#guild_id/members/index.ts28
-rw-r--r--api/src/routes/guilds/#guild_id/regions.ts8
-rw-r--r--api/src/routes/guilds/#guild_id/roles.ts49
-rw-r--r--api/src/routes/guilds/#guild_id/templates.ts53
-rw-r--r--api/src/routes/guilds/#guild_id/vanity-url.ts29
-rw-r--r--api/src/routes/guilds/#guild_id/voice-states/#user_id/index.ts59
-rw-r--r--api/src/routes/guilds/#guild_id/voice-states/@me/index.ts15
-rw-r--r--api/src/routes/guilds/#guild_id/welcome_screen.ts23
-rw-r--r--api/src/routes/guilds/#guild_id/widget.json.ts4
-rw-r--r--api/src/routes/guilds/#guild_id/widget.png.ts3
-rw-r--r--api/src/routes/guilds/#guild_id/widget.ts21
-rw-r--r--api/src/routes/guilds/index.ts24
-rw-r--r--api/src/routes/guilds/templates/index.ts13
-rw-r--r--api/src/routes/invites/index.ts34
-rw-r--r--api/src/routes/outbound-promotions.ts11
-rw-r--r--api/src/routes/ping.ts3
-rw-r--r--api/src/routes/science.ts3
-rw-r--r--api/src/routes/sticker-packs/#id/index.ts19
-rw-r--r--api/src/routes/sticker-packs/index.ts11
-rw-r--r--api/src/routes/store/applications.ts12
-rw-r--r--api/src/routes/store/skus.ts12
-rw-r--r--api/src/routes/users/#id/index.ts5
-rw-r--r--api/src/routes/users/#id/profile.ts20
-rw-r--r--api/src/routes/users/@me/affinities/guilds.ts3
-rw-r--r--api/src/routes/users/@me/affinities/users.ts (renamed from api/src/routes/users/@me/affinities/user.ts)3
-rw-r--r--api/src/routes/users/@me/applications/#app_id/entitlements.ts11
-rw-r--r--api/src/routes/users/@me/billing/country-code.ts11
-rw-r--r--api/src/routes/users/@me/billing/subscriptions.ts11
-rw-r--r--api/src/routes/users/@me/channels.ts50
-rw-r--r--api/src/routes/users/@me/connections.ts11
-rw-r--r--api/src/routes/users/@me/delete.ts4
-rw-r--r--api/src/routes/users/@me/devices.ts3
-rw-r--r--api/src/routes/users/@me/disable.ts3
-rw-r--r--api/src/routes/users/@me/guilds.ts6
-rw-r--r--api/src/routes/users/@me/index.ts42
-rw-r--r--api/src/routes/users/@me/library.ts3
-rw-r--r--api/src/routes/users/@me/relationships.ts235
-rw-r--r--api/src/routes/users/@me/settings.ts7
-rw-r--r--api/src/routes/voice/regions.ts8
-rw-r--r--api/src/schema/Ban.ts9
-rw-r--r--api/src/schema/Channel.ts68
-rw-r--r--api/src/schema/Emoji.ts13
-rw-r--r--api/src/schema/Guild.ts106
-rw-r--r--api/src/schema/Invite.ts22
-rw-r--r--api/src/schema/Member.ts29
-rw-r--r--api/src/schema/Message.ts92
-rw-r--r--api/src/schema/Roles.ts29
-rw-r--r--api/src/schema/Template.ts19
-rw-r--r--api/src/schema/User.ts74
-rw-r--r--api/src/schema/Widget.ts10
-rw-r--r--api/src/schema/index.ts11
-rw-r--r--api/src/test/password_test.ts12
-rw-r--r--api/src/util/FieldError.ts25
-rw-r--r--api/src/util/Message.ts2
-rw-r--r--api/src/util/String.ts6
-rw-r--r--api/src/util/Voice.ts50
-rw-r--r--api/src/util/VoiceState.ts54
-rw-r--r--api/src/util/cdn.ts13
-rw-r--r--api/src/util/index.ts9
-rw-r--r--api/src/util/instanceOf.ts214
-rw-r--r--api/src/util/passwordStrength.ts2
-rw-r--r--api/src/util/route.ts102
94 files changed, 1489 insertions, 1748 deletions
diff --git a/api/src/Server.ts b/api/src/Server.ts

index 0f444f86..4a226d12 100644 --- a/api/src/Server.ts +++ b/api/src/Server.ts
@@ -1,3 +1,4 @@ +import { OptionsJson } from 'body-parser'; import "missing-native-js-functions"; import { Connection } from "mongoose"; import { Server, ServerOptions } from "lambert-server"; @@ -11,6 +12,7 @@ import path from "path"; import { initRateLimits } from "./middlewares/RateLimit"; import TestClient from "./middlewares/TestClient"; import { initTranslation } from "./middlewares/Translation"; +import morgan from "morgan"; export interface FosscordServerOptions extends ServerOptions {} @@ -36,8 +38,31 @@ export class FosscordServer extends Server { await Config.init(); await initEvent(); + + /* + DOCUMENTATION: uses LOG_REQUESTS environment variable + + # only log 200 and 204 + LOG_REQUESTS=200 204 + # log everything except 200 and 204 + LOG_REQUESTS=-200 204 + # log all requests + LOG_REQUESTS=- + */ + + let logRequests = process.env["LOG_REQUESTS"] != undefined; + if(logRequests) { + this.app.use(morgan("combined", { + skip: (req, res) => { + var skip = !(process.env["LOG_REQUESTS"]?.includes(res.statusCode.toString()) ?? false); + if(process.env["LOG_REQUESTS"]?.charAt(0) == '-') skip = !skip; + return skip; + } + })); + } + this.app.use(CORS); - this.app.use(BodyParser({ inflate: true, limit: 1024 * 1024 * 10 })); // 10MB + this.app.use(BodyParser({ inflate: true, limit: "10mb" })); const app = this.app; const api = Router(); // @ts-ignore @@ -65,6 +90,9 @@ export class FosscordServer extends Server { this.app.use(ErrorHandler); TestClient(this.app); + if(logRequests){ + console.log("Warning: Request logging is enabled! This will spam your console!\nTo disable this, unset the 'LOG_REQUESTS' environment variable!"); + } return super.start(); } } diff --git a/api/src/index.ts b/api/src/index.ts
index fe59310f..adc7649c 100644 --- a/api/src/index.ts +++ b/api/src/index.ts
@@ -1,12 +1,3 @@ export * from "./Server"; export * from "./middlewares/"; -export * from "./schema/Ban"; -export * from "./schema/Channel"; -export * from "./schema/Guild"; -export * from "./schema/Invite"; -export * from "./schema/Message"; -export * from "./util/instanceOf"; -export * from "./util/instanceOf"; -export * from "./util/RandomInviteID"; -export * from "./util/String"; -export { check as checkPassword } from "./util/passwordStrength"; +export * from "./util/"; diff --git a/api/src/middlewares/BodyParser.ts b/api/src/middlewares/BodyParser.ts
index b0ff699d..4cb376bc 100644 --- a/api/src/middlewares/BodyParser.ts +++ b/api/src/middlewares/BodyParser.ts
@@ -6,6 +6,8 @@ export function BodyParser(opts?: OptionsJson) { const jsonParser = bodyParser.json(opts); return (req: Request, res: Response, next: NextFunction) => { + if (!req.headers["content-type"]) req.headers["content-type"] = "application/json"; + jsonParser(req, res, (err) => { if (err) { // TODO: different errors for body parser (request size limit, wrong body type, invalid body, ...) diff --git a/api/src/middlewares/ErrorHandler.ts b/api/src/middlewares/ErrorHandler.ts
index be2586cf..96e703ce 100644 --- a/api/src/middlewares/ErrorHandler.ts +++ b/api/src/middlewares/ErrorHandler.ts
@@ -1,8 +1,9 @@ import { NextFunction, Request, Response } from "express"; import { HTTPError } from "lambert-server"; import { EntityNotFoundError } from "typeorm"; -import { FieldError } from "../util/instanceOf"; +import { FieldError } from "@fosscord/api"; import { ApiError } from "@fosscord/util"; +const EntityNotFoundErrorRegex = /"(\w+)"/; export function ErrorHandler(error: Error, req: Request, res: Response, next: NextFunction) { if (!error) return next(); @@ -18,9 +19,9 @@ export function ErrorHandler(error: Error, req: Request, res: Response, next: Ne code = error.code; message = error.message; httpcode = error.httpStatus; - } else if (error instanceof EntityNotFoundError) { - message = `${(error as any).stringifyTarget || "Item"} could not be found`; - code = 404; + } else if (error.name === "EntityNotFoundError") { + message = `${error.message.match(EntityNotFoundErrorRegex)?.[1] || "Item"} could not be found`; + code = httpcode = 404; } else if (error instanceof FieldError) { code = Number(error.code); message = error.message; diff --git a/api/src/middlewares/RateLimit.ts b/api/src/middlewares/RateLimit.ts
index dffbc0d9..1a38cfcf 100644 --- a/api/src/middlewares/RateLimit.ts +++ b/api/src/middlewares/RateLimit.ts
@@ -1,6 +1,6 @@ import { Config, listenEvent } from "@fosscord/util"; import { NextFunction, Request, Response, Router } from "express"; -import { getIpAdress } from "../util/ipAddress"; +import { getIpAdress } from "@fosscord/api"; import { API_PREFIX_TRAILING_SLASH } from "./Authentication"; // Docs: https://discord.com/developers/docs/topics/rate-limits @@ -107,7 +107,8 @@ export default function rateLimit(opts: { } export async function initRateLimits(app: Router) { - const { routes, global, ip, error } = Config.get().limits.rate; + const { routes, global, ip, error, disabled } = Config.get().limits.rate; + if (disabled) return; await listenEvent(EventRateLimit, (event) => { Cache.set(event.channel_id as string, event.data); event.acknowledge?.(); diff --git a/api/src/middlewares/TestClient.ts b/api/src/middlewares/TestClient.ts
index 73e7b9c2..79f8f442 100644 --- a/api/src/middlewares/TestClient.ts +++ b/api/src/middlewares/TestClient.ts
@@ -67,6 +67,8 @@ export default function TestClient(app: Application) { res.set("Cache-Control", "public, max-age=" + 60 * 60 * 24); res.set("content-type", "text/html"); + if (req.url.startsWith("/invite")) return res.send(html.replace("9b2b7f0632acd0c5e781", "9f24f709a3de09b67c49")); + res.send(html); }); } diff --git a/api/src/routes/applications/detectable.ts b/api/src/routes/applications/detectable.ts new file mode 100644
index 00000000..411e95bf --- /dev/null +++ b/api/src/routes/applications/detectable.ts
@@ -0,0 +1,11 @@ +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([]).status(200); +}); + +export default router; diff --git a/api/src/routes/auth/login.ts b/api/src/routes/auth/login.ts
index 7fd0f870..ff04f8aa 100644 --- a/api/src/routes/auth/login.ts +++ b/api/src/routes/auth/login.ts
@@ -1,93 +1,70 @@ import { Request, Response, Router } from "express"; -import { check, FieldErrors, Length } from "../../util/instanceOf"; +import { FieldErrors, route } from "@fosscord/api"; import bcrypt from "bcrypt"; -import jwt from "jsonwebtoken"; -import { Config, User } from "@fosscord/util"; -import { adjustEmail } from "./register"; +import { Config, User, generateToken, adjustEmail } from "@fosscord/util"; const router: Router = Router(); export default router; -router.post( - "/", - check({ - login: new Length(String, 2, 100), // email or telephone - password: new Length(String, 8, 72), - $undelete: Boolean, - $captcha_key: String, - $login_source: String, - $gift_code_sku_id: String - }), - async (req: Request, res: Response) => { - const { login, password, captcha_key, undelete } = req.body; - const email = adjustEmail(login); - console.log("login", email); - - const config = Config.get(); - - if (config.login.requireCaptcha && config.security.captcha.enabled) { - if (!captcha_key) { - const { sitekey, service } = config.security.captcha; - return res.status(400).json({ - captcha_key: ["captcha-required"], - captcha_sitekey: sitekey, - captcha_service: service - }); - } - - // TODO: check captcha - } +export interface LoginSchema { + login: string; + password: string; + undelete?: boolean; + captcha_key?: string; + login_source?: string; + gift_code_sku_id?: string; +} - const user = await User.findOneOrFail({ - where: [{ phone: login }, { email: login }], - select: ["data", "id", "disabled", "deleted", "settings"] - }).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 }); +router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Response) => { + const { login, password, captcha_key, undelete } = req.body as LoginSchema; + const email = adjustEmail(login); + console.log("login", email); + + const config = Config.get(); + + if (config.login.requireCaptcha && config.security.captcha.enabled) { + if (!captcha_key) { + const { sitekey, service } = config.security.captcha; + return res.status(400).json({ + captcha_key: ["captcha-required"], + captcha_sitekey: sitekey, + captcha_service: service + }); } - // 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" } }); - } + // TODO: check captcha + } - const token = await generateToken(user.id); + const user = await User.findOneOrFail({ + where: [{ phone: login }, { email: login }], + select: ["data", "id", "disabled", "deleted", "settings"] + }).catch((e) => { + throw FieldErrors({ login: { message: req.t("auth:login.INVALID_LOGIN"), code: "INVALID_LOGIN" } }); + }); - // 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 + 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 }); + } - res.json({ token, settings: user.settings }); + // 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" } }); } -); - -export async function generateToken(id: string) { - const iat = Math.floor(Date.now() / 1000); - const algorithm = "HS256"; - - return new Promise((res, rej) => { - jwt.sign( - { id: id, iat }, - Config.get().security.jwtSecret, - { - algorithm - }, - (err, token) => { - if (err) return rej(err); - return res(token); - } - ); - }); -} + + 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 diff --git a/api/src/routes/auth/register.ts b/api/src/routes/auth/register.ts
index a9518e91..9c058399 100644 --- a/api/src/routes/auth/register.ts +++ b/api/src/routes/auth/register.ts
@@ -1,234 +1,230 @@ import { Request, Response, Router } from "express"; -import { trimSpecial, User, Snowflake, Config, defaultSettings } from "@fosscord/util"; +import { trimSpecial, User, Snowflake, Config, defaultSettings, generateToken, Invite, adjustEmail } from "@fosscord/util"; import bcrypt from "bcrypt"; -import { check, Email, EMAIL_REGEX, FieldErrors, Length } from "../../util/instanceOf"; +import { FieldErrors, route, getIpAdress, IPAnalysis, isProxy } from "@fosscord/api"; import "missing-native-js-functions"; -import { generateToken } from "./login"; -import { getIpAdress, IPAnalysis, isProxy } from "../../util/ipAddress"; import { HTTPError } from "lambert-server"; -import { In } from "typeorm"; const router: Router = Router(); -router.post( - "/", - check({ - username: new Length(String, 2, 32), - // TODO: check min password length in config - // prevent Denial of Service with max length of 72 chars - password: new Length(String, 8, 72), - consent: Boolean, - $email: new Length(Email, 5, 100), - $fingerprint: String, - $invite: String, - $date_of_birth: Date, // "2000-04-03" - $gift_code_sku_id: String, - $captcha_key: String - }), - async (req: Request, res: Response) => { - const { - email, - username, - password, - consent, - fingerprint, - invite, - date_of_birth, - gift_code_sku_id, // ? what is this - captcha_key - } = req.body; - - // get register Config - const { register, security } = Config.get(); - const ip = getIpAdress(req); - - if (register.blockProxies) { - if (isProxy(await IPAnalysis(ip))) { - console.log(`proxy ${ip} blocked from registration`); - throw new HTTPError("Your IP is blocked from registration"); - } +export interface RegisterSchema { + /** + * @minLength 2 + * @maxLength 32 + */ + username: string; + /** + * @minLength 1 + * @maxLength 72 + */ + password?: string; + consent: boolean; + /** + * @TJS-format email + */ + email?: string; + fingerprint?: string; + invite?: string; + /** + * @TJS-type string + */ + date_of_birth?: Date; // "2000-04-03" + gift_code_sku_id?: string; + captcha_key?: string; +} + +router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Response) => { + let { + email, + username, + password, + consent, + fingerprint, + invite, + date_of_birth, + gift_code_sku_id, // ? what is this + captcha_key + } = req.body; + + // get register Config + const { register, security } = Config.get(); + const ip = getIpAdress(req); + + if (register.blockProxies) { + if (isProxy(await IPAnalysis(ip))) { + console.log(`proxy ${ip} blocked from registration`); + throw new HTTPError("Your IP is blocked from registration"); } + } - console.log("register", req.body.email, req.body.username, ip); - // TODO: automatically join invite - // TODO: gift_code_sku_id? - // TODO: check password strength + console.log("register", req.body.email, req.body.username, ip); + // TODO: gift_code_sku_id? + // TODO: check password strength - // adjusted_email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick - let adjusted_email = adjustEmail(email); + // email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick + email = adjustEmail(email); - // adjusted_password will be the hash of the password - let adjusted_password = ""; + // trim special uf8 control characters -> Backspace, Newline, ... + username = trimSpecial(username); - // trim special uf8 control characters -> Backspace, Newline, ... - let adjusted_username = trimSpecial(username); + // discriminator will be randomly generated + let discriminator = ""; - // discriminator will be randomly generated - let discriminator = ""; + // check if registration is allowed + if (!register.allowNewRegistration) { + throw FieldErrors({ + email: { code: "REGISTRATION_DISABLED", message: req.t("auth:register.REGISTRATION_DISABLED") } + }); + } - // check if registration is allowed - if (!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 (!consent) { + throw FieldErrors({ + consent: { code: "CONSENT_REQUIRED", message: req.t("auth:register.CONSENT_REQUIRED") } + }); + } + + 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.findOneOrFail({ email: email }).catch((e) => {}); - // check if the user agreed to the Terms of Service - if (!consent) { + if (exists) { throw FieldErrors({ - consent: { code: "CONSENT_REQUIRED", message: req.t("auth:register.CONSENT_REQUIRED") } + 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") } + }); + } - // require invite to register -> e.g. for organizations to send invites to their employees - if (register.requireInvite && !invite) { + if (register.dateOfBirth.required && !date_of_birth) { + throw FieldErrors({ + date_of_birth: { code: "BASE_TYPE_REQUIRED", message: req.t("common:field.BASE_TYPE_REQUIRED") } + }); + } else if (register.dateOfBirth.minimum) { + const minimum = new Date(); + minimum.setFullYear(minimum.getFullYear() - register.dateOfBirth.minimum); + date_of_birth = new Date(date_of_birth); + + // higher is younger + if (date_of_birth > minimum) { throw FieldErrors({ - email: { code: "INVITE_ONLY", message: req.t("auth:register.INVITE_ONLY") } + date_of_birth: { + code: "DATE_OF_BIRTH_UNDERAGE", + message: req.t("auth:register.DATE_OF_BIRTH_UNDERAGE", { years: register.dateOfBirth.minimum }) + } }); } + } - if (email) { - // replace all dots and chars after +, if its a gmail.com email - if (!adjusted_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.findOneOrFail({ email: adjusted_email }).catch((e) => {}); + if (!register.allowMultipleAccounts) { + // TODO: check if fingerprint was eligible generated + const exists = await User.findOne({ where: { fingerprints: fingerprint } }); - if (exists) { - throw FieldErrors({ - email: { - code: "EMAIL_ALREADY_REGISTERED", - message: req.t("auth:register.EMAIL_ALREADY_REGISTERED") - } - }); - } - } else if (register.email.necessary) { + if (exists) { throw FieldErrors({ - email: { code: "BASE_TYPE_REQUIRED", message: req.t("common:field.BASE_TYPE_REQUIRED") } + email: { + code: "EMAIL_ALREADY_REGISTERED", + message: req.t("auth:register.EMAIL_ALREADY_REGISTERED") + } }); } + } - if (register.dateOfBirth.necessary && !date_of_birth) { - throw FieldErrors({ - date_of_birth: { code: "BASE_TYPE_REQUIRED", message: req.t("common:field.BASE_TYPE_REQUIRED") } + if (register.requireCaptcha && security.captcha.enabled) { + if (!captcha_key) { + const { sitekey, service } = security.captcha; + return res.status(400).json({ + captcha_key: ["captcha-required"], + captcha_sitekey: sitekey, + captcha_service: service }); - } else if (register.dateOfBirth.minimum) { - const minimum = new Date(); - minimum.setFullYear(minimum.getFullYear() - register.dateOfBirth.minimum); - - // higher is younger - if (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 (!register.allowMultipleAccounts) { - // TODO: check if fingerprint was eligible generated - const exists = await User.findOne({ where: { fingerprints: In(fingerprint) } }); - - if (exists) { - throw FieldErrors({ - email: { - code: "EMAIL_ALREADY_REGISTERED", - message: req.t("auth:register.EMAIL_ALREADY_REGISTERED") - } - }); - } } - if (register.requireCaptcha && security.captcha.enabled) { - if (!captcha_key) { - const { sitekey, service } = security.captcha; - return res.status(400).json({ - captcha_key: ["captcha-required"], - captcha_sitekey: sitekey, - captcha_service: service - }); - } - - // TODO: check captcha - } + // TODO: check captcha + } + if (password) { // the salt is saved in the password refer to bcrypt docs - adjusted_password = await bcrypt.hash(password, 12); - - let exists; - // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists - // if it all five times already exists, abort with USERNAME_TOO_MANY_USERS error - // else just continue - // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the mongodb database? - for (let tries = 0; tries < 5; tries++) { - discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0"); - exists = await User.findOne({ where: { discriminator, username: adjusted_username }, select: ["id"] }); - if (!exists) break; - } + password = await bcrypt.hash(password, 12); + } else if (register.password.required) { + throw FieldErrors({ + password: { code: "BASE_TYPE_REQUIRED", message: req.t("common:field.BASE_TYPE_REQUIRED") } + }); + } - if (exists) { - throw FieldErrors({ - username: { - code: "USERNAME_TOO_MANY_USERS", - message: req.t("auth:register.USERNAME_TOO_MANY_USERS") - } - }); - } + let exists; + // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists + // if it all five times already exists, abort with USERNAME_TOO_MANY_USERS error + // else just continue + // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the mongodb database? + for (let tries = 0; tries < 5; tries++) { + discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0"); + exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"] }); + if (!exists) break; + } - // TODO: save date_of_birth - // appearently discord doesn't save the date of birth and just calculate if nsfw is allowed - // if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false - - const user = await new User({ - created_at: new Date(), - username: adjusted_username, - discriminator, - id: Snowflake.generate(), - bot: false, - system: false, - desktop: false, - mobile: false, - premium: true, - premium_type: 2, - bio: "", - mfa_enabled: false, - verified: false, - disabled: false, - deleted: false, - email: adjusted_email, - nsfw_allowed: true, // TODO: depending on age - public_flags: "0", - flags: "0", // TODO: generate - data: { - hash: adjusted_password, - valid_tokens_since: new Date() - }, - settings: { ...defaultSettings, locale: req.language || "en-US" }, - fingerprints: [] - }).save(); - - return res.json({ token: await generateToken(user.id) }); + if (exists) { + throw FieldErrors({ + username: { + code: "USERNAME_TOO_MANY_USERS", + message: req.t("auth:register.USERNAME_TOO_MANY_USERS") + } + }); } -); - -export function adjustEmail(email: string): string | undefined { - // body parser already checked if it is a valid email - const parts = <RegExpMatchArray>email.match(EMAIL_REGEX); - // @ts-ignore - if (!parts || parts.length < 5) return undefined; - const domain = parts[5]; - const user = parts[1]; - - // TODO: check accounts with uncommon email domains - if (domain === "gmail.com" || domain === "googlemail.com") { - // replace .dots and +alternatives -> Gmail Dot Trick https://support.google.com/mail/answer/7436150 and https://generator.email/blog/gmail-generator - return user.replace(/[.]|(\+.*)/g, "") + "@gmail.com"; + + // TODO: save date_of_birth + // appearently discord doesn't save the date of birth and just calculate if nsfw is allowed + // if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false + + const user = await new User({ + created_at: new Date(), + username: username, + discriminator, + id: Snowflake.generate(), + bot: false, + system: false, + desktop: false, + mobile: false, + premium: true, + premium_type: 2, + bio: "", + mfa_enabled: false, + verified: true, + disabled: false, + deleted: false, + email: email, + nsfw_allowed: true, // TODO: depending on age + public_flags: "0", + flags: "0", // TODO: generate + data: { + hash: password, + valid_tokens_since: new Date() + }, + settings: { ...defaultSettings, locale: req.language || "en-US" }, + fingerprints: [] + }).save(); + + if (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, invite); + } else if (register.requireInvite) { + // 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") } + }); } - return email; -} + return res.json({ token: await generateToken(user.id) }); +}); export default router; diff --git a/api/src/routes/channels/#channel_id/index.ts b/api/src/routes/channels/#channel_id/index.ts
index 4aa5a5b9..61c851e8 100644 --- a/api/src/routes/channels/#channel_id/index.ts +++ b/api/src/routes/channels/#channel_id/index.ts
@@ -1,47 +1,81 @@ -import { ChannelDeleteEvent, Channel, ChannelUpdateEvent, emitEvent, getPermission } from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { HTTPError } from "lambert-server"; -import { ChannelModifySchema } from "../../../schema/Channel"; -import { check } from "../../../util/instanceOf"; +import { + Channel, + ChannelDeleteEvent, + ChannelPermissionOverwriteType, + ChannelType, + ChannelUpdateEvent, + emitEvent, + Recipient, + handleFile +} 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("/", async (req: Request, res: Response) => { +router.get("/", route({ permission: "VIEW_CHANNEL" }), async (req: Request, res: Response) => { const { channel_id } = req.params; const channel = await Channel.findOneOrFail({ id: channel_id }); - const permission = await getPermission(req.user_id, channel.guild_id, channel_id); - permission.hasThrow("VIEW_CHANNEL"); - return res.send(channel); }); -router.delete("/", async (req: Request, res: Response) => { +router.delete("/", route({ permission: "MANAGE_CHANNELS" }), async (req: Request, res: Response) => { const { channel_id } = req.params; - const channel = await Channel.findOneOrFail({ id: channel_id }); - - const permission = await getPermission(req.user_id, channel?.guild_id, channel_id); - permission.hasThrow("MANAGE_CHANNELS"); - - // TODO: Dm channel "close" not delete - const data = channel; + 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) + ]); + } - await emitEvent({ event: "CHANNEL_DELETE", data, channel_id } as ChannelDeleteEvent); - - await Channel.delete({ id: channel_id }); - - res.send(data); + res.send(channel); }); -router.patch("/", check(ChannelModifySchema), async (req: Request, res: Response) => { +export interface ChannelModifySchema { + /** + * @maxLength 100 + */ + name?: string; + type?: ChannelType; + topic?: string; + icon?: string | null; + bitrate?: number; + user_limit?: number; + rate_limit_per_user?: number; + position?: number; + permission_overwrites?: { + id: string; + type: ChannelPermissionOverwriteType; + allow: bigint; + deny: bigint; + }[]; + parent_id?: string; + id?: string; // is not used (only for guild create) + nsfw?: boolean; + rtc_region?: string; + default_auto_archive_duration?: number; +} + +router.patch("/", route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }), async (req: Request, res: Response) => { var payload = req.body as ChannelModifySchema; const { channel_id } = req.params; - - const permission = await getPermission(req.user_id, undefined, channel_id); - permission.hasThrow("MANAGE_CHANNELS"); + if (payload.icon) payload.icon = await handleFile(`/channel-icons/${channel_id}`, payload.icon); const channel = await Channel.findOneOrFail({ id: channel_id }); channel.assign(payload); diff --git a/api/src/routes/channels/#channel_id/invites.ts b/api/src/routes/channels/#channel_id/invites.ts
index fe22d3bc..22420983 100644 --- a/api/src/routes/channels/#channel_id/invites.ts +++ b/api/src/routes/channels/#channel_id/invites.ts
@@ -1,14 +1,25 @@ import { Router, Request, Response } from "express"; import { HTTPError } from "lambert-server"; -import { check } from "../../../util/instanceOf"; -import { random } from "../../../util/RandomInviteID"; -import { InviteCreateSchema } from "../../../schema/Invite"; +import { route } from "@fosscord/api"; +import { random } from "@fosscord/api"; import { getPermission, Channel, Invite, InviteCreateEvent, emitEvent, User, Guild, PublicInviteRelation } from "@fosscord/util"; import { isTextChannel } from "./messages"; const router: Router = Router(); -router.post("/", check(InviteCreateSchema), async (req: Request, res: Response) => { +export interface InviteCreateSchema { + target_user_id?: string; + target_type?: string; + validate?: string; // ? what is this + max_age?: number; + max_uses?: number; + temporary?: boolean; + unique?: boolean; + target_user?: string; + target_user_type?: number; +} + +router.post("/", route({ body: "InviteCreateSchema", permission: "CREATE_INSTANT_INVITE" }), 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"] }); @@ -19,23 +30,6 @@ router.post("/", check(InviteCreateSchema), async (req: Request, res: Response) } const { guild_id } = channel; - const permission = await getPermission(user_id, guild_id, undefined, { - guild_select: [ - "banner", - "description", - "features", - "icon", - "id", - "name", - "nsfw", - "nsfw_level", - "splash", - "vanity_url_code", - "verification_level" - ] as (keyof Guild)[] - }); - permission.hasThrow("CREATE_INSTANT_INVITE"); - const expires_at = new Date(req.body.max_age * 1000 + Date.now()); const invite = await new Invite({ @@ -52,14 +46,14 @@ router.post("/", check(InviteCreateSchema), async (req: Request, res: Response) }).save(); const data = invite.toJSON(); data.inviter = await User.getPublicUser(req.user_id); - data.guild = permission.cache.guild; + data.guild = await Guild.findOne({ id: guild_id }); data.channel = channel; await emitEvent({ event: "INVITE_CREATE", data, guild_id } as InviteCreateEvent); res.status(201).send(data); }); -router.get("/", async (req: Request, res: Response) => { +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({ id: channel_id }); @@ -68,8 +62,6 @@ router.get("/", async (req: Request, res: Response) => { throw new HTTPError("This channel doesn't exist", 404); } const { guild_id } = channel; - const permission = await getPermission(user_id, guild_id); - permission.hasThrow("MANAGE_CHANNELS"); const invites = await Invite.find({ where: { guild_id }, relations: PublicInviteRelation }); diff --git a/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts b/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts
index 0fd5f2be..786e4581 100644 --- a/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts +++ b/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts
@@ -1,14 +1,18 @@ import { emitEvent, getPermission, MessageAckEvent, ReadState } from "@fosscord/util"; import { Request, Response, Router } from "express"; - -import { check } from "../../../../../util/instanceOf"; +import { route } from "@fosscord/api"; const router = Router(); // TODO: check if message exists // TODO: send read state event to all channel members -router.post("/", check({ $manual: Boolean, $mention_count: Number }), async (req: Request, res: Response) => { +export interface MessageAcknowledgeSchema { + manual?: boolean; + mention_count?: number; +} + +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); @@ -22,7 +26,7 @@ router.post("/", check({ $manual: Boolean, $mention_count: Number }), async (req data: { channel_id, message_id, - version: 496 + version: 3763 } } as MessageAckEvent); diff --git a/api/src/routes/channels/#channel_id/messages/#message_id/index.ts b/api/src/routes/channels/#channel_id/messages/#message_id/index.ts
index 7a00de43..7f7de264 100644 --- a/api/src/routes/channels/#channel_id/messages/#message_id/index.ts +++ b/api/src/routes/channels/#channel_id/messages/#message_id/index.ts
@@ -1,16 +1,17 @@ import { Channel, emitEvent, getPermission, MessageDeleteEvent, Message, MessageUpdateEvent } from "@fosscord/util"; import { Router, Response, Request } from "express"; -import { MessageCreateSchema } from "../../../../../schema/Message"; -import { check } from "../../../../../util/instanceOf"; -import { handleMessage, postHandleMessage } from "../../../../../util/Message"; +import { route } from "@fosscord/api"; +import { handleMessage, postHandleMessage } from "@fosscord/api"; +import { MessageCreateSchema } from "../index"; const router = Router(); +// TODO: message content/embed string length limit -router.patch("/", check(MessageCreateSchema), async (req: Request, res: Response) => { +router.patch("/", route({ body: "MessageCreateSchema", permission: "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({ id: message_id, channel_id }); + const message = await Message.findOneOrFail({ where: { id: message_id, channel_id }, relations: ["attachments"] }); const permissions = await getPermission(req.user_id, undefined, channel_id); @@ -45,16 +46,17 @@ router.patch("/", check(MessageCreateSchema), async (req: Request, res: Response return res.json(message); }); -// TODO: delete attachments in message - -router.delete("/", async (req: Request, res: Response) => { +// permission check only if deletes messagr from other user +router.delete("/", route({}), async (req: Request, res: Response) => { const { message_id, channel_id } = req.params; const channel = await Channel.findOneOrFail({ id: channel_id }); const message = await Message.findOneOrFail({ id: message_id }); - const permission = await getPermission(req.user_id, channel.guild_id, channel_id); - if (message.author_id !== req.user_id) permission.hasThrow("MANAGE_MESSAGES"); + if (message.author_id !== req.user_id) { + const permission = await getPermission(req.user_id, channel.guild_id, channel_id); + permission.hasThrow("MANAGE_MESSAGES"); + } await Message.delete({ id: message_id }); diff --git a/api/src/routes/channels/#channel_id/messages/#message_id/reactions.ts b/api/src/routes/channels/#channel_id/messages/#message_id/reactions.ts
index f60484b5..f2b83d40 100644 --- a/api/src/routes/channels/#channel_id/messages/#message_id/reactions.ts +++ b/api/src/routes/channels/#channel_id/messages/#message_id/reactions.ts
@@ -13,6 +13,7 @@ import { 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"; @@ -35,14 +36,11 @@ function getEmoji(emoji: string): PartialEmoji { }; } -router.delete("/", async (req: Request, res: Response) => { +router.delete("/", route({ permission: "MANAGE_MESSAGES" }), async (req: Request, res: Response) => { const { message_id, channel_id } = req.params; const channel = await Channel.findOneOrFail({ id: channel_id }); - const permissions = await getPermission(req.user_id, undefined, channel_id); - permissions.hasThrow("MANAGE_MESSAGES"); - await Message.update({ id: message_id, channel_id }, { reactions: [] }); await emitEvent({ @@ -58,13 +56,10 @@ router.delete("/", async (req: Request, res: Response) => { res.sendStatus(204); }); -router.delete("/:emoji", async (req: Request, res: Response) => { +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 permissions = await getPermission(req.user_id, undefined, channel_id); - permissions.hasThrow("MANAGE_MESSAGES"); - const message = await Message.findOneOrFail({ id: message_id, channel_id }); const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name); @@ -88,7 +83,7 @@ router.delete("/:emoji", async (req: Request, res: Response) => { res.sendStatus(204); }); -router.get("/:emoji", async (req: Request, res: Response) => { +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); @@ -96,9 +91,6 @@ router.get("/:emoji", async (req: Request, res: Response) => { 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 permissions = await getPermission(req.user_id, undefined, channel_id); - permissions.hasThrow("VIEW_CHANNEL"); - const users = await User.find({ where: { id: In(reaction.user_ids) @@ -109,7 +101,7 @@ router.get("/:emoji", async (req: Request, res: Response) => { res.json(users); }); -router.put("/:emoji/:user_id", async (req: Request, res: Response) => { +router.put("/:emoji/:user_id", route({ permission: "READ_MESSAGE_HISTORY" }), 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); @@ -118,13 +110,11 @@ router.put("/:emoji/:user_id", async (req: Request, res: Response) => { const message = await Message.findOneOrFail({ id: message_id, channel_id }); const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name); - const permissions = await getPermission(req.user_id, undefined, channel_id); - permissions.hasThrow("READ_MESSAGE_HISTORY"); - if (!already_added) permissions.hasThrow("ADD_REACTIONS"); + if (!already_added) req.permission!.hasThrow("ADD_REACTIONS"); if (emoji.id) { const external_emoji = await Emoji.findOneOrFail({ id: emoji.id }); - if (!already_added) permissions.hasThrow("USE_EXTERNAL_EMOJIS"); + if (!already_added) req.permission!.hasThrow("USE_EXTERNAL_EMOJIS"); emoji.animated = external_emoji.animated; emoji.name = external_emoji.name; } @@ -154,7 +144,7 @@ router.put("/:emoji/:user_id", async (req: Request, res: Response) => { res.sendStatus(204); }); -router.delete("/:emoji/:user_id", async (req: Request, res: Response) => { +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); @@ -162,10 +152,11 @@ router.delete("/:emoji/:user_id", async (req: Request, res: Response) => { const channel = await Channel.findOneOrFail({ id: channel_id }); const message = await Message.findOneOrFail({ id: message_id, channel_id }); - const permissions = await getPermission(req.user_id, undefined, channel_id); - if (user_id === "@me") user_id = req.user_id; - else permissions.hasThrow("MANAGE_MESSAGES"); + 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); diff --git a/api/src/routes/channels/#channel_id/messages/bulk-delete.ts b/api/src/routes/channels/#channel_id/messages/bulk-delete.ts
index 5c486676..a0fe7cc0 100644 --- a/api/src/routes/channels/#channel_id/messages/bulk-delete.ts +++ b/api/src/routes/channels/#channel_id/messages/bulk-delete.ts
@@ -1,18 +1,21 @@ import { Router, Response, Request } from "express"; import { Channel, Config, emitEvent, getPermission, MessageDeleteBulkEvent, Message } from "@fosscord/util"; import { HTTPError } from "lambert-server"; - -import { check } from "../../../../util/instanceOf"; +import { route } from "@fosscord/api"; import { In } from "typeorm"; const router: Router = Router(); export default router; +export interface BulkDeleteSchema { + messages: string[]; +} + // TODO: should users be able to bulk delete messages or only bots? // TODO: should this request fail, if you provide messages older than 14 days/invalid ids? // https://discord.com/developers/docs/resources/channel#bulk-delete-messages -router.post("/", check({ messages: [String] }), async (req: Request, res: Response) => { +router.post("/", route({ body: "BulkDeleteSchema" }), async (req: Request, res: Response) => { const { channel_id } = req.params; const channel = await Channel.findOneOrFail({ id: channel_id }); if (!channel.guild_id) throw new HTTPError("Can't bulk delete dm channel messages", 400); diff --git a/api/src/routes/channels/#channel_id/messages/index.ts b/api/src/routes/channels/#channel_id/messages/index.ts
index ad590d05..fab20977 100644 --- a/api/src/routes/channels/#channel_id/messages/index.ts +++ b/api/src/routes/channels/#channel_id/messages/index.ts
@@ -1,12 +1,19 @@ import { Router, Response, Request } from "express"; -import { Attachment, Channel, ChannelType, getPermission, Message } from "@fosscord/util"; +import { + Attachment, + Channel, + ChannelType, + DmChannelDTO, + Embed, + emitEvent, + getPermission, + Message, + MessageCreateEvent, + uploadFile +} from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { MessageCreateSchema } from "../../../../schema/Message"; -import { check, instanceOf, Length } from "../../../../util/instanceOf"; +import { handleMessage, postHandleMessage, route } from "@fosscord/api"; import multer from "multer"; -import { Query } from "mongoose"; -import { sendMessage } from "../../../../util/Message"; -import { uploadFile } from "../../../../util/cdn"; import { FindManyOptions, LessThan, MoreThan } from "typeorm"; const router: Router = Router(); @@ -31,6 +38,31 @@ export function isTextChannel(type: ChannelType): boolean { } } +export interface MessageCreateSchema { + content?: string; + nonce?: string; + tts?: boolean; + flags?: string; + embeds?: Embed[]; + embed?: Embed; + // TODO: ^ embed is deprecated in favor of embeds (https://discord.com/developers/docs/resources/channel#message-object) + allowed_mentions?: { + parse?: string[]; + roles?: string[]; + users?: string[]; + replied_user?: boolean; + }; + message_reference?: { + message_id: string; + channel_id: string; + guild_id?: string; + fail_if_not_exists?: boolean; + }; + payload_json?: string; + file?: any; + attachments?: any[]; //TODO we should create an interface for attachments +} + // https://discord.com/developers/docs/resources/channel#create-message // get messages router.get("/", async (req: Request, res: Response) => { @@ -39,17 +71,12 @@ router.get("/", async (req: Request, res: Response) => { 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"); - try { - instanceOf({ $around: String, $after: String, $before: String, $limit: new Length(Number, 1, 100) }, req.query, { - path: "query", - req - }); - } catch (error) { - return res.status(400).json({ code: 50035, message: "Invalid Query", success: false, errors: error }); - } - var { around, after, before, limit }: { around?: string; after?: string; before?: string; limit?: number } = req.query; - if (!limit) limit = 50; var halfLimit = Math.floor(limit / 2); const permissions = await getPermission(req.user_id, channel.guild_id, channel_id); @@ -109,39 +136,77 @@ const messageUpload = multer({ // TODO: check allowed_mentions // Send message -router.post("/", messageUpload.single("file"), async (req: Request, res: Response) => { - const { channel_id } = req.params; - var body = req.body as MessageCreateSchema; - const attachments: Attachment[] = []; - - if (req.file) { - try { - const file = await uploadFile(`/attachments/${channel_id}`, req.file); - attachments.push({ ...file, proxy_url: file.url }); - } catch (error) { - return res.status(400).json(error); +router.post( + "/", + messageUpload.single("file"), + async (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); } - } - if (body.payload_json) { - body = JSON.parse(body.payload_json); - } + next(); + }, + route({ body: "MessageCreateSchema", permission: "SEND_MESSAGES" }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + var body = req.body as MessageCreateSchema; + const attachments: Attachment[] = []; + + if (req.file) { + try { + const file = await uploadFile(`/attachments/${req.params.channel_id}`, req.file); + attachments.push({ ...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 = []; + 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() + }); - const errors = instanceOf(MessageCreateSchema, body, { req }); - if (errors !== true) throw errors; - - const embeds = []; - if (body.embed) embeds.push(body.embed); - const data = await sendMessage({ - ...body, - type: 0, - pinned: false, - author_id: req.user_id, - embeds, - channel_id, - attachments, - edited_timestamp: undefined - }); - - return res.json(data); -}); + message = await message.save(); + + await channel.assign({ last_message_id: message.id }).save(); + + if (channel.isDm()) { + const channel_dto = await DmChannelDTO.from(channel); + + for (let recipient of channel.recipients!) { + if (recipient.closed) { + await emitEvent({ + event: "CHANNEL_CREATE", + data: channel_dto.excludedRecipients([recipient.user_id]), + user_id: recipient.user_id + }); + } + } + + //Only one recipients should be closed here, since in group DMs the recipient is deleted not closed + await Promise.all( + channel + .recipients!.filter((r) => r.closed) + .map(async (r) => { + r.closed = false; + return await r.save(); + }) + ); + } + + await emitEvent({ event: "MESSAGE_CREATE", channel_id: channel_id, data: message } as MessageCreateEvent); + 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/api/src/routes/channels/#channel_id/permissions.ts b/api/src/routes/channels/#channel_id/permissions.ts
index 9c49542b..bc7ad5b8 100644 --- a/api/src/routes/channels/#channel_id/permissions.ts +++ b/api/src/routes/channels/#channel_id/permissions.ts
@@ -2,65 +2,65 @@ import { Channel, ChannelPermissionOverwrite, ChannelUpdateEvent, emitEvent, get import { Router, Response, Request } from "express"; import { HTTPError } from "lambert-server"; -import { check } from "../../../util/instanceOf"; +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", check({ allow: String, deny: String, type: Number, id: String }), async (req: Request, res: Response) => { - const { channel_id, overwrite_id } = req.params; - const body = req.body as { allow: bigint; deny: bigint; type: number; id: string }; +export interface ChannelPermissionOverwriteSchema extends ChannelPermissionOverwrite {} - var channel = await Channel.findOneOrFail({ id: channel_id }); - if (!channel.guild_id) throw new HTTPError("Channel not found", 404); +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 { allow: bigint; deny: bigint; type: number; id: string }; - const permissions = await getPermission(req.user_id, channel.guild_id, channel_id); - permissions.hasThrow("MANAGE_ROLES"); + var channel = await Channel.findOneOrFail({ id: channel_id }); + if (!channel.guild_id) throw new HTTPError("Channel not found", 404); - if (body.type === 0) { - if (!(await Role.count({ id: overwrite_id }))) throw new HTTPError("role not found", 404); - } else if (body.type === 1) { - if (!(await Member.count({ id: overwrite_id }))) throw new HTTPError("user not found", 404); - } else throw new HTTPError("type not supported", 501); + if (body.type === 0) { + if (!(await Role.count({ id: overwrite_id }))) throw new HTTPError("role not found", 404); + } else if (body.type === 1) { + if (!(await Member.count({ 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, - allow: body.allow, - deny: body.deny - }; - channel.permission_overwrites.push(overwrite); + var overwrite: ChannelPermissionOverwrite = channel.permission_overwrites.find((x) => x.id === overwrite_id); + if (!overwrite) { + // @ts-ignore + overwrite = { + id: overwrite_id, + type: body.type, + allow: body.allow, + deny: body.deny + }; + channel.permission_overwrites!.push(overwrite); + } + overwrite.allow = body.allow; + overwrite.deny = body.deny; + + await Promise.all([ + channel.save(), + emitEvent({ + event: "CHANNEL_UPDATE", + channel_id, + data: channel + } as ChannelUpdateEvent) + ]); + + return res.sendStatus(204); } - overwrite.allow = body.allow; - overwrite.deny = body.deny; - - // @ts-ignore - channel = await Channel.findOneOrFailAndUpdate({ id: channel_id }, channel, { new: true }); - - await emitEvent({ - event: "CHANNEL_UPDATE", - channel_id, - data: channel - } as ChannelUpdateEvent); - - return res.sendStatus(204); -}); +); // TODO: check permission hierarchy -router.delete("/:overwrite_id", async (req: Request, res: Response) => { +router.delete("/:overwrite_id", route({ permission: "MANAGE_ROLES" }), async (req: Request, res: Response) => { const { channel_id, overwrite_id } = req.params; - const permissions = await getPermission(req.user_id, undefined, channel_id); - permissions.hasThrow("MANAGE_ROLES"); - const channel = await Channel.findOneOrFail({ 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); + channel.permission_overwrites = channel.permission_overwrites!.filter((x) => x.id === overwrite_id); await Promise.all([ channel.save(), diff --git a/api/src/routes/channels/#channel_id/pins.ts b/api/src/routes/channels/#channel_id/pins.ts
index 33309c86..e71e659f 100644 --- a/api/src/routes/channels/#channel_id/pins.ts +++ b/api/src/routes/channels/#channel_id/pins.ts
@@ -1,19 +1,26 @@ -import { Channel, ChannelPinsUpdateEvent, Config, emitEvent, getPermission, Message, MessageUpdateEvent } from "@fosscord/util"; +import { + Channel, + ChannelPinsUpdateEvent, + Config, + emitEvent, + getPermission, + Message, + MessageUpdateEvent, + DiscordApiErrors +} from "@fosscord/util"; import { Router, Request, Response } from "express"; import { HTTPError } from "lambert-server"; -import { DiscordApiErrors } from "@fosscord/util"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.put("/:message_id", async (req: Request, res: Response) => { +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({ id: message_id }); - const permission = await getPermission(req.user_id, message.guild_id, channel_id); - permission.hasThrow("VIEW_CHANNEL"); // * in dm channels anyone can pin messages -> only check for guilds - if (message.guild_id) permission.hasThrow("MANAGE_MESSAGES"); + if (message.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES"); const pinned_count = await Message.count({ channel: { id: channel_id }, pinned: true }); const { maxPins } = Config.get().limits.channel; @@ -26,7 +33,6 @@ router.put("/:message_id", async (req: Request, res: Response) => { channel_id, data: message } as MessageUpdateEvent), - emitEvent({ event: "CHANNEL_PINS_UPDATE", channel_id, @@ -41,14 +47,11 @@ router.put("/:message_id", async (req: Request, res: Response) => { res.sendStatus(204); }); -router.delete("/:message_id", async (req: Request, res: Response) => { +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({ id: channel_id }); - - const permission = await getPermission(req.user_id, channel.guild_id, channel_id); - permission.hasThrow("VIEW_CHANNEL"); - if (channel.guild_id) permission.hasThrow("MANAGE_MESSAGES"); + if (channel.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES"); const message = await Message.findOneOrFail({ id: message_id }); message.pinned = false; @@ -76,13 +79,9 @@ router.delete("/:message_id", async (req: Request, res: Response) => { res.sendStatus(204); }); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({ permission: ["READ_MESSAGE_HISTORY"] }), async (req: Request, res: Response) => { const { channel_id } = req.params; - const channel = await Channel.findOneOrFail({ id: channel_id }); - const permission = await getPermission(req.user_id, channel.guild_id, channel_id); - permission.hasThrow("VIEW_CHANNEL"); - let pins = await Message.find({ channel_id: channel_id, pinned: true }); res.send(pins); diff --git a/api/src/routes/channels/#channel_id/recipients.ts b/api/src/routes/channels/#channel_id/recipients.ts
index ea6bc563..83b62049 100644 --- a/api/src/routes/channels/#channel_id/recipients.ts +++ b/api/src/routes/channels/#channel_id/recipients.ts
@@ -1,5 +1,58 @@ -import { Router, Response, Request } from "express"; +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(); -// TODO: + +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(new Recipient({ 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/api/src/routes/channels/#channel_id/typing.ts b/api/src/routes/channels/#channel_id/typing.ts
index f1fb3c86..a9dcb315 100644 --- a/api/src/routes/channels/#channel_id/typing.ts +++ b/api/src/routes/channels/#channel_id/typing.ts
@@ -1,29 +1,29 @@ import { Channel, emitEvent, Member, TypingStartEvent } from "@fosscord/util"; +import { route } from "@fosscord/api"; import { Router, Request, Response } from "express"; -import { HTTPError } from "lambert-server"; - const router: Router = Router(); -router.post("/", async (req: Request, res: Response) => { +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({ id: channel_id }); - const member = await Member.findOneOrFail({ id: user_id }); + const member = await Member.findOneOrFail({ where: { id: user_id }, relations: ["roles"] }); await emitEvent({ event: "TYPING_START", channel_id: channel_id, data: { // this is the paylod - member: { ...member, roles: member.roles.map((x) => x.id) }, + member: { ...member, roles: member.roles?.map((x) => x.id) }, channel_id, timestamp, user_id, guild_id: channel.guild_id } } as TypingStartEvent); + res.sendStatus(204); }); diff --git a/api/src/routes/channels/#channel_id/webhooks.ts b/api/src/routes/channels/#channel_id/webhooks.ts
index e4125879..7b894455 100644 --- a/api/src/routes/channels/#channel_id/webhooks.ts +++ b/api/src/routes/channels/#channel_id/webhooks.ts
@@ -1,5 +1,5 @@ import { Router, Response, Request } from "express"; -import { check, Length } from "../../../util/instanceOf"; +import { route } from "@fosscord/api"; import { Channel, Config, getPermission, trimSpecial, Webhook } from "@fosscord/util"; import { HTTPError } from "lambert-server"; import { isTextChannel } from "./messages/index"; @@ -7,9 +7,16 @@ import { DiscordApiErrors } from "@fosscord/util"; const router: Router = Router(); // TODO: webhooks +export interface WebhookCreateSchema { + /** + * @maxLength 80 + */ + name: string; + avatar: string; +} // TODO: use Image Data Type for avatar instead of String -router.post("/", check({ name: new Length(String, 1, 80), $avatar: String }), async (req: Request, res: Response) => { +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({ id: channel_id }); @@ -20,12 +27,11 @@ router.post("/", check({ name: new Length(String, 1, 80), $avatar: String }), as const { maxWebhooks } = Config.get().limits.channel; if (webhook_count > maxWebhooks) throw DiscordApiErrors.MAXIMUM_WEBHOOKS.withParams(maxWebhooks); - const permission = await getPermission(req.user_id, channel.guild_id); - permission.hasThrow("MANAGE_WEBHOOKS"); - var { avatar, name } = req.body as { name: string; avatar?: string }; name = trimSpecial(name); if (name === "clyde") throw new HTTPError("Invalid name", 400); + + // TODO: save webhook in database and send response }); export default router; diff --git a/api/src/routes/discoverable-guilds.ts b/api/src/routes/discoverable-guilds.ts
index 0808727f..f667eb2a 100644 --- a/api/src/routes/discoverable-guilds.ts +++ b/api/src/routes/discoverable-guilds.ts
@@ -1,10 +1,10 @@ import { Guild } from "@fosscord/util"; import { Router, Request, Response } from "express"; -import { In } from "typeorm"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { limit } = req.params; // ! this only works using SQL querys diff --git a/api/src/routes/experiments.ts b/api/src/routes/experiments.ts
index 3bdbed62..966ed99c 100644 --- a/api/src/routes/experiments.ts +++ b/api/src/routes/experiments.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", (req: Request, res: Response) => { +router.get("/", route({}), (req: Request, res: Response) => { // TODO: res.send({ fingerprint: "", assignments: [] }); }); diff --git a/api/src/routes/gateway.ts b/api/src/routes/gateway.ts
index 3120718e..88d9dfda 100644 --- a/api/src/routes/gateway.ts +++ b/api/src/routes/gateway.ts
@@ -1,11 +1,26 @@ import { Config } from "@fosscord/util"; import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", (req: Request, res: Response) => { +router.get("/", route({}), (req: Request, res: Response) => { const { endpoint } = Config.get().gateway; res.json({ url: endpoint || process.env.GATEWAY || "ws://localhost:3002" }); }); +router.get("/bot", route({}), (req: Request, res: Response) => { + const { endpoint } = Config.get().gateway; + res.json({ + url: endpoint || 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/api/src/routes/guilds/#guild_id/bans.ts b/api/src/routes/guilds/#guild_id/bans.ts
index 31aa2385..e7d46898 100644 --- a/api/src/routes/guilds/#guild_id/bans.ts +++ b/api/src/routes/guilds/#guild_id/bans.ts
@@ -1,20 +1,22 @@ import { Request, Response, Router } from "express"; import { emitEvent, getPermission, GuildBanAddEvent, GuildBanRemoveEvent, Guild, Ban, User, Member } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { getIpAdress } from "../../../util/ipAddress"; -import { BanCreateSchema } from "../../../schema/Ban"; -import { check } from "../../../util/instanceOf"; +import { getIpAdress, route } from "@fosscord/api"; -const router: Router = Router(); +export interface BanCreateSchema { + delete_message_days?: string; + reason?: string; +} -router.get("/", async (req: Request, res: Response) => { +const router: Router = Router(); +router.get("/", route({ permission: "BAN_MEMBERS" }), async (req: Request, res: Response) => { const { guild_id } = req.params; var bans = await Ban.find({ guild_id: guild_id }); return res.json(bans); }); -router.get("/:user", async (req: Request, res: Response) => { +router.get("/:user", route({ permission: "BAN_MEMBERS" }), async (req: Request, res: Response) => { const { guild_id } = req.params; const user_id = req.params.ban; @@ -22,15 +24,14 @@ router.get("/:user", async (req: Request, res: Response) => { return res.json(ban); }); -router.put("/:user_id", check(BanCreateSchema), async (req: Request, res: Response) => { +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; const banned_user = await User.getPublicUser(banned_user_id); - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("BAN_MEMBERS"); + if (req.user_id === banned_user_id) throw new HTTPError("You can't ban yourself", 400); - if (perms.cache.guild?.owner_id === banned_user_id) throw new HTTPError("You can't ban the owner", 400); + if (req.permission!.cache.guild?.owner_id === banned_user_id) throw new HTTPError("You can't ban the owner", 400); const ban = new Ban({ user_id: banned_user_id, @@ -56,17 +57,14 @@ router.put("/:user_id", check(BanCreateSchema), async (req: Request, res: Respon return res.json(ban); }); -router.delete("/:user_id", async (req: Request, res: Response) => { - var { guild_id } = req.params; - var banned_user_id = req.params.user_id; +router.delete("/:user_id", route({ permission: "BAN_MEMBERS" }), async (req: Request, res: Response) => { + const { guild_id, user_id } = req.params; - const banned_user = await User.getPublicUser(banned_user_id); - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("BAN_MEMBERS"); + const banned_user = await User.getPublicUser(user_id); await Promise.all([ Ban.delete({ - user_id: banned_user_id, + user_id: user_id, guild_id }), diff --git a/api/src/routes/guilds/#guild_id/channels.ts b/api/src/routes/guilds/#guild_id/channels.ts
index 5aa1d33d..a36e5448 100644 --- a/api/src/routes/guilds/#guild_id/channels.ts +++ b/api/src/routes/guilds/#guild_id/channels.ts
@@ -1,22 +1,18 @@ import { Router, Response, Request } from "express"; import { Channel, ChannelUpdateEvent, getPermission, emitEvent } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { ChannelModifySchema } from "../../../schema/Channel"; - -import { check } from "../../../util/instanceOf"; +import { route } from "@fosscord/api"; +import { ChannelModifySchema } from "../../channels/#channel_id"; const router = Router(); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; const channels = await Channel.find({ guild_id }); res.json(channels); }); -// TODO: check if channel type is permitted -// TODO: check if parent_id exists - -router.post("/", check(ChannelModifySchema), async (req: Request, res: Response) => { +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; @@ -26,45 +22,39 @@ router.post("/", check(ChannelModifySchema), async (req: Request, res: Response) res.status(201).json(channel); }); -// TODO: check if parent_id exists -router.patch( - "/", - check([{ id: String, $position: Number, $lock_permissions: Boolean, $parent_id: String }]), - async (req: Request, res: Response) => { - // changes guild channel position - const { guild_id } = req.params; - const body = req.body as { id: string; position?: number; lock_permissions?: boolean; parent_id?: string }[]; - - const permission = await getPermission(req.user_id, guild_id); - permission.hasThrow("MANAGE_CHANNELS"); - - await Promise.all([ - body.map(async (x) => { - if (!x.position && !x.parent_id) throw new HTTPError(`You need to at least specify position or parent_id`, 400); +export type ChannelReorderSchema = { id: string; position?: number; lock_permissions?: boolean; parent_id?: string }[]; - const opts: any = {}; - if (x.position) 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; - } +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 && !x.parent_id) throw new HTTPError(`You need to at least specify position or parent_id`, 400); + + const opts: any = {}; + if (x.position) 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({ guild_id, id: x.id }); + await Channel.update({ guild_id, id: x.id }, opts); + const channel = await Channel.findOneOrFail({ guild_id, id: x.id }); - await emitEvent({ event: "CHANNEL_UPDATE", data: channel, channel_id: x.id, guild_id } as ChannelUpdateEvent); - }) - ]); + await emitEvent({ event: "CHANNEL_UPDATE", data: channel, channel_id: x.id, guild_id } as ChannelUpdateEvent); + }) + ]); - res.sendStatus(204); - } -); + res.sendStatus(204); +}); export default router; diff --git a/api/src/routes/guilds/#guild_id/delete.ts b/api/src/routes/guilds/#guild_id/delete.ts
index bbbd1fa4..bd158c56 100644 --- a/api/src/routes/guilds/#guild_id/delete.ts +++ b/api/src/routes/guilds/#guild_id/delete.ts
@@ -1,26 +1,20 @@ 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("/", async (req: Request, res: Response) => { +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); - // do not put everything into promise all, because of "QueryFailedError: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" - - await Message.delete({ guild_id }); // messages must be deleted before channel - await Promise.all([ - Role.delete({ guild_id }), - Channel.delete({ guild_id }), - Emoji.delete({ guild_id }), - Member.delete({ guild_id }), + Guild.delete({ id: guild_id }), // this will also delete all guild related data emitEvent({ event: "GUILD_DELETE", data: { @@ -30,9 +24,6 @@ router.post("/", async (req: Request, res: Response) => { } as GuildDeleteEvent) ]); - await Invite.delete({ guild_id }); // invite must be deleted after channel - await Guild.delete({ id: guild_id }); // guild must be deleted after everything else - return res.sendStatus(204); }); diff --git a/api/src/routes/guilds/#guild_id/index.ts b/api/src/routes/guilds/#guild_id/index.ts
index 9d302a48..d8ee86ff 100644 --- a/api/src/routes/guilds/#guild_id/index.ts +++ b/api/src/routes/guilds/#guild_id/index.ts
@@ -1,23 +1,35 @@ import { Request, Response, Router } from "express"; -import { emitEvent, getPermission, Guild, GuildUpdateEvent, Member } from "@fosscord/util"; +import { emitEvent, getPermission, Guild, GuildUpdateEvent, handleFile, Member } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { GuildUpdateSchema } from "../../../schema/Guild"; - -import { check } from "../../../util/instanceOf"; -import { handleFile } from "../../../util/cdn"; +import { route } from "@fosscord/api"; import "missing-native-js-functions"; +import { GuildCreateSchema } from "../index"; const router = Router(); -router.get("/", async (req: Request, res: Response) => { +export interface GuildUpdateSchema extends Omit<GuildCreateSchema, "channels"> { + banner?: string | null; + splash?: string | null; + description?: string; + features?: string[]; + verification_level?: number; + default_message_notifications?: number; + system_channel_flags?: number; + explicit_content_filter?: number; + public_updates_channel_id?: string; + afk_timeout?: number; + afk_channel_id?: string; + preferred_locale?: string; +} + +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; - const [guild, member_count, member] = await Promise.all([ + const [guild, member] = await Promise.all([ Guild.findOneOrFail({ id: guild_id }), - Member.count({ guild_id: guild_id, id: req.user_id }), - Member.findOneOrFail({ id: req.user_id }) + Member.findOne({ guild_id: guild_id, id: req.user_id }) ]); - if (!member_count) throw new HTTPError("You are not a member of the guild you are trying to access", 401); + 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; @@ -25,14 +37,11 @@ router.get("/", async (req: Request, res: Response) => { return res.json(guild); }); -router.patch("/", check(GuildUpdateSchema), async (req: Request, res: Response) => { +router.patch("/", route({ body: "GuildUpdateSchema", permission: "MANAGE_GUILD" }), async (req: Request, res: Response) => { const body = req.body as GuildUpdateSchema; const { guild_id } = req.params; // TODO: guild update check image - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - if (body.icon) body.icon = await handleFile(`/icons/${guild_id}`, body.icon); if (body.banner) body.banner = await handleFile(`/banners/${guild_id}`, body.banner); if (body.splash) body.splash = await handleFile(`/splashes/${guild_id}`, body.splash); diff --git a/api/src/routes/guilds/#guild_id/invites.ts b/api/src/routes/guilds/#guild_id/invites.ts
index 39a934ee..b7534e31 100644 --- a/api/src/routes/guilds/#guild_id/invites.ts +++ b/api/src/routes/guilds/#guild_id/invites.ts
@@ -1,14 +1,12 @@ import { getPermission, Invite, PublicInviteRelation } from "@fosscord/util"; +import { route } from "@fosscord/api"; import { Request, Response, Router } from "express"; const router = Router(); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({ permission: "MANAGE_GUILD" }), async (req: Request, res: Response) => { const { guild_id } = req.params; - const permissions = await getPermission(req.user_id, guild_id); - permissions.hasThrow("MANAGE_GUILD"); - const invites = await Invite.find({ where: { guild_id }, relations: PublicInviteRelation }); return res.json(invites); diff --git a/api/src/routes/guilds/#guild_id/members/#member_id/index.ts b/api/src/routes/guilds/#guild_id/members/#member_id/index.ts
index 0d62e555..ab489743 100644 --- a/api/src/routes/guilds/#guild_id/members/#member_id/index.ts +++ b/api/src/routes/guilds/#guild_id/members/#member_id/index.ts
@@ -1,23 +1,15 @@ import { Request, Response, Router } from "express"; -import { - Guild, - Member, - User, - GuildMemberAddEvent, - getPermission, - PermissionResolvable, - Role, - GuildMemberUpdateEvent, - emitEvent -} from "@fosscord/util"; +import { Member, getPermission, Role, GuildMemberUpdateEvent, emitEvent } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { check } from "../../../../../util/instanceOf"; -import { MemberChangeSchema } from "../../../../../schema/Member"; -import { In } from "typeorm"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", async (req: Request, res: Response) => { +export interface MemberChangeSchema { + roles?: string[]; +} + +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id, member_id } = req.params; await Member.IsInGuildOrFail(req.user_id, guild_id); @@ -26,8 +18,9 @@ router.get("/", async (req: Request, res: Response) => { return res.json(member); }); -router.patch("/", check(MemberChangeSchema), async (req: Request, res: Response) => { - const { guild_id, member_id } = req.params; +router.patch("/", route({ body: "MemberChangeSchema" }), async (req: Request, res: Response) => { + let { guild_id, member_id } = req.params; + if (member_id === "@me") member_id = req.user_id; const body = req.body as MemberChangeSchema; const member = await Member.findOneOrFail({ where: { id: member_id, guild_id }, relations: ["roles", "user"] }); @@ -39,7 +32,7 @@ router.patch("/", check(MemberChangeSchema), async (req: Request, res: Response) } await member.save(); - // do not use promise.all as we have to first write to db before emitting the event + // 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, @@ -49,7 +42,7 @@ router.patch("/", check(MemberChangeSchema), async (req: Request, res: Response) res.json(member); }); -router.put("/", async (req: Request, res: Response) => { +router.put("/", route({}), async (req: Request, res: Response) => { let { guild_id, member_id } = req.params; if (member_id === "@me") member_id = req.user_id; @@ -59,12 +52,9 @@ router.put("/", async (req: Request, res: Response) => { res.sendStatus(204); }); -router.delete("/", async (req: Request, res: Response) => { +router.delete("/", route({ permission: "KICK_MEMBERS" }), async (req: Request, res: Response) => { const { guild_id, member_id } = req.params; - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("KICK_MEMBERS"); - await Member.removeFromGuild(member_id, guild_id); res.sendStatus(204); }); diff --git a/api/src/routes/guilds/#guild_id/members/#member_id/nick.ts b/api/src/routes/guilds/#guild_id/members/#member_id/nick.ts
index 3f2975e6..27f7f65d 100644 --- a/api/src/routes/guilds/#guild_id/members/#member_id/nick.ts +++ b/api/src/routes/guilds/#guild_id/members/#member_id/nick.ts
@@ -1,11 +1,14 @@ import { getPermission, Member, PermissionResolvable } from "@fosscord/util"; +import { route } from "@fosscord/api"; import { Request, Response, Router } from "express"; -import { check } from "lambert-server"; -import { MemberNickChangeSchema } from "../../../../../schema/Member"; const router = Router(); -router.patch("/", check(MemberNickChangeSchema), async (req: Request, res: Response) => { +export interface MemberNickChangeSchema { + nick: string; +} + +router.patch("/", route({ body: "MemberNickChangeSchema" }), async (req: Request, res: Response) => { var { guild_id, member_id } = req.params; var permissionString: PermissionResolvable = "MANAGE_NICKNAMES"; if (member_id === "@me") { diff --git a/api/src/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts b/api/src/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
index cb9bad9a..8f5ca7ba 100644 --- a/api/src/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts +++ b/api/src/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts
@@ -1,24 +1,19 @@ import { getPermission, Member } from "@fosscord/util"; +import { route } from "@fosscord/api"; import { Request, Response, Router } from "express"; const router = Router(); -router.delete("/:member_id/roles/:role_id", async (req: Request, res: Response) => { +router.delete("/", route({ permission: "MANAGE_ROLES" }), async (req: Request, res: Response) => { const { guild_id, role_id, member_id } = req.params; - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_ROLES"); - await Member.removeRole(member_id, guild_id, role_id); res.sendStatus(204); }); -router.put("/:member_id/roles/:role_id", async (req: Request, res: Response) => { +router.put("/", route({ permission: "MANAGE_ROLES" }), async (req: Request, res: Response) => { const { guild_id, role_id, member_id } = req.params; - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_ROLES"); - await Member.addRole(member_id, guild_id, role_id); res.sendStatus(204); }); diff --git a/api/src/routes/guilds/#guild_id/members/index.ts b/api/src/routes/guilds/#guild_id/members/index.ts
index 0bfd71cb..386276c8 100644 --- a/api/src/routes/guilds/#guild_id/members/index.ts +++ b/api/src/routes/guilds/#guild_id/members/index.ts
@@ -1,34 +1,28 @@ import { Request, Response, Router } from "express"; import { Guild, Member, PublicMemberProjection } from "@fosscord/util"; -import { instanceOf, Length } from "../../../../util/instanceOf"; +import { route } from "@fosscord/api"; import { MoreThan } from "typeorm"; +import { HTTPError } from "lambert-server"; const router = Router(); // TODO: not allowed for user -> only allowed for bots with privileged intents // TODO: send over websocket -router.get("/", async (req: Request, res: Response) => { - const { guild_id } = req.params; - const guild = await Guild.findOneOrFail({ id: guild_id }); - await Member.IsInGuildOrFail(req.user_id, guild_id); - - try { - instanceOf({ $limit: new Length(Number, 1, 1000), $after: String }, req.query, { - path: "query", - req, - ref: { obj: null, key: "" } - }); - } catch (error) { - return res.status(400).json({ code: 50035, message: "Invalid Query", success: false, errors: error }); - } +// TODO: check for GUILD_MEMBERS intent - const { limit, after } = (<unknown>req.query) as { limit?: number; after?: string }; +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 || 1, + take: limit, order: { id: "ASC" } }); diff --git a/api/src/routes/guilds/#guild_id/regions.ts b/api/src/routes/guilds/#guild_id/regions.ts
index 212c9bcd..75d24fd1 100644 --- a/api/src/routes/guilds/#guild_id/regions.ts +++ b/api/src/routes/guilds/#guild_id/regions.ts
@@ -1,11 +1,11 @@ -import {Config, Guild, Member} from "@fosscord/util"; +import { Config, Guild, Member } from "@fosscord/util"; import { Request, Response, Router } from "express"; -import {getVoiceRegions} from "../../../util/Voice"; -import {getIpAdress} from "../../../util/ipAddress"; +import { getVoiceRegions, route } from "@fosscord/api"; +import { getIpAdress } from "@fosscord/api"; const router = Router(); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; const guild = await Guild.findOneOrFail({ id: guild_id }); //TODO we should use an enum for guild's features and not hardcoded strings diff --git a/api/src/routes/guilds/#guild_id/roles.ts b/api/src/routes/guilds/#guild_id/roles.ts
index 6a318688..d1d60906 100644 --- a/api/src/routes/guilds/#guild_id/roles.ts +++ b/api/src/routes/guilds/#guild_id/roles.ts
@@ -2,24 +2,34 @@ import { Request, Response, Router } from "express"; import { Role, getPermission, - Snowflake, Member, GuildRoleCreateEvent, GuildRoleUpdateEvent, GuildRoleDeleteEvent, emitEvent, - Config + Config, + DiscordApiErrors } from "@fosscord/util"; import { HTTPError } from "lambert-server"; - -import { check } from "../../../util/instanceOf"; -import { RoleModifySchema, RolePositionUpdateSchema } from "../../../schema/Roles"; -import { DiscordApiErrors } from "@fosscord/util"; -import { In } from "typeorm"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { +export interface RoleModifySchema { + name?: string; + permissions?: bigint; + color?: number; + hoist?: boolean; // whether the role should be displayed separately in the sidebar + mentionable?: boolean; // whether the role should be mentionable + position?: number; +} + +export type RolePositionUpdateSchema = { + id: string; + position: number; +}[]; + +router.get("/", route({}), async (req: Request, res: Response) => { const guild_id = req.params.guild_id; await Member.IsInGuildOrFail(req.user_id, guild_id); @@ -29,13 +39,10 @@ router.get("/", async (req: Request, res: Response) => { return res.json(roles); }); -router.post("/", check(RoleModifySchema), async (req: Request, res: Response) => { +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 perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_ROLES"); - const role_count = await Role.count({ guild_id }); const { maxRoles } = Config.get().limits.guild; @@ -50,7 +57,7 @@ router.post("/", check(RoleModifySchema), async (req: Request, res: Response) => ...body, guild_id: guild_id, managed: false, - permissions: String(perms.bitfield & (body.permissions || 0n)), + permissions: String(req.permission!.bitfield & (body.permissions || 0n)), tags: undefined }); @@ -69,14 +76,11 @@ router.post("/", check(RoleModifySchema), async (req: Request, res: Response) => res.json(role); }); -router.delete("/:role_id", async (req: Request, res: Response) => { +router.delete("/:role_id", route({ permission: "MANAGE_ROLES" }), async (req: Request, res: Response) => { const guild_id = req.params.guild_id; const { role_id } = req.params; if (role_id === guild_id) throw new HTTPError("You can't delete the @everyone role"); - const permissions = await getPermission(req.user_id, guild_id); - permissions.hasThrow("MANAGE_ROLES"); - await Promise.all([ Role.delete({ id: role_id, @@ -97,14 +101,11 @@ router.delete("/:role_id", async (req: Request, res: Response) => { // TODO: check role hierarchy -router.patch("/:role_id", check(RoleModifySchema), async (req: Request, res: Response) => { +router.patch("/:role_id", route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), async (req: Request, res: Response) => { const { role_id, guild_id } = req.params; const body = req.body as RoleModifySchema; - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_ROLES"); - - const role = new Role({ ...body, id: role_id, guild_id, permissions: String(perms.bitfield & (body.permissions || 0n)) }); + const role = new Role({ ...body, id: role_id, guild_id, permissions: String(req.permission!.bitfield & (body.permissions || 0n)) }); await Promise.all([ role.save(), @@ -121,7 +122,7 @@ router.patch("/:role_id", check(RoleModifySchema), async (req: Request, res: Res res.json(role); }); -router.patch("/", check(RolePositionUpdateSchema), async (req: Request, res: Response) => { +router.patch("/", route({ body: "RolePositionUpdateSchema" }), async (req: Request, res: Response) => { const { guild_id } = req.params; const body = req.body as RolePositionUpdateSchema; @@ -130,7 +131,7 @@ router.patch("/", check(RolePositionUpdateSchema), async (req: Request, res: Res await Promise.all(body.map(async (x) => Role.update({ guild_id, id: x.id }, { position: x.position }))); - const roles = await Role.find({ guild_id, id: In(body.map((x) => x.id)) }); + const roles = await Role.find({ where: body.map((x) => ({ id: x.id, guild_id })) }); await Promise.all( roles.map((x) => diff --git a/api/src/routes/guilds/#guild_id/templates.ts b/api/src/routes/guilds/#guild_id/templates.ts
index a7613abf..5179e761 100644 --- a/api/src/routes/guilds/#guild_id/templates.ts +++ b/api/src/routes/guilds/#guild_id/templates.ts
@@ -1,9 +1,8 @@ import { Request, Response, Router } from "express"; -import { Guild, getPermission, Template } from "@fosscord/util"; +import { Guild, Template } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { TemplateCreateSchema, TemplateModifySchema } from "../../../schema/Template"; -import { check } from "../../../util/instanceOf"; -import { generateCode } from "../../../util/String"; +import { route } from "@fosscord/api"; +import { generateCode } from "@fosscord/api"; const router: Router = Router(); @@ -24,7 +23,17 @@ const TemplateGuildProjection: (keyof Guild)[] = [ "icon" ]; -router.get("/", async (req: Request, res: Response) => { +export interface TemplateCreateSchema { + name: string; + description?: string; +} + +export interface TemplateModifySchema { + name: string; + description?: string; +} + +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; var templates = await Template.find({ source_guild_id: guild_id }); @@ -32,12 +41,9 @@ router.get("/", async (req: Request, res: Response) => { return res.json(templates); }); -router.post("/", check(TemplateCreateSchema), async (req: Request, res: Response) => { +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 perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - const exists = await Template.findOneOrFail({ id: guild_id }).catch((e) => {}); if (exists) throw new HTTPError("Template already exists", 400); @@ -54,44 +60,31 @@ router.post("/", check(TemplateCreateSchema), async (req: Request, res: Response res.json(template); }); -router.delete("/:code", async (req: Request, res: Response) => { - const guild_id = req.params.guild_id; - const { code } = req.params; - - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); +router.delete("/:code", route({ permission: "MANAGE_GUILD" }), async (req: Request, res: Response) => { + const { code, guild_id } = req.params; const template = await Template.delete({ - code + code, + source_guild_id: guild_id }); res.json(template); }); -router.put("/:code", async (req: Request, res: Response) => { - // synchronizes the 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 perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - const template = await new Template({ code, serialized_source_guild: guild }).save(); res.json(template); }); -router.patch("/:code", check(TemplateModifySchema), async (req: Request, res: Response) => { - // updates the template description - const { guild_id } = req.params; - const { code } = req.params; +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 perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - - const template = await new Template({ code, name: name, description: description }).save(); + const template = await new Template({ code, name: name, description: description, source_guild_id: guild_id }).save(); res.json(template); }); diff --git a/api/src/routes/guilds/#guild_id/vanity-url.ts b/api/src/routes/guilds/#guild_id/vanity-url.ts
index 58940b42..7f2cea9e 100644 --- a/api/src/routes/guilds/#guild_id/vanity-url.ts +++ b/api/src/routes/guilds/#guild_id/vanity-url.ts
@@ -1,40 +1,43 @@ import { Channel, ChannelType, getPermission, Guild, Invite, trimSpecial } from "@fosscord/util"; import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; import { HTTPError } from "lambert-server"; -import { check, Length } from "../../../util/instanceOf"; const router = Router(); const InviteRegex = /\W/g; -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({ permission: "MANAGE_GUILD" }), async (req: Request, res: Response) => { const { guild_id } = req.params; - const permission = await getPermission(req.user_id, guild_id); - permission.hasThrow("MANAGE_GUILD"); - const guild = await Guild.findOneOrFail({ where: { id: guild_id }, relations: ["vanity_url"] }); if (!guild.vanity_url) return res.json({ code: null }); return res.json({ code: guild.vanity_url_code, uses: guild.vanity_url.uses }); }); +export interface VanityUrlSchema { + /** + * @minLength 1 + * @maxLength 20 + */ + code?: string; +} + // TODO: check if guild is elgible for vanity url -router.patch("/", check({ code: new Length(String, 0, 20) }), async (req: Request, res: Response) => { +router.patch("/", route({ body: "VanityUrlSchema", permission: "MANAGE_GUILD" }), async (req: Request, res: Response) => { const { guild_id } = req.params; - const code = req.body.code.replace(InviteRegex); + const body = req.body as VanityUrlSchema; + const code = body.code?.replace(InviteRegex, ""); - await Invite.findOneOrFail({ code }); + const invite = await Invite.findOne({ code }); + if (invite) throw new HTTPError("Invite already exists"); const guild = await Guild.findOneOrFail({ id: guild_id }); - const permission = await getPermission(req.user_id, guild_id); - permission.hasThrow("MANAGE_GUILD"); - const { id } = await Channel.findOneOrFail({ guild_id, type: ChannelType.GUILD_TEXT }); - guild.vanity_url_code = code; Promise.all([ - guild.save(), + Guild.update({ id: guild_id }, { vanity_url_code: code }), Invite.delete({ code: guild.vanity_url_code }), new Invite({ code: code, diff --git a/api/src/routes/guilds/#guild_id/voice-states/#user_id/index.ts b/api/src/routes/guilds/#guild_id/voice-states/#user_id/index.ts
index 02951f81..f9fbea54 100644 --- a/api/src/routes/guilds/#guild_id/voice-states/#user_id/index.ts +++ b/api/src/routes/guilds/#guild_id/voice-states/#user_id/index.ts
@@ -1,15 +1,60 @@ -import { check } from "../../../../../util/instanceOf"; -import { VoiceStateUpdateSchema } from "../../../../../schema"; +import { Channel, ChannelType, DiscordApiErrors, emitEvent, getPermission, VoiceState, VoiceStateUpdateEvent } from "@fosscord/util"; +import { route } from "@fosscord/api"; import { Request, Response, Router } from "express"; -import { updateVoiceState } from "../../../../../util/VoiceState"; const router = Router(); +//TODO need more testing when community guild and voice stage channel are working -router.patch("/", check(VoiceStateUpdateSchema), async (req: Request, res: Response) => { +export interface VoiceStateUpdateSchema { + channel_id: string; + guild_id?: string; + suppress?: boolean; + request_to_speak_timestamp?: Date; + self_mute?: boolean; + self_deaf?: boolean; + self_video?: boolean; +} + +router.patch("/", route({ body: "VoiceStateUpdateSchema" }), async (req: Request, res: Response) => { const body = req.body as VoiceStateUpdateSchema; - const { guild_id, user_id } = req.params; - await updateVoiceState(body, guild_id, req.user_id, user_id) + 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({ + 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({ 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; \ No newline at end of file +export default router; diff --git a/api/src/routes/guilds/#guild_id/voice-states/@me/index.ts b/api/src/routes/guilds/#guild_id/voice-states/@me/index.ts deleted file mode 100644
index 42ba543e..00000000 --- a/api/src/routes/guilds/#guild_id/voice-states/@me/index.ts +++ /dev/null
@@ -1,15 +0,0 @@ -import { check } from "../../../../../util/instanceOf"; -import { VoiceStateUpdateSchema } from "../../../../../schema"; -import { Request, Response, Router } from "express"; -import { updateVoiceState } from "../../../../../util/VoiceState"; - -const router = Router(); - -router.patch("/", check(VoiceStateUpdateSchema), async (req: Request, res: Response) => { - const body = req.body as VoiceStateUpdateSchema; - const { guild_id } = req.params; - await updateVoiceState(body, guild_id, req.user_id) - return res.sendStatus(204); -}); - -export default router; \ No newline at end of file diff --git a/api/src/routes/guilds/#guild_id/welcome_screen.ts b/api/src/routes/guilds/#guild_id/welcome_screen.ts
index defbcd40..7141f17e 100644 --- a/api/src/routes/guilds/#guild_id/welcome_screen.ts +++ b/api/src/routes/guilds/#guild_id/welcome_screen.ts
@@ -1,31 +1,36 @@ import { Request, Response, Router } from "express"; import { Guild, getPermission, Snowflake, Member } from "@fosscord/util"; import { HTTPError } from "lambert-server"; - -import { check } from "../../../util/instanceOf"; -import { GuildUpdateWelcomeScreenSchema } from "../../../schema/Guild"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { +export interface GuildUpdateWelcomeScreenSchema { + welcome_channels?: { + channel_id: string; + description: string; + emoji_id?: string; + emoji_name: string; + }[]; + enabled?: boolean; + description?: string; +} + +router.get("/", route({}), async (req: Request, res: Response) => { const guild_id = req.params.guild_id; const guild = await Guild.findOneOrFail({ id: guild_id }); - await Member.IsInGuildOrFail(req.user_id, guild_id); res.json(guild.welcome_screen); }); -router.patch("/", check(GuildUpdateWelcomeScreenSchema), async (req: Request, res: Response) => { +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({ id: guild_id }); - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - 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; diff --git a/api/src/routes/guilds/#guild_id/widget.json.ts b/api/src/routes/guilds/#guild_id/widget.json.ts
index 193ed095..c31519fa 100644 --- a/api/src/routes/guilds/#guild_id/widget.json.ts +++ b/api/src/routes/guilds/#guild_id/widget.json.ts
@@ -1,7 +1,7 @@ import { Request, Response, Router } from "express"; import { Config, Permissions, Guild, Invite, Channel, Member } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { random } from "../../../util/RandomInviteID"; +import { random, route } from "@fosscord/api"; const router: Router = Router(); @@ -14,7 +14,7 @@ const router: Router = Router(); // 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("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; const guild = await Guild.findOneOrFail({ id: guild_id }); diff --git a/api/src/routes/guilds/#guild_id/widget.png.ts b/api/src/routes/guilds/#guild_id/widget.png.ts
index 89b31153..4c82b740 100644 --- a/api/src/routes/guilds/#guild_id/widget.png.ts +++ b/api/src/routes/guilds/#guild_id/widget.png.ts
@@ -1,6 +1,7 @@ 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"; @@ -10,7 +11,7 @@ const router: Router = Router(); // https://discord.com/developers/docs/resources/guild#get-guild-widget-image // TODO: Cache the response -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; const guild = await Guild.findOneOrFail({ id: guild_id }); diff --git a/api/src/routes/guilds/#guild_id/widget.ts b/api/src/routes/guilds/#guild_id/widget.ts
index fcf71402..2640618d 100644 --- a/api/src/routes/guilds/#guild_id/widget.ts +++ b/api/src/routes/guilds/#guild_id/widget.ts
@@ -1,31 +1,28 @@ import { Request, Response, Router } from "express"; -import { getPermission, Guild } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { check } from "../../../util/instanceOf"; -import { WidgetModifySchema } from "../../../schema/Widget"; +import { Guild } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +export interface WidgetModifySchema { + enabled: boolean; // whether the widget is enabled + channel_id: string; // the widget channel id +} const router: Router = Router(); // https://discord.com/developers/docs/resources/guild#get-guild-widget-settings -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { guild_id } = req.params; - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - const guild = await Guild.findOneOrFail({ 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("/", check(WidgetModifySchema), async (req: Request, res: Response) => { +router.patch("/", route({ body: "WidgetModifySchema", permission: "MANAGE_GUILD" }), async (req: Request, res: Response) => { const body = req.body as WidgetModifySchema; const { guild_id } = req.params; - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_GUILD"); - 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 diff --git a/api/src/routes/guilds/index.ts b/api/src/routes/guilds/index.ts
index e5830647..abde147d 100644 --- a/api/src/routes/guilds/index.ts +++ b/api/src/routes/guilds/index.ts
@@ -1,15 +1,26 @@ import { Router, Request, Response } from "express"; -import { Role, Guild, Snowflake, Config, User, Member, Channel } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { check } from "./../../util/instanceOf"; -import { GuildCreateSchema } from "../../schema/Guild"; -import { DiscordApiErrors } from "@fosscord/util"; +import { Role, Guild, Snowflake, Config, Member, Channel, DiscordApiErrors, handleFile } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { ChannelModifySchema } from "../channels/#channel_id"; const router: Router = Router(); +export interface GuildCreateSchema { + /** + * @maxLength 100 + */ + name: string; + region?: string; + icon?: string | null; + channels?: ChannelModifySchema[]; + guild_template_code?: string; + system_channel_id?: string; + rules_channel_id?: string; +} + //TODO: create default channel -router.post("/", check(GuildCreateSchema), async (req: Request, res: Response) => { +router.post("/", route({ body: "GuildCreateSchema" }), async (req: Request, res: Response) => { const body = req.body as GuildCreateSchema; const { maxGuilds } = Config.get().limits.user; @@ -22,6 +33,7 @@ router.post("/", check(GuildCreateSchema), async (req: Request, res: Response) = await Guild.insert({ name: body.name, + icon: await handleFile(`/icons/${guild_id}`, body.icon as string), region: Config.get().regions.default, owner_id: req.user_id, afk_timeout: 300, diff --git a/api/src/routes/guilds/templates/index.ts b/api/src/routes/guilds/templates/index.ts
index 58201f65..b5e243e9 100644 --- a/api/src/routes/guilds/templates/index.ts +++ b/api/src/routes/guilds/templates/index.ts
@@ -1,12 +1,15 @@ import { Request, Response, Router } from "express"; const router: Router = Router(); import { Template, Guild, Role, Snowflake, Config, User, Member } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { GuildTemplateCreateSchema } from "../../../schema/Guild"; -import { check } from "../../../util/instanceOf"; +import { route } from "@fosscord/api"; import { DiscordApiErrors } from "@fosscord/util"; -router.get("/:code", async (req: Request, res: Response) => { +export interface GuildTemplateCreateSchema { + name: string; + avatar?: string | null; +} + +router.get("/:code", route({}), async (req: Request, res: Response) => { const { code } = req.params; const template = await Template.findOneOrFail({ code: code }); @@ -14,7 +17,7 @@ router.get("/:code", async (req: Request, res: Response) => { res.json(template); }); -router.post("/:code", check(GuildTemplateCreateSchema), async (req: Request, res: Response) => { +router.post("/:code", route({ body: "GuildTemplateCreateSchema" }), async (req: Request, res: Response) => { const { code } = req.params; const body = req.body as GuildTemplateCreateSchema; diff --git a/api/src/routes/invites/index.ts b/api/src/routes/invites/index.ts
index b8c24c1f..0fcf7c86 100644 --- a/api/src/routes/invites/index.ts +++ b/api/src/routes/invites/index.ts
@@ -1,9 +1,11 @@ import { Router, Request, Response } from "express"; -import { getPermission, Guild, Invite, Member, PublicInviteRelation } from "@fosscord/util"; +import { emitEvent, getPermission, Guild, Invite, InviteDeleteEvent, Member, PublicInviteRelation } from "@fosscord/util"; +import { route } from "@fosscord/api"; import { HTTPError } from "lambert-server"; + const router: Router = Router(); -router.get("/:code", async (req: Request, res: Response) => { +router.get("/:code", route({}), async (req: Request, res: Response) => { const { code } = req.params; const invite = await Invite.findOneOrFail({ where: { code }, relations: PublicInviteRelation }); @@ -11,19 +13,15 @@ router.get("/:code", async (req: Request, res: Response) => { res.status(200).send(invite); }); -router.post("/:code", async (req: Request, res: Response) => { +router.post("/:code", route({}), async (req: Request, res: Response) => { const { code } = req.params; + const invite = await Invite.joinGuild(req.user_id, code); - const invite = await Invite.findOneOrFail({ code }); - if (invite.uses++ >= invite.max_uses) await Invite.delete({ code }); - else await invite.save(); - - await Member.addToGuild(req.user_id, invite.guild_id); - - res.status(200).send(invite); + res.json(invite); }); -router.delete("/:code", async (req: Request, res: Response) => { +// * 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({ code }); const { guild_id, channel_id } = invite; @@ -33,7 +31,19 @@ router.delete("/:code", async (req: Request, res: Response) => { 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 }), Guild.update({ vanity_url_code: code }, { vanity_url_code: undefined })]); + await Promise.all([ + Invite.delete({ code }), + Guild.update({ vanity_url_code: code }, { vanity_url_code: undefined }), + 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 }); }); diff --git a/api/src/routes/outbound-promotions.ts b/api/src/routes/outbound-promotions.ts new file mode 100644
index 00000000..411e95bf --- /dev/null +++ b/api/src/routes/outbound-promotions.ts
@@ -0,0 +1,11 @@ +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([]).status(200); +}); + +export default router; diff --git a/api/src/routes/ping.ts b/api/src/routes/ping.ts
index 38daf81e..5cdea705 100644 --- a/api/src/routes/ping.ts +++ b/api/src/routes/ping.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", (req: Request, res: Response) => { +router.get("/", route({}), (req: Request, res: Response) => { res.send("pong"); }); diff --git a/api/src/routes/science.ts b/api/src/routes/science.ts
index b16ef783..8556a3ad 100644 --- a/api/src/routes/science.ts +++ b/api/src/routes/science.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.post("/", (req: Request, res: Response) => { +router.post("/", route({}), (req: Request, res: Response) => { // TODO: res.sendStatus(204); }); diff --git a/api/src/routes/sticker-packs/#id/index.ts b/api/src/routes/sticker-packs/#id/index.ts new file mode 100644
index 00000000..7f723e97 --- /dev/null +++ b/api/src/routes/sticker-packs/#id/index.ts
@@ -0,0 +1,19 @@ +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: "", + stickers: [], + name: "", + sku_id: "", + cover_sticker_id: "", + description: "", + banner_asset_id: "" + }).status(200); +}); + +export default router; diff --git a/api/src/routes/sticker-packs/index.ts b/api/src/routes/sticker-packs/index.ts new file mode 100644
index 00000000..d671c161 --- /dev/null +++ b/api/src/routes/sticker-packs/index.ts
@@ -0,0 +1,11 @@ +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({ sticker_packs: [] }).status(200); +}); + +export default router; diff --git a/api/src/routes/store/applications.ts b/api/src/routes/store/applications.ts new file mode 100644
index 00000000..352c1752 --- /dev/null +++ b/api/src/routes/store/applications.ts
@@ -0,0 +1,12 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/applications/:id", route({}), async (req: Request, res: Response) => { + //TODO + const { id } = req.params; + res.json([]).status(200); +}); + +export default router; diff --git a/api/src/routes/store/skus.ts b/api/src/routes/store/skus.ts new file mode 100644
index 00000000..7d0e12eb --- /dev/null +++ b/api/src/routes/store/skus.ts
@@ -0,0 +1,12 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/skus/:id", route({}), async (req: Request, res: Response) => { + //TODO + const { id } = req.params; + res.json([]).status(200); +}); + +export default router; diff --git a/api/src/routes/users/#id/index.ts b/api/src/routes/users/#id/index.ts
index 3841756b..bdb1060f 100644 --- a/api/src/routes/users/#id/index.ts +++ b/api/src/routes/users/#id/index.ts
@@ -1,9 +1,10 @@ import { Router, Request, Response } from "express"; -import { User } from "../../../../../util/dist"; +import { User } from "@fosscord/util"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const { id } = req.params; res.json(await User.getPublicUser(id)); diff --git a/api/src/routes/users/#id/profile.ts b/api/src/routes/users/#id/profile.ts
index 8be03b47..15457547 100644 --- a/api/src/routes/users/#id/profile.ts +++ b/api/src/routes/users/#id/profile.ts
@@ -1,9 +1,17 @@ import { Router, Request, Response } from "express"; -import { PublicConnectedAccount, PublicUser, User, UserPublic } from "../../../../../util/dist"; +import { PublicConnectedAccount, PublicUser, User, UserPublic } from "@fosscord/util"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { +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 user = await User.getPublicUser(req.params.id, { relations: ["connected_accounts"] }); @@ -11,6 +19,7 @@ router.get("/", async (req: Request, res: Response) => { connected_accounts: user.connected_accounts, premium_guild_since: null, // TODO premium_since: null, // TODO + mutual_guilds: [], // TODO {id: "", nick: null} when ?with_mutual_guilds=true user: { username: user.username, discriminator: user.discriminator, @@ -25,11 +34,4 @@ router.get("/", async (req: Request, res: Response) => { }); }); -export interface UserProfileResponse { - user: UserPublic; - connected_accounts: PublicConnectedAccount; - premium_guild_since?: Date; - premium_since?: Date; -} - export default router; diff --git a/api/src/routes/users/@me/affinities/guilds.ts b/api/src/routes/users/@me/affinities/guilds.ts
index fa6be0e7..8d744744 100644 --- a/api/src/routes/users/@me/affinities/guilds.ts +++ b/api/src/routes/users/@me/affinities/guilds.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", (req: Request, res: Response) => { +router.get("/", route({}), (req: Request, res: Response) => { // TODO: res.status(200).send({ guild_affinities: [] }); }); diff --git a/api/src/routes/users/@me/affinities/user.ts b/api/src/routes/users/@me/affinities/users.ts
index 0790a8a4..6d4e4991 100644 --- a/api/src/routes/users/@me/affinities/user.ts +++ b/api/src/routes/users/@me/affinities/users.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", (req: Request, res: Response) => { +router.get("/", route({}), (req: Request, res: Response) => { // TODO: res.status(200).send({ user_affinities: [], inverse_user_affinities: [] }); }); diff --git a/api/src/routes/users/@me/applications/#app_id/entitlements.ts b/api/src/routes/users/@me/applications/#app_id/entitlements.ts new file mode 100644
index 00000000..411e95bf --- /dev/null +++ b/api/src/routes/users/@me/applications/#app_id/entitlements.ts
@@ -0,0 +1,11 @@ +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([]).status(200); +}); + +export default router; diff --git a/api/src/routes/users/@me/billing/country-code.ts b/api/src/routes/users/@me/billing/country-code.ts new file mode 100644
index 00000000..33d40796 --- /dev/null +++ b/api/src/routes/users/@me/billing/country-code.ts
@@ -0,0 +1,11 @@ +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({ country_code: "US" }).status(200); +}); + +export default router; diff --git a/api/src/routes/users/@me/billing/subscriptions.ts b/api/src/routes/users/@me/billing/subscriptions.ts new file mode 100644
index 00000000..411e95bf --- /dev/null +++ b/api/src/routes/users/@me/billing/subscriptions.ts
@@ -0,0 +1,11 @@ +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([]).status(200); +}); + +export default router; diff --git a/api/src/routes/users/@me/channels.ts b/api/src/routes/users/@me/channels.ts
index 6fd396b8..b5782eca 100644 --- a/api/src/routes/users/@me/channels.ts +++ b/api/src/routes/users/@me/channels.ts
@@ -1,46 +1,22 @@ -import { Router, Request, Response } from "express"; -import { Channel, ChannelCreateEvent, ChannelType, Snowflake, trimSpecial, User, emitEvent } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; - -import { DmChannelCreateSchema } from "../../../schema/Channel"; -import { check } from "../../../util/instanceOf"; -import { In } from "typeorm"; -import { Recipient } from "../../../../../util/dist/entities/Recipient"; +import { Request, Response, Router } from "express"; +import { Recipient, DmChannelDTO, Channel } from "@fosscord/util"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { - const recipients = await Recipient.find({ where: { user_id: req.user_id }, relations: ["channel"] }); - - res.json(recipients.map((x) => x.channel)); +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("/", check(DmChannelCreateSchema), async (req: Request, res: Response) => { - const body = req.body as DmChannelCreateSchema; - - body.recipients = body.recipients.filter((x) => x !== req.user_id).unique(); - - const recipients = await User.find({ id: In(body.recipients) }); +export interface DmChannelCreateSchema { + name?: string; + recipients: string[]; +} - if (recipients.length !== body.recipients.length) { - throw new HTTPError("Recipient/s not found"); - } - - const type = body.recipients.length === 1 ? ChannelType.DM : ChannelType.GROUP_DM; - const name = trimSpecial(body.name); - - const channel = await new Channel({ - name, - type, - owner_id: req.user_id, - created_at: new Date(), - last_message_id: null, - recipients: [...body.recipients.map((x) => new Recipient({ id: x })), new Recipient({ id: req.user_id })] - }).save(); - - await emitEvent({ event: "CHANNEL_CREATE", data: channel, user_id: req.user_id } as ChannelCreateEvent); - - res.json(channel); +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/api/src/routes/users/@me/connections.ts b/api/src/routes/users/@me/connections.ts new file mode 100644
index 00000000..411e95bf --- /dev/null +++ b/api/src/routes/users/@me/connections.ts
@@ -0,0 +1,11 @@ +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([]).status(200); +}); + +export default router; diff --git a/api/src/routes/users/@me/delete.ts b/api/src/routes/users/@me/delete.ts
index e5fda948..39ceefd9 100644 --- a/api/src/routes/users/@me/delete.ts +++ b/api/src/routes/users/@me/delete.ts
@@ -1,10 +1,12 @@ 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("/", async (req: Request, res: Response) => { +router.post("/", route({}), async (req: Request, res: Response) => { const user = await User.findOneOrFail({ id: req.user_id }); //User object let correctpass = true; diff --git a/api/src/routes/users/@me/devices.ts b/api/src/routes/users/@me/devices.ts
index b16ef783..8556a3ad 100644 --- a/api/src/routes/users/@me/devices.ts +++ b/api/src/routes/users/@me/devices.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.post("/", (req: Request, res: Response) => { +router.post("/", route({}), (req: Request, res: Response) => { // TODO: res.sendStatus(204); }); diff --git a/api/src/routes/users/@me/disable.ts b/api/src/routes/users/@me/disable.ts
index 7b8a130c..259ced96 100644 --- a/api/src/routes/users/@me/disable.ts +++ b/api/src/routes/users/@me/disable.ts
@@ -1,10 +1,11 @@ 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("/", async (req: Request, res: Response) => { +router.post("/", route({}), async (req: Request, res: Response) => { const user = await User.findOneOrFail({ id: req.user_id }); //User object let correctpass = true; diff --git a/api/src/routes/users/@me/guilds.ts b/api/src/routes/users/@me/guilds.ts
index fb88281b..4ba03cec 100644 --- a/api/src/routes/users/@me/guilds.ts +++ b/api/src/routes/users/@me/guilds.ts
@@ -1,18 +1,18 @@ import { Router, Request, Response } from "express"; import { Guild, Member, User, GuildDeleteEvent, GuildMemberRemoveEvent, emitEvent } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import { In } from "typeorm"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { +router.get("/", route({}), async (req: Request, res: Response) => { const members = await Member.find({ relations: ["guild"], where: { id: req.user_id } }); res.json(members.map((x) => x.guild)); }); // user send to leave a certain guild -router.delete("/:id", async (req: Request, res: Response) => { +router.delete("/:id", route({}), async (req: Request, res: Response) => { const guild_id = req.params.id; const guild = await Guild.findOneOrFail({ where: { id: guild_id }, select: ["owner_id"] }); diff --git a/api/src/routes/users/@me/index.ts b/api/src/routes/users/@me/index.ts
index 68649215..f6bb04d7 100644 --- a/api/src/routes/users/@me/index.ts +++ b/api/src/routes/users/@me/index.ts
@@ -1,23 +1,47 @@ import { Router, Request, Response } from "express"; -import { User, PrivateUserProjection } from "@fosscord/util"; -import { UserModifySchema } from "../../../schema/User"; -import { check } from "../../../util/instanceOf"; -import { handleFile } from "../../../util/cdn"; +import { User, PrivateUserProjection, emitEvent, UserUpdateEvent, handleFile } from "@fosscord/util"; +import { route } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { - res.json(await User.getPublicUser(req.user_id, { select: PrivateUserProjection })); +export interface UserModifySchema { + /** + * @minLength 1 + * @maxLength 100 + */ + username?: string; + avatar?: string | null; + /** + * @maxLength 1024 + */ + bio?: string; + accent_color?: number; + banner?: string | null; + password?: string; + new_password?: string; + code?: string; +} + +router.get("/", route({}), async (req: Request, res: Response) => { + res.json(await User.findOne({ select: PrivateUserProjection, where: { id: req.user_id } })); }); -router.patch("/", check(UserModifySchema), async (req: Request, res: Response) => { +router.patch("/", route({ body: "UserModifySchema" }), async (req: Request, res: Response) => { const body = req.body as UserModifySchema; 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); - const user = await new User({ ...body, id: req.user_id }).save(); - // TODO: dispatch user update event + await new User({ ...body, id: req.user_id }).save(); + + //Need to reload user from db due to https://github.com/typeorm/typeorm/issues/3490 + const user = await User.findOneOrFail({ where: { id: req.user_id }, select: PrivateUserProjection }); + // 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); }); diff --git a/api/src/routes/users/@me/library.ts b/api/src/routes/users/@me/library.ts
index d771cb5e..7ac13bae 100644 --- a/api/src/routes/users/@me/library.ts +++ b/api/src/routes/users/@me/library.ts
@@ -1,8 +1,9 @@ import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; const router = Router(); -router.get("/", (req: Request, res: Response) => { +router.get("/", route({}), (req: Request, res: Response) => { // TODO: res.status(200).send([]); }); diff --git a/api/src/routes/users/@me/relationships.ts b/api/src/routes/users/@me/relationships.ts
index 8d6d8c9e..567c734e 100644 --- a/api/src/routes/users/@me/relationships.ts +++ b/api/src/routes/users/@me/relationships.ts
@@ -11,61 +11,149 @@ import { import { Router, Response, Request } from "express"; import { HTTPError } from "lambert-server"; import { DiscordApiErrors } from "@fosscord/util"; - -import { check, Length } from "../../../util/instanceOf"; +import { route } from "@fosscord/api"; const router = Router(); const userProjection: (keyof User)[] = ["relationships", ...PublicUserProjection]; -router.get("/", async (req: Request, res: Response) => { - const user = await User.findOneOrFail({ where: { id: req.user_id }, select: ["relationships"] }); +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ where: { id: req.user_id }, relations: ["relationships", "relationships.to"] }); + + //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(user.relationships); + return res.json(related_users); }); +export interface RelationshipPutSchema { + type?: RelationshipType; +} + +router.put("/:id", route({ body: "RelationshipPutSchema" }), async (req: Request, res: Response) => { + return await updateRelationship( + req, + res, + await User.findOneOrFail({ id: req.params.id }, { relations: ["relationships", "relationships.to"], select: userProjection }), + req.body.type ?? RelationshipType.friends + ); +}); + +export interface RelationshipPostSchema { + discriminator: string; + username: string; +} + +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({ id: req.user_id }, { select: userProjection, relations: ["relationships"] }); + const friend = await User.findOneOrFail({ 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({ id: req.user_id }, { relations: ["relationships"], select: userProjection }); + const user = await User.findOneOrFail( + { id: req.user_id }, + { relations: ["relationships", "relationships.to"], select: userProjection } + ); - var relationship = user.relationships.find((x) => x.id === id); - const friendRequest = friend.relationships.find((x) => x.id === req.user_id); + 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 = new Relationship({ id, type: RelationshipType.blocked }); - user.relationships.push(relationship); + relationship = await new Relationship({ to_id: id, type: RelationshipType.blocked, from_id: req.user_id }).save(); } if (friendRequest && friendRequest.type !== RelationshipType.blocked) { - friend.relationships.remove(friendRequest); await Promise.all([ - user.save(), + Relationship.delete({ id: friendRequest.id }), emitEvent({ event: "RELATIONSHIP_REMOVE", - data: friendRequest, + data: friendRequest.toPublicRelationship(), user_id: id } as RelationshipRemoveEvent) ]); } - await Promise.all([ - user.save(), - emitEvent({ - event: "RELATIONSHIP_ADD", - data: { - ...relationship, - user: { ...friend } - }, - user_id: req.user_id - } as RelationshipAddEvent) - ]); + await emitEvent({ + event: "RELATIONSHIP_ADD", + data: relationship.toPublicRelationship(), + user_id: req.user_id + } as RelationshipAddEvent); return res.sendStatus(204); } @@ -73,40 +161,43 @@ async function updateRelationship(req: Request, res: Response, friend: User, typ const { maxFriends } = Config.get().limits.user; if (user.relationships.length >= maxFriends) throw DiscordApiErrors.MAXIMUM_FRIENDS.withParams(maxFriends); - var incoming_relationship = new Relationship({ nickname: undefined, type: RelationshipType.incoming, id: req.user_id }); - var outgoing_relationship = new Relationship({ nickname: undefined, type: RelationshipType.outgoing, id }); + var incoming_relationship = new Relationship({ nickname: undefined, type: RelationshipType.incoming, to: user, from: friend }); + var outgoing_relationship = new Relationship({ + 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; - outgoing_relationship.type = RelationshipType.friends; - } else friend.relationships.push(incoming_relationship); + } 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"); - } else user.relationships.push(outgoing_relationship); + outgoing_relationship = relationship; + outgoing_relationship.type = RelationshipType.friends; + } await Promise.all([ - user.save(), - friend.save(), + incoming_relationship.save(), + outgoing_relationship.save(), emitEvent({ event: "RELATIONSHIP_ADD", - data: { - ...outgoing_relationship, - user: { ...friend } - }, + data: outgoing_relationship.toPublicRelationship(), user_id: req.user_id } as RelationshipAddEvent), emitEvent({ event: "RELATIONSHIP_ADD", data: { - ...incoming_relationship, - should_notify: true, - user: { ...user } + ...incoming_relationship.toPublicRelationship(), + should_notify: true }, user_id: id } as RelationshipAddEvent) @@ -114,71 +205,3 @@ async function updateRelationship(req: Request, res: Response, friend: User, typ return res.sendStatus(204); } - -router.put("/:id", check({ $type: new Length(Number, 1, 4) }), async (req: Request, res: Response) => { - return await updateRelationship( - req, - res, - await User.findOneOrFail({ id: req.params.id }, { relations: ["relationships"], select: userProjection }), - req.body.type - ); -}); - -router.post("/", check({ discriminator: String, username: String }), async (req: Request, res: Response) => { - return await updateRelationship( - req, - res, - await User.findOneOrFail({ - relations: ["relationships"], - select: userProjection, - where: req.body as { discriminator: string; username: string } - }), - req.body.type - ); -}); - -router.delete("/:id", 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({ id: req.user_id }, { select: userProjection, relations: ["relationships"] }); - const friend = await User.findOneOrFail({ id: id }, { select: userProjection, relations: ["relationships"] }); - - const relationship = user.relationships.find((x) => x.id === id); - const friendRequest = friend.relationships.find((x) => x.id === req.user_id); - - if (relationship?.type === RelationshipType.blocked) { - // unblock user - user.relationships.remove(relationship); - - await Promise.all([ - user.save(), - emitEvent({ event: "RELATIONSHIP_REMOVE", user_id: req.user_id, data: relationship } as RelationshipRemoveEvent) - ]); - return res.sendStatus(204); - } - if (!relationship || !friendRequest) throw new HTTPError("You are not friends with the user", 404); - if (friendRequest.type === RelationshipType.blocked) throw new HTTPError("The user blocked you"); - - user.relationships.remove(relationship); - friend.relationships.remove(friendRequest); - - await Promise.all([ - user.save(), - friend.save(), - emitEvent({ - event: "RELATIONSHIP_REMOVE", - data: relationship, - user_id: req.user_id - } as RelationshipRemoveEvent), - emitEvent({ - event: "RELATIONSHIP_REMOVE", - data: friendRequest, - user_id: id - } as RelationshipRemoveEvent) - ]); - - return res.sendStatus(204); -}); - -export default router; diff --git a/api/src/routes/users/@me/settings.ts b/api/src/routes/users/@me/settings.ts
index 90ee6372..9d7a2545 100644 --- a/api/src/routes/users/@me/settings.ts +++ b/api/src/routes/users/@me/settings.ts
@@ -1,11 +1,12 @@ import { Router, Response, Request } from "express"; import { User, UserSettings } from "@fosscord/util"; -import { check } from "../../../util/instanceOf"; -import { UserSettingsSchema } from "../../../schema/User"; +import { route } from "@fosscord/api"; const router = Router(); -router.patch("/", check(UserSettingsSchema), async (req: Request, res: Response) => { +export interface UserSettingsSchema extends UserSettings {} + +router.patch("/", route({ body: "UserSettingsSchema" }), async (req: Request, res: Response) => { const body = req.body as UserSettings; // only users can update user settings diff --git a/api/src/routes/voice/regions.ts b/api/src/routes/voice/regions.ts
index 812aa8f6..4de304ee 100644 --- a/api/src/routes/voice/regions.ts +++ b/api/src/routes/voice/regions.ts
@@ -1,11 +1,11 @@ import { Router, Request, Response } from "express"; -import {getIpAdress} from "../../util/ipAddress"; -import {getVoiceRegions} from "../../util/Voice"; +import { getIpAdress, route } from "@fosscord/api"; +import { getVoiceRegions } from "@fosscord/api"; const router: Router = Router(); -router.get("/", async (req: Request, res: Response) => { - res.json(await getVoiceRegions(getIpAdress(req), true))//vip true? +router.get("/", route({}), async (req: Request, res: Response) => { + res.json(await getVoiceRegions(getIpAdress(req), true)); //vip true? }); export default router; diff --git a/api/src/schema/Ban.ts b/api/src/schema/Ban.ts deleted file mode 100644
index 947a60ea..00000000 --- a/api/src/schema/Ban.ts +++ /dev/null
@@ -1,9 +0,0 @@ -export const BanCreateSchema = { - $delete_message_days: String, - $reason: String, -}; - -export interface BanCreateSchema { - delete_message_days?: string; - reason?: string; -} diff --git a/api/src/schema/Channel.ts b/api/src/schema/Channel.ts deleted file mode 100644
index cfbc7205..00000000 --- a/api/src/schema/Channel.ts +++ /dev/null
@@ -1,68 +0,0 @@ -import { ChannelType } from "@fosscord/util"; -import { Length } from "../util/instanceOf"; - -export const ChannelModifySchema = { - name: new Length(String, 2, 100), - type: new Length(Number, 0, 13), - $topic: new Length(String, 0, 1024), - $bitrate: Number, - $user_limit: Number, - $rate_limit_per_user: new Length(Number, 0, 21600), - $position: Number, - $permission_overwrites: [ - { - id: String, - type: new Length(Number, 0, 1), // either 0 (role) or 1 (member) - allow: BigInt, - deny: BigInt - } - ], - $parent_id: String, - $rtc_region: String, - $default_auto_archive_duration: Number, - $id: String, // kept for backwards compatibility does nothing (need for guild create) - $nsfw: Boolean -}; - -export const DmChannelCreateSchema = { - $name: String, - recipients: new Length([String], 1, 10) -}; - -export interface DmChannelCreateSchema { - name?: string; - recipients: string[]; -} - -export interface ChannelModifySchema { - name: string; - type: number; - topic?: string; - bitrate?: number; - user_limit?: number; - rate_limit_per_user?: number; - position?: number; - permission_overwrites?: { - id: string; - type: number; - allow: bigint; - deny: bigint; - }[]; - parent_id?: string; - id?: string; // is not used (only for guild create) - nsfw?: boolean; - rtc_region?: string; - default_auto_archive_duration?: number; -} - -export const ChannelGuildPositionUpdateSchema = [ - { - id: String, - $position: Number - } -]; - -export type ChannelGuildPositionUpdateSchema = { - id: string; - position?: number; -}[]; diff --git a/api/src/schema/Emoji.ts b/api/src/schema/Emoji.ts deleted file mode 100644
index 0406919c..00000000 --- a/api/src/schema/Emoji.ts +++ /dev/null
@@ -1,13 +0,0 @@ -// https://discord.com/developers/docs/resources/emoji - -export const EmojiCreateSchema = { - name: String, //name of the emoji - image: String, // image data the 128x128 emoji image uri - $roles: Array //roles allowed to use this emoji -}; - -export interface EmojiCreateSchema { - name: string; // name of the emoji - image: string; // image data the 128x128 emoji image uri - roles?: string[]; //roles allowed to use this emoji -} diff --git a/api/src/schema/Guild.ts b/api/src/schema/Guild.ts deleted file mode 100644
index 29c78ab0..00000000 --- a/api/src/schema/Guild.ts +++ /dev/null
@@ -1,106 +0,0 @@ -import { Channel } from "@fosscord/util"; -import { Length } from "../util/instanceOf"; -import { ChannelModifySchema } from "./Channel"; - -export const GuildCreateSchema = { - name: new Length(String, 2, 100), - $region: String, // auto complete voice region of the user - $icon: String, - $channels: [ChannelModifySchema], - $guild_template_code: String, - $system_channel_id: String, - $rules_channel_id: String -}; - -export interface GuildCreateSchema { - name: string; - region?: string; - icon?: string; - channels?: ChannelModifySchema[]; - guild_template_code?: string; - system_channel_id?: string; - rules_channel_id?: string; -} - -export const GuildUpdateSchema = { - ...GuildCreateSchema, - name: undefined, - $name: new Length(String, 2, 100), - $banner: String, - $splash: String, - $description: String, - $features: [String], - $icon: String, - $verification_level: Number, - $default_message_notifications: Number, - $system_channel_flags: Number, - $system_channel_id: String, - $explicit_content_filter: Number, - $public_updates_channel_id: String, - $afk_timeout: Number, - $afk_channel_id: String, - $preferred_locale: String -}; -// @ts-ignore -delete GuildUpdateSchema.$channels; - -export interface GuildUpdateSchema extends Omit<GuildCreateSchema, "channels"> { - banner?: string; - splash?: string; - description?: string; - features?: string[]; - verification_level?: number; - default_message_notifications?: number; - system_channel_flags?: number; - explicit_content_filter?: number; - public_updates_channel_id?: string; - afk_timeout?: number; - afk_channel_id?: string; - preferred_locale?: string; -} - -export const GuildTemplateCreateSchema = { - name: String, - $avatar: String -}; - -export interface GuildTemplateCreateSchema { - name: string; - avatar?: string; -} - -export const GuildUpdateWelcomeScreenSchema = { - $welcome_channels: [ - { - channel_id: String, - description: String, - $emoji_id: String, - emoji_name: String - } - ], - $enabled: Boolean, - $description: new Length(String, 0, 140) -}; - -export interface GuildUpdateWelcomeScreenSchema { - welcome_channels?: { - channel_id: string; - description: string; - emoji_id?: string; - emoji_name: string; - }[]; - enabled?: boolean; - description?: string; -} - -export const VoiceStateUpdateSchema = { - channel_id: String, // Snowflake - $suppress: Boolean, - $request_to_speak_timestamp: String // ISO8601 timestamp -}; - -export interface VoiceStateUpdateSchema { - channel_id: string; // Snowflake - suppress?: boolean; - request_to_speak_timestamp?: string // ISO8601 timestamp -} diff --git a/api/src/schema/Invite.ts b/api/src/schema/Invite.ts deleted file mode 100644
index da6192bc..00000000 --- a/api/src/schema/Invite.ts +++ /dev/null
@@ -1,22 +0,0 @@ -export const InviteCreateSchema = { - $target_user_id: String, - $target_type: String, - $validate: String, //? wtf is this - $max_age: Number, - $max_uses: Number, - $temporary: Boolean, - $unique: Boolean, - $target_user: String, - $target_user_type: Number -}; -export interface InviteCreateSchema { - target_user_id?: string; - target_type?: string; - validate?: string; //? wtf is this - max_age?: number; - max_uses?: number; - temporary?: boolean; - unique?: boolean; - target_user?: string; - target_user_type?: number; -} diff --git a/api/src/schema/Member.ts b/api/src/schema/Member.ts deleted file mode 100644
index 607d0a06..00000000 --- a/api/src/schema/Member.ts +++ /dev/null
@@ -1,29 +0,0 @@ -export const MemberCreateSchema = { - id: String, - nick: String, - guild_id: String, - joined_at: Date -}; - -export interface MemberCreateSchema { - id: string; - nick: string; - guild_id: string; - joined_at: Date; -} - -export const MemberNickChangeSchema = { - nick: String -}; - -export interface MemberNickChangeSchema { - nick: string; -} - -export const MemberChangeSchema = { - $roles: [String] -}; - -export interface MemberChangeSchema { - roles?: string[]; -} diff --git a/api/src/schema/Message.ts b/api/src/schema/Message.ts deleted file mode 100644
index d39f685a..00000000 --- a/api/src/schema/Message.ts +++ /dev/null
@@ -1,92 +0,0 @@ -import { Embed } from "@fosscord/util"; -import { Length } from "../util/instanceOf"; - -export const EmbedImage = { - $url: String, - $width: Number, - $height: Number -}; - -const embed = { - $title: new Length(String, 0, 256), //title of embed - $type: String, // type of embed (always "rich" for webhook embeds) - $description: new Length(String, 0, 2048), // description of embed - $url: String, // url of embed - $timestamp: String, // ISO8601 timestamp - $color: Number, // color code of the embed - $footer: { - text: new Length(String, 0, 2048), - icon_url: String, - proxy_icon_url: String - }, // footer object footer information - $image: EmbedImage, // image object image information - $thumbnail: EmbedImage, // thumbnail object thumbnail information - $video: EmbedImage, // video object video information - $provider: { - name: String, - url: String - }, // provider object provider information - $author: { - name: new Length(String, 0, 256), - url: String, - icon_url: String, - proxy_icon_url: String - }, // author object author information - $fields: new Length( - [ - { - name: new Length(String, 0, 256), - value: new Length(String, 0, 1024), - $inline: Boolean - } - ], - 0, - 25 - ) -}; - -export const MessageCreateSchema = { - $content: new Length(String, 0, 2000), - $nonce: String, - $tts: Boolean, - $flags: String, - $embed: embed, - // TODO: ^ embed is deprecated in favor of embeds (https://discord.com/developers/docs/resources/channel#message-object) - // $embeds: [embed], - $allowed_mentions: { - $parse: [String], - $roles: [String], - $users: [String], - $replied_user: Boolean - }, - $message_reference: { - message_id: String, - channel_id: String, - $guild_id: String, - $fail_if_not_exists: Boolean - }, - $payload_json: String, - $file: Object -}; - -export interface MessageCreateSchema { - content?: string; - nonce?: string; - tts?: boolean; - flags?: string; - embed?: Embed & { timestamp?: string }; - allowed_mentions?: { - parse?: string[]; - roles?: string[]; - users?: string[]; - replied_user?: boolean; - }; - message_reference?: { - message_id: string; - channel_id: string; - guild_id?: string; - fail_if_not_exists?: boolean; - }; - payload_json?: string; - file?: any; -} diff --git a/api/src/schema/Roles.ts b/api/src/schema/Roles.ts deleted file mode 100644
index e1a34ae8..00000000 --- a/api/src/schema/Roles.ts +++ /dev/null
@@ -1,29 +0,0 @@ -export const RoleModifySchema = { - $name: String, - $permissions: BigInt, - $color: Number, - $hoist: Boolean, // whether the role should be displayed separately in the sidebar - $mentionable: Boolean, // whether the role should be mentionable - $position: Number -}; - -export interface RoleModifySchema { - name?: string; - permissions?: bigint; - color?: number; - hoist?: boolean; // whether the role should be displayed separately in the sidebar - mentionable?: boolean; // whether the role should be mentionable - position?: number; -} - -export const RolePositionUpdateSchema = [ - { - id: String, - position: Number - } -]; - -export type RolePositionUpdateSchema = { - id: string; - position: number; -}[]; diff --git a/api/src/schema/Template.ts b/api/src/schema/Template.ts deleted file mode 100644
index 88e36c53..00000000 --- a/api/src/schema/Template.ts +++ /dev/null
@@ -1,19 +0,0 @@ -export const TemplateCreateSchema = { - name: String, - $description: String, -}; - -export interface TemplateCreateSchema { - name: string; - description?: string; -} - -export const TemplateModifySchema = { - name: String, - $description: String, -}; - -export interface TemplateModifySchema { - name: string; - description?: string; -} diff --git a/api/src/schema/User.ts b/api/src/schema/User.ts deleted file mode 100644
index 0d094b9e..00000000 --- a/api/src/schema/User.ts +++ /dev/null
@@ -1,74 +0,0 @@ -import { UserSettings } from "../../../util/dist"; -import { Length } from "../util/instanceOf"; - -export const UserModifySchema = { - $username: new Length(String, 2, 32), - $avatar: String, - $bio: new Length(String, 0, 190), - $accent_color: Number, - $banner: String, - $password: String, - $new_password: String, - $code: String // 2fa code -}; - -export interface UserModifySchema { - username?: string; - avatar?: string | null; - bio?: string; - accent_color?: number | null; - banner?: string | null; - password?: string; - new_password?: string; - code?: string; -} - -export const UserSettingsSchema = { - $afk_timeout: Number, - $allow_accessibility_detection: Boolean, - $animate_emoji: Boolean, - $animate_stickers: Number, - $contact_sync_enabled: Boolean, - $convert_emoticons: Boolean, - $custom_status: { - $emoji_id: String, - $emoji_name: String, - $expires_at: Number, - $text: String - }, - $default_guilds_restricted: Boolean, - $detect_platform_accounts: Boolean, - $developer_mode: Boolean, - $disable_games_tab: Boolean, - $enable_tts_command: Boolean, - $explicit_content_filter: Number, - $friend_source_flags: { - all: Boolean - }, - $gateway_connected: Boolean, - $gif_auto_play: Boolean, - $guild_folders: [ - { - color: Number, - guild_ids: [String], - id: Number, - name: String - } - ], - $guild_positions: [String], - $inline_attachment_media: Boolean, - $inline_embed_media: Boolean, - $locale: String, - $message_display_compact: Boolean, - $native_phone_integration_enabled: Boolean, - $render_embeds: Boolean, - $render_reactions: Boolean, - $restricted_guilds: [String], - $show_current_game: Boolean, - $status: String, - $stream_notifications_enabled: Boolean, - $theme: String, - $timezone_offset: Number -}; - -export interface UserSettingsSchema extends UserSettings {} diff --git a/api/src/schema/Widget.ts b/api/src/schema/Widget.ts deleted file mode 100644
index d37a38de..00000000 --- a/api/src/schema/Widget.ts +++ /dev/null
@@ -1,10 +0,0 @@ -// https://discord.com/developers/docs/resources/guild#guild-widget-object -export const WidgetModifySchema = { - enabled: Boolean, // whether the widget is enabled - channel_id: String // the widget channel id -}; - -export interface WidgetModifySchema { - enabled: boolean; // whether the widget is enabled - channel_id: string; // the widget channel id -} diff --git a/api/src/schema/index.ts b/api/src/schema/index.ts deleted file mode 100644
index b5f38a2f..00000000 --- a/api/src/schema/index.ts +++ /dev/null
@@ -1,11 +0,0 @@ -export * from "./Ban"; -export * from "./Channel"; -export * from "./Emoji"; -export * from "./Guild"; -export * from "./Invite"; -export * from "./Member"; -export * from "./Message"; -export * from "./Roles"; -export * from "./Template"; -export * from "./User"; -export * from "./Widget"; diff --git a/api/src/test/password_test.ts b/api/src/test/password_test.ts
index 59d36621..983b18ae 100644 --- a/api/src/test/password_test.ts +++ b/api/src/test/password_test.ts
@@ -1,12 +1,12 @@ -import { check } from "./../util/passwordStrength"; +import { checkPassword } from "@fosscord/api"; -console.log(check("123456789012345")); +console.log(checkPassword("123456789012345")); // -> 0.25 -console.log(check("ABCDEFGHIJKLMOPQ")); +console.log(checkPassword("ABCDEFGHIJKLMOPQ")); // -> 0.25 -console.log(check("ABC123___...123")); +console.log(checkPassword("ABC123___...123")); // -> -console.log(check("")); +console.log(checkPassword("")); // -> -// console.log(check("")); +// console.log(checkPassword("")); // // -> diff --git a/api/src/util/FieldError.ts b/api/src/util/FieldError.ts new file mode 100644
index 00000000..0b3f93d2 --- /dev/null +++ b/api/src/util/FieldError.ts
@@ -0,0 +1,25 @@ +import "missing-native-js-functions"; + +export function FieldErrors(fields: Record<string, { code?: string; message: string }>) { + return new FieldError( + 50035, + "Invalid Form Body", + fields.map(({ message, code }) => ({ + _errors: [ + { + message, + code: code || "BASE_TYPE_INVALID" + } + ] + })) + ); +} + +// TODO: implement Image data type: Data URI scheme that supports JPG, GIF, and PNG formats. An example Data URI format is: data:image/jpeg;base64,BASE64_ENCODED_JPEG_IMAGE_DATA +// Ensure you use the proper content type (image/jpeg, image/png, image/gif) that matches the image data being provided. + +export class FieldError extends Error { + constructor(public code: string | number, public message: string, public errors?: any) { + super(message); + } +} diff --git a/api/src/util/Message.ts b/api/src/util/Message.ts
index fea553bc..f8230124 100644 --- a/api/src/util/Message.ts +++ b/api/src/util/Message.ts
@@ -22,7 +22,7 @@ import { import { HTTPError } from "lambert-server"; import fetch from "node-fetch"; import cheerio from "cheerio"; -import { MessageCreateSchema } from "../schema/Message"; +import { MessageCreateSchema } from "../routes/channels/#channel_id/messages"; // TODO: check webhook, application, system author diff --git a/api/src/util/String.ts b/api/src/util/String.ts
index 49fba237..67d87e37 100644 --- a/api/src/util/String.ts +++ b/api/src/util/String.ts
@@ -1,14 +1,14 @@ import { Request } from "express"; import { ntob } from "./Base64"; -import { FieldErrors } from "./instanceOf"; +import { FieldErrors } from "./FieldError"; export function checkLength(str: string, min: number, max: number, key: string, req: Request) { if (str.length < min || str.length > max) { throw FieldErrors({ [key]: { code: "BASE_TYPE_BAD_LENGTH", - message: req.t("common:field.BASE_TYPE_BAD_LENGTH", { length: `${min} - ${max}` }), - }, + message: req.t("common:field.BASE_TYPE_BAD_LENGTH", { length: `${min} - ${max}` }) + } }); } } diff --git a/api/src/util/Voice.ts b/api/src/util/Voice.ts
index 087bdfa8..f06b1aaa 100644 --- a/api/src/util/Voice.ts +++ b/api/src/util/Voice.ts
@@ -1,32 +1,32 @@ -import {Config} from "@fosscord/util"; -import {distanceBetweenLocations, IPAnalysis} from "./ipAddress"; +import { Config } from "@fosscord/util"; +import { distanceBetweenLocations, IPAnalysis } from "./ipAddress"; export async function getVoiceRegions(ipAddress: string, vip: boolean) { - const regions = Config.get().regions; - const availableRegions = regions.available.filter(ar => vip ? true : !ar.vip); - let optimalId = regions.default + const regions = Config.get().regions; + const availableRegions = regions.available.filter((ar) => (vip ? true : !ar.vip)); + let optimalId = regions.default; - if(!regions.useDefaultAsOptimal) { - const clientIpAnalysis = await IPAnalysis(ipAddress) + if (!regions.useDefaultAsOptimal) { + const clientIpAnalysis = await IPAnalysis(ipAddress); - let min = Number.POSITIVE_INFINITY + let min = Number.POSITIVE_INFINITY; - for (let ar of availableRegions) { - //TODO the endpoint location should be saved in the database if not already present to prevent IPAnalysis call - const dist = distanceBetweenLocations(clientIpAnalysis, ar.location || (await IPAnalysis(ar.endpoint))) + for (let ar of availableRegions) { + //TODO the endpoint location should be saved in the database if not already present to prevent IPAnalysis call + const dist = distanceBetweenLocations(clientIpAnalysis, ar.location || (await IPAnalysis(ar.endpoint))); - if(dist < min) { - min = dist - optimalId = ar.id - } - } - } + if (dist < min) { + min = dist; + optimalId = ar.id; + } + } + } - return availableRegions.map(ar => ({ - id: ar.id, - name: ar.name, - custom: ar.custom, - deprecated: ar.deprecated, - optimal: ar.id === optimalId - })) -} \ No newline at end of file + return availableRegions.map((ar) => ({ + id: ar.id, + name: ar.name, + custom: ar.custom, + deprecated: ar.deprecated, + optimal: ar.id === optimalId + })); +} diff --git a/api/src/util/VoiceState.ts b/api/src/util/VoiceState.ts deleted file mode 100644
index 07022ec9..00000000 --- a/api/src/util/VoiceState.ts +++ /dev/null
@@ -1,54 +0,0 @@ -import { Channel, ChannelType, DiscordApiErrors, emitEvent, getPermission, VoiceState, VoiceStateUpdateEvent } from "@fosscord/util"; -import { VoiceStateUpdateSchema } from "../schema"; - - -//TODO need more testing when community guild and voice stage channel are working -export async function updateVoiceState(vsuSchema: VoiceStateUpdateSchema, guildId: string, userId: string, targetUserId?: string) { - const perms = await getPermission(userId, guildId, vsuSchema.channel_id); - - /* - From https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state - You must have the MUTE_MEMBERS permission to unsuppress yourself. 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 (targetUserId !== undefined || (vsuSchema.suppress !== undefined && !vsuSchema.suppress)) { - perms.hasThrow("MUTE_MEMBERS"); - } - if (vsuSchema.request_to_speak_timestamp !== undefined && vsuSchema.request_to_speak_timestamp !== "") { - perms.hasThrow("REQUEST_TO_SPEAK") - } - - if (!targetUserId) { - targetUserId = userId; - } else { - if (vsuSchema.suppress !== undefined && vsuSchema.suppress) - vsuSchema.request_to_speak_timestamp = "" //Need to check if empty string is the right value - } - - //TODO assumed that empty string means clean, need to test if it's right - let voiceState - try { - voiceState = await VoiceState.findOneOrFail({ - guild_id: guildId, - channel_id: vsuSchema.channel_id, - user_id: targetUserId - }); - } catch (error) { - throw DiscordApiErrors.UNKNOWN_VOICE_STATE; - } - - voiceState.assign(vsuSchema); - const channel = await Channel.findOneOrFail({ guild_id: guildId, id: vsuSchema.channel_id }) - if (channel.type !== ChannelType.GUILD_STAGE_VOICE) { - throw DiscordApiErrors.CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE; - } - - await Promise.all([ - voiceState.save(), - emitEvent({ - event: "VOICE_STATE_UPDATE", - data: voiceState, - guild_id: guildId - } as VoiceStateUpdateEvent)]); - return; -} \ No newline at end of file diff --git a/api/src/util/cdn.ts b/api/src/util/cdn.ts
index 3c71d980..8c6e9ac9 100644 --- a/api/src/util/cdn.ts +++ b/api/src/util/cdn.ts
@@ -38,3 +38,16 @@ export async function handleFile(path: string, body?: string): Promise<string | throw new HTTPError("Invalid " + path); } } + +export async function deleteFile(path: string) { + const response = await fetch(`${Config.get().cdn.endpoint || "http://localhost:3003"}${path}`, { + headers: { + signature: Config.get().security.requestSignature + }, + method: "DELETE" + }); + const result = await response.json(); + + if (response.status !== 200) throw result; + return result; +} diff --git a/api/src/util/index.ts b/api/src/util/index.ts new file mode 100644
index 00000000..3e47ce4e --- /dev/null +++ b/api/src/util/index.ts
@@ -0,0 +1,9 @@ +export * from "./Base64"; +export * from "./FieldError"; +export * from "./ipAddress"; +export * from "./Message"; +export * from "./passwordStrength"; +export * from "./RandomInviteID"; +export * from "./route"; +export * from "./String"; +export * from "./Voice"; diff --git a/api/src/util/instanceOf.ts b/api/src/util/instanceOf.ts deleted file mode 100644
index 4d9034e5..00000000 --- a/api/src/util/instanceOf.ts +++ /dev/null
@@ -1,214 +0,0 @@ -// different version of lambert-server instanceOf with discord error format - -import { NextFunction, Request, Response } from "express"; -import { Tuple } from "lambert-server"; -import "missing-native-js-functions"; - -export const OPTIONAL_PREFIX = "$"; -export const EMAIL_REGEX = - /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - -export function check(schema: any) { - return (req: Request, res: Response, next: NextFunction) => { - try { - const result = instanceOf(schema, req.body, { path: "body", req, ref: { obj: null, key: "" } }); - if (result === true) return next(); - throw result; - } catch (error) { - return res.status(400).json({ code: 50035, message: "Invalid Form Body", success: false, errors: error }); - } - }; -} - -export function FieldErrors(fields: Record<string, { code?: string; message: string }>) { - return new FieldError( - 50035, - "Invalid Form Body", - fields.map(({ message, code }) => ({ - _errors: [ - { - message, - code: code || "BASE_TYPE_INVALID" - } - ] - })) - ); -} - -// TODO: implement Image data type: Data URI scheme that supports JPG, GIF, and PNG formats. An example Data URI format is: data:image/jpeg;base64,BASE64_ENCODED_JPEG_IMAGE_DATA -// Ensure you use the proper content type (image/jpeg, image/png, image/gif) that matches the image data being provided. - -export class FieldError extends Error { - constructor(public code: string | number, public message: string, public errors?: any) { - super(message); - } -} - -export class Email { - constructor(public email: string) {} - check() { - return !!this.email.match(EMAIL_REGEX); - } -} - -export class Length { - constructor(public type: any, public min: number, public max: number) {} - - check(value: string) { - if (typeof value === "string" || Array.isArray(value)) return value.length >= this.min && value.length <= this.max; - if (typeof value === "number" || typeof value === "bigint") return value >= this.min && value <= this.max; - return false; - } -} - -export function instanceOf( - type: any, - value: any, - { - path = "", - optional = false, - errors = {}, - req, - ref - }: { path?: string; optional?: boolean; errors?: any; req: Request; ref?: { key: string | number; obj: any } } -): Boolean { - if (!ref) ref = { obj: null, key: "" }; - if (!path) path = "body"; - if (!type) return true; // no type was specified - - try { - if (value == null) { - if (optional) return true; - throw new FieldError("BASE_TYPE_REQUIRED", req.t("common:field.BASE_TYPE_REQUIRED")); - } - - switch (type) { - case String: - value = `${value}`; - ref.obj[ref.key] = value; - if (typeof value === "string") return true; - throw new FieldError("BASE_TYPE_STRING", req.t("common:field.BASE_TYPE_STRING")); - case Number: - value = Number(value); - ref.obj[ref.key] = value; - if (typeof value === "number" && !isNaN(value)) return true; - throw new FieldError("BASE_TYPE_NUMBER", req.t("common:field.BASE_TYPE_NUMBER")); - case BigInt: - try { - value = BigInt(value); - ref.obj[ref.key] = value; - if (typeof value === "bigint") return true; - } catch (error) {} - throw new FieldError("BASE_TYPE_BIGINT", req.t("common:field.BASE_TYPE_BIGINT")); - case Boolean: - if (value == "true") value = true; - if (value == "false") value = false; - ref.obj[ref.key] = value; - if (typeof value === "boolean") return true; - throw new FieldError("BASE_TYPE_BOOLEAN", req.t("common:field.BASE_TYPE_BOOLEAN")); - - case Email: - if (new Email(value).check()) return true; - throw new FieldError("EMAIL_TYPE_INVALID_EMAIL", req.t("common:field.EMAIL_TYPE_INVALID_EMAIL")); - case Date: - value = new Date(value); - ref.obj[ref.key] = value; - // value.getTime() can be < 0, if it is before 1970 - if (!isNaN(value)) return true; - throw new FieldError("DATE_TYPE_PARSE", req.t("common:field.DATE_TYPE_PARSE")); - } - - if (typeof type === "object") { - if (Array.isArray(type)) { - if (!Array.isArray(value)) throw new FieldError("BASE_TYPE_ARRAY", req.t("common:field.BASE_TYPE_ARRAY")); - if (!type.length) return true; // type array didn't specify any type - - return ( - value.every((val, i) => { - errors[i] = {}; - - if ( - instanceOf(type[0], val, { - path: `${path}[${i}]`, - optional, - errors: errors[i], - req, - ref: { key: i, obj: value } - }) === true - ) { - delete errors[i]; - return true; - } - - return false; - }) || errors - ); - } else if (type?.constructor?.name != "Object") { - if (type instanceof Tuple) { - if ((<Tuple>type).types.some((x) => instanceOf(x, value, { path, optional, errors, req, ref }))) return true; - throw new FieldError("BASE_TYPE_CHOICES", req.t("common:field.BASE_TYPE_CHOICES", { types: type.types })); - } else if (type instanceof Length) { - let length = <Length>type; - if (instanceOf(length.type, value, { path, optional, req, ref, errors }) !== true) return errors; - let val = ref.obj[ref.key]; - if ((<Length>type).check(val)) return true; - throw new FieldError( - "BASE_TYPE_BAD_LENGTH", - req.t("common:field.BASE_TYPE_BAD_LENGTH", { - length: `${type.min} - ${type.max}` - }) - ); - } - try { - if (value instanceof type) return true; - } catch (error) { - throw new FieldError("BASE_TYPE_CLASS", req.t("common:field.BASE_TYPE_CLASS", { type })); - } - } - - if (typeof value !== "object") throw new FieldError("BASE_TYPE_OBJECT", req.t("common:field.BASE_TYPE_OBJECT")); - - const diff = Object.keys(value).missing( - Object.keys(type).map((x) => (x.startsWith(OPTIONAL_PREFIX) ? x.slice(OPTIONAL_PREFIX.length) : x)) - ); - - if (diff.length) throw new FieldError("UNKOWN_FIELD", req.t("common:field.UNKOWN_FIELD", { key: diff })); - - return ( - Object.keys(type).every((key) => { - let newKey = key; - const OPTIONAL = key.startsWith(OPTIONAL_PREFIX); - if (OPTIONAL) newKey = newKey.slice(OPTIONAL_PREFIX.length); - errors[newKey] = {}; - - if ( - instanceOf(type[key], value[newKey], { - path: `${path}.${newKey}`, - optional: OPTIONAL, - errors: errors[newKey], - req, - ref: { key: newKey, obj: value } - }) === true - ) { - delete errors[newKey]; - return true; - } - - return false; - }) || errors - ); - } else if (typeof type === "number" || typeof type === "string" || typeof type === "boolean") { - if (value === type) return true; - throw new FieldError("BASE_TYPE_CONSTANT", req.t("common:field.BASE_TYPE_CONSTANT", { value: type })); - } else if (typeof type === "bigint") { - if (BigInt(value) === type) return true; - throw new FieldError("BASE_TYPE_CONSTANT", req.t("common:field.BASE_TYPE_CONSTANT", { value: type })); - } - - return type == value; - } catch (error) { - let e = error as FieldError; - errors._errors = [{ message: e.message, code: e.code }]; - return errors; - } -} diff --git a/api/src/util/passwordStrength.ts b/api/src/util/passwordStrength.ts
index dfffa2c0..047df008 100644 --- a/api/src/util/passwordStrength.ts +++ b/api/src/util/passwordStrength.ts
@@ -16,7 +16,7 @@ const blocklist: string[] = []; // TODO: update ones passwordblocklist is stored * * Returns: 0 > pw > 1 */ -export function check(password: string): number { +export function checkPassword(password: string): number { const { minLength, minNumbers, minUpperCase, minSymbols } = Config.get().register.password; var strength = 0; diff --git a/api/src/util/route.ts b/api/src/util/route.ts new file mode 100644
index 00000000..e7c7ed1c --- /dev/null +++ b/api/src/util/route.ts
@@ -0,0 +1,102 @@ +import { DiscordApiErrors, EVENT, Event, EventData, getPermission, PermissionResolvable, Permissions } from "@fosscord/util"; +import { NextFunction, Request, Response } from "express"; +import fs from "fs"; +import path from "path"; +import Ajv from "ajv"; +import { AnyValidateFunction } from "ajv/dist/core"; +import { FieldErrors } from ".."; +import addFormats from "ajv-formats"; + +const SchemaPath = path.join(__dirname, "..", "..", "assets", "schemas.json"); +const schemas = JSON.parse(fs.readFileSync(SchemaPath, { encoding: "utf8" })); +export const ajv = new Ajv({ + allErrors: true, + parseDate: true, + allowDate: true, + schemas, + coerceTypes: true, + messages: true, + strict: true, + strictRequired: true +}); +addFormats(ajv); + +declare global { + namespace Express { + interface Request { + permission?: Permissions; + } + } +} + +export type RouteResponse = { status?: number; body?: `${string}Response`; headers?: Record<string, string> }; + +export interface RouteOptions { + permission?: PermissionResolvable; + body?: `${string}Schema`; // typescript interface name + test?: { + response?: RouteResponse; + body?: any; + path?: string; + event?: EVENT | EVENT[]; + headers?: Record<string, string>; + }; +} + +// Normalizer is introduced to workaround https://github.com/ajv-validator/ajv/issues/1287 +// this removes null values as ajv doesn't treat them as undefined +// normalizeBody allows to handle circular structures without issues +// taken from https://github.com/serverless/serverless/blob/master/lib/classes/ConfigSchemaHandler/index.js#L30 (MIT license) +const normalizeBody = (body: any = {}) => { + const normalizedObjectsSet = new WeakSet(); + const normalizeObject = (object: any) => { + if (normalizedObjectsSet.has(object)) return; + normalizedObjectsSet.add(object); + if (Array.isArray(object)) { + for (const [index, value] of object.entries()) { + if (typeof value === "object") normalizeObject(value); + } + } else { + for (const [key, value] of Object.entries(object)) { + if (value == null) { + if (key === "icon" || key === "avatar" || key === "banner" || key === "splash") continue; + delete object[key]; + } else if (typeof value === "object") { + normalizeObject(value); + } + } + } + }; + normalizeObject(body); + return body; +}; + +export function route(opts: RouteOptions) { + var validate: AnyValidateFunction<any> | undefined; + if (opts.body) { + validate = ajv.getSchema(opts.body); + if (!validate) throw new Error(`Body schema ${opts.body} not found`); + } + + return async (req: Request, res: Response, next: NextFunction) => { + if (opts.permission) { + const required = new Permissions(opts.permission); + const permission = await getPermission(req.user_id, req.params.guild_id, req.params.channel_id); + + // bitfield comparison: check if user lacks certain permission + if (!permission.has(required)) { + throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(opts.permission as string); + } + } + + if (validate) { + const valid = validate(normalizeBody(req.body)); + if (!valid) { + const fields: Record<string, { code?: string; message: string }> = {}; + validate.errors?.forEach((x) => (fields[x.instancePath.slice(1)] = { code: x.keyword, message: x.message || "" })); + throw FieldErrors(fields); + } + } + next(); + }; +}