Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions app/api/comments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { comment, user } from "@/lib/schema"
import { nanoid } from "nanoid"
import { eq, and, desc, isNull } from "drizzle-orm"
import { track } from "@vercel/analytics/server"

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const ruleId = searchParams.get("ruleId")

if (!ruleId) {
return NextResponse.json({ error: "Rule ID is required" }, { status: 400 })
}

// Get all comments for the rule with user information
const comments = await db
.select({
id: comment.id,
content: comment.content,
parentId: comment.parentId,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
user: {
id: user.id,
name: user.name,
image: user.image,
},
})
.from(comment)
.leftJoin(user, eq(comment.userId, user.id))
.where(eq(comment.ruleId, ruleId))
.orderBy(desc(comment.createdAt))

// Organize comments into threads (parent comments with their replies)
const parentComments = comments.filter(c => !c.parentId)
const replies = comments.filter(c => c.parentId)

const threaded = parentComments.map(parent => ({
...parent,
replies: replies.filter(reply => reply.parentId === parent.id)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}))

return NextResponse.json(threaded)
} catch (error) {
console.error("Error fetching comments:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}

export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
})

if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const body = await request.json()
const { ruleId, content, parentId } = body

if (!ruleId || !content?.trim()) {
return NextResponse.json({
error: "Rule ID and content are required"
}, { status: 400 })
}

// Validate parentId if provided (ensure it exists and belongs to the same rule)
if (parentId) {
const parentComment = await db
.select()
.from(comment)
.where(and(
eq(comment.id, parentId),
eq(comment.ruleId, ruleId)
))
.limit(1)

if (parentComment.length === 0) {
return NextResponse.json({
error: "Parent comment not found"
}, { status: 400 })
}
}

const id = nanoid()
const now = new Date()

const [newComment] = await db.insert(comment).values({
id,
userId: session.user.id,
ruleId,
parentId: parentId || null,
content: content.trim(),
createdAt: now,
updatedAt: now,
}).returning()

// Get the comment with user info for response
const commentWithUser = await db
.select({
id: comment.id,
content: comment.content,
parentId: comment.parentId,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
user: {
id: user.id,
name: user.name,
image: user.image,
},
})
.from(comment)
.leftJoin(user, eq(comment.userId, user.id))
.where(eq(comment.id, id))
.limit(1)

await track('Comment Posted', {
ruleId,
commentId: id,
isReply: Boolean(parentId)
})

return NextResponse.json(commentWithUser[0])
} catch (error) {
console.error("Error creating comment:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}

export async function DELETE(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
})

if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const { commentId } = await request.json()

if (!commentId) {
return NextResponse.json({ error: "Comment ID is required" }, { status: 400 })
}

// Delete the comment (only if it belongs to the authenticated user)
const deletedComment = await db
.delete(comment)
.where(and(
eq(comment.id, commentId),
eq(comment.userId, session.user.id)
))
.returning()

if (deletedComment.length === 0) {
return NextResponse.json({
error: "Comment not found or unauthorized"
}, { status: 404 })
}

await track('Comment Deleted', { commentId })
return NextResponse.json({ message: "Comment deleted successfully" })
} catch (error) {
console.error("Error deleting comment:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}
78 changes: 78 additions & 0 deletions app/api/feed/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server"
import { db } from "@/lib/db"
import { rating, comment } from "@/lib/schema"
import { eq, avg, count, sql } from "drizzle-orm"

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const ruleIds = searchParams.get("ruleIds")?.split(",") || []

if (ruleIds.length === 0) {
return NextResponse.json({})
}

// Get rating stats for all rules
const ratingStats = await db
.select({
ruleId: rating.ruleId,
averageRating: avg(rating.rating),
totalRatings: count(rating.id),
})
.from(rating)
.where(sql`${rating.ruleId} = ANY(${ruleIds})`)
.groupBy(rating.ruleId)

// Get comment counts for all rules
const commentStats = await db
.select({
ruleId: comment.ruleId,
totalComments: count(comment.id),
})
.from(comment)
.where(sql`${comment.ruleId} = ANY(${ruleIds})`)
Comment on lines +23 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical SQL injection vulnerability: User input is directly interpolated into SQL queries without proper sanitization.

View Details
📝 Patch Details
diff --git a/app/api/feed/stats/route.ts b/app/api/feed/stats/route.ts
index 4ded31a..e849dec 100644
--- a/app/api/feed/stats/route.ts
+++ b/app/api/feed/stats/route.ts
@@ -1,7 +1,7 @@
 import { NextRequest, NextResponse } from "next/server"
 import { db } from "@/lib/db"
 import { rating, comment } from "@/lib/schema"
-import { eq, avg, count, sql } from "drizzle-orm"
+import { eq, avg, count, sql, inArray } from "drizzle-orm"
 
 export async function GET(request: NextRequest) {
   try {
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
         totalRatings: count(rating.id),
       })
       .from(rating)
-      .where(sql`${rating.ruleId} = ANY(${ruleIds})`)
+      .where(inArray(rating.ruleId, ruleIds))
       .groupBy(rating.ruleId)
 
     // Get comment counts for all rules
@@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
         totalComments: count(comment.id),
       })
       .from(comment)
