181 lines
5.3 KiB
TypeScript
181 lines
5.3 KiB
TypeScript
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<Selectable<Invite>, 'id' | 'email' | 'createdAt' | 'consumedAt'> & { roleIds?: number[] }
|
|
|
|
const register: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = 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<number[]>('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,
|
|
},
|
|
}
|