Release 2025-05-19
This commit is contained in:
@@ -1,221 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
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)}`
|
||||
)
|
||||
: null
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: context.locals.user.id },
|
||||
data: {
|
||||
displayName: input.displayName ?? null,
|
||||
link: input.link ?? null,
|
||||
picture: pictureUrl,
|
||||
},
|
||||
})
|
||||
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user