Release 2025-05-19

This commit is contained in:
pluja
2025-05-19 10:19:49 +00:00
parent 046c4559e5
commit 2657f936bc
267 changed files with 0 additions and 49432 deletions

View File

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

View File

@@ -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.',
})
}
},
}),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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