Files
gunshiz b9601b739d
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 3m47s
feat : imrpove admin comment
2026-10-08 15:06:19 +07:00

60 lines
3.1 KiB
TypeScript

import * as z from "zod";
import { HttpError } from "@/lib/security/http";
export const MAX_COMMENT_IMAGES = 5;
export const MAX_COMMENT_IMAGE_BYTES = 10 * 1024 * 1024;
export const MAX_COMMENT_BODY_BYTES = MAX_COMMENT_IMAGES * MAX_COMMENT_IMAGE_BYTES + 64 * 1024;
export const COMMENT_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
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}$/),
]);
export function parseTarget(value: unknown) {
const result = targetSchema.safeParse(value);
if (!result.success) throw new HttpError(400, "invalid-target");
return result.data.startsWith("guide:")
? { kind: "guide" as const, id: result.data.slice(6) }
: { kind: "stygian" as const, id: Number(result.data.slice(8)) };
}
export function parseCommentForm(form: FormData, editing = false) {
const rawText = form.get("text");
const text = typeof rawText === "string" ? rawText.trim() : "";
const files = form.getAll("image");
const keep = form.getAll("keepImageId");
const rawReply = form.get("replyToId");
const replyToId = rawReply === null || rawReply === "" ? null : rawReply;
const version = Number(form.get("version"));
if (text.length > 4000 || files.length + keep.length > MAX_COMMENT_IMAGES ||
keep.some((id) => !uuidSchema.safeParse(id).success) || new Set(keep).size !== keep.length ||
(!editing && keep.length) || (editing && (!Number.isSafeInteger(version) || version < 1)) ||
(replyToId !== null && !uuidSchema.safeParse(replyToId).success))
throw new HttpError(400, "invalid-comment");
if (!text && !files.length && !keep.length) throw new HttpError(400, "empty-comment");
for (const file of files) {
if (!(file instanceof File) || !COMMENT_IMAGE_TYPES.includes(file.type as typeof COMMENT_IMAGE_TYPES[number]))
throw new HttpError(415, "unsupported-image-type");
if (!file.size || file.size > MAX_COMMENT_IMAGE_BYTES) throw new HttpError(413, "image-too-large");
}
return { text, files: files as File[], keep: keep as string[], replyToId: replyToId as string | null, version };
}
const cursorSchema = z.strictObject({ time: z.iso.datetime(), id: z.uuid(), likes: z.number().int().nonnegative().max(2147483647) });
export type CommentCursor = z.infer<typeof cursorSchema>;
export function decodeCursor(value: string | null): CommentCursor | null {
if (!value) return null;
try {
if (value.length > 512) throw new Error();
return cursorSchema.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
} catch { throw new HttpError(400, "invalid-cursor"); }
}
export function encodeCursor(value: CommentCursor) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}