"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 { 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 { 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 { 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 { try { const author = await requireShareAuthor(); const isAdmin = getAdminDiscordIds().includes(author.discordId); const ownership = isAdmin ? and( eq(shareComments.id, commentId), eq(shareComments.shareId, shareId) ) : and( eq(shareComments.id, commentId), eq(shareComments.shareId, shareId), eq(shareComments.authorDiscordId, author.discordId) ); const deleted = await db .delete(shareComments) .where(ownership) .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); } }