feat: add share pages comments and server actions
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { cdn, shareComments, shareTexts } from "@/db/schema";
|
||||
import { getAdminDiscordIds } from "@/lib/auth/auth";
|
||||
import { createShare } from "@/lib/share/create";
|
||||
import { requireShareAuthor } from "@/lib/share/current-author";
|
||||
import { ShareHttpError } from "@/lib/share/http-error";
|
||||
import { enforceShareRateLimit } from "@/lib/share/rate-limit";
|
||||
import {
|
||||
validateOptionalImage,
|
||||
validateShareComment,
|
||||
validateShareDescription,
|
||||
validateShareText,
|
||||
} from "@/lib/share/validation";
|
||||
|
||||
type ActionResult =
|
||||
| { success: true; id?: string }
|
||||
| { success: false; error: string; status: number; retryAfter?: number };
|
||||
|
||||
function actionError(error: unknown): ActionResult {
|
||||
if (error instanceof ShareHttpError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
status: error.status,
|
||||
...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
|
||||
};
|
||||
}
|
||||
console.error("Share action failed", error);
|
||||
return { success: false, error: "Internal server error", status: 500 };
|
||||
}
|
||||
|
||||
export async function createShareAction(formData: FormData): Promise<ActionResult> {
|
||||
try {
|
||||
const author = await requireShareAuthor();
|
||||
const content = validateShareText(formData.get("text"));
|
||||
const description = validateShareDescription(formData.get("description"));
|
||||
const image = await validateOptionalImage(formData.get("image"));
|
||||
|
||||
await enforceShareRateLimit({
|
||||
key: `sender:${author.discordId}`,
|
||||
limit: 10,
|
||||
windowSeconds: 60 * 60,
|
||||
});
|
||||
|
||||
const share = await createShare({
|
||||
content,
|
||||
description,
|
||||
image,
|
||||
source: "web",
|
||||
author,
|
||||
});
|
||||
return { success: true, id: share.id };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCommentAction(
|
||||
shareId: string,
|
||||
body: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const author = await requireShareAuthor();
|
||||
const comment = validateShareComment(body);
|
||||
const [share] = await db
|
||||
.select({ id: shareTexts.id })
|
||||
.from(shareTexts)
|
||||
.where(eq(shareTexts.id, shareId))
|
||||
.limit(1);
|
||||
if (!share) throw new ShareHttpError("Share not found", 404);
|
||||
|
||||
await enforceShareRateLimit({
|
||||
key: `comment:${author.discordId}`,
|
||||
limit: 30,
|
||||
windowSeconds: 60 * 60,
|
||||
});
|
||||
|
||||
await db.insert(shareComments).values({
|
||||
shareId,
|
||||
body: comment,
|
||||
authorId: author.userId,
|
||||
authorDiscordId: author.discordId,
|
||||
authorName: author.displayName,
|
||||
authorAvatarUrl: author.avatarUrl,
|
||||
});
|
||||
revalidatePath(`/share/${shareId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteShareAction(shareId: string): Promise<ActionResult> {
|
||||
try {
|
||||
const author = await requireShareAuthor();
|
||||
const [share] = await db
|
||||
.select({
|
||||
id: shareTexts.id,
|
||||
authorDiscordId: shareTexts.authorDiscordId,
|
||||
imageCdnId: shareTexts.imageCdnId,
|
||||
})
|
||||
.from(shareTexts)
|
||||
.where(eq(shareTexts.id, shareId))
|
||||
.limit(1);
|
||||
if (!share) throw new ShareHttpError("Share not found", 404);
|
||||
const isAdmin = getAdminDiscordIds().includes(author.discordId);
|
||||
if (share.authorDiscordId !== author.discordId && !isAdmin) {
|
||||
throw new ShareHttpError("Forbidden", 403);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(shareTexts).where(eq(shareTexts.id, shareId));
|
||||
if (share.imageCdnId) {
|
||||
await tx.delete(cdn).where(eq(cdn.id, share.imageCdnId));
|
||||
}
|
||||
});
|
||||
revalidatePath(`/share/${shareId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCommentAction(
|
||||
shareId: string,
|
||||
commentId: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const author = await requireShareAuthor();
|
||||
const isAdmin = getAdminDiscordIds().includes(author.discordId);
|
||||
const deleted = await db
|
||||
.delete(shareComments)
|
||||
.where(
|
||||
and(
|
||||
eq(shareComments.id, commentId),
|
||||
eq(shareComments.shareId, shareId),
|
||||
isAdmin
|
||||
? eq(shareComments.shareId, shareId)
|
||||
: eq(shareComments.authorDiscordId, author.discordId)
|
||||
)
|
||||
)
|
||||
.returning({ id: shareComments.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new ShareHttpError("Comment not found or forbidden", 404);
|
||||
}
|
||||
revalidatePath(`/share/${shareId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return actionError(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user