From 859fdd679b2f42ae4fd90b3a1a7958370fcdccb8 Mon Sep 17 00:00:00 2001 From: Flam3rboy <34555296+Flam3rboy@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:42:40 +0200 Subject: [PATCH] :bug: fix body parse treating null not as undefined (except for icons/avatars) --- api/assets/schemas.json | 63 +++++++------------ api/package.json | 3 +- api/scripts/globalSetup.js | 3 +- api/src/routes/auth/register.ts | 30 ++++----- .../routes/channels/#channel_id/invites.ts | 6 +- api/src/routes/guilds/#guild_id/index.ts | 6 +- api/src/routes/guilds/index.ts | 4 +- api/src/routes/guilds/templates/index.ts | 2 +- api/src/routes/users/@me/index.ts | 2 +- api/src/util/route.ts | 45 ++++++++++--- 10 files changed, 88 insertions(+), 76 deletions(-) diff --git a/api/assets/schemas.json b/api/assets/schemas.json index 9c34f968..e27087a9 100644 --- a/api/assets/schemas.json +++ b/api/assets/schemas.json @@ -26,8 +26,7 @@ "type": "string" }, "date_of_birth": { - "type": "string", - "format": "date-time" + "type": "string" }, "gift_code_sku_id": { "type": "string" @@ -713,22 +712,13 @@ "type": "object", "properties": { "target_user_id": { - "type": [ - "null", - "string" - ] + "type": "string" }, "target_type": { - "type": [ - "null", - "string" - ] + "type": "string" }, "validate": { - "type": [ - "null", - "string" - ] + "type": "string" }, "max_age": { "type": "integer" @@ -2539,7 +2529,10 @@ "type": "string" }, "icon": { - "type": "string" + "type": [ + "null", + "string" + ] }, "channels": { "type": "array", @@ -2551,10 +2544,7 @@ "type": "string" }, "system_channel_id": { - "type": [ - "null", - "string" - ] + "type": "string" }, "rules_channel_id": { "type": "string" @@ -2820,10 +2810,7 @@ ] }, "description": { - "type": [ - "null", - "string" - ] + "type": "string" }, "features": { "type": "array", @@ -2844,19 +2831,13 @@ "type": "integer" }, "public_updates_channel_id": { - "type": [ - "null", - "string" - ] + "type": "string" }, "afk_timeout": { "type": "integer" }, "afk_channel_id": { - "type": [ - "null", - "string" - ] + "type": "string" }, "preferred_locale": { "type": "string" @@ -2869,16 +2850,16 @@ "type": "string" }, "icon": { - "type": "string" + "type": [ + "null", + "string" + ] }, "guild_template_code": { "type": "string" }, "system_channel_id": { - "type": [ - "null", - "string" - ] + "type": "string" }, "rules_channel_id": { "type": "string" @@ -5718,7 +5699,10 @@ "type": "string" }, "avatar": { - "type": "string" + "type": [ + "null", + "string" + ] } }, "additionalProperties": false, @@ -6241,10 +6225,7 @@ "type": "string" }, "accent_color": { - "type": [ - "null", - "integer" - ] + "type": "integer" }, "banner": { "type": [ diff --git a/api/package.json b/api/package.json index d93a6269..ad959e57 100644 --- a/api/package.json +++ b/api/package.json @@ -5,7 +5,8 @@ "main": "dist/Server.js", "types": "dist/Server.d.ts", "scripts": { - "test": "npm run build && jest --coverage --verbose --forceExit ./tests", + "test:only": "node -r ./scripts/tsconfig-paths-bootstrap.js node_modules/.bin/jest --coverage --verbose --forceExit ./tests", + "test": "npm run build && npm run test:only", "test:watch": "jest --watch", "start": "npm run build && node -r ./scripts/tsconfig-paths-bootstrap.js dist/start", "build": "npx tsc -b .", diff --git a/api/scripts/globalSetup.js b/api/scripts/globalSetup.js index 76cd8e0d..98e70fb9 100644 --- a/api/scripts/globalSetup.js +++ b/api/scripts/globalSetup.js @@ -1,10 +1,11 @@ const fs = require("fs"); +const path = require("path"); const { FosscordServer } = require("../dist/Server"); const Server = new FosscordServer({ port: 3001 }); global.server = Server; module.exports = async () => { try { - fs.unlinkSync(`${__dirname}/../database.db`); + fs.unlinkSync(path.join(__dirname, "..", "database.db")); } catch {} return await Server.start(); }; diff --git a/api/src/routes/auth/register.ts b/api/src/routes/auth/register.ts index 33f089b2..efe91625 100644 --- a/api/src/routes/auth/register.ts +++ b/api/src/routes/auth/register.ts @@ -27,13 +27,16 @@ export interface RegisterSchema { 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) => { - const { + let { email, username, password, @@ -61,14 +64,11 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re // 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); - - // adjusted_password will be the hash of the password - let adjusted_password = ""; + // email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick + email = adjustEmail(email); // trim special uf8 control characters -> Backspace, Newline, ... - let adjusted_username = trimSpecial(username); + username = trimSpecial(username); // discriminator will be randomly generated let discriminator = ""; @@ -96,10 +96,10 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re 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") } }); + 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: adjusted_email }).catch((e) => {}); + const exists = await User.findOneOrFail({ email: email }).catch((e) => {}); if (exists) { throw FieldErrors({ @@ -122,6 +122,7 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re } 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) { @@ -162,7 +163,7 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re } // the salt is saved in the password refer to bcrypt docs - adjusted_password = await bcrypt.hash(password, 12); + 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 @@ -171,7 +172,7 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re // 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"] }); + exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"] }); if (!exists) break; } @@ -190,7 +191,7 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re const user = await new User({ created_at: new Date(), - username: adjusted_username, + username: username, discriminator, id: Snowflake.generate(), bot: false, @@ -204,12 +205,12 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re verified: false, disabled: false, deleted: false, - email: adjusted_email, + email: email, nsfw_allowed: true, // TODO: depending on age public_flags: "0", flags: "0", // TODO: generate data: { - hash: adjusted_password, + hash: password, valid_tokens_since: new Date() }, settings: { ...defaultSettings, locale: req.language || "en-US" }, @@ -220,6 +221,7 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re }); export function adjustEmail(email: string): string | undefined { + if (!email) return email; // body parser already checked if it is a valid email const parts = email.match(EMAIL_REGEX); // @ts-ignore diff --git a/api/src/routes/channels/#channel_id/invites.ts b/api/src/routes/channels/#channel_id/invites.ts index 71612e31..22420983 100644 --- a/api/src/routes/channels/#channel_id/invites.ts +++ b/api/src/routes/channels/#channel_id/invites.ts @@ -8,9 +8,9 @@ import { isTextChannel } from "./messages"; const router: Router = Router(); export interface InviteCreateSchema { - target_user_id?: string | null; - target_type?: string | null; - validate?: string | null; // ? what is this + target_user_id?: string; + target_type?: string; + validate?: string; // ? what is this max_age?: number; max_uses?: number; temporary?: boolean; diff --git a/api/src/routes/guilds/#guild_id/index.ts b/api/src/routes/guilds/#guild_id/index.ts index 7e4bf28a..63000b84 100644 --- a/api/src/routes/guilds/#guild_id/index.ts +++ b/api/src/routes/guilds/#guild_id/index.ts @@ -11,15 +11,15 @@ const router = Router(); export interface GuildUpdateSchema extends Omit { banner?: string | null; splash?: string | null; - description?: 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 | null; + public_updates_channel_id?: string; afk_timeout?: number; - afk_channel_id?: string | null; + afk_channel_id?: string; preferred_locale?: string; } diff --git a/api/src/routes/guilds/index.ts b/api/src/routes/guilds/index.ts index 2334bb9c..2e68d953 100644 --- a/api/src/routes/guilds/index.ts +++ b/api/src/routes/guilds/index.ts @@ -12,10 +12,10 @@ export interface GuildCreateSchema { */ name: string; region?: string; - icon?: string; + icon?: string | null; channels?: ChannelModifySchema[]; guild_template_code?: string; - system_channel_id?: string | null; + system_channel_id?: string; rules_channel_id?: string; } diff --git a/api/src/routes/guilds/templates/index.ts b/api/src/routes/guilds/templates/index.ts index eb3867c8..b5e243e9 100644 --- a/api/src/routes/guilds/templates/index.ts +++ b/api/src/routes/guilds/templates/index.ts @@ -6,7 +6,7 @@ import { DiscordApiErrors } from "@fosscord/util"; export interface GuildTemplateCreateSchema { name: string; - avatar?: string; + avatar?: string | null; } router.get("/:code", route({}), async (req: Request, res: Response) => { diff --git a/api/src/routes/users/@me/index.ts b/api/src/routes/users/@me/index.ts index c0002d79..da2f3348 100644 --- a/api/src/routes/users/@me/index.ts +++ b/api/src/routes/users/@me/index.ts @@ -16,7 +16,7 @@ export interface UserModifySchema { * @maxLength 1024 */ bio?: string; - accent_color?: number | null; + accent_color?: number; banner?: string | null; password?: string; new_password?: string; diff --git a/api/src/util/route.ts b/api/src/util/route.ts index 6cd8f622..678ca64c 100644 --- a/api/src/util/route.ts +++ b/api/src/util/route.ts @@ -43,10 +43,37 @@ export interface RouteOptions { }; } +// 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; + var validate: AnyValidateFunction | undefined; if (opts.body) { - // @ts-ignore validate = ajv.getSchema(opts.body); if (!validate) throw new Error(`Body schema ${opts.body} not found`); } @@ -60,14 +87,14 @@ export function route(opts: RouteOptions) { if (!permission.has(required)) { throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(opts.permission as string); } + } - if (validate) { - const valid = validate(req.body); - if (!valid) { - const fields: Record = {}; - validate.errors?.forEach((x) => (fields[x.instancePath] = { code: x.keyword, message: x.message || "" })); - throw FieldErrors(fields); - } + if (validate) { + const valid = validate(normalizeBody(req.body)); + if (!valid) { + const fields: Record = {}; + validate.errors?.forEach((x) => (fields[x.instancePath.slice(1)] = { code: x.keyword, message: x.message || "" })); + throw FieldErrors(fields); } } next();