Browse Source

refactor: Update user validation and constraints

Owen Diffey 1 week ago
parent
commit
1777b4343d

+ 12 - 15
backend/src/modules/DataModule/migrations/1725485641-create-users-table.ts

@@ -10,11 +10,13 @@ export const up = async ({
 			// @ts-ignore
 			type: DataTypes.OBJECTID,
 			autoNull: false,
-			primaryKey: true
+			primaryKey: true,
+			unique: true
 		},
 		username: {
-			type: DataTypes.STRING,
-			allowNull: false
+			type: DataTypes.CITEXT,
+			allowNull: false,
+			unique: true
 		},
 		role: {
 			type: DataTypes.ENUM("admin", "moderator", "user"),
@@ -27,11 +29,13 @@ export const up = async ({
 		},
 		emailVerificationToken: {
 			type: DataTypes.STRING,
-			allowNull: true
+			allowNull: true,
+			unique: true
 		},
 		emailAddress: {
-			type: DataTypes.STRING,
-			allowNull: false
+			type: DataTypes.CITEXT,
+			allowNull: false,
+			unique: true
 		},
 		avatarType: {
 			type: DataTypes.ENUM("gravatar", "initials"),
@@ -58,20 +62,13 @@ export const up = async ({
 		},
 		passwordResetCode: {
 			type: DataTypes.STRING,
-			allowNull: true
+			allowNull: true,
+			unique: true
 		},
 		passwordResetExpiresAt: {
 			type: DataTypes.DATE,
 			allowNull: true
 		},
-		passwordSetCode: {
-			type: DataTypes.STRING,
-			allowNull: true
-		},
-		passwordSetExpiresAt: {
-			type: DataTypes.DATE,
-			allowNull: true
-		},
 		songsRequested: {
 			type: DataTypes.BIGINT,
 			allowNull: false,

+ 12 - 20
backend/src/modules/DataModule/models/User.ts

@@ -55,10 +55,6 @@ export class User extends Model<
 
 	declare passwordResetExpiresAt: CreationOptional<Date | null>;
 
-	declare passwordSetCode: CreationOptional<string | null>;
-
-	declare passwordSetExpiresAt: CreationOptional<Date | null>;
-
 	// End services
 
 	// Statistics
@@ -164,11 +160,13 @@ export const schema = {
 	_id: {
 		type: DataTypes.OBJECTID,
 		autoNull: false,
-		primaryKey: true
+		primaryKey: true,
+		unique: true
 	},
 	username: {
-		type: DataTypes.STRING,
-		allowNull: false
+		type: DataTypes.CITEXT,
+		allowNull: false,
+		unique: true
 	},
 	role: {
 		type: DataTypes.ENUM(...Object.values(UserRole)),
@@ -181,11 +179,13 @@ export const schema = {
 	},
 	emailVerificationToken: {
 		type: DataTypes.STRING,
-		allowNull: true
+		allowNull: true,
+		unique: true
 	},
 	emailAddress: {
-		type: DataTypes.STRING,
-		allowNull: false
+		type: DataTypes.CITEXT,
+		allowNull: false,
+		unique: true
 	},
 	avatarType: {
 		type: DataTypes.ENUM(...Object.values(UserAvatarType)),
@@ -205,20 +205,13 @@ export const schema = {
 	},
 	passwordResetCode: {
 		type: DataTypes.STRING,
-		allowNull: true
+		allowNull: true,
+		unique: true
 	},
 	passwordResetExpiresAt: {
 		type: DataTypes.DATE,
 		allowNull: true
 	},
-	passwordSetCode: {
-		type: DataTypes.STRING,
-		allowNull: true
-	},
-	passwordSetExpiresAt: {
-		type: DataTypes.DATE,
-		allowNull: true
-	},
 	songsRequested: {
 		type: DataTypes.BIGINT,
 		allowNull: false,
@@ -289,7 +282,6 @@ export const options = {
 				"emailVerificationToken",
 				"password",
 				"passwordResetCode",
-				"passwordSetCode",
 			]
 		}
 	}

+ 34 - 3
backend/src/modules/DataModule/models/User/jobs/UpdateById.ts

@@ -5,6 +5,7 @@ import { UserAvatarType } from "../UserAvatarType";
 import { UserAvatarColor } from "../UserAvatarColor";
 import isAdmin from "@/modules/DataModule/permissions/isAdmin";
 import isSelf from "@/modules/DataModule/permissions/modelPermissions/isSelf";
+import { Op } from "sequelize";
 
 export default class UpdateById extends UpdateByIdJob {
 	protected static _model = User;
@@ -18,8 +19,8 @@ export default class UpdateById extends UpdateByIdJob {
 			.pattern(/^[0-9a-fA-F]{24}$/)
 			.required(),
 		query: Joi.object({
-			username: Joi.string().max(64).optional(), // TODO: format and unique validation
-			emailAddress: Joi.string().max(64).optional(), // TODO: format and unique validation
+			username: Joi.string().min(2).max(32).regex(/^_*[a-zA-Z0-9][a-zA-Z0-9_]*$/).optional(),
+			emailAddress: Joi.string().email().min(3).max(254).optional(), // TODO: Whitelist regex
 			name: Joi.string().max(64).optional(),
 			location: Joi.string().max(50).optional().allow(null, ""),
 			bio: Joi.string().max(200).optional().allow(null, ""), // TODO: Nullify empty strings
@@ -33,5 +34,35 @@ export default class UpdateById extends UpdateByIdJob {
 			anonymousSongRequests: Joi.boolean().optional(),
 			activityWatch: Joi.boolean().optional()
 		}).required()
-	});
+	})
+		.external(async ({ _id, query }) => {
+			if (query.emailAddress === undefined) return;
+
+			const user = await User.findOne({
+				where: {
+					[Op.not]: {
+						_id
+					},
+					username: query.username
+				}
+			});
+
+			if (user instanceof User)
+				throw new Error("A user with that username already exists.");
+		}, "uniqueUsername")
+		.external(async ({ _id, query }) => {
+			if (query.emailAddress === undefined) return;
+
+			const user = await User.findOne({
+				where: {
+					[Op.not]: {
+						_id
+					},
+					emailAddress: query.emailAddress
+				}
+			});
+
+			if (user instanceof User)
+				throw new Error("A user with that email already exists.");
+		}, "uniqueEmail");
 }