mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-10-15 11:15:45 -04:00
fix: validate server action inputs for views and comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
3 files changed
+67
-5
No files matched your search
@@ -27,6 +27,16 @@ export const getPost = (slug: string): Post | undefined => {
|
||||
return allPosts.find((post) => post.slug === slug);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the post for a page slug in the "notes/<slug>" format used by the `page` and `comment` tables
|
||||
* (e.g. "notes/dark-mode"), or undefined if it isn't an existing post.
|
||||
*/
|
||||
export const getPostByPageSlug = (pageSlug: string): Post | undefined => {
|
||||
const prefix = `${POSTS_DIR}/`;
|
||||
if (!pageSlug.startsWith(prefix)) return undefined;
|
||||
return getPost(pageSlug.slice(prefix.length));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the front matter of ALL posts, sorted reverse chronologically.
|
||||
*/
|
||||
|
||||
+51
-5
@@ -3,10 +3,21 @@
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@/lib/db/schema";
|
||||
import { getPostByPageSlug } from "@/lib/posts";
|
||||
|
||||
const commentContentSchema = z.string().trim().min(1).max(10_000);
|
||||
const commentIdSchema = z.uuid();
|
||||
|
||||
const createCommentSchema = z.object({
|
||||
content: commentContentSchema,
|
||||
pageSlug: z.string(),
|
||||
parentId: commentIdSchema.optional(),
|
||||
});
|
||||
|
||||
export type CommentWithUser = typeof schema.comment.$inferSelect & {
|
||||
user: Pick<typeof schema.user.$inferSelect, "id" | "name" | "image">;
|
||||
@@ -95,17 +106,42 @@ export const createComment = async (data: {
|
||||
throw new Error("You must be logged in to comment");
|
||||
}
|
||||
|
||||
const parsed = createCommentSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
throw new Error("Invalid comment");
|
||||
}
|
||||
const { content, pageSlug, parentId } = parsed.data;
|
||||
|
||||
// only allow comments on existing posts that haven't disabled them
|
||||
const post = getPostByPageSlug(pageSlug);
|
||||
if (!post || post.noComments) {
|
||||
throw new Error("Comments are closed for this page");
|
||||
}
|
||||
|
||||
try {
|
||||
// Replies must point at an existing comment on the same page
|
||||
if (parentId) {
|
||||
const parent = await db
|
||||
.select({ pageSlug: schema.comment.pageSlug })
|
||||
.from(schema.comment)
|
||||
.where(eq(schema.comment.id, parentId))
|
||||
.then((results) => results[0]);
|
||||
|
||||
if (!parent || parent.pageSlug !== pageSlug) {
|
||||
throw new Error("Parent comment not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the comment
|
||||
await db.insert(schema.comment).values({
|
||||
content: data.content,
|
||||
pageSlug: data.pageSlug,
|
||||
parentId: data.parentId || null,
|
||||
content,
|
||||
pageSlug,
|
||||
parentId: parentId ?? null,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
// Revalidate page
|
||||
revalidatePath(`/${data.pageSlug}`);
|
||||
revalidatePath(`/${pageSlug}`);
|
||||
} catch (error) {
|
||||
console.error("[server/comments] error creating comment:", error);
|
||||
throw new Error("Failed to create comment", { cause: error });
|
||||
@@ -121,6 +157,12 @@ export const updateComment = async (commentId: string, content: string) => {
|
||||
throw new Error("You must be logged in to update a comment");
|
||||
}
|
||||
|
||||
const parsedId = commentIdSchema.safeParse(commentId);
|
||||
const parsedContent = commentContentSchema.safeParse(content);
|
||||
if (!parsedId.success || !parsedContent.success) {
|
||||
throw new Error("Invalid comment");
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the comment to verify ownership
|
||||
const comment = await db
|
||||
@@ -145,7 +187,7 @@ export const updateComment = async (commentId: string, content: string) => {
|
||||
await db
|
||||
.update(schema.comment)
|
||||
.set({
|
||||
content,
|
||||
content: parsedContent.data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.comment.id, commentId));
|
||||
@@ -167,6 +209,10 @@ export const deleteComment = async (commentId: string) => {
|
||||
throw new Error("You must be logged in to delete a comment");
|
||||
}
|
||||
|
||||
if (!commentIdSchema.safeParse(commentId).success) {
|
||||
throw new Error("Invalid comment ID");
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the comment to verify ownership and get the page_slug for revalidation
|
||||
const comment = await db
|
||||
|
||||
@@ -5,6 +5,7 @@ import { revalidateTag } from "next/cache";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { page } from "@/lib/db/schema";
|
||||
import { getPostByPageSlug } from "@/lib/posts";
|
||||
|
||||
/**
|
||||
* Retrieves the number of views for a given slug, or 0 if the slug does not exist or on error
|
||||
@@ -42,6 +43,11 @@ export const getAllViewCounts = async (): Promise<Record<string, number>> => {
|
||||
* Increments the view count for a given slug (upserts if doesn't exist)
|
||||
*/
|
||||
export const incrementViews = async (slug: string): Promise<number> => {
|
||||
// this is a public endpoint: only count views for posts that actually exist
|
||||
if (typeof slug !== "string" || !getPostByPageSlug(slug)) {
|
||||
throw new Error("Invalid slug");
|
||||
}
|
||||
|
||||
try {
|
||||
// Atomic upsert: insert new row with views=1, or increment existing row
|
||||
const [result] = await db
|
||||
|
||||
Reference in new issue
Block a user