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