-      .where(sql`${comment.ruleId} = ANY(${ruleIds})`)
+      .where(inArray(comment.ruleId, ruleIds))
       .groupBy(comment.ruleId)
 
     // Combine the stats
@@ -75,4 +75,4 @@ export async function GET(request: NextRequest) {
     console.error("Error fetching feed stats:", error)
     return NextResponse.json({ error: "Internal server error" }, { status: 500 })
   }
-}
\ No newline at end of file
+}

Analysis

Lines 23 and 33 use sql${rating.ruleId} = ANY(${ruleIds}) and `sql`${comment.ruleId} = ANY(${ruleIds}) where ruleIds comes directly from URL query parameters that are split by comma. This creates a SQL injection vulnerability because the user-controlled ruleIds array is interpolated directly into the SQL query without proper sanitization or parameterization.

An attacker could manipulate the ruleIds parameter to inject malicious SQL code. For example, a request like /api/feed/stats?ruleIds='; DROP TABLE rating; -- could potentially execute dangerous SQL commands.

The fix is to use Drizzle ORM's inArray() function instead of raw SQL interpolation: .where(inArray(rating.ruleId, ruleIds)) and .where(inArray(comment.ruleId, ruleIds)). This will properly parameterize the query and prevent SQL injection attacks.

.groupBy(comment.ruleId)

// Combine the stats
const stats: Record<string, {
averageRating: number
totalRatings: number
totalComments: number
}> = {}

// Initialize all rule IDs with zero stats
ruleIds.forEach(ruleId => {
stats[ruleId] = {
averageRating: 0,
totalRatings: 0,
totalComments: 0
}
})

// Add rating stats
ratingStats.forEach(stat => {
if (stat.ruleId) {
stats[stat.ruleId] = {
...stats[stat.ruleId],
averageRating: Number(stat.averageRating) || 0,
totalRatings: stat.totalRatings || 0,
}
}
})

// Add comment stats
commentStats.forEach(stat => {
if (stat.ruleId) {
stats[stat.ruleId] = {
...stats[stat.ruleId],
totalComments: stat.totalComments || 0,
}
}
})

return NextResponse.json(stats)
} catch (error) {
console.error("Error fetching feed stats:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}
132 changes: 132 additions & 0 deletions app/api/ratings/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { rating, user } from "@/lib/schema"
import { nanoid } from "nanoid"
import { eq, and, avg, count } from "drizzle-orm"
import { track } from "@vercel/analytics/server"

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const ruleId = searchParams.get("ruleId")

if (!ruleId) {
return NextResponse.json({ error: "Rule ID is required" }, { status: 400 })
}

// Get average rating and count
const ratingStats = await db
.select({
averageRating: avg(rating.rating),
totalRatings: count(rating.id),
})
.from(rating)
.where(eq(rating.ruleId, ruleId))

// Get user's rating if authenticated
let userRating = null
try {
const session = await auth.api.getSession({
headers: request.headers
})

if (session) {
const userRatingResult = await db
.select({
rating: rating.rating,
})
.from(rating)
.where(and(
eq(rating.ruleId, ruleId),
eq(rating.userId, session.user.id)
))
.limit(1)

userRating = userRatingResult[0]?.rating || null
}
} catch (error) {
// Continue without user rating if auth fails
}

return NextResponse.json({
averageRating: ratingStats[0]?.averageRating ? Number(ratingStats[0].averageRating) : 0,
totalRatings: ratingStats[0]?.totalRatings || 0,
userRating,
})
} catch (error) {
console.error("Error fetching ratings:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}

export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
})

if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const body = await request.json()
const { ruleId, rating: ratingValue } = body

if (!ruleId || !ratingValue || ratingValue < 1 || ratingValue > 5) {
return NextResponse.json({
error: "Rule ID and rating (1-5) are required"
}, { status: 400 })
}

const id = nanoid()
const now = new Date()

// Check if user already rated this rule
const existingRating = await db
.select()
.from(rating)
.where(and(
eq(rating.ruleId, ruleId),
eq(rating.userId, session.user.id)
))
.limit(1)

let result
if (existingRating.length > 0) {
// Update existing rating
[result] = await db
.update(rating)
.set({
rating: ratingValue,
updatedAt: now,
})
.where(and(
eq(rating.ruleId, ruleId),
eq(rating.userId, session.user.id)
))
.returning()
} else {
// Create new rating
[result] = await db.insert(rating).values({
id,
userId: session.user.id,
ruleId,
rating: ratingValue,
createdAt: now,
updatedAt: now,
}).returning()
}

await track('Rating Submitted', {
ruleId,
rating: ratingValue,
isUpdate: existingRating.length > 0
})

return NextResponse.json(result)
} catch (error) {
console.error("Error creating/updating rating:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}
Loading