384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { Currency, KycLevelClarification } 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 { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
|
import { prisma } from '../lib/prisma'
|
|
import { separateServiceUrlsByType } from '../lib/urls'
|
|
import {
|
|
imageFileSchemaRequired,
|
|
stringListOfContactMethodsSchema,
|
|
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 matches = await findServicesBySimilarity(input.name, 0.3)
|
|
|
|
return await prisma.service.findMany({
|
|
where: {
|
|
id: {
|
|
in: matches.map(({ id }) => id),
|
|
},
|
|
serviceVisibility: {
|
|
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
description: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
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 (Array.isArray(value)) {
|
|
serializedValue = value.map((item) => String(item)).join(', ')
|
|
} 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: 'EDIT_SERVICE',
|
|
notes: combinedNotes,
|
|
status: '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',
|
|
}),
|
|
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
|
tosUrls: stringListOfUrlsSchemaRequired,
|
|
contactMethods: stringListOfContactMethodsSchema,
|
|
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
|
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
|
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,
|
|
rulesConfirm: z.literal('on', {
|
|
errorMap: () => ({
|
|
message: 'You must accept the suggestion rules and process to continue',
|
|
}),
|
|
}),
|
|
/** @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',
|
|
})
|
|
|
|
const serviceWithSameSlug = await prisma.service.findUnique({
|
|
select: { id: true, name: true, slug: true, description: true },
|
|
where: { slug: input.slug },
|
|
})
|
|
|
|
if (!input.skipDuplicateCheck) {
|
|
const possibleDuplicates = [
|
|
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
|
|
...(await findPossibleDuplicates(input)),
|
|
]
|
|
|
|
if (possibleDuplicates.length > 0) {
|
|
return {
|
|
hasDuplicates: true,
|
|
possibleDuplicates,
|
|
extraNotes: serializeExtraNotes(input, [
|
|
'skipDuplicateCheck',
|
|
'message',
|
|
'imageFile',
|
|
'captcha-value',
|
|
'captcha-solution-hash',
|
|
'rulesConfirm',
|
|
]),
|
|
serviceSuggestion: undefined,
|
|
service: undefined,
|
|
} as const
|
|
}
|
|
} else {
|
|
if (serviceWithSameSlug) {
|
|
throw new ActionError({
|
|
message: 'Slug already in use, try a different one',
|
|
code: 'BAD_REQUEST',
|
|
})
|
|
}
|
|
}
|
|
|
|
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
|
|
|
const {
|
|
web: serviceUrls,
|
|
onion: onionUrls,
|
|
i2p: i2pUrls,
|
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
|
|
|
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,
|
|
tosUrls: input.tosUrls,
|
|
onionUrls,
|
|
i2pUrls,
|
|
kycLevel: input.kycLevel,
|
|
kycLevelClarification: input.kycLevelClarification,
|
|
acceptedCurrencies: input.acceptedCurrencies,
|
|
imageUrl,
|
|
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
|
overallScore: 0,
|
|
privacyScore: 0,
|
|
trustScore: 0,
|
|
serviceVisibility: 'UNLISTED',
|
|
categories: {
|
|
connect: input.categories.map((id) => ({ id })),
|
|
},
|
|
attributes: {
|
|
create: input.attributes.map((id) => ({
|
|
attributeId: id,
|
|
})),
|
|
},
|
|
contactMethods: {
|
|
create: input.contactMethods.map((value) => ({
|
|
value,
|
|
})),
|
|
},
|
|
},
|
|
select: serviceSelect,
|
|
})
|
|
|
|
const serviceSuggestion = await tx.serviceSuggestion.create({
|
|
data: {
|
|
notes: input.notes,
|
|
type: 'CREATE_SERVICE',
|
|
status: '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,
|
|
},
|
|
})
|
|
},
|
|
}),
|
|
}
|