Files
kycnotme/web/src/actions/account.ts
2025-06-24 14:30:07 +00:00

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 }
},
}),
}