import { ActionError } from 'astro:actions' import { z } from 'astro:content' 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 } 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').optional().nullable(), link: z .string() .url('Must be a valid URL') .max(255, 'URL must be 255 characters or less') .optional() .nullable(), pictureFile: imageFileSchema, removePicture: z.coerce.boolean().default(false), }), 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 !== undefined && input.displayName !== context.locals.user.displayName && !context.locals.user.karmaUnlocks.displayName ) { throw new ActionError({ code: 'FORBIDDEN', message: makeKarmaUnlockMessage(karmaUnlocksById.displayName), }) } if ( input.link !== undefined && 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), }) } 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: input.displayName ?? null, link: input.link ?? null, picture: input.removePicture ? null : (pictureUrl ?? undefined), }, }) return { user } }, }), }