summary refs log tree commit diff
path: root/api/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'api/src/util')
-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
10 files changed, 179 insertions, 298 deletions
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(); + }; +}