Files
kycnotme/web/src/actions/serviceSuggestion.ts
2025-05-19 10:23:36 +00:00

360 lines
11 KiB
TypeScript

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