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();
+ };
+}
|