Browse Source

refactor: change job payload validation to use joi

I didn't add a schema for every job yet. We can do that as we go.
I decided that to be consistent, a job payload must be either undefined or an object.
In the process of writing the schema's, I had to fix a few issues, like exeting subscribeMany with an empty array
Validation error messages still have to be improved
Kristian Vos 6 months ago
parent
commit
b5ede50478

+ 87 - 0
backend/package-lock.json

@@ -16,6 +16,7 @@
 				"cookie-parser": "^1.4.6",
 				"cors": "^2.8.5",
 				"express": "^4.18.2",
+				"joi": "^17.13.3",
 				"moment": "^2.29.4",
 				"mongoose": "^7.2.0",
 				"mongoose-update-versioning": "^0.3.0",
@@ -144,6 +145,19 @@
 				"npm": ">=6.14.13"
 			}
 		},
+		"node_modules/@hapi/hoek": {
+			"version": "9.3.0",
+			"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+			"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
+		},
+		"node_modules/@hapi/topo": {
+			"version": "5.1.0",
+			"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+			"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+			"dependencies": {
+				"@hapi/hoek": "^9.0.0"
+			}
+		},
 		"node_modules/@humanwhocodes/config-array": {
 			"version": "0.11.8",
 			"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
@@ -340,6 +354,24 @@
 				"@redis/client": "^1.0.0"
 			}
 		},
+		"node_modules/@sideway/address": {
+			"version": "4.1.5",
+			"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
+			"integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
+			"dependencies": {
+				"@hapi/hoek": "^9.0.0"
+			}
+		},
+		"node_modules/@sideway/formula": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
+			"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
+		},
+		"node_modules/@sideway/pinpoint": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
+			"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
+		},
 		"node_modules/@sinonjs/commons": {
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz",
@@ -3304,6 +3336,18 @@
 			"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==",
 			"dev": true
 		},
+		"node_modules/joi": {
+			"version": "17.13.3",
+			"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
+			"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
+			"dependencies": {
+				"@hapi/hoek": "^9.3.0",
+				"@hapi/topo": "^5.1.0",
+				"@sideway/address": "^4.1.5",
+				"@sideway/formula": "^3.0.1",
+				"@sideway/pinpoint": "^2.0.0"
+			}
+		},
 		"node_modules/js-sdsl": {
 			"version": "4.4.0",
 			"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
@@ -5667,6 +5711,19 @@
 			"integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
 			"dev": true
 		},
+		"@hapi/hoek": {
+			"version": "9.3.0",
+			"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+			"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
+		},
+		"@hapi/topo": {
+			"version": "5.1.0",
+			"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+			"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+			"requires": {
+				"@hapi/hoek": "^9.0.0"
+			}
+		},
 		"@humanwhocodes/config-array": {
 			"version": "0.11.8",
 			"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
@@ -5824,6 +5881,24 @@
 			"integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==",
 			"requires": {}
 		},
+		"@sideway/address": {
+			"version": "4.1.5",
+			"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
+			"integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
+			"requires": {
+				"@hapi/hoek": "^9.0.0"
+			}
+		},
+		"@sideway/formula": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
+			"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
+		},
+		"@sideway/pinpoint": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
+			"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
+		},
 		"@sinonjs/commons": {
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz",
@@ -8066,6 +8141,18 @@
 			"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==",
 			"dev": true
 		},
+		"joi": {
+			"version": "17.13.3",
+			"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
+			"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
+			"requires": {
+				"@hapi/hoek": "^9.3.0",
+				"@hapi/topo": "^5.1.0",
+				"@sideway/address": "^4.1.5",
+				"@sideway/formula": "^3.0.1",
+				"@sideway/pinpoint": "^2.0.0"
+			}
+		},
 		"js-sdsl": {
 			"version": "4.4.0",
 			"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",

+ 1 - 0
backend/package.json

@@ -24,6 +24,7 @@
 		"cookie-parser": "^1.4.6",
 		"cors": "^2.8.5",
 		"express": "^4.18.2",
+		"joi": "^17.13.3",
 		"moment": "^2.29.4",
 		"mongoose": "^7.2.0",
 		"mongoose-update-versioning": "^0.3.0",

+ 2 - 1
backend/src/Job.spec.ts

@@ -7,7 +7,7 @@ import { TestModule } from "@/tests/support/TestModule";
 describe("Job", function () {
 	class TestJob extends Job {
 		public constructor(options?: JobOptions) {
-			super(new TestModule(), null, options);
+			super(new TestModule(), undefined, options);
 		}
 
 		protected async _execute() {}
@@ -125,6 +125,7 @@ describe("Job", function () {
 			const job = new TestJob();
 			const stub = sinon.stub();
 			Reflect.set(job, "_validate", stub);
+			Reflect.set(job, "_validated", true);
 			Reflect.set(job, "_authorize", sinon.stub());
 
 			await job.execute();

+ 36 - 1
backend/src/Job.ts

@@ -2,6 +2,7 @@ import { SessionSchema } from "@models/sessions/schema";
 import { getErrorMessage } from "@common/utils/getErrorMessage";
 import { generateUuid } from "@common/utils/generateUuid";
 import { HydratedDocument } from "mongoose";
+import Joi from "joi";
 import JobContext from "@/JobContext";
 import JobStatistics, { JobStatisticsType } from "@/JobStatistics";
 import LogBook, { Log } from "@/LogBook";
@@ -178,6 +179,10 @@ export default abstract class Job {
 		return (this.constructor as typeof Job)._apiEnabled;
 	}
 
+	public getPayloadSchema() {
+		return (this.constructor as typeof Job)._payloadSchema;
+	}
+
 	protected static _hasPermission:
 		| boolean
 		| CallableFunction
@@ -202,7 +207,30 @@ export default abstract class Job {
 		}, Promise.resolve(false));
 	}
 
-	protected async _validate() {}
+	// If a job expects a payload, it must override this
+	protected static _payloadSchema: Joi.ObjectSchema<any> | null = null;
+
+	// Whether this _validate has been called. May not be modified by classes that extend Job
+	protected _validated = false;
+
+	// If a class that extends Job overrides _validate, it must still call super._validate, so this always gets called
+	protected async _validate() {
+		const payloadSchema = this.getPayloadSchema();
+
+		if (this._payload === undefined && !payloadSchema)
+			this._validated = true;
+		else if (!payloadSchema) {
+			throw new Error(
+				"Payload provided, but no payload schema specified."
+			);
+		} else {
+			await payloadSchema.validateAsync(this._payload, {
+				presence: "required"
+			});
+		}
+
+		this._validated = true;
+	}
 
 	protected async _authorize() {
 		await this._context.assertPermission(this.getPath());
@@ -227,6 +255,13 @@ export default abstract class Job {
 		try {
 			await this._validate();
 
+			// Safety check, to make sure this class' _validate function was called
+			if (!this._validated) {
+				throw new Error(
+					"Validate function was fine, but validated was false. Warning. Make sure to call super when you override _validate."
+				);
+			}
+
 			await this._authorize();
 
 			const data = await this._execute();

+ 4 - 10
backend/src/modules/DataModule/CreateJob.ts

@@ -1,17 +1,11 @@
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 
 export default abstract class CreateJob extends DataModuleJob {
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (typeof this._payload.query !== "object")
-			throw new Error("Query is not an object");
-
-		if (Object.keys(this._payload.query).length === 0)
-			throw new Error("Empty query object provided");
-	}
+	protected static _payloadSchema = Joi.object({
+		query: Joi.object().min(1).required()
+	});
 
 	protected async _execute() {
 		const { query } = this._payload;

+ 6 - 8
backend/src/modules/DataModule/DeleteByIdJob.ts

@@ -1,15 +1,13 @@
-import { isObjectIdOrHexString } from "mongoose";
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 
 export default abstract class DeleteByIdJob extends DataModuleJob {
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (!isObjectIdOrHexString(this._payload._id))
-			throw new Error("_id is not an ObjectId");
-	}
+	protected static _payloadSchema = Joi.object({
+		_id: Joi.string()
+			.pattern(/^[0-9a-fA-F]{24}$/)
+			.required()
+	});
 
 	protected async _execute() {
 		const { _id } = this._payload;

+ 12 - 12
backend/src/modules/DataModule/DeleteManyByIdJob.ts

@@ -1,20 +1,20 @@
-import { isValidObjectId } from "mongoose";
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 
-export default abstract class FindManyByIdJob extends DataModuleJob {
+export default abstract class DeleteManyByIdJob extends DataModuleJob {
 	protected static _isBulk = true;
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-		if (!Array.isArray(this._payload._ids))
-			throw new Error("Payload._ids must be an array");
-		if (!this._payload._ids.every((_id: unknown) => isValidObjectId(_id)))
-			throw new Error(
-				"One or more payload._ids item(s) is not a valid ObjectId"
-			);
-	}
+	protected static _payloadSchema = Joi.object({
+		_ids: Joi.array()
+			.items(
+				Joi.string()
+					.pattern(/^[0-9a-fA-F]{24}$/)
+					.required()
+			)
+			.min(1)
+			.required()
+	});
 
 	protected async _execute() {
 		const model = await DataModule.getModel(this.getModelName());

+ 6 - 8
backend/src/modules/DataModule/FindByIdJob.ts

@@ -1,15 +1,13 @@
-import { isObjectIdOrHexString } from "mongoose";
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 
 export default abstract class FindByIdJob extends DataModuleJob {
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (!isObjectIdOrHexString(this._payload._id))
-			throw new Error("_id is not an ObjectId");
-	}
+	protected static _payloadSchema = Joi.object({
+		_id: Joi.string()
+			.pattern(/^[0-9a-fA-F]{24}$/)
+			.required()
+	});
 
 	protected async _execute() {
 		const model = await DataModule.getModel(this.getModelName());

+ 11 - 11
backend/src/modules/DataModule/FindManyByIdJob.ts

@@ -1,20 +1,20 @@
-import { isValidObjectId } from "mongoose";
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 
 export default abstract class FindManyByIdJob extends DataModuleJob {
 	protected static _isBulk = true;
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-		if (!Array.isArray(this._payload._ids))
-			throw new Error("Payload._ids must be an array");
-		if (!this._payload._ids.every((_id: unknown) => isValidObjectId(_id)))
-			throw new Error(
-				"One or more payload._ids item(s) is not a valid ObjectId"
-			);
-	}
+	protected static _payloadSchema = Joi.object({
+		_ids: Joi.array()
+			.items(
+				Joi.string()
+					.pattern(/^[0-9a-fA-F]{24}$/)
+					.required()
+			)
+			.min(1)
+			.required()
+	});
 
 	protected async _execute() {
 		const model = await DataModule.getModel(this.getModelName());

+ 26 - 59
backend/src/modules/DataModule/GetDataJob.ts

@@ -1,70 +1,37 @@
 import { Model } from "mongoose";
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 import { FilterType, GetData } from "./plugins/getData";
 
 export default abstract class GetDataJob extends DataModuleJob {
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (typeof this._payload.page !== "number")
-			throw new Error("Page must be a number");
-
-		if (typeof this._payload.pageSize !== "number")
-			throw new Error("Page size must be a number");
-
-		if (!Array.isArray(this._payload.properties))
-			throw new Error("Properties must be an array");
-
-		this._payload.properties.forEach((property: unknown) => {
-			if (typeof property !== "string")
-				throw new Error("Property must be a string");
-		});
-
-		if (
-			typeof this._payload.sort !== "object" ||
-			Array.isArray(this._payload.sort)
-		)
-			throw new Error("Sort must be an object");
-
-		Object.values(this._payload.sort).forEach(sort => {
-			if (sort !== "ascending" && sort !== "descending")
-				throw new Error("Sort must be ascending or descending");
-		});
-
-		if (!Array.isArray(this._payload.queries))
-			throw new Error("Queries must be an array");
-
-		Object.values(this._payload.queries).forEach((query: any) => {
-			if (!query || typeof query !== "object" || Array.isArray(query))
-				throw new Error("Query must be an object");
-
-			if (
-				!query.filter ||
-				typeof query.filter !== "object" ||
-				Array.isArray(query.filter)
+	protected static _payloadSchema = Joi.object({
+		page: Joi.number().required(),
+		pageSize: Joi.number().required(),
+		properties: Joi.array()
+			.items(Joi.string().required())
+			.min(1)
+			.required(),
+		sort: Joi.object()
+			.pattern(
+				/^/,
+				Joi.string().valid("ascending", "descending").required()
 			)
-				throw new Error("Query filter must be an object");
-
-			if (typeof query.filter.property !== "string")
-				throw new Error("Query filter property must be a string");
-
-			if (
-				!Object.values(FilterType).find(
-					value => value === query.filterType
-				)
+			.required(),
+		queries: Joi.array()
+			.items(
+				Joi.object({
+					filter: Joi.object({
+						property: Joi.string().required()
+					}).required(),
+					filterType: Joi.string()
+						.valid(...Object.values(FilterType))
+						.required()
+				})
 			)
-				throw new Error("Invalid Query filter type");
-		});
-
-		if (
-			!["and", "or", "nor"].find(
-				value => value === this._payload.operator
-			)
-		)
-			throw new Error("Operator must be one of; and, or, nor");
-	}
+			.required(),
+		operator: Joi.string().valid("and", "or", "nor").required()
+	});
 
 	protected async _execute() {
 		const model = await DataModule.getModel<Model<any> & Partial<GetData>>(

+ 7 - 14
backend/src/modules/DataModule/UpdateByIdJob.ts

@@ -1,21 +1,14 @@
-import { isObjectIdOrHexString } from "mongoose";
+import Joi from "joi";
 import DataModule from "../DataModule";
 import DataModuleJob from "./DataModuleJob";
 
 export default abstract class UpdateByIdJob extends DataModuleJob {
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (!isObjectIdOrHexString(this._payload._id))
-			throw new Error("_id is not an ObjectId");
-
-		if (typeof this._payload.query !== "object")
-			throw new Error("Query is not an object");
-
-		if (Object.keys(this._payload.query).length === 0)
-			throw new Error("Empty query object provided");
-	}
+	protected static _payloadSchema = Joi.object({
+		_id: Joi.string()
+			.pattern(/^[0-9a-fA-F]{24}$/)
+			.required(),
+		query: Joi.object().min(1).required()
+	});
 
 	protected async _execute() {
 		const { _id, query } = this._payload;

+ 5 - 22
backend/src/modules/DataModule/models/news/jobs/Newest.ts

@@ -1,3 +1,4 @@
+import Joi from "joi";
 import DataModule from "@/modules/DataModule";
 import DataModuleJob from "@/modules/DataModule/DataModuleJob";
 import { NewsModel } from "../schema";
@@ -7,28 +8,10 @@ export default class Newest extends DataModuleJob {
 
 	protected static _hasPermission = true;
 
-	protected override async _validate() {
-		if (
-			typeof this._payload !== "object" &&
-			typeof this._payload !== "undefined" &&
-			this._payload !== null
-		)
-			throw new Error("Payload must be an object or undefined");
-
-		if (
-			typeof this._payload?.showToNewUsers !== "boolean" &&
-			typeof this._payload?.showToNewUsers !== "undefined" &&
-			this._payload?.showToNewUsers !== null
-		)
-			throw new Error("Show to new users must be a boolean or undefined");
-
-		if (
-			typeof this._payload?.limit !== "number" &&
-			typeof this._payload?.limit !== "undefined" &&
-			this._payload?.limit !== null
-		)
-			throw new Error("Limit must be a number or undefined");
-	}
+	protected static _payloadSchema = Joi.object({
+		showToNewUsers: Joi.boolean().optional(),
+		limit: Joi.number().min(1).optional()
+	});
 
 	protected async _execute() {
 		const model = await DataModule.getModel<NewsModel>(this.getModelName());

+ 4 - 15
backend/src/modules/DataModule/models/stations/jobs/Index.ts

@@ -1,5 +1,6 @@
 import { HydratedDocument } from "mongoose";
 import { forEachIn } from "@common/utils/forEachIn";
+import Joi from "joi";
 import DataModule from "@/modules/DataModule";
 import DataModuleJob from "@/modules/DataModule/DataModuleJob";
 import isDj from "@/modules/DataModule/permissions/modelPermissions/isDj";
@@ -12,21 +13,9 @@ export default class Index extends DataModuleJob {
 
 	protected static _hasPermission = true;
 
-	protected override async _validate() {
-		if (
-			typeof this._payload !== "object" &&
-			typeof this._payload !== "undefined" &&
-			this._payload !== null
-		)
-			throw new Error("Payload must be an object or undefined");
-
-		if (
-			typeof this._payload?.adminFilter !== "boolean" &&
-			typeof this._payload?.adminFilter !== "undefined" &&
-			this._payload?.adminFilter !== null
-		)
-			throw new Error("Admin filter must be a boolean or undefined");
-	}
+	protected static _payloadSchema = Joi.object({
+		adminFilter: Joi.boolean().optional()
+	});
 
 	protected async _execute() {
 		const model = await DataModule.getModel<StationModel>(

+ 10 - 15
backend/src/modules/DataModule/models/users/jobs/GetModelPermissions.ts

@@ -1,5 +1,5 @@
-import { isObjectIdOrHexString } from "mongoose";
 import { forEachIn } from "@common/utils/forEachIn";
+import Joi from "joi";
 import CacheModule from "@/modules/CacheModule";
 import DataModule from "@/modules/DataModule";
 import GetPermissions, { GetPermissionsResult } from "./GetPermissions";
@@ -29,20 +29,15 @@ export default class GetModelPermissions extends DataModuleJob {
 
 	protected static _hasPermission = true;
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (typeof this._payload.modelName !== "string")
-			throw new Error("Model name must be a string");
-
-		if (
-			!isObjectIdOrHexString(this._payload.modelId) &&
-			typeof this._payload.modelId !== "undefined" &&
-			this._payload.modelId !== null
-		)
-			throw new Error("Model Id must be an ObjectId or undefined");
-	}
+	protected static _payloadSchema = Joi.object({
+		modelName: Joi.string().required(), // TODO improve
+		modelId: Joi.string()
+			.pattern(/^[0-9a-fA-F]{24}$/)
+			.optional(),
+		modelIds: Joi.array()
+			.items(Joi.string().pattern(/^[0-9a-fA-F]{24}$/))
+			.optional()
+	}).oxor("modelId", "modelIds"); // Both modelId and modelIds are optional. But they cannot be provided at the same time.
 
 	// _authorize calls GetPermissions and GetModelPermissions, so to avoid ending up in an infinite loop, just override it
 	protected override async _authorize() {}

+ 4 - 7
backend/src/modules/EventsModule.ts

@@ -251,13 +251,10 @@ export class EventsModule extends BaseModule {
 			const permissions =
 				// eslint-disable-next-line
 				// @ts-ignore
-				(await new GetPermissions(
-					{},
-					{
-						session: jobContext.getSession(),
-						socketId: jobContext.getSocketId()
-					}
-				).execute()) as unknown as GetPermissionsResult;
+				(await new GetPermissions(undefined, {
+					session: jobContext.getSession(),
+					socketId: jobContext.getSocketId()
+				}).execute()) as unknown as GetPermissionsResult;
 
 			hasPermission = permissions[permission];
 		}

+ 2 - 2
backend/src/modules/EventsModule/jobs/Subscribe.spec.ts

@@ -163,7 +163,7 @@ describe("Subscribe job", async function () {
 
 			await job
 				.execute()
-				.should.eventually.be.rejectedWith("Payload must be an object");
+				.should.eventually.be.rejectedWith(`"value" is required`);
 		});
 
 		it("should not allow no channel", async function () {
@@ -171,7 +171,7 @@ describe("Subscribe job", async function () {
 
 			await job
 				.execute()
-				.should.eventually.be.rejectedWith("Channel must be a string");
+				.should.eventually.be.rejectedWith('"channel" is required');
 		});
 
 		it("should not allow no socket id", async function () {

+ 8 - 8
backend/src/modules/EventsModule/jobs/Subscribe.ts

@@ -1,22 +1,22 @@
+import Joi from "joi";
 import Job, { JobOptions } from "@/Job";
 import EventsModule from "@/modules/EventsModule";
 import Event from "@/modules/EventsModule/Event";
 
+// TODO support more channels types if more apply
+export const channelRegex = /^data\.[a-zA-Z]+\.[a-z]+(?::[A-z0-9]{24})?$/;
+
 export default class Subscribe extends Job {
 	protected static _hasPermission = true;
 
+	protected static _payloadSchema = Joi.object({
+		channel: Joi.string().pattern(channelRegex).required()
+	});
+
 	public constructor(payload?: unknown, options?: JobOptions) {
 		super(EventsModule, payload, options);
 	}
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (typeof this._payload.channel !== "string")
-			throw new Error("Channel must be a string");
-	}
-
 	protected override async _authorize() {
 		// Channel could be data.news.created, or something like data.news.updated:SOME_OBJECT_ID
 		const { channel } = this._payload;

+ 10 - 13
backend/src/modules/EventsModule/jobs/SubscribeMany.ts

@@ -1,28 +1,25 @@
 import { forEachIn } from "@common/utils/forEachIn";
+import Joi from "joi";
 import Job, { JobOptions } from "@/Job";
 import EventsModule from "@/modules/EventsModule";
 import Event from "../Event";
 
+import { channelRegex } from "./Subscribe";
+
 export default class SubscribeMany extends Job {
 	protected static _hasPermission = true;
 
+	protected static _payloadSchema = Joi.object({
+		channels: Joi.array()
+			.items(Joi.string().pattern(channelRegex).required())
+			.min(1)
+			.required()
+	});
+
 	public constructor(payload?: unknown, options?: JobOptions) {
 		super(EventsModule, payload, options);
 	}
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (!Array.isArray(this._payload.channels))
-			throw new Error("Channels must be an array");
-
-		this._payload.channels.forEach((channel: unknown) => {
-			if (typeof channel !== "string")
-				throw new Error("Channel must be a string");
-		});
-	}
-
 	protected override async _authorize() {
 		// Channel could be data.news.created, or something like data.news.updated:SOME_OBJECT_ID
 		await forEachIn(this._payload.channels, async channel => {

+ 6 - 8
backend/src/modules/EventsModule/jobs/Unsubscribe.ts

@@ -1,21 +1,19 @@
+import Joi from "joi";
 import Job, { JobOptions } from "@/Job";
 import EventsModule from "@/modules/EventsModule";
+import { channelRegex } from "./Subscribe";
 
 export default class Unsubscribe extends Job {
+	protected static _payloadSchema = Joi.object({
+		channel: Joi.string().pattern(channelRegex).required()
+	});
+
 	public constructor(payload?: unknown, options?: JobOptions) {
 		super(EventsModule, payload, options);
 	}
 
 	protected static _hasPermission = true;
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (typeof this._payload.channel !== "string")
-			throw new Error("Channel must be a string");
-	}
-
 	protected async _execute() {
 		const socketId = this._context.getSocketId();
 

+ 10 - 13
backend/src/modules/EventsModule/jobs/UnsubscribeMany.ts

@@ -1,26 +1,23 @@
+import Joi from "joi";
 import Job, { JobOptions } from "@/Job";
 import EventsModule from "@/modules/EventsModule";
 
+import { channelRegex } from "./Subscribe";
+
 export default class UnsubscribeMany extends Job {
+	protected static _payloadSchema = Joi.object({
+		channels: Joi.array()
+			.items(Joi.string().pattern(channelRegex).required())
+			.min(1)
+			.required()
+	});
+
 	public constructor(payload?: unknown, options?: JobOptions) {
 		super(EventsModule, payload, options);
 	}
 
 	protected static _hasPermission = true;
 
-	protected override async _validate() {
-		if (typeof this._payload !== "object" || this._payload === null)
-			throw new Error("Payload must be an object");
-
-		if (!Array.isArray(this._payload.channels))
-			throw new Error("Channels must be an array");
-
-		this._payload.channels.forEach((channel: unknown) => {
-			if (typeof channel !== "string")
-				throw new Error("Channel must be a string");
-		});
-	}
-
 	protected async _execute() {
 		const socketId = this._context.getSocketId();
 

+ 5 - 3
backend/src/modules/WebSocketModule.ts

@@ -195,7 +195,7 @@ export class WebSocketModule extends BaseModule {
 
 			const Job = EventsModule.getJob("unsubscribeAll");
 
-			await JobQueue.runJob(Job, null, {
+			await JobQueue.runJob(Job, undefined, {
 				socketId
 			});
 
@@ -227,11 +227,11 @@ export class WebSocketModule extends BaseModule {
 			if (!Array.isArray(data) || data.length < 1)
 				throw new Error("Invalid request");
 
-			const [moduleJob, payload, options] = data;
+			const [moduleJob, _payload, options] = data;
 			const moduleName = moduleJob.substring(0, moduleJob.indexOf("."));
 			const jobName = moduleJob.substring(moduleJob.indexOf(".") + 1);
 
-			const { callbackRef } = options ?? payload ?? {};
+			const { callbackRef } = options ?? _payload ?? {};
 
 			if (!callbackRef)
 				throw new Error(
@@ -261,6 +261,8 @@ export class WebSocketModule extends BaseModule {
 				if (!session) throw new Error("Session not found.");
 			}
 
+			// Transform null to undefined, as JSON doesn't support undefined
+			const payload = _payload === null ? undefined : _payload;
 			await JobQueue.queueJob(Job, payload, {
 				session,
 				socketId: socket.getSocketId(),

+ 3 - 1
frontend/src/composables/useEvents.ts

@@ -61,6 +61,8 @@ export const useEvents = () => {
 	};
 
 	const unsubscribeMany = async uuids => {
+		if (uuids.length === 0) return;
+
 		const _subscriptions = Object.fromEntries(
 			Object.entries(subscriptions.value)
 				.filter(([uuid]) => uuids.includes(uuid))
@@ -69,7 +71,7 @@ export const useEvents = () => {
 
 		await websocketStore.unsubscribeMany(_subscriptions);
 
-		return forEachIn(uuids, async uuid => {
+		await forEachIn(uuids, async uuid => {
 			delete subscriptions.value[uuid];
 		});
 	};

+ 6 - 4
frontend/src/stores/userAuth.ts

@@ -200,10 +200,12 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 		!!(permissions.value && permissions.value[permission]);
 
 	const updatePermissions = () =>
-		websocketStore.runJob("data.users.getPermissions", {}).then(data => {
-			permissions.value = data;
-			gotPermissions.value = true;
-		});
+		websocketStore
+			.runJob("data.users.getPermissions", undefined)
+			.then(data => {
+				permissions.value = data;
+				gotPermissions.value = true;
+			});
 
 	const resetCookieExpiration = () => {
 		const cookies = {};

+ 11 - 7
frontend/src/stores/websocket.ts

@@ -24,7 +24,7 @@ export const useWebsocketStore = defineStore("websocket", () => {
 			const callbackRef = generateUuid();
 			const message = JSON.stringify([
 				job,
-				payload ?? {},
+				payload ?? undefined,
 				{ callbackRef }
 			]);
 
@@ -81,9 +81,11 @@ export const useWebsocketStore = defineStore("websocket", () => {
 			subscriptions.value[channel].status = "subscribing";
 		});
 
-		await runJob("events.subscribeMany", {
-			channels: channelsToSubscribeTo
-		});
+		if (channelsToSubscribeTo.length > 0) {
+			await runJob("events.subscribeMany", {
+				channels: channelsToSubscribeTo
+			});
+		}
 
 		channelsToSubscribeTo.forEach(channel => {
 			subscriptions.value[channel].status = "subscribed";
@@ -129,9 +131,11 @@ export const useWebsocketStore = defineStore("websocket", () => {
 				Object.keys(subscriptions.value[channel].callbacks).length <= 1
 		);
 
-		await runJob("events.unsubscribeMany", {
-			channels: channelsToUnsubscribeFrom
-		});
+		if (channelsToUnsubscribeFrom.length > 0) {
+			await runJob("events.unsubscribeMany", {
+				channels: channelsToUnsubscribeFrom
+			});
+		}
 
 		channelsToUnsubscribeFrom.forEach(channel => {
 			delete subscriptions.value[channel];