262 lines
7.9 KiB
TypeScript
262 lines
7.9 KiB
TypeScript
import { ActionError } from 'astro:actions'
|
|
import { z } from 'astro:content'
|
|
import { pick } from 'lodash-es'
|
|
|
|
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
|
import { createAccount } from '../lib/accountCreate'
|
|
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
|
import { saveFileLocally } from '../lib/fileStorage'
|
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
|
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
|
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
|
import { prisma } from '../lib/prisma'
|
|
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
|
import { login, logout, setUserSessionIdCookie } from '../lib/userCookies'
|
|
import {
|
|
generateUserSecretToken,
|
|
hashUserSecretToken,
|
|
parseUserSecretToken,
|
|
USER_SECRET_TOKEN_REGEX,
|
|
} from '../lib/userSecretToken'
|
|
import { imageFileSchema } from '../lib/zodUtils'
|
|
|
|
export const accountActions = {
|
|
login: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'guest',
|
|
input: z.object({
|
|
token: z.string().regex(USER_SECRET_TOKEN_REGEX).transform(parseUserSecretToken),
|
|
redirect: z.string().optional(),
|
|
}),
|
|
handler: async (input, context) => {
|
|
await logout(context)
|
|
|
|
const tokenHash = hashUserSecretToken(input.token)
|
|
const matchedUser = await prisma.user.findFirst({
|
|
where: {
|
|
secretTokenHash: tokenHash,
|
|
},
|
|
})
|
|
|
|
if (!matchedUser) {
|
|
throw new ActionError({
|
|
code: 'UNAUTHORIZED',
|
|
message: 'No user exists with this token',
|
|
})
|
|
}
|
|
|
|
await login(context, makeUserWithKarmaUnlocks(matchedUser))
|
|
|
|
return {
|
|
user: matchedUser,
|
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
redirect: input.redirect || context.request.headers.get('referer') || '/',
|
|
}
|
|
},
|
|
}),
|
|
|
|
preGenerateToken: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'guest',
|
|
handler: async () => {
|
|
const token = generateUserSecretToken()
|
|
await redisPreGeneratedSecretTokens.storePreGeneratedToken(token)
|
|
return {
|
|
token,
|
|
} as const
|
|
},
|
|
}),
|
|
|
|
generate: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'guest',
|
|
input: z
|
|
.object({
|
|
token: z.string().regex(USER_SECRET_TOKEN_REGEX).transform(parseUserSecretToken).optional(),
|
|
/** @deprecated Honey pot field, do not use */
|
|
message: z.unknown().optional(),
|
|
...captchaFormSchemaProperties,
|
|
})
|
|
.superRefine(captchaFormSchemaSuperRefine),
|
|
handler: async (input, context) => {
|
|
await handleHoneypotTrap({
|
|
input,
|
|
honeyPotTrapField: 'message',
|
|
userId: context.locals.user?.id,
|
|
location: 'account.generate',
|
|
})
|
|
|
|
const isValidToken = input.token
|
|
? await redisPreGeneratedSecretTokens.validateAndConsumePreGeneratedToken(input.token)
|
|
: true
|
|
if (!isValidToken) {
|
|
throw new ActionError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Invalid or expired token',
|
|
})
|
|
}
|
|
|
|
const { token, user: newUser } = await createAccount(input.token)
|
|
await setUserSessionIdCookie(context.cookies, newUser.secretTokenHash)
|
|
context.locals.user = makeUserWithKarmaUnlocks(newUser)
|
|
|
|
return {
|
|
token,
|
|
user: newUser,
|
|
} as const
|
|
},
|
|
}),
|
|
|
|
impersonate: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'admin',
|
|
input: z.object({
|
|
targetUserId: z.coerce.number().int().positive(),
|
|
redirect: z.string().optional(),
|
|
}),
|
|
handler: async (input, context) => {
|
|
const adminUser = context.locals.user
|
|
|
|
const targetUser = await prisma.user.findUnique({
|
|
where: { id: input.targetUserId },
|
|
})
|
|
|
|
if (!targetUser) {
|
|
throw new ActionError({
|
|
code: 'NOT_FOUND',
|
|
message: 'Target user not found',
|
|
})
|
|
}
|
|
|
|
if (targetUser.admin) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Cannot impersonate admin user',
|
|
})
|
|
}
|
|
|
|
await startImpersonating(context, adminUser, makeUserWithKarmaUnlocks(targetUser))
|
|
|
|
return {
|
|
adminUser,
|
|
impersonatedUser: targetUser,
|
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
redirect: input.redirect || context.request.headers.get('referer') || '/',
|
|
}
|
|
},
|
|
}),
|
|
|
|
update: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'user',
|
|
input: z.object({
|
|
id: z.coerce.number().int().positive(),
|
|
displayName: z.string().max(100, 'Display name must be 100 characters or less').nullable(),
|
|
link: z.string().url('Must be a valid URL').max(255, 'URL must be 255 characters or less').nullable(),
|
|
pictureFile: imageFileSchema,
|
|
removePicture: z.coerce.boolean(),
|
|
}),
|
|
handler: async (input, context) => {
|
|
if (input.id !== context.locals.user.id) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You can only update your own profile',
|
|
})
|
|
}
|
|
|
|
if (
|
|
input.displayName !== null &&
|
|
input.displayName !== context.locals.user.displayName &&
|
|
!context.locals.user.karmaUnlocks.displayName
|
|
) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: makeKarmaUnlockMessage(karmaUnlocksById.displayName),
|
|
})
|
|
}
|
|
|
|
if (
|
|
input.link !== null &&
|
|
input.link !== context.locals.user.link &&
|
|
!context.locals.user.karmaUnlocks.websiteLink
|
|
) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: makeKarmaUnlockMessage(karmaUnlocksById.websiteLink),
|
|
})
|
|
}
|
|
|
|
if (input.pictureFile !== undefined && !context.locals.user.karmaUnlocks.profilePicture) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: makeKarmaUnlockMessage(karmaUnlocksById.profilePicture),
|
|
})
|
|
}
|
|
|
|
if (input.removePicture && !context.locals.user.karmaUnlocks.profilePicture) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: makeKarmaUnlockMessage(karmaUnlocksById.profilePicture),
|
|
})
|
|
}
|
|
|
|
const pictureUrl =
|
|
input.pictureFile && input.pictureFile.size > 0
|
|
? await saveFileLocally(
|
|
input.pictureFile,
|
|
input.pictureFile.name,
|
|
`users/pictures/${String(context.locals.user.id)}`
|
|
)
|
|
: undefined
|
|
|
|
const user = await prisma.user.update({
|
|
where: { id: context.locals.user.id },
|
|
data: {
|
|
displayName: context.locals.user.karmaUnlocks.displayName ? (input.displayName ?? null) : undefined,
|
|
link: context.locals.user.karmaUnlocks.websiteLink ? (input.link ?? null) : undefined,
|
|
picture: context.locals.user.karmaUnlocks.profilePicture
|
|
? input.removePicture
|
|
? null
|
|
: (pictureUrl ?? undefined)
|
|
: undefined,
|
|
},
|
|
})
|
|
|
|
return { user }
|
|
},
|
|
}),
|
|
|
|
delete: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'user',
|
|
input: z
|
|
.object({
|
|
...captchaFormSchemaProperties,
|
|
})
|
|
.superRefine(captchaFormSchemaSuperRefine),
|
|
handler: async (_input, context) => {
|
|
if (context.locals.user.admin || context.locals.user.moderator) {
|
|
throw new ActionError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Admins and moderators cannot delete their own accounts.',
|
|
})
|
|
}
|
|
|
|
await prisma.user.delete({
|
|
where: { id: context.locals.user.id },
|
|
})
|
|
|
|
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
|
|
|
|
if (context.locals.actualUser) {
|
|
await stopImpersonating(context)
|
|
} else {
|
|
await logout(context)
|
|
}
|
|
|
|
return { deletedUser }
|
|
},
|
|
}),
|
|
}
|