import _ from 'lodash' import * as z from 'zod' import type { RouteHandler } from 'fastify' import config from '../../../config.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 '../../../../shared/types.db.ts' import { sql, type Selectable } from 'kysely' 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' }, } type InviteMinimal = Pick, 'id' | 'email' | 'createdAt' | 'consumedAt'> & { roleIds?: number[] } const register: RouteHandler<{ Body: z.infer }> = async function (request, reply) { const { db } = request.server try { // TODO validate and ensure same as confirm const email = request.body.email.trim().toLowerCase() let invite: InviteMinimal | null | undefined = null if (request.body.inviteEmail && request.body.inviteToken) { let latestInvite: { id: number } | null | undefined = null ;[invite, latestInvite] = await Promise.all([ db .selectFrom('invite as i') .innerJoin('invites_roles as ir', 'ir.inviteId', 'i.id') .select([ 'id', 'email', 'createdAt', 'consumedAt', (eb) => eb.fn.agg('array_agg', ['ir.roleId']).as('roleIds'), ]) .where((eb) => eb.and({ email: request.body.inviteEmail, token: request.body.inviteToken, }), ) .groupBy('id') .executeTakeFirst(), db .selectFrom('invite') .select('id') .where('email', '=', request.body.inviteEmail) .orderBy('createdAt', 'desc') .executeTakeFirst(), ]) 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 db .selectFrom('admission as a') .innerJoin('admissions_roles as ar', 'ar.admissionId', 'a.id') .select(['regex', (eb) => eb.fn.agg('array_agg', ['ar.roleId']).as('roleIds')]) .groupBy('regex') .execute() .then((admissions) => admissions.filter((admission) => new RegExp(admission.regex).test(email))) const roleIds: number[] = Array.from( new Set([invite?.roleIds || [], ...admissions.map((admission) => admission.roleIds || [])].flat() as number[]), ) if (!roleIds.length) { throw new StatusError(...errors.notAuthorized) } const trx = await db.startTransaction().execute() const user = await trx .insertInto('user') .values({ email, password: await hashPassword(request.body.password), emailVerifiedAt: invite?.email === email ? sql`now()` : null, }) .returningAll() .executeTakeFirstOrThrow() await trx .insertInto('users_roles') .values(roleIds.map((roleId) => ({ roleId, userId: user.id }))) .execute() if (invite) { trx .updateTable('invite') .set({ consumedAt: sql`now()`, consumedById: user.id }) .execute() } if (!user.emailVerifiedAt) { const token = generateToken() await trx .insertInto('emailToken') .values({ userId: user.id, email: user.email, token, }) .execute() 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(), }) } await trx.commit().execute() const roles = await db.selectFrom('role').select(['id', 'name']).where('id', 'in', roleIds).execute() reply.type('application/json') return reply.status(201).send(Object.assign(_.omit(user, 'password'), { roles })) } catch (err) { this.log.error(err) // @ts-ignore if (err.code == 23505) { const statusError = new StatusError(...errors.duplicateEmail) return reply .status(statusError.status || 500) .type('application/json') .send(statusError.toJSON()) } else { throw err } } } export default { handler: register, schema: { body: BodySchema, resopnse: ResponseSchema, }, }