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 }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { AttributeCategory, AttributeType } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const attributeInputSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
category: z.nativeEnum(AttributeCategory),
|
||||
type: z.nativeEnum(AttributeType),
|
||||
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens'),
|
||||
})
|
||||
|
||||
const attributeSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
category: true,
|
||||
type: true,
|
||||
privacyPoints: true,
|
||||
trustPoints: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.AttributeSelect
|
||||
|
||||
export const adminAttributeActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
category: z.nativeEnum(AttributeCategory),
|
||||
type: z.nativeEnum(AttributeType),
|
||||
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const slug = slugify(input.title, { lower: true, strict: true })
|
||||
|
||||
const attribute = await prisma.attribute.create({
|
||||
data: {
|
||||
...input,
|
||||
slug,
|
||||
},
|
||||
select: attributeSelect,
|
||||
})
|
||||
return { attribute }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: attributeInputSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { id, title, slug, ...data } = input
|
||||
|
||||
const existingAttribute = await prisma.attribute.findUnique({
|
||||
where: { id },
|
||||
select: { title: true, slug: true },
|
||||
})
|
||||
|
||||
if (!existingAttribute) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Attribute not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check for slug uniqueness (ignore current attribute)
|
||||
const slugConflict = await prisma.attribute.findFirst({
|
||||
where: { slug, NOT: { id } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (slugConflict) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Slug already in use',
|
||||
})
|
||||
}
|
||||
|
||||
const attribute = await prisma.attribute.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
...data,
|
||||
},
|
||||
select: attributeSelect,
|
||||
})
|
||||
|
||||
return { attribute }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive('Attribute ID must be a positive integer.'),
|
||||
}),
|
||||
handler: async ({ id }) => {
|
||||
try {
|
||||
await prisma.attribute.delete({
|
||||
where: { id },
|
||||
})
|
||||
return { success: true, message: 'Attribute deleted successfully.' }
|
||||
} catch (error) {
|
||||
// Prisma throws an error if the record to delete is not found,
|
||||
// or if there are related records that prevent deletion (foreign key constraints).
|
||||
// We can customize the error message based on the type of error if needed.
|
||||
console.error('Error deleting attribute:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to delete attribute. It might be in use or already deleted.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { EventType } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export const adminEventActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z
|
||||
.object({
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
type: z.nativeEnum(EventType).default('NORMAL'),
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.endedAt && data.startedAt > data.endedAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endedAt'],
|
||||
message: 'Ended at must be after started at',
|
||||
})
|
||||
}
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
...input,
|
||||
visible: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
|
||||
toggle: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const existingEvent = await prisma.event.findUnique({ where: { id: input.eventId } })
|
||||
if (!existingEvent) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Event not found',
|
||||
})
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: input.eventId },
|
||||
data: {
|
||||
visible: !existingEvent.visible,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z
|
||||
.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
type: z.nativeEnum(EventType).default('NORMAL'),
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.endedAt && data.startedAt > data.endedAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endedAt'],
|
||||
message: 'Ended at must be after started at',
|
||||
})
|
||||
}
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { eventId, ...data } = input
|
||||
const existingEvent = await prisma.event.findUnique({ where: { id: eventId } })
|
||||
if (!existingEvent) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Event not found',
|
||||
})
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const event = await prisma.event.delete({ where: { id: input.eventId } })
|
||||
return { event }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { adminAttributeActions } from './attribute'
|
||||
import { adminEventActions } from './event'
|
||||
import { adminServiceActions } from './service'
|
||||
import { adminServiceSuggestionActions } from './serviceSuggestion'
|
||||
import { adminUserActions } from './user'
|
||||
import { verificationStep } from './verificationStep'
|
||||
|
||||
export const adminActions = {
|
||||
attribute: adminAttributeActions,
|
||||
event: adminEventActions,
|
||||
service: adminServiceActions,
|
||||
serviceSuggestions: adminServiceSuggestionActions,
|
||||
user: adminUserActions,
|
||||
verificationStep,
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import {
|
||||
imageFileSchema,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../../lib/zodUtils'
|
||||
|
||||
const serviceSchemaBase = z.object({
|
||||
id: z.number(),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
name: z.string().min(1).max(20),
|
||||
description: z.string().min(1),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||
verificationSummary: z.string().optional().nullable().default(null),
|
||||
verificationProofMd: z.string().optional().nullable().default(null),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
||||
referral: z.string().optional().nullable().default(null),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
})
|
||||
|
||||
const addSlugIfMissing = <
|
||||
T extends {
|
||||
slug?: string | null | undefined
|
||||
name: string
|
||||
},
|
||||
>(
|
||||
input: T
|
||||
) => ({
|
||||
...input,
|
||||
slug:
|
||||
input.slug ??
|
||||
slugify(input.name, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
remove: /[^a-zA-Z0-9\-._]/g,
|
||||
replacement: '-',
|
||||
}),
|
||||
})
|
||||
|
||||
const contactMethodSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
label: z.string().min(1).max(50),
|
||||
value: z.string().min(1).max(200),
|
||||
iconId: z.string().min(1).max(50),
|
||||
info: z.string().max(200).optional().default(''),
|
||||
serviceId: z.number(),
|
||||
})
|
||||
|
||||
export const adminServiceActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const { imageFile, ...serviceData } = input
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
create: input.attributes.map((attributeId) => ({
|
||||
attribute: {
|
||||
connect: { id: attributeId },
|
||||
},
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const { id, categories, attributes, imageFile, ...data } = input
|
||||
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
NOT: { id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
|
||||
// Get existing attributes and categories to compute differences
|
||||
const existingService = await prisma.service.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
categories: true,
|
||||
attributes: {
|
||||
include: {
|
||||
attribute: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingService) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Find categories to connect and disconnect
|
||||
const existingCategoryIds = existingService.categories.map((c) => c.id)
|
||||
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
|
||||
|
||||
// Find attributes to connect and disconnect
|
||||
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
||||
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
|
||||
|
||||
const service = await prisma.service.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
disconnect: categoriesToRemove.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
// Connect new attributes
|
||||
create: attributesToAdd.map((attributeId) => ({
|
||||
attribute: {
|
||||
connect: { id: attributeId },
|
||||
},
|
||||
})),
|
||||
// Delete specific attributes that are no longer needed
|
||||
deleteMany: attributesToRemove.map((attributeId) => ({
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
createContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: contactMethodSchema.omit({ id: true }),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: input,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
updateContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: contactMethodSchema,
|
||||
handler: async (input) => {
|
||||
const { id, ...data } = input
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
deleteContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ServiceSuggestionStatus } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export const adminServiceSuggestionActions = {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
suggestionId: z.coerce.number().int().positive(),
|
||||
status: z.nativeEnum(ServiceSuggestionStatus),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const suggestion = await prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
serviceId: true,
|
||||
},
|
||||
where: { id: input.suggestionId },
|
||||
})
|
||||
|
||||
if (!suggestion) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Suggestion not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.serviceSuggestion.update({
|
||||
where: { id: suggestion.id },
|
||||
data: {
|
||||
status: input.status,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
message: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
suggestionId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(1000),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const suggestion = await prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
where: { id: input.suggestionId },
|
||||
})
|
||||
|
||||
if (!suggestion) {
|
||||
throw new Error('Suggestion not found')
|
||||
}
|
||||
|
||||
await prisma.serviceSuggestionMessage.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
suggestionId: suggestion.id,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
|
||||
const selectUserReturnFields = {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
link: true,
|
||||
picture: true,
|
||||
admin: true,
|
||||
verified: true,
|
||||
verifier: true,
|
||||
verifiedLink: true,
|
||||
secretTokenHash: true,
|
||||
totalKarma: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
spammer: true,
|
||||
} as const satisfies Prisma.UserSelect
|
||||
|
||||
export const adminUserActions = {
|
||||
search: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
name: z.string().min(1, 'User name is required'),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { name: input.name },
|
||||
select: selectUserReturnFields,
|
||||
})
|
||||
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1, 'Name is required').max(255, 'Name must be less than 255 characters'),
|
||||
link: z
|
||||
.string()
|
||||
.url('Invalid URL')
|
||||
.nullable()
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null),
|
||||
pictureFile: z.instanceof(File).optional(),
|
||||
verifier: z.boolean().default(false),
|
||||
admin: z.boolean().default(false),
|
||||
spammer: z.boolean().default(false),
|
||||
verifiedLink: z
|
||||
.string()
|
||||
.url('Invalid URL')
|
||||
.nullable()
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
displayName: z
|
||||
.string()
|
||||
.max(50, 'Display Name must be less than 50 characters')
|
||||
.nullable()
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
}),
|
||||
handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'User not found',
|
||||
})
|
||||
}
|
||||
|
||||
let pictureUrl = picture ?? null
|
||||
if (pictureFile && pictureFile.size > 0) {
|
||||
pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
...valuesToUpdate,
|
||||
verified: !!valuesToUpdate.verifiedLink,
|
||||
picture: pictureUrl,
|
||||
},
|
||||
select: selectUserReturnFields,
|
||||
})
|
||||
|
||||
return {
|
||||
updatedUser,
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
internalNotes: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(1000),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const note = await prisma.internalUserNote.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
userId: input.userId,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { note }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.internalUserNote.delete({
|
||||
where: {
|
||||
id: input.noteId,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(1000),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const note = await prisma.internalUserNote.update({
|
||||
where: {
|
||||
id: input.noteId,
|
||||
},
|
||||
data: {
|
||||
content: input.content,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { note }
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
serviceAffiliations: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
role: z.enum(['OWNER', 'ADMIN', 'MODERATOR', 'SUPPORT', 'TEAM_MEMBER']),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
// Check if the user exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'User not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the service exists
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the service affiliation already exists
|
||||
const existingAffiliation = await prisma.serviceUser.findUnique({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: input.userId,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let serviceAffiliation
|
||||
|
||||
if (existingAffiliation) {
|
||||
// Update existing affiliation
|
||||
serviceAffiliation = await prisma.serviceUser.update({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: input.userId,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: input.role as ServiceUserRole,
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceAffiliation, serviceName: service.name, updated: true }
|
||||
} else {
|
||||
// Create new affiliation
|
||||
serviceAffiliation = await prisma.serviceUser.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
serviceId: input.serviceId,
|
||||
role: input.role as ServiceUserRole,
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceAffiliation, serviceName: service.name }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error managing service affiliation:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error managing service affiliation',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
remove: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const serviceAffiliation = await prisma.serviceUser.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
include: {
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceAffiliation }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { VerificationStepStatus } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const verificationStepSchemaBase = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(200, 'Description must be 200 characters or less'),
|
||||
status: z.nativeEnum(VerificationStepStatus),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
evidenceMd: z.string().optional().nullable().default(null),
|
||||
})
|
||||
|
||||
const verificationStepUpdateSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
title: z.string().min(1, 'Title is required').optional(),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(200, 'Description must be 200 characters or less')
|
||||
.optional(),
|
||||
status: z.nativeEnum(VerificationStepStatus).optional(),
|
||||
evidenceMd: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
const verificationStepIdSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
|
||||
export const verificationStep = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: verificationStepSchemaBase,
|
||||
handler: async (input) => {
|
||||
const { serviceId, title, description, status, evidenceMd } = input
|
||||
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
const newVerificationStep = await prisma.verificationStep.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
evidenceMd,
|
||||
service: {
|
||||
connect: { id: serviceId },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { verificationStep: newVerificationStep }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: verificationStepUpdateSchema,
|
||||
handler: async (input) => {
|
||||
const { id, ...dataToUpdate } = input
|
||||
|
||||
const existingStep = await prisma.verificationStep.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!existingStep) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Verification step not found',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedVerificationStep = await prisma.verificationStep.update({
|
||||
where: { id },
|
||||
data: dataToUpdate,
|
||||
})
|
||||
|
||||
return { verificationStep: updatedVerificationStep }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: verificationStepIdSchema,
|
||||
handler: async ({ id }) => {
|
||||
const existingStep = await prisma.verificationStep.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!existingStep) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Verification step not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.verificationStep.delete({ where: { id } })
|
||||
|
||||
return { success: true, deletedId: id }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:schema'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { makeKarmaUnlockMessage } from '../lib/karmaUnlocks'
|
||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { timeTrapSecretKey } from '../lib/timeTrapSecret'
|
||||
|
||||
import type { CommentStatus, Prisma } from '@prisma/client'
|
||||
|
||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5
|
||||
const MAX_COMMENTS_PER_WINDOW = 1
|
||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5
|
||||
|
||||
export const commentActions = {
|
||||
vote: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
commentId: z.coerce.number().int().positive(),
|
||||
downvote: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
try {
|
||||
// Check user karma requirement
|
||||
if (!context.locals.user.karmaUnlocks.voteComments) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.voteComments),
|
||||
})
|
||||
}
|
||||
|
||||
// Handle the vote in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Get existing vote if any
|
||||
const existingVote = await tx.commentVote.findUnique({
|
||||
where: {
|
||||
commentId_userId: {
|
||||
commentId: input.commentId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingVote) {
|
||||
// If vote type is the same, remove the vote
|
||||
if (existingVote.downvote === input.downvote) {
|
||||
await tx.commentVote.delete({
|
||||
where: { id: existingVote.id },
|
||||
})
|
||||
} else {
|
||||
// If vote type is different, update the vote
|
||||
await tx.commentVote.update({
|
||||
where: { id: existingVote.id },
|
||||
data: { downvote: input.downvote },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Create new vote
|
||||
await tx.commentVote.create({
|
||||
data: {
|
||||
downvote: input.downvote,
|
||||
commentId: input.commentId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof ActionError) throw error
|
||||
|
||||
console.error('Error voting on comment:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error voting on comment',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z
|
||||
.object({
|
||||
content: z.string().min(10).max(2000),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
parentId: z.coerce.number().optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
rating: z.coerce.number().int().min(1).max(5).optional(),
|
||||
encTimestamp: z.string().min(1), // time trap field
|
||||
internalNote: z.string().max(500).optional(),
|
||||
issueKycRequested: z.coerce.boolean().optional(),
|
||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||
issueScam: z.coerce.boolean().optional(),
|
||||
issueDetails: z.string().max(120).optional(),
|
||||
orderId: z.string().max(100).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.rating && data.parentId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['parentId'],
|
||||
message: 'Ratings cannot be provided for replies',
|
||||
})
|
||||
}
|
||||
if (!data.parentId) {
|
||||
if (data.content.length < 30) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.too_small,
|
||||
minimum: 30,
|
||||
type: 'string',
|
||||
inclusive: true,
|
||||
path: ['content'],
|
||||
message: 'Content must be at least 30 characters',
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (context.locals.user.karmaUnlocks.commentsDisabled) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: makeKarmaUnlockMessage(karmaUnlocksById.commentsDisabled),
|
||||
})
|
||||
}
|
||||
|
||||
await handleHoneypotTrap({
|
||||
input,
|
||||
honeyPotTrapField: 'message',
|
||||
userId: context.locals.user.id,
|
||||
location: 'comment.create',
|
||||
})
|
||||
|
||||
// --- Time Trap Validation Start ---
|
||||
try {
|
||||
const algorithm = 'aes-256-cbc'
|
||||
const decodedValue = Buffer.from(input.encTimestamp, 'base64').toString('utf8')
|
||||
const [ivHex, encryptedHex] = decodedValue.split(':')
|
||||
|
||||
if (!ivHex || !encryptedHex) {
|
||||
throw new Error('Invalid time trap format.')
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(algorithm, timeTrapSecretKey, iv)
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
const originalTimestamp = parseInt(decrypted, 10)
|
||||
if (isNaN(originalTimestamp)) {
|
||||
throw new Error('Invalid timestamp data.')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const timeDiff = now - originalTimestamp
|
||||
const minTimeSeconds = 2 // 2 seconds
|
||||
const maxTimeMinutes = 60 // 1 hour
|
||||
|
||||
if (timeDiff < minTimeSeconds * 1000 || timeDiff > maxTimeMinutes * 60 * 1000) {
|
||||
console.warn(`Time trap triggered: ${(timeDiff / 1000).toLocaleString()}s`)
|
||||
throw new Error('Invalid submission timing.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Time trap validation failed:', err instanceof Error ? err.message : 'Unknown error')
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid request',
|
||||
})
|
||||
}
|
||||
// --- Time Trap Validation End ---
|
||||
|
||||
// --- Rate Limit Check Start ---
|
||||
const isVerifiedUser = context.locals.user.admin || context.locals.user.verified
|
||||
const maxCommentsPerWindow = isVerifiedUser
|
||||
? MAX_COMMENTS_PER_WINDOW_VERIFIED_USER
|
||||
: MAX_COMMENTS_PER_WINDOW
|
||||
|
||||
const windowStart = new Date(Date.now() - COMMENT_RATE_LIMIT_WINDOW_MINUTES * 60 * 1000)
|
||||
const recentCommentCount = await prisma.comment.findMany({
|
||||
where: {
|
||||
authorId: context.locals.user.id,
|
||||
createdAt: {
|
||||
gte: windowStart,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (recentCommentCount.length >= maxCommentsPerWindow) {
|
||||
const oldestCreatedAt = recentCommentCount.reduce<Date | null>((oldestDate, comment) => {
|
||||
if (!oldestDate) return comment.createdAt
|
||||
if (comment.createdAt < oldestDate) return comment.createdAt
|
||||
return oldestDate
|
||||
}, null)
|
||||
|
||||
console.warn(`Rate limit exceeded for user ${context.locals.user.id.toLocaleString()}`)
|
||||
throw new ActionError({
|
||||
code: 'TOO_MANY_REQUESTS', // Use specific 429 code
|
||||
message: `Rate limit exceeded. Please wait ${oldestCreatedAt ? `${formatDistanceStrict(oldestCreatedAt, windowStart)} ` : ''}before commenting again.`,
|
||||
})
|
||||
}
|
||||
// --- Rate Limit Check End ---
|
||||
|
||||
// --- Format Internal Note from Issue Reports ---
|
||||
let formattedInternalNote: string | null = null
|
||||
// Track if this is an issue report
|
||||
const isIssueReport =
|
||||
input.issueKycRequested === true || input.issueFundsBlocked === true || input.issueScam === true
|
||||
|
||||
if (isIssueReport) {
|
||||
const issueTypes = []
|
||||
if (input.issueKycRequested) issueTypes.push('KYC REQUESTED')
|
||||
if (input.issueFundsBlocked) issueTypes.push('FUNDS BLOCKED')
|
||||
if (input.issueScam) issueTypes.push('POTENTIAL SCAM')
|
||||
|
||||
const details = input.issueDetails?.trim() ?? ''
|
||||
|
||||
formattedInternalNote = `[${issueTypes.join(', ')}]${details ? `: ${details}` : ''}`
|
||||
} else if (input.internalNote?.trim()) {
|
||||
formattedInternalNote = input.internalNote.trim()
|
||||
}
|
||||
|
||||
// Determine if admin review is needed (always true for issue reports)
|
||||
const requiresAdminReview = isIssueReport || !!(formattedInternalNote && !context.locals.user.admin)
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// First deactivate any existing ratings if providing a new rating
|
||||
if (input.rating) {
|
||||
await tx.comment.updateMany({
|
||||
where: {
|
||||
serviceId: input.serviceId,
|
||||
authorId: context.locals.user.id,
|
||||
rating: { not: null },
|
||||
},
|
||||
data: {
|
||||
ratingActive: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check for existing orderId for this service if provided
|
||||
if (input.orderId?.trim()) {
|
||||
const existingOrderId = await tx.comment.findFirst({
|
||||
where: {
|
||||
serviceId: input.serviceId,
|
||||
orderId: input.orderId.trim(),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existingOrderId) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This Order ID has already been reported for this service.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare data object with proper type safety
|
||||
const commentData: Prisma.CommentCreateInput = {
|
||||
content: input.content,
|
||||
service: { connect: { id: input.serviceId } },
|
||||
author: { connect: { id: context.locals.user.id } },
|
||||
|
||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
||||
requiresAdminReview,
|
||||
orderId: input.orderId?.trim() ?? null,
|
||||
kycRequested: input.issueKycRequested === true,
|
||||
fundsBlocked: input.issueFundsBlocked === true,
|
||||
}
|
||||
|
||||
if (input.parentId) {
|
||||
commentData.parent = { connect: { id: input.parentId } }
|
||||
}
|
||||
|
||||
if (input.rating) {
|
||||
commentData.rating = input.rating
|
||||
commentData.ratingActive = true
|
||||
}
|
||||
|
||||
if (formattedInternalNote) {
|
||||
commentData.internalNote = formattedInternalNote
|
||||
}
|
||||
|
||||
const newComment = await tx.comment.create({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const notiPref = await getOrCreateNotificationPreferences(
|
||||
context.locals.user.id,
|
||||
{ enableAutowatchMyComments: true },
|
||||
tx
|
||||
)
|
||||
|
||||
if (notiPref.enableAutowatchMyComments) {
|
||||
await tx.notificationPreferences.update({
|
||||
where: { userId: context.locals.user.id },
|
||||
data: {
|
||||
watchedComments: { connect: { id: newComment.id } },
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ActionError) throw error
|
||||
|
||||
console.error('Error creating comment:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error creating comment',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
moderate: defineProtectedAction({
|
||||
permissions: ['admin', 'verifier'],
|
||||
input: z.object({
|
||||
commentId: z.number(),
|
||||
userId: z.number(),
|
||||
action: z.enum([
|
||||
'status',
|
||||
'suspicious',
|
||||
'requires-admin-review',
|
||||
'community-note',
|
||||
'internal-note',
|
||||
'private-context',
|
||||
'order-id-status',
|
||||
'kyc-requested',
|
||||
'funds-blocked',
|
||||
]),
|
||||
value: z.union([
|
||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
]),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
try {
|
||||
const comment = await prisma.comment.findUnique({
|
||||
where: { id: input.commentId },
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
serviceId: true,
|
||||
createdAt: true,
|
||||
authorId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!comment) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Comment not found',
|
||||
})
|
||||
}
|
||||
|
||||
const updateData: Prisma.CommentUpdateInput = {}
|
||||
|
||||
switch (input.action) {
|
||||
case 'status':
|
||||
updateData.status = input.value as CommentStatus
|
||||
break
|
||||
case 'suspicious': {
|
||||
const isSpam = !!input.value
|
||||
updateData.suspicious = isSpam
|
||||
updateData.ratingActive = false
|
||||
|
||||
if (!isSpam && comment.rating) {
|
||||
const newestRatingOrActiveRating = await prisma.comment.findFirst({
|
||||
where: {
|
||||
serviceId: comment.serviceId,
|
||||
authorId: comment.authorId,
|
||||
id: { not: input.commentId },
|
||||
rating: { not: null },
|
||||
OR: [{ createdAt: { gt: comment.createdAt } }, { ratingActive: true }],
|
||||
},
|
||||
})
|
||||
updateData.ratingActive = !newestRatingOrActiveRating
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'requires-admin-review':
|
||||
updateData.requiresAdminReview = !!input.value
|
||||
break
|
||||
case 'community-note':
|
||||
updateData.communityNote = input.value as string
|
||||
break
|
||||
case 'internal-note':
|
||||
updateData.internalNote = input.value as string
|
||||
break
|
||||
case 'private-context':
|
||||
updateData.privateContext = input.value as string
|
||||
break
|
||||
case 'order-id-status':
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
||||
break
|
||||
case 'kyc-requested':
|
||||
updateData.kycRequested = !!input.value
|
||||
break
|
||||
case 'funds-blocked':
|
||||
updateData.fundsBlocked = !!input.value
|
||||
break
|
||||
}
|
||||
|
||||
// Update the comment
|
||||
await prisma.comment.update({
|
||||
where: { id: input.commentId },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ActionError) throw error
|
||||
|
||||
console.error('Error moderating comment:', error)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error moderating comment',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { accountActions } from './account'
|
||||
import { adminActions } from './admin'
|
||||
import { commentActions } from './comment'
|
||||
import { notificationActions } from './notifications'
|
||||
import { serviceActions } from './service'
|
||||
import { serviceSuggestionActions } from './serviceSuggestion'
|
||||
|
||||
/**
|
||||
* @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { actions } from 'astro:actions'
|
||||
* import { server } from '~/actions' // WRONG!!!!
|
||||
*
|
||||
* const result = Astro.getActionResult(actions.admin.attribute.create)
|
||||
* ```
|
||||
*/
|
||||
export const server = {
|
||||
account: accountActions,
|
||||
admin: adminActions,
|
||||
comment: commentActions,
|
||||
notification: notificationActions,
|
||||
service: serviceActions,
|
||||
serviceSuggestion: serviceSuggestionActions,
|
||||
}
|
||||
|
||||
// Don't create an object named actions, put the actions in the server object instead. Astro will automatically export the server object as actions.
|
||||
@@ -1,132 +0,0 @@
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
export const notificationActions = {
|
||||
updateReadStatus: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
notificationId: z.literal('all').or(z.coerce.number().int().positive()),
|
||||
read: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notification.updateMany({
|
||||
where:
|
||||
input.notificationId === 'all'
|
||||
? { userId: context.locals.user.id, read: !input.read }
|
||||
: { userId: context.locals.user.id, id: input.notificationId },
|
||||
data: {
|
||||
read: input.read,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
preferences: {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
|
||||
enableAutowatchMyComments: z.coerce.boolean().optional(),
|
||||
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
|
||||
enableAutowatchMyComments: input.enableAutowatchMyComments,
|
||||
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
enableOnMyCommentStatusChange: input.enableOnMyCommentStatusChange,
|
||||
enableAutowatchMyComments: input.enableAutowatchMyComments,
|
||||
enableNotifyPendingRepliesOnWatch: input.enableNotifyPendingRepliesOnWatch,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
watchComment: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
commentId: z.coerce.number().int().positive(),
|
||||
watch: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
watchedComments: input.watch
|
||||
? { connect: { id: input.commentId } }
|
||||
: { disconnect: { id: input.commentId } },
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
watchedComments: input.watch ? { connect: { id: input.commentId } } : undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
watchService: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
watchType: z.enum(['all', 'comments', 'events', 'verification']),
|
||||
value: z.coerce.boolean(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
onEventCreatedForServices:
|
||||
input.watchType === 'events' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: { disconnect: { id: input.serviceId } }
|
||||
: undefined,
|
||||
onRootCommentCreatedForServices:
|
||||
input.watchType === 'comments' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: { disconnect: { id: input.serviceId } }
|
||||
: undefined,
|
||||
onVerificationChangeForServices:
|
||||
input.watchType === 'verification' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: { disconnect: { id: input.serviceId } }
|
||||
: undefined,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
onEventCreatedForServices:
|
||||
input.watchType === 'events' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: undefined
|
||||
: undefined,
|
||||
onRootCommentCreatedForServices:
|
||||
input.watchType === 'comments' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: undefined
|
||||
: undefined,
|
||||
onVerificationChangeForServices:
|
||||
input.watchType === 'verification' || input.watchType === 'all'
|
||||
? input.value
|
||||
? { connect: { id: input.serviceId } }
|
||||
: undefined
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
export const serviceActions = {
|
||||
requestVerification: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
action: z.enum(['request', 'withdraw']),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: {
|
||||
id: input.serviceId,
|
||||
},
|
||||
select: {
|
||||
verificationStatus: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
message: 'Service not found',
|
||||
code: 'NOT_FOUND',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
service.verificationStatus === 'VERIFICATION_SUCCESS' ||
|
||||
service.verificationStatus === 'VERIFICATION_FAILED'
|
||||
) {
|
||||
throw new ActionError({
|
||||
message: 'Service is already verified or marked as scam',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
const existingRequest = await prisma.serviceVerificationRequest.findUnique({
|
||||
where: {
|
||||
serviceId_userId: {
|
||||
serviceId: input.serviceId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
switch (input.action) {
|
||||
case 'withdraw': {
|
||||
if (!existingRequest) {
|
||||
throw new ActionError({
|
||||
message: 'You have not requested verification for this service',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
await prisma.serviceVerificationRequest.delete({
|
||||
where: {
|
||||
id: existingRequest.id,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
case 'request': {
|
||||
if (existingRequest) {
|
||||
throw new ActionError({
|
||||
message: 'You have already requested verification for this service',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.serviceVerificationRequest.create({
|
||||
data: {
|
||||
serviceId: input.serviceId,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.notificationPreferences.upsert({
|
||||
where: { userId: context.locals.user.id },
|
||||
update: {
|
||||
onVerificationChangeForServices: {
|
||||
connect: { id: input.serviceId },
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
onVerificationChangeForServices: {
|
||||
connect: { id: input.serviceId },
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import {
|
||||
Currency,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceVisibility,
|
||||
VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import {
|
||||
imageFileSchemaRequired,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../lib/zodUtils'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const SUGGESTION_MESSAGE_RATE_LIMIT_WINDOW_MINUTES = 1
|
||||
const MAX_SUGGESTION_MESSAGES_PER_WINDOW = 5
|
||||
|
||||
export const SUGGESTION_NOTES_MAX_LENGTH = 1000
|
||||
export const SUGGESTION_NAME_MAX_LENGTH = 20
|
||||
export const SUGGESTION_SLUG_MAX_LENGTH = 20
|
||||
export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||
|
||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
const possibleDuplicates = await prisma.service.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
input: T,
|
||||
skipKeys: (keyof T)[] = []
|
||||
): string => {
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
||||
.map(([key, value]) => {
|
||||
let serializedValue = ''
|
||||
if (typeof value === 'string') {
|
||||
serializedValue = value
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
serializedValue = value.toString()
|
||||
} else {
|
||||
try {
|
||||
serializedValue = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
return `- ${key}: ${serializedValue}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export const serviceSuggestionActions = {
|
||||
editService: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'not-spammer',
|
||||
input: z
|
||||
.object({
|
||||
notes: z.string().max(SUGGESTION_NOTES_MAX_LENGTH).optional(),
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
extraNotes: z.string().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: 'serviceSuggestion.editService',
|
||||
})
|
||||
|
||||
const service = await prisma.service.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: { id: input.serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
message: 'Service not found',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
// Combine notes and extraNotes if available
|
||||
const combinedNotes = input.extraNotes
|
||||
? `${input.notes ?? ''}\n\nSuggested changes:\n${input.extraNotes}`
|
||||
: input.notes
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: {
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
notes: combinedNotes,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { serviceSuggestion, service }
|
||||
},
|
||||
}),
|
||||
createService: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'not-spammer',
|
||||
input: z
|
||||
.object({
|
||||
notes: z.string().max(SUGGESTION_NOTES_MAX_LENGTH).optional(),
|
||||
name: z.string().min(1).max(SUGGESTION_NAME_MAX_LENGTH),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
.refine(
|
||||
async (slug) => {
|
||||
const exists = await prisma.service.findUnique({
|
||||
select: { id: true },
|
||||
where: { slug },
|
||||
})
|
||||
return !exists
|
||||
},
|
||||
{ message: 'Slug must be unique, try a different one' }
|
||||
),
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||
imageFile: imageFileSchemaRequired,
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
skipDuplicateCheck: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => value === 'true'),
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
|
||||
handler: async (input, context) => {
|
||||
await handleHoneypotTrap({
|
||||
input,
|
||||
honeyPotTrapField: 'message',
|
||||
userId: context.locals.user.id,
|
||||
location: 'serviceSuggestion.createService',
|
||||
})
|
||||
|
||||
if (!input.skipDuplicateCheck) {
|
||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
||||
|
||||
if (possibleDuplicates.length > 0) {
|
||||
return {
|
||||
hasDuplicates: true,
|
||||
possibleDuplicates,
|
||||
extraNotes: serializeExtraNotes(input, [
|
||||
'skipDuplicateCheck',
|
||||
'message',
|
||||
'imageFile',
|
||||
'captcha-value',
|
||||
'captcha-solution-hash',
|
||||
]),
|
||||
serviceSuggestion: undefined,
|
||||
service: undefined,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
|
||||
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
||||
const serviceSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
} satisfies Prisma.ServiceSelect
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
serviceUrls: input.serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls: input.onionUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
imageUrl,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
create: input.attributes.map((id) => ({
|
||||
attributeId: id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: serviceSelect,
|
||||
})
|
||||
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: {
|
||||
notes: input.notes,
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
hasDuplicates: false,
|
||||
possibleDuplicates: [],
|
||||
extraNotes: undefined,
|
||||
serviceSuggestion,
|
||||
service,
|
||||
} as const
|
||||
})
|
||||
|
||||
return {
|
||||
hasDuplicates: false,
|
||||
possibleDuplicates: [],
|
||||
extraNotes: undefined,
|
||||
serviceSuggestion,
|
||||
service,
|
||||
} as const
|
||||
},
|
||||
}),
|
||||
message: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
suggestionId: z.coerce.number().int().positive(),
|
||||
content: z.string().min(1).max(SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
// --- Rate Limit Check Start --- (Admins are exempt)
|
||||
if (!context.locals.user.admin) {
|
||||
const windowStart = new Date(Date.now() - SUGGESTION_MESSAGE_RATE_LIMIT_WINDOW_MINUTES * 60 * 1000)
|
||||
const recentMessages = await prisma.serviceSuggestionMessage.findMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
createdAt: {
|
||||
gte: windowStart,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }, // Get the oldest first to calculate wait time
|
||||
})
|
||||
|
||||
if (recentMessages.length >= MAX_SUGGESTION_MESSAGES_PER_WINDOW) {
|
||||
const oldestMessageInWindow = recentMessages[0]
|
||||
if (!oldestMessageInWindow) {
|
||||
console.error(
|
||||
'Error determining oldest message for rate limit, but length check passed. User:',
|
||||
context.locals.user.id
|
||||
)
|
||||
throw new ActionError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Could not determine rate limit window. Please try again.',
|
||||
})
|
||||
}
|
||||
const timeToWait = formatDistanceStrict(oldestMessageInWindow.createdAt, windowStart)
|
||||
console.warn(
|
||||
`Suggestion message rate limit exceeded for user ${context.locals.user.id.toLocaleString()}`
|
||||
)
|
||||
throw new ActionError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: `Rate limit exceeded. Please wait ${timeToWait} before sending another message.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
// --- Rate Limit Check End ---
|
||||
|
||||
const suggestion = await prisma.serviceSuggestion.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
where: { id: input.suggestionId },
|
||||
})
|
||||
|
||||
if (!suggestion) {
|
||||
throw new ActionError({
|
||||
message: 'Suggestion not found',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
|
||||
if (suggestion.userId !== context.locals.user.id) {
|
||||
throw new ActionError({
|
||||
message: 'Not authorized to send messages',
|
||||
code: 'UNAUTHORIZED',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.serviceSuggestionMessage.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
suggestionId: suggestion.id,
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user