Files
kycnotme/web/src/actions/comment.ts
2025-05-23 18:23:14 +00:00

443 lines
15 KiB
TypeScript

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 = 2
const MAX_COMMENTS_PER_WINDOW = 1
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
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', 'moderator'],
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',
})
}
},
}),
}