158 lines
4.6 KiB
TypeScript
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);
|
|
}
|
|
}
|