diff --git a/server/src/config/services.ts b/server/src/config/services.ts index af49cc2d4..66fb18f47 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -37,9 +37,6 @@ import crypto from "crypto"; import { games, GameDig } from "gamedig"; import jmespath from "jmespath"; -import { fileURLToPath } from "url"; -import { ObjectId } from "mongodb"; - // DB Modules import { NormalizeData, NormalizeDataUptimeDetails } from "../utils/dataUtils.js"; import { GenerateAvatarImage } from "../utils/imageProcessing.js"; @@ -48,7 +45,7 @@ import { ParseBoolean } from "../utils/utils.js"; // Models import Monitor from "../db/models/Monitor.js"; import User from "../db/models/User.js"; -import InviteToken from "../db/models/InviteToken.js"; +import InviteToken from "../db/models/Invite.js"; import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; @@ -74,10 +71,14 @@ import { MongoChecksRepository, MongoMonitorStatsRepository, MongoStatusPagesRepository, + MongoUsersRepository, + MongoInvitesRepository, IMonitorsRepository, IChecksRepository, IMonitorStatsRepository, IStatusPagesRepository, + IUsersRepository, + IInvitesRepository, } from "@/repositories/index.js"; export type InitializedSerivces = { @@ -107,6 +108,8 @@ export type InitializedSerivces = { checksRepository: IChecksRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; + usersRepository: IUsersRepository; + invitesRepository: IInvitesRepository; }; export const initializeServices = async ({ @@ -158,6 +161,8 @@ export const initializeServices = async ({ const checksRepository = new MongoChecksRepository(logger); const monitorStatsRepository = new MongoMonitorStatsRepository(); const statusPagesRepository = new MongoStatusPagesRepository(); + const usersRepository = new MongoUsersRepository(); + const invitesRepository = new MongoInvitesRepository(); const networkService = new NetworkService({ axios, @@ -240,6 +245,8 @@ export const initializeServices = async ({ errorService, jobQueue: superSimpleQueue, monitorsRepository, + usersRepository, + invitesRepository, }); const diagnosticService = new DiagnosticService(); @@ -298,6 +305,8 @@ export const initializeServices = async ({ checksRepository, monitorStatsRepository, statusPagesRepository, + usersRepository, + invitesRepository, }; Object.values(services).forEach((service) => { diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 435813a36..1b76b249e 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -38,6 +38,7 @@ class AuthController { newUser.email = newUser.email.toLowerCase(); } await registrationBodyValidation.validateAsync(newUser); + const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file); res.status(200).json({ success: true, diff --git a/server/src/db/models/Invite.ts b/server/src/db/models/Invite.ts new file mode 100755 index 000000000..25ddd9a72 --- /dev/null +++ b/server/src/db/models/Invite.ts @@ -0,0 +1,30 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Invite } from "@/types/invite.js"; + +type InviteDocumentBase = Omit & { + teamId: Types.ObjectId; + expiry: Date; +}; + +interface InviteDocument extends InviteDocumentBase { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const InviteSchema = new Schema( + { + email: { type: String, required: true, unique: true }, + teamId: { type: Schema.Types.ObjectId, ref: "Team", immutable: true, required: true }, + role: { type: [String], required: true, default: ["user"] }, + token: { type: String, required: true }, + expiry: { type: Date, default: Date.now, expires: 3600 }, + }, + { timestamps: true } +); + +const InviteModel = model("Invite", InviteSchema); + +export type { InviteDocument }; +export { InviteModel }; +export default InviteModel; diff --git a/server/src/db/models/InviteToken.js b/server/src/db/models/InviteToken.js deleted file mode 100755 index 079600647..000000000 --- a/server/src/db/models/InviteToken.js +++ /dev/null @@ -1,35 +0,0 @@ -import mongoose from "mongoose"; -const InviteTokenSchema = mongoose.Schema( - { - email: { - type: String, - required: true, - unique: true, - }, - teamId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Team", - immutable: true, - required: true, - }, - role: { - type: Array, - required: true, - default: ["user"], - }, - token: { - type: String, - required: true, - }, - expiry: { - type: Date, - default: Date.now, - expires: 3600, - }, - }, - { - timestamps: true, - } -); - -export default mongoose.model("InviteToken", InviteTokenSchema); diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d91aa0778..d378c6750 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -12,3 +12,6 @@ export { default as StatusPageModel } from "@/db/models/StatusPage.js"; export * from "@/db/models/User.js"; export { default as UserModel } from "@/db/models/User.js"; + +export * from "@/db/models/Invite.js"; +export { default as InviteModel } from "@/db/models/Invite.js"; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 92d9b2371..7afe3a9f4 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -10,5 +10,8 @@ export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-s export * from "@/repositories/status-pages/IStatusPagesRepository.js"; export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; -export * from "@/repositories/users/IUserRepository.js"; -export { default as MongoUserRepository } from "@/repositories/users/MongoUserRepository.js"; +export * from "@/repositories/users/IUsersRepository.js"; +export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js"; + +export * from "@/repositories/invites/IInvitesRepository.js"; +export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; diff --git a/server/src/repositories/invites/IInvitesRepository.ts b/server/src/repositories/invites/IInvitesRepository.ts new file mode 100644 index 000000000..066a51a0f --- /dev/null +++ b/server/src/repositories/invites/IInvitesRepository.ts @@ -0,0 +1,11 @@ +import type { Invite } from "@/types/index.js"; + +export interface IInvitesRepository { + // create + // fetch + findByTokenAndDelete(token: string): Promise; + // update + + // delete + // other +} diff --git a/server/src/repositories/invites/MongoInviteRepository.ts b/server/src/repositories/invites/MongoInviteRepository.ts new file mode 100644 index 000000000..16e56440b --- /dev/null +++ b/server/src/repositories/invites/MongoInviteRepository.ts @@ -0,0 +1,45 @@ +import { IInvitesRepository } from "@/repositories/index.js"; +import type { Invite } from "@/types/index.js"; +import { type InviteDocument, InviteModel } from "@/db/models/index.js"; +import { AppError } from "@/utils/AppError.js"; +import mongoose from "mongoose"; + +class MongoInvitesRepository implements IInvitesRepository { + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: InviteDocument): Invite => { + return { + id: this.toStringId(doc._id), + email: doc.email, + teamId: this.toStringId(doc.teamId), + role: doc.role ?? [], + token: doc.token, + expiry: this.toDateString(doc.expiry), + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + findByTokenAndDelete = async (token: string) => { + const invite = await InviteModel.findOneAndDelete({ + token, + }); + if (invite === null) { + throw new AppError({ message: "Invite not found", status: 404 }); + } + return this.toEntity(invite); + }; +} +export default MongoInvitesRepository; diff --git a/server/src/repositories/users/IUserRepository.ts b/server/src/repositories/users/IUserRepository.ts deleted file mode 100644 index 259ba781b..000000000 --- a/server/src/repositories/users/IUserRepository.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { User } from "@/types/index.js"; - -export interface IUsersRepository { - // create - // fetch - // update - // delete - // other -} diff --git a/server/src/repositories/users/IUsersRepository.ts b/server/src/repositories/users/IUsersRepository.ts new file mode 100644 index 000000000..9678de47a --- /dev/null +++ b/server/src/repositories/users/IUsersRepository.ts @@ -0,0 +1,12 @@ +import type { User } from "@/types/index.js"; +export interface IUsersRepository { + // create + create(user: Partial, imageFile?: Express.Multer.File | null): Promise; + // fetch + findByEmail(email: string): Promise; + // update + updateById(id: string, patch: Partial, file?: Express.Multer.File | null): Promise; + // delete + // other + findSuperAdmin(): Promise; +} diff --git a/server/src/repositories/users/MongoUserRepository.ts b/server/src/repositories/users/MongoUserRepository.ts deleted file mode 100644 index 82c5eb5b6..000000000 --- a/server/src/repositories/users/MongoUserRepository.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { IUsersRepository } from "@/repositories/index.js"; -class MongoUserRepository implements IUsersRepository {} - -export default MongoUserRepository; diff --git a/server/src/repositories/users/MongoUsersRepository.ts b/server/src/repositories/users/MongoUsersRepository.ts new file mode 100644 index 000000000..b2687ff4a --- /dev/null +++ b/server/src/repositories/users/MongoUsersRepository.ts @@ -0,0 +1,125 @@ +import mongoose from "mongoose"; +import { IUsersRepository } from "@/repositories/index.js"; +import { UserModel, type UserDocument } from "@/db/models/index.js"; +import type { User, UserProfileImage } from "@/types/index.js"; +import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; +import { ParseBoolean } from "@/utils/utils.js"; +import { AppError } from "@/utils/AppError.js"; +const SERVICE_NAME = "MongoUsersRepository"; + +class MongoUsersRepository implements IUsersRepository { + static SERVICE_NAME = SERVICE_NAME; + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + private mapProfileImage = (image?: (UserProfileImage & { data?: Buffer }) | null) => { + if (!image) { + return undefined; + } + return { + data: image.data, + contentType: image.contentType, + }; + }; + + protected toEntity = (doc: UserDocument): User => { + return { + id: this.toStringId(doc._id), + firstName: doc.firstName, + lastName: doc.lastName, + email: doc.email, + password: doc.password, + avatarImage: doc.avatarImage ?? undefined, + profileImage: this.mapProfileImage(doc.profileImage), + isActive: doc.isActive ?? false, + isVerified: doc.isVerified ?? false, + role: doc.role ?? [], + teamId: this.toStringId(doc.teamId), + checkTTL: doc.checkTTL ?? undefined, + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + create = async (user: Partial, imageFile: Express.Multer.File | null) => { + if (imageFile) { + // 1. Save the full size image + user.profileImage = { + data: imageFile.buffer, + contentType: imageFile.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await GenerateAvatarImage(imageFile); + user.avatarImage = avatar; + } + + const newUser = new UserModel(user); + await newUser.save(); + const sanitizedUser = await UserModel.findOne({ _id: newUser._id }).select("-password").select("-profileImage"); + if (!sanitizedUser) { + throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 }); + } + return this.toEntity(sanitizedUser); + }; + + findByEmail = async (email: string) => { + const user = await UserModel.findOne({ email: email }).select("-profileImage"); + if (!user) { + throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(user); + }; + + updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => { + const candidateUser = { ...patch }; + + if (ParseBoolean(candidateUser.deleteProfileImage) === true) { + candidateUser.profileImage = undefined; + candidateUser.avatarImage = undefined; + } else if (file) { + // 1. Save the full size image + candidateUser.profileImage = { + data: file.buffer, + contentType: file.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await GenerateAvatarImage(file); + candidateUser.avatarImage = avatar; + } + + const updatedUser = await UserModel.findOneAndUpdate( + { _id: id }, + candidateUser, + { new: true } // Returns updated user instead of pre-update user + ) + .select("-password") + .select("-profileImage"); + if (!updatedUser) { + throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedUser); + }; + + findSuperAdmin = async () => { + const superAdmin = await UserModel.findOne({ role: "superadmin" }); + if (superAdmin !== null) { + return true; + } + return false; + }; +} + +export default MongoUsersRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index e409c9f98..dca362ebe 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -1,5 +1,7 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; +import { IInvitesRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; +import Team from "@/db/models/Team.js"; import type { User } from "@/types/index.js"; +import bcrypt from "bcryptjs"; const SERVICE_NAME = "userService"; @@ -16,6 +18,8 @@ class UserService { private jobQueue: any; private crypto: any; private monitorsRepository: IMonitorsRepository; + private usersRepository: IUsersRepository; + private invitesRepository: IInvitesRepository; constructor({ crypto, @@ -28,6 +32,8 @@ class UserService { errorService, jobQueue, monitorsRepository, + usersRepository, + invitesRepository, }: { crypto: any; db: any; @@ -39,6 +45,8 @@ class UserService { errorService: any; jobQueue: any; monitorsRepository: IMonitorsRepository; + usersRepository: IUsersRepository; + invitesRepository: IInvitesRepository; }) { this.db = db; this.emailService = emailService; @@ -50,6 +58,8 @@ class UserService { this.jobQueue = jobQueue; this.crypto = crypto; this.monitorsRepository = monitorsRepository; + this.usersRepository = usersRepository; + this.invitesRepository = invitesRepository; } get serviceName() { @@ -66,33 +76,37 @@ class UserService { registerUser = async (user: Partial, inviteToken: string, file: any) => { // Create a new user // If superAdmin exists, a token should be attached to all further register requests - const superAdminExists = await this.db.userModule.checkSuperadmin(); + const superAdminExists = await this.usersRepository.findSuperAdmin(); if (superAdminExists) { - const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(inviteToken); - user.role = invitedUser.role; - user.teamId = invitedUser.teamId; + const invite = await this.invitesRepository.findByTokenAndDelete(inviteToken); + user.role = invite.role; + user.teamId = invite.teamId; } else { // This is the first account, create JWT secret to use if one is not supplied by env const jwtSecret = this.crypto.randomBytes(64).toString("hex"); await this.db.settingsModule.updateAppSettings({ jwtSecret }); + // Create a new team + const team = new Team({ + email: user.email, + }); + user.teamId = team._id; } - const newUser = await this.db.userModule.insertUser({ ...user }, file); + const newUser = await this.usersRepository.create({ ...user }, file); this.logger.debug({ message: "New user created", service: SERVICE_NAME, method: "registerUser", - details: newUser._id, + details: newUser.id, }); - const userForToken = { ...newUser._doc }; - delete userForToken.profileImage; - delete userForToken.avatarImage; + delete newUser.profileImage; + delete newUser.avatarImage; const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(userForToken, appSettings); + const token = this.issueToken(newUser, appSettings); try { const html = await this.emailService.buildEmail("welcomeEmailTemplate", { @@ -120,17 +134,18 @@ class UserService { loginUser = async (email: string, password: string) => { // Check if user exists - const user = await this.db.userModule.getUserByEmail(email); + const user = await this.usersRepository.findByEmail(email); // Compare password - const match = await user.comparePassword(password); + const match = await bcrypt.compare(password, user.password); + if (match !== true) { throw this.errorService.createAuthenticationError(this.stringService.authIncorrectPassword); } // Remove password from user object. Should this be abstracted to DB layer? - const userWithoutPassword = { ...user._doc }; - delete userWithoutPassword.password; - delete userWithoutPassword.avatarImage; + const userWithoutPassword = { ...user }; + userWithoutPassword.password = ""; + userWithoutPassword.avatarImage = ""; // Happy path, return token const appSettings = await this.settingsService.getSettings(); @@ -140,16 +155,16 @@ class UserService { return { user: userWithoutPassword, token }; }; - editUser = async (updates: any, file: any, currentUser: any) => { + editUser = async (updates: Partial, file: any, currentUser: any) => { // Change Password check if (updates?.password && updates?.newPassword) { // Get user's email // Add user email to body for DB operation updates.email = currentUser.email; // Get user - const user = await this.db.userModule.getUserByEmail(currentUser.email); + const user = await this.usersRepository.findByEmail(currentUser.email); // Compare passwords - const match = await user.comparePassword(updates?.password); + const match = await bcrypt.compare(updates?.password, user.password); // If not a match, throw a 403 // 403 instead of 401 to avoid triggering axios interceptor if (!match) { @@ -159,8 +174,7 @@ class UserService { updates.password = updates.newPassword; } - const updatedUser = await this.db.userModule.updateUser({ userId: currentUser?._id, user: updates, file: file }); - return updatedUser; + return await this.usersRepository.updateById(currentUser.id, updates, file); }; checkSuperadminExists = async () => { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index b6495497d..9416de9fa 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -4,3 +4,4 @@ export * from "@/types/monitorStats.js"; export * from "@/types/statusPage.js"; export * from "@/types/network.js"; export * from "@/types/user.js"; +export * from "@/types/invite.js"; diff --git a/server/src/types/invite.ts b/server/src/types/invite.ts new file mode 100644 index 000000000..8e65d54e9 --- /dev/null +++ b/server/src/types/invite.ts @@ -0,0 +1,12 @@ +import { UserRole } from "./user.js"; + +export interface Invite { + id: string; + email: string; + teamId: string; + role: UserRole[]; + token: string; + expiry: string; + createdAt: string; + updatedAt: string; +} diff --git a/server/src/utils/utils.js b/server/src/utils/utils.js index bcb4d8e5e..ee4ee1c6d 100755 --- a/server/src/utils/utils.js +++ b/server/src/utils/utils.js @@ -1,8 +1,3 @@ -/** - * Converts a request body parameter to a boolean. - * @param {string | boolean} value - * @returns {boolean} - */ const ParseBoolean = (value) => { if (value === true || value === "true") { return true;