feat : imrpove admin comment
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 3m47s

This commit is contained in:
2026-10-08 15:06:19 +07:00 Unverified
parent 721e2e5cf8
commit b9601b739d
11 changed files with 215 additions and 42 deletions
+6
View File
@@ -9,6 +9,9 @@ import { securityLog } from "@/lib/security/http";
export const AUDIT_ACTIONS = [
"comment.hidden",
"comment.restored",
"comment.deleted",
"comment.author_banned",
"comment.author_unbanned",
"guide.created",
"guide.overview.saved",
"guide.weapon.saved",
@@ -189,6 +192,9 @@ export const AUDIT_TARGET_LABELS: Record<
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
"comment.hidden": "ซ่อน Comment",
"comment.restored": "กู้คืน Comment",
"comment.deleted": "ลบ Comment ถาวร",
"comment.author_banned": "แบนผู้เขียน Comment",
"comment.author_unbanned": "ยกเลิกแบนผู้เขียน Comment",
"guide.created": "สร้าง Guide",
"guide.overview.saved": "บันทึก Overview",
"guide.weapon.saved": "บันทึก Weapons",
+110
View File
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
comment: {} as Record<string, unknown>, author: {} as Record<string, unknown>,
rootHidden: false, trashed: false, deletes: vi.fn(), updates: vi.fn(), audit: vi.fn(), changed: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() }));
vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: state.changed }));
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: vi.fn() }));
vi.mock("@/lib/audit-log", () => ({ auditActor: (actor: unknown) => actor, writeAuditLog: state.audit }));
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: vi.fn(), publicMediaUrl: vi.fn() }));
vi.mock("@/db", () => ({ getDb: () => database }));
import { comments, guides, sessions, users } from "@/db/schema";
import { mutateComment } from "./repository";
import { publishComment } from "./publish";
const id = "72df08ab-50dd-4cbd-9a69-70949d34cf9f";
const guideId = "72df08ab-50dd-4cbd-9a69-70949d34cf9e";
const admin = { id: "admin", admin: true };
const reader = { id: "reader", admin: false };
function select(fields: Record<string, unknown>) {
let table: unknown;
const rows = () => {
if (table === comments) return fields.comment
? [{ comment: state.comment, thread: { id: "thread", guideId } }]
: [{ hidden: state.rootHidden }];
if (table === guides) return [{ id: guideId, name: "Amber", slug: "amber", public: true, trashedAt: state.trashed ? new Date() : null }];
if (table === users) return fields.role || fields.banned ? [state.author] : [{ id: admin.id, name: "Admin" }];
return [];
};
const query = {
from(value: unknown) { table = value; return query; },
innerJoin() { return query; }, where() { return query; }, limit() { return query; }, for() { return query; },
then(resolve: (value: unknown[]) => unknown) { return Promise.resolve(rows()).then(resolve); },
};
return query;
}
const database = {
transaction: async (task: (tx: unknown) => unknown) => task(database), select,
selectDistinct: select,
update: (table: unknown) => ({ set: (value: Record<string, unknown>) => ({ where: async () => {
state.updates(table, value);
if (table === users) Object.assign(state.author, value);
} }) }),
delete: (table: unknown) => ({ where: async () => { state.deletes(table); } }),
};
beforeEach(() => {
state.comment = { id, authorId: reader.id, threadId: "thread", rootId: null, hidden: false, deletedAt: null };
state.author = { id: reader.id, email: "[email protected]", role: "user", emailVerified: true, banned: false };
state.rootHidden = false; state.trashed = false;
state.deletes.mockReset(); state.updates.mockReset(); state.audit.mockReset(); state.changed.mockReset();
});
describe("admin comment deletion", () => {
it.each(["visible", "hidden", "hidden root", "deleted placeholder"])("allows an admin to delete another author's %s comment and records it", async (visibility) => {
state.comment.hidden = visibility === "hidden";
state.comment.deletedAt = visibility === "deleted placeholder" ? new Date() : null;
if (visibility === "hidden root") { state.comment.rootId = id; state.rootHidden = true; }
await mutateComment(id, admin, "delete");
expect(state.deletes).toHaveBeenCalledWith(comments);
expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.deleted", targetId: id }));
expect(state.changed).toHaveBeenCalledWith(`guide:${guideId}`);
});
it("rejects deletion by another regular user", async () => {
await expect(mutateComment(id, { id: "other", admin: false }, "delete")).rejects.toMatchObject({ status: 403 });
expect(state.deletes).not.toHaveBeenCalled();
});
it("retains author deletion", async () => {
await mutateComment(id, reader, "delete");
expect(state.deletes).toHaveBeenCalledWith(comments);
});
it("rejects deletion for a trashed guide", async () => {
state.trashed = true;
await expect(mutateComment(id, admin, "delete")).rejects.toMatchObject({ status: 409 });
expect(state.deletes).not.toHaveBeenCalled();
});
});
describe("account bans from comment moderation", () => {
it("sets Better Auth's ban fields, revokes sessions, and audits without deleting comments", async () => {
await mutateComment(id, admin, "ban", true);
expect(state.updates).toHaveBeenCalledWith(users, { banned: true, banReason: "Banned by comment moderation", banExpires: null });
expect(state.deletes).toHaveBeenCalledWith(sessions);
expect(state.deletes).not.toHaveBeenCalledWith(comments);
expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.author_banned" }));
});
it("clears the ban and audits without revoking sessions", async () => {
state.author.banned = true;
await mutateComment(id, admin, "ban", false);
expect(state.author).toMatchObject({ banned: false, banReason: null, banExpires: null });
expect(state.deletes).not.toHaveBeenCalled();
expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.author_unbanned" }));
});
it.each(["regular user", "self", "another admin"])("rejects bans for %s", async (scenario) => {
const actor = scenario === "regular user" ? reader : admin;
if (scenario === "self") { state.author.id = admin.id; state.comment.authorId = admin.id; }
if (scenario === "another admin") state.author.role = "admin";
await expect(mutateComment(id, actor, "ban", true)).rejects.toMatchObject({ status: 403 });
expect(state.updates).not.toHaveBeenCalled(); expect(state.deletes).not.toHaveBeenCalled();
});
it.each(["new comment", "reply", "edit"])("rejects a banned author's %s before processing uploads", async (kind) => {
state.author.banned = true;
const body = new FormData(); body.set("text", "New comment");
if (kind === "reply") body.set("replyToId", id);
await expect(publishComment(new Request("https://guide.example.test/api/comments", { method: "POST", body }), reader,
kind === "edit" ? { id } : { target: `guide:${guideId}` })).rejects.toMatchObject({ status: 403, message: "comment-author-banned" });
expect(state.updates).not.toHaveBeenCalled();
});
});
+11 -1
View File
@@ -4,7 +4,7 @@ import { notifyCommentChange } from "./events";
import { and, eq, inArray } from "drizzle-orm";
import { getDb } from "@/db";
import { commentAttachments, commentRevisions, commentRevisionAttachments, comments, commentThreads } from "@/db/schema";
import { commentAttachments, commentRevisions, commentRevisionAttachments, comments, commentThreads, users } from "@/db/schema";
import { inspectImage } from "@/lib/media/inspect";
import { getMediaStorage } from "@/lib/media/storage";
import { boundedBody, HttpError, withUploadSlot } from "@/lib/security/http";
@@ -17,6 +17,13 @@ type ParsedForm = ReturnType<typeof parseCommentForm>;
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
type Writer = Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0];
async function requireCommentPosting(db: Pick<Writer, "select">, userId: string, lock = false) {
const query = db.select({ banned: users.banned }).from(users).where(eq(users.id, userId)).limit(1);
const [user] = await (lock ? query.for("update") : query);
if (!user) throw new HttpError(401, "sign-in-required");
if (user.banned) throw new HttpError(403, "comment-author-banned");
}
async function uploadCommentImage(storage: Awaited<ReturnType<typeof getMediaStorage>>, image: Upload, bytes: Uint8Array) {
for (let attempt = 0; ; attempt++) {
try {
@@ -49,6 +56,7 @@ async function saveRevision(tx: Pick<Writer, "insert" | "select">, id: string, v
export async function publishComment(request: Request, viewer: CommentViewer, options: { target?: string; id?: string }) {
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;")) throw new HttpError(415, "expected-multipart");
await requireCommentPosting(getDb(), viewer.id);
const initial = options.id ? await authorizeComment(commentId(options.id), viewer) : null;
if (initial && (initial.comment.authorId !== viewer.id || initial.comment.deletedAt || initial.comment.hidden || initial.rootHidden))
throw new HttpError(403, "comment-not-editable");
@@ -69,6 +77,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
}
if (options.id) {
const version = await withCommentLock(options.id, viewer, async (tx, context) => {
await requireCommentPosting(tx, viewer.id, true);
const c = context.comment;
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
@@ -82,6 +91,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
const thread = await ensureCommentThread(options.target!, viewer);
return await getDb().transaction(async (tx) => {
await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, thread.id)).for("update");
await requireCommentPosting(tx, viewer.id, true);
const target = await getCommentTarget(options.target!, viewer, tx);
if (!target.writable) throw new HttpError(409, "guide-trashed");
let rootId: string | null = null;
+24 -4
View File
@@ -5,7 +5,7 @@ import { notifyCommentChange } from "./events";
import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { getDb, type Database } from "@/db";
import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users, notifications } from "@/db/schema";
import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users, sessions, notifications } from "@/db/schema";
import { notifyNotificationChange } from "@/lib/notifications/events";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
import { getCustomerSession } from "@/lib/auth/server";
@@ -144,6 +144,7 @@ export async function listComments(options: {
cursorTime: sql<string>`to_char(${comments.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
comment: comments, revision: commentRevisions, authorName: users.name, authorImage: users.image,
authorAdmin: sql<boolean>`coalesce(${users.role} = 'admin' and ${users.emailVerified}, false)`,
authorBanned: users.banned,
replyToName: recipient.name, rootHidden: root.hidden, likes, replyCount,
hasReplies: sql<boolean>`exists(select 1 from ${comments} children where children.root_id = coalesce(${comments.rootId}, ${comments.id}) and children.reply_to_id = ${comments.id} and children.deleted_at is null)`,
reaction: viewer ? sql<number>`coalesce((select value from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.userId} = ${viewer.id}), 0)` : sql<number>`0`,
@@ -165,6 +166,8 @@ export async function listComments(options: {
return {
id: c.id, rootId: c.rootId, replyToId: c.replyToId, replyToName: row.replyToName,
authorName: row.authorName, authorImage: row.authorImage, authorAdmin: row.authorAdmin,
authorBanned: Boolean(viewer?.admin && row.authorBanned),
canBanAuthor: Boolean(viewer?.admin && viewer.id !== c.authorId && !row.authorAdmin && !row.guideTrashedAt),
targetImage: row.guideCoverId ? `/media/${row.guideCoverId}` : null,
text: contentAllowed ? row.revision.text : "", images: contentAllowed ? images.get(row.revision.id) ?? [] : [],
version: c.version, createdAt: c.createdAt.toISOString(), editedAt: row.revision.createdAt.toISOString(), hidden, ownHidden: c.hidden,
@@ -205,13 +208,25 @@ export async function withCommentLock<T>(id: string, viewer: CommentViewer, task
});
}
export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart", value?: number | boolean) {
export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart" | "ban", value?: number | boolean) {
let target = "";
let deletedImages: { objectKey: string }[] = [];
let notificationUsers: { userId: string }[] = [];
const result = await withCommentLock(id, viewer, async (tx, context) => {
target = context.destination.target;
const c = context.comment;
if (action === "ban") {
requireCommentAdmin(viewer);
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified })
.from(users).where(eq(users.id, c.authorId)).limit(1).for("update");
if (!author || author.id === viewer.id || Boolean(isAuthorizedAdmin(author))) throw new HttpError(403, "comment-author-ban-not-allowed");
await tx.update(users).set({ banned: Boolean(value), banReason: value ? "Banned by comment moderation" : null, banExpires: null }).where(eq(users.id, author.id));
if (value) await tx.delete(sessions).where(eq(sessions.userId, author.id));
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.author_banned" : "comment.author_unbanned", targetType: "comment", targetId: id,
metadata: { discussionTarget: target, authorId: author.id } });
return;
}
if (action === "moderation") {
requireCommentAdmin(viewer);
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
@@ -222,9 +237,9 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: "
metadata: { discussionTarget: context.destination.target } });
return;
}
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
if ((c.deletedAt || c.hidden || context.rootHidden) && !(action === "delete" && viewer.admin)) throw new HttpError(409, "comment-unavailable");
if (action === "delete") {
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.authorId !== viewer.id && !viewer.admin) throw new HttpError(403, "not-comment-author");
const descendants = sql`with recursive descendants(id) as (
select ${comments.id} from ${comments} where ${comments.id} = ${id} and ${comments.threadId} = ${c.threadId}
union
@@ -238,6 +253,11 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: "
// Delete the entire subtree in one statement so self-referencing foreign keys remain valid.
// Revisions, attachment metadata, revision links, and reactions cascade automatically.
await tx.delete(comments).where(sql`${comments.id} in (${descendants})`);
if (viewer.admin) {
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
await writeAuditLog(tx, auditActor(actor), { action: "comment.deleted", targetType: "comment", targetId: id,
metadata: { discussionTarget: context.destination.target } });
}
} else if (action === "heart") {
requireCommentAdmin(viewer);
await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id));
+2
View File
@@ -8,6 +8,8 @@ export interface CommentItem {
authorName: string;
authorImage: string | null;
authorAdmin: boolean;
authorBanned?: boolean;
canBanAuthor?: boolean;
text: string;
images: CommentImage[];
version: number;
+1
View File
@@ -9,6 +9,7 @@ export const uuidSchema = z.uuid();
export const reactionSchema = z.strictObject({ value: z.union([z.literal(-1), z.literal(0), z.literal(1)]) });
export const moderationSchema = z.strictObject({ hidden: z.boolean() });
export const heartSchema = z.strictObject({ hearted: z.boolean() });
export const commentBanSchema = z.strictObject({ banned: z.boolean() });
export const targetSchema = z.union([
z.string().regex(/^guide:[0-9a-fA-F-]{36}$/).refine((value) => uuidSchema.safeParse(value.slice(6)).success),
z.string().regex(/^stygian:[1-9]\d{0,8}$/),