Files

158 lines
4.6 KiB
TypeScript

"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 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);
}
}