UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

582 lines (535 loc) 21.2 kB
/** * User service module for handling user account operations. * Provides REST API handlers for authentication, registration, profile management, * email verification, password recovery, and guest user lifecycle management. * * @module src/api/user/user.service.js * @namespace UserService */ import { loggerFactory } from '../../server/logger.js'; import { DataQuery } from '../../server/data-query.js'; import { hashPassword, verifyPassword, verifyJWT, createSessionAndUserToken, createUserAndSession, refreshSessionAndToken, logoutSession, jwtSign, validatePasswordMiddleware, } from '../../server/auth.js'; import { MailerProvider } from '../../mailer/MailerProvider.js'; import { CoreWsEmitter } from '../../ws/core/core.ws.emit.js'; import { CoreWsMailerChannel } from '../../ws/core/channels/core.ws.mailer.js'; import validator from 'validator'; import { DataBaseProviderService } from '../../db/DataBaseProvider.js'; import { FileFactory, FileCleanup } from '../file/file.service.js'; import { UserDto } from './user.model.js'; import { timer } from '../../client/components/core/CommonJs.js'; import { GuestService } from './guest.service.js'; import { resolveHostKeyContext } from '../../server/conf.js'; const logger = loggerFactory(import.meta); /** * User Service for handling REST API user operations. * Manages authentication, registration, profile CRUD, email verification, * password recovery, session management, and guest user lifecycle. * @namespace UserService */ class UserService { /** * POST - Create or authenticate users. * Supports authentication, guest account creation, email verification, * and password recovery email sending. * @async * @function post * @memberof UserService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} User data with auth token, or status message. * @throws {Error} If authentication fails, email is invalid, or email send error. */ static post = async (req, res, options) => { /** @type {import('../file/file.model.js').FileModel} */ const File = DataBaseProviderService.getModel('File', options); /** @type {import('./user.model.js').UserModel} */ const User = DataBaseProviderService.getModel('User', options); if (req.params.id === 'recover-verify-email') { const user = await User.findOne({ email: req.body.email, }); // Simulate success even if email doesn't exist to prevent email enumeration attacks if (!user) { await timer(3000); return { message: 'email send successfully' }; } const token = jwtSign({ email: req.body.email }, options, 15); const payloadToken = jwtSign({ email: req.body.email }, options, 15); const id = resolveHostKeyContext(options); const translate = MailerProvider.instance[id].translateTemplates.recoverEmail; const recoverUrl = `${process.env.NODE_ENV === 'development' ? 'http://' : 'https://'}${req.body.hostname}${req.body.proxyPath }recover?payload=${payloadToken}`; const sendResult = await MailerProvider.send({ id, sendOptions: { to: req.body.email, // list of receivers subject: translate.H1[req.lang], // Subject line text: translate.H1[req.lang], // plain text body html: MailerProvider.instance[id].templates.userRecoverEmail .replace('{{H1}}', translate.H1[req.lang]) .replace('{{P1}}', translate.P1[req.lang]) .replace('{{TOKEN}}', token) .replace(`{{COMPANY}}`, req.body.hostname) // html body .replace('{{RECOVER_WEB_URL}}', recoverUrl) .replace('{{RECOVER_BTN_LABEL}}', translate.BTN_LABEL[req.lang]), attachments: [ // { // filename: 'logo.png', // path: `./logo.png`, // cid: 'logo', // <img src='cid:logo'> // }, ], }, }); if (!sendResult) throw new Error('email send error'); return { message: 'email send successfully' }; } if (req.path.startsWith('/mailer') && req.params.id === 'verify-email') { if (!validator.isEmail(req.body.email)) throw { message: 'invalid email' }; const token = jwtSign({ email: req.body.email }, options, 15); const id = resolveHostKeyContext(options); const user = await User.findById(req.auth.user._id); if (user.emailConfirmed) throw new Error('email already confirmed'); if (user.email !== req.body.email) { req.body.emailConfirmed = false; const result = await User.findByIdAndUpdate( req.auth.user._id, { emailConfirmed: false, email: req.body.email }, { runValidators: true, }, ); } const translate = MailerProvider.instance[id].translateTemplates.confirmEmail; const sendResult = await MailerProvider.send({ id, sendOptions: { to: req.body.email, // list of receivers subject: translate.H1[req.lang], // Subject line text: translate.H1[req.lang], // plain text body html: MailerProvider.instance[id].templates.userVerifyEmail .replace('{{H1}}', translate.H1[req.lang]) .replace('{{P1}}', translate.P1[req.lang]) .replace('{{TOKEN}}', token) .replace(`{{COMPANY}}`, req.body.hostname), // html body attachments: [ // { // filename: 'logo.png', // path: `./logo.png`, // cid: 'logo', // <img src='cid:logo'> // }, ], }, }); if (!sendResult) throw new Error('email send error'); return { message: 'email send successfully' }; } switch (req.params.id) { case 'auth': const user = await User.findOne({ email: req.body.email, }); const getMinutesRemaining = () => (-1 * user.failedLoginAttempts - new Date().getTime()) / (1000 * 60); const accountLocketMessage = () => `Account locked. Please try again in: ${getMinutesRemaining() < 1 ? `${(getMinutesRemaining() * 60).toFixed(0)} s` : `${getMinutesRemaining().toFixed(0)} min` }.`; if (user) { const { _id } = user; const validPassword = await verifyPassword(req.body.password, user.password); if (validPassword === true) { { if (getMinutesRemaining() <= 0 || user.failedLoginAttempts >= 0) { const user = await User.findOne({ _id, }).select(UserDto.select.get()); // Check if profileImageId exists, if not set to null explicitly if (!user.profileImageId) { user.profileImageId = null; } else { const fileExists = await File.findById(user.profileImageId); if (!fileExists) { await User.findByIdAndUpdate(_id, { profileImageId: null }, { runValidators: true }); user.profileImageId = null; } } await User.findByIdAndUpdate( _id, { lastLoginDate: new Date(), failedLoginAttempts: 0 }, { runValidators: true, }, ); const { jwtid } = await createSessionAndUserToken(user, User, req, res, options); return { token: jwtSign( UserDto.auth.payload(user, jwtid, req.ip, req.headers['user-agent'], options.host, options.path), options, ), user, }; } else throw new Error(accountLocketMessage()); } } else { if (user.failedLoginAttempts >= 5) { await User.findByIdAndUpdate( _id, { failedLoginAttempts: -1 * (+new Date() + 60 * 1000 * 15) }, { runValidators: true, }, ); setTimeout( async () => { await User.findByIdAndUpdate( _id, { failedLoginAttempts: 0 }, { runValidators: true, }, ); }, 60 * 1000 * 15, ); throw new Error(`Account locked. Please try again in: 15 min.`); } else if (user.failedLoginAttempts < 0 && getMinutesRemaining() > 0) { throw new Error(accountLocketMessage()); } else if (user.failedLoginAttempts < 0 && getMinutesRemaining() < 0) { await User.findByIdAndUpdate( _id, { failedLoginAttempts: 0 }, { runValidators: true, }, ); user.failedLoginAttempts = 0; } try { await User.findByIdAndUpdate( _id, { failedLoginAttempts: user.failedLoginAttempts + 1 }, { runValidators: true, }, ); } catch (error) { logger.error(error, { params: req.params, body: req.body }); } throw new Error(`Invalid credentials. Remaining attempts: ${5 - user.failedLoginAttempts}`); } } else throw new Error('Invalid credentials'); case 'guest': { return await GuestService.create(req, options); } default: return await createUserAndSession(req, res, User, options); } }; /** * GET - Retrieve user data. * Supports public profile lookup by username, asset retrieval, * email lookup, password recovery flow, email verification, * admin user listing, authentication refresh, and profile retrieval. * @async * @function get * @memberof UserService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} User data with optional session token. * @throws {Error} If user not found, profile is private, or token invalid. */ static get = async (req, res, options) => { /** @type {import('../file/file.model.js').FileModel} */ const File = DataBaseProviderService.getModel('File', options); /** @type {import('./user.model.js').UserModel} */ const User = DataBaseProviderService.getModel('User', options); if (req.path.startsWith('/u/')) { // First lookup user by username const userByUsername = await User.findOne({ username: req.params.username, }); if (!userByUsername) throw new Error('User not found'); if (!userByUsername.publicProfile) throw new Error('Public profile is private'); // Then fetch complete public data by ID const user = await User.findOne({ _id: userByUsername._id, }).select(UserDto.public.get()); return user; } if (req.path.startsWith('/assets')) { options.png.header(res, req); return options.png.buffer[req.params.id]; } if (req.path.startsWith('/email')) { return await User.findOne({ email: req.params.email, }).select(UserDto.select.get()); } if (req.path.startsWith('/recover')) { let payload; try { payload = verifyJWT(req.params.id, options); } catch (error) { logger.error(error, { 'req.params.id': req.params.id }); options.png.header(res, req); return options.png.buffer['invalid-token']; } const user = await User.findOne({ email: payload.email, }); if (user) { const { _id } = user; await User.findByIdAndUpdate( _id, { recoverTimeOut: new Date(+new Date() + 1000 * 60 * 15) }, { runValidators: true }, ); options.png.header(res, req); return options.png.buffer['recover']; } else { options.png.header(res, req); return options.png.buffer['invalid-token']; } } if (req.path.startsWith('/mailer')) { let payload; try { payload = verifyJWT(req.params.id, options); } catch (error) { logger.error(error, { 'req.params.id': req.params.id }); options.png.header(res, req); return options.png.buffer['invalid-token']; } const user = await User.findOne({ email: payload.email, }); if (user) { const { _id } = user; { const user = await User.findByIdAndUpdate(_id, { emailConfirmed: true }, { runValidators: true }); } const userWsId = CoreWsMailerChannel.getUserWsId(resolveHostKeyContext(options), user._id.toString()); if (userWsId && CoreWsMailerChannel.client[userWsId]) { CoreWsEmitter.emit(CoreWsMailerChannel.channel, CoreWsMailerChannel.client[userWsId], { status: 'email-confirmed', id: userWsId, }); } options.png.header(res, req); return options.png.buffer['check']; } else { options.png.header(res, req); return options.png.buffer['invalid-token']; } } switch (req.params.id) { case 'all': { if (req.auth.user.role === 'admin') { // Use DataQuery.parse for filtering, sorting, and pagination const { query, sort, skip, limit, page } = DataQuery.parse(req.query); // Apply default sort if no sort was specified const finalSort = Object.keys(sort).length > 0 ? sort : { updatedAt: -1 }; const [data, total] = await Promise.all([ User.find(query).select(UserDto.select.get()).sort(finalSort).skip(skip).limit(limit), User.countDocuments(query), ]); return { data, page, limit, total, totalPages: Math.ceil(total / limit), }; } else throw new Error(`Invalid token user id`); } case 'auth': { let user; if (req.auth.user._id.match('guest')) { return await GuestService.auth(req, options); } else user = await User.findOne({ _id: req.auth.user._id, }); if (!user) throw new Error('user not found'); return { token: await refreshSessionAndToken(req, res, User, options), user: await User.findOne({ _id: req.auth.user._id, }).select(UserDto.select.get()), }; } default: { const user = await User.findOne({ _id: req.auth.user._id, }); switch (user.role) { case 'admin': { if (req.params.id) return await User.findById(req.params.id); return await User.find(); } default: if (req.auth.user._id !== req.params.id) throw new Error(`Invalid token user id`); return await User.findOne({ _id: req.params.id, }).select(UserDto.select.get()); } } } }; /** * DELETE - Remove user accounts or log out sessions. * Supports admin bulk deletion, user self-deletion, and session logout. * @async * @function delete * @memberof UserService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Deleted user data or logout status message. * @throws {Error} If logout fails, user not found, or token invalid. */ static delete = async (req, res, options) => { /** @type {import('./user.model.js').UserModel} */ const User = DataBaseProviderService.getModel('User', options); if (req.params.id === 'logout') { const result = await logoutSession(User, req, res); if (!result) throw new Error('Logout failed'); return { message: 'Logged out successfully' }; } switch (req.params.id) { default: { const user = await User.findOne({ _id: req.auth.user._id, }); switch (user.role) { case 'admin': { if (req.params.id) return await User.findByIdAndDelete(req.params.id); else return await User.deleteMany(); } default: if (req.auth.user._id !== req.params.id) throw new Error(`Invalid token user id`); const user = await User.findOne({ _id: req.params.id, }).select(UserDto.select.get()); if (user) { await User.findByIdAndDelete(req.params.id); return user; } else throw new Error('user not found'); } } } }; /** * PUT - Update user data. * Supports profile image upload, password recovery, and profile field updates. * @async * @function put * @memberof UserService * @param {Object} req - Express request object. * @param {Object} res - Express response object. * @param {Object} options - Request options containing host and path. * @returns {Promise<Object>} Updated user data. * @throws {Error} If user not found, token invalid, or invalid file. */ static put = async (req, res, options) => { /** @type {import('../file/file.model.js').FileModel} */ const File = DataBaseProviderService.getModel('File', options); /** @type {import('./user.model.js').UserModel} */ const User = DataBaseProviderService.getModel('User', options); // req.path | req.baseUrl if (req.path.startsWith('/profile-image')) { const _id = req.auth.user._id; if (_id !== req.params.id) throw new Error(`Invalid token user id`); const user = await User.findOne({ _id, }); if (!user) throw new Error(`User not found`); const [imageFile] = await FileFactory.upload(req, File); // Clean up old profile image if being replaced if (user.profileImageId && imageFile) { await FileCleanup.cleanupReplacedFiles({ oldDoc: user, newData: { profileImageId: imageFile._id.toString() }, fileFields: ['profileImageId'], File, }); } if (!imageFile) throw new Error('invalid file'); await User.findByIdAndUpdate( _id, { profileImageId: imageFile._id.toString(), }, { runValidators: true }, ); return await User.findOne({ _id, }).select(UserDto.select.get()); } if (req.path.startsWith('/recover')) { const payload = verifyJWT(req.params.id, options); const user = await User.findOne({ email: payload.email, }); if (process.env.NODE_ENV === 'development') { logger.warn('Only production check image token request on mailer GET /user/recover, set fallback timeout'); user.recoverTimeOut = new Date(+new Date() + 1000 * 60 * 15); } if (user && new Date().getTime() < new Date(user.recoverTimeOut).getTime()) { const validatePassword = validatePasswordMiddleware(req.body.password); if (validatePassword.status === 'error') throw new Error(validatePassword.message); await User.findByIdAndUpdate( user._id, { password: await hashPassword(req.body.password), recoverTimeOut: new Date(), failedLoginAttempts: 0 }, { runValidators: true }, ); return await User.findOne({ _id: user._id, }).select(UserDto.select.get()); } else throw new Error('invalid token'); } else delete req.body.password; switch (req.params.id) { default: { const user = await User.findOne({ _id: req.auth.user._id, }); switch (user.role) { case 'admin': { return await User.findByIdAndUpdate(req.params.id, req.body, { runValidators: true, }); } default: { const _id = req.auth.user._id; if (_id !== req.params.id) throw new Error(`Invalid token user id`); const user = await User.findOne({ _id }); const updateData = { email: req.body.email && !user.emailConfirmed ? req.body.email : user.email, username: req.body.username, }; if (req.body.publicProfile !== undefined) updateData.publicProfile = req.body.publicProfile; if (req.body.briefDescription !== undefined) updateData.briefDescription = req.body.briefDescription; await User.findByIdAndUpdate(_id, updateData, { runValidators: true }); return await User.findOne({ _id, }).select(UserDto.select.get()); } } } } }; } export { UserService };