Release 2025-05-19
This commit is contained in:
@@ -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