summary refs log tree commit diff
path: root/src/api/util/handlers/route.ts
blob: d43ae10332e87f5e0b0a8a5e8224fd623fe5b604 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import {
	DiscordApiErrors,
	EVENT,
	FieldErrors,
	FosscordApiErrors,
	getPermission,
	getRights,
	PermissionResolvable,
	Permissions,
	RightResolvable,
	Rights
} from "@fosscord/util";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import { AnyValidateFunction } from "ajv/dist/core";
import { NextFunction, Request, Response } from "express";
import fs from "fs";
import path from "path";

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;
	right?: RightResolvable;
	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" || key === "discovery_splash") continue;
					delete object[key];
				} else if (typeof value === "object") {
					normalizeObject(value);
				}
			}
		}
	};
	normalizeObject(body);
	return body;
};

export function route(opts: RouteOptions) {
	let 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);
			req.permission = await getPermission(req.user_id, req.params.guild_id, req.params.channel_id);

			// bitfield comparison: check if user lacks certain permission
			if (!req.permission.has(required)) {
				throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(opts.permission as string);
			}
		}

		if (opts.right) {
			const required = new Rights(opts.right);
			req.rights = await getRights(req.user_id);

			if (!req.rights || !req.rights.has(required)) {
				throw FosscordApiErrors.MISSING_RIGHTS.withParams(opts.right as string);
			}
		}

		if (validate) {
			const valid = validate(normalizeBody(req.body));
			if (!valid) {
				const fields: Record<string, { code?: string; message: string }> = {};
				if (process.env.LOG_INVALID_BODY) {
					console.log(`Got invalid request: ${req.method} ${req.originalUrl}`);
					console.log(req.body);
					validate.errors?.forEach((x) => console.log(x.params));
				}
				validate.errors?.forEach((x) => (fields[x.instancePath.slice(1)] = { code: x.keyword, message: x.message || "" }));
				throw FieldErrors(fields);
			}
		}
		next();
	};
}