import _ from 'lodash' import * as z from 'zod' import type { RouteHandler } from 'fastify' import config from '../../../config.ts' import emitter from '../../../lib/emitter.ts' import knex from '../../../lib/knex.ts' import AdmissionQueries from '../../../services/admissions/queries.ts' import InviteQueries from '../../../services/invites/queries.ts' import UserQueries from '../../../services/users/queries.ts' import verifyEmailTemplate from '../../../templates/emails/verify_email.ts' import sendMail from '../../../lib/send_mail.ts' import StatusError from '../../../lib/status_error.ts' import { generateToken, hashPassword } from '../helpers.ts' import type { Invite } from '../../../services/invites/types.ts' import type { Role } from '../../../services/roles/types.ts' const admissionQueries = AdmissionQueries({ knex, emitter }) const inviteQueries = InviteQueries({ knex, emitter }) const userQueries = UserQueries({ knex, emitter }) const { errors, timeouts } = config.auth const BodySchema = z.object({ email: z.email(), password: z.string(), inviteEmail: z.email().optional(), inviteToken: z.string().optional(), // required: config.auth.requireInvite ? ['email', 'password', 'inviteEmail', 'inviteToken'] : ['email', 'password'], }) const ResponseSchema = { 201: { type: 'object', properties: { id: { type: 'integer' }, email: { type: 'string' }, password: { type: 'string' }, emailVerifiedAt: { type: 'string' }, bannedAt: { type: 'string' }, blockedAt: { type: 'string' }, loginAttempts: { type: 'integer' }, }, }, '4XX': { $ref: 'status-error' }, } const register: RouteHandler<{ Body: z.infer }> = async function (request, reply) { try { // TODO validate and ensure same as confirm const email = request.body.email.trim().toLowerCase() let invite: Invite | null = null if (request.body.inviteEmail && request.body.inviteToken) { invite = await inviteQueries.findOne({ email: request.body.inviteEmail, token: request.body.inviteToken }) const latestInvite = await inviteQueries.findOne({ email: request.body.inviteEmail, sort: [{ column: 'createdAt', order: 'desc' }], }) if (!invite) { throw new StatusError(...errors.tokenNotFound) } else if (invite.id !== latestInvite.id) { throw new StatusError(...errors.tokenNotLatest) } else if (Date.now() > new Date(invite.createdAt).getTime() + timeouts.invite) { throw new StatusError(...errors.tokenExpired) } else if (invite.consumedAt) { throw new StatusError(...errors.tokenConsumed) } } const admissions = await admissionQueries.findMatches(email) const roles = Array.from( new Set( [ invite?.roles ? invite.roles.map((role) => role.id) : [], ...admissions.map((admission) => (admission.roles ? admission.roles.map((role: Role) => role.id) : [])), ].flat(), ), ) if (!roles.length) { throw new StatusError(...errors.notAuthorized) } const user = await knex.transaction(async (trx) => { const user = await userQueries.create( { email, password: await hashPassword(request.body.password), roles, emailVerifiedAt: invite && invite.email === email ? (knex.fn.now() as unknown as string) : null, }, trx, ) if (invite) { await inviteQueries.consume(invite.id, user.id, trx) } if (!user.emailVerifiedAt) { const token = generateToken() await trx('email_token').insert({ user_id: user.id, email: user.email, token, }) const link = `${new URL('/auth/verify-email', config.site.url)}?email=${user.email}&token=${token}` await sendMail({ to: user.email, subject: `Verify ${config.site.title} account`, html: await verifyEmailTemplate({ link }).text(), }) } return userQueries.findById(user.id) }) reply.type('application/json') return reply.status(201).send(_.omit(user, 'password')) } catch (err) { this.log.error(err) // @ts-ignore if (err.code == 23505) { err = new StatusError(...errors.duplicateEmail) } if (err instanceof StatusError) { return reply .status(err.status || 500) .type('application/json') .send(err.toJSON()) } else { throw err } } } export default { handler: register, schema: { body: BodySchema, resopnse: ResponseSchema, }, }