/** * Zod validation schemas for all API routes. * Centralizes input validation so every route has consistent, * type-safe request body parsing with XSS protection. */ import { z } from "zod/v4" // ── XSS Sanitization ───────────────────────────────────────────────────── /** * Strip dangerous HTML/script patterns from user input. * Allows basic text but removes script tags, event handlers, and data URIs. */ function sanitizeHtml(input: string): string { return input // Remove script tags and their content .replace(/)<[^<]*)*<\/script>/gi, '') // Remove event handlers (onclick, onerror, etc.) .replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '') .replace(/\s*on\w+\s*=\s*[^\s>]*/gi, '') // Remove javascript: and data: URIs .replace(/javascript\s*:/gi, '') .replace(/data\s*:\s*text\/html/gi, '') // Remove iframe, object, embed tags .replace(/<(iframe|object|embed|applet|form)\b[^>]*>/gi, '') .replace(/<\/(iframe|object|embed|applet|form)>/gi, '') } /** Zod transform that sanitizes string input */ const sanitizedString = (maxLen: number) => z.string().max(maxLen).transform(sanitizeHtml) const sanitizedText = (maxLen: number) => z.string().max(maxLen).transform(sanitizeHtml) // ── Prompts ────────────────────────────────────────────────────────────── const validCategories = ["Content", "Development", "Marketing", "Business", "Education", "Creative", "Research"] as const export const createPromptSchema = z.object({ title: z.string().min(1).max(200).transform(sanitizeHtml), description: sanitizedText(2000).optional(), template: z.string().min(1).max(50000), // Template is code — don't sanitize (rendered in code blocks) schema: z.record(z.string(), z.unknown()).optional(), category: z.enum(validCategories).optional(), tags: z.array(z.string().max(50).transform(sanitizeHtml)).max(20).optional(), modelDefault: z.string().max(100).optional(), visibility: z.enum(["public", "unlisted", "private"]).optional(), }) // ── Collections ────────────────────────────────────────────────────────── export const createCollectionSchema = z.object({ name: z.string().min(1).max(200).transform(sanitizeHtml), description: sanitizedText(2000).optional(), isPublic: z.boolean().optional(), }) export const updateCollectionSchema = z.object({ id: z.string().min(1), name: z.string().min(1).max(200).transform(sanitizeHtml).optional(), description: sanitizedText(2000).optional(), isPublic: z.boolean().optional(), }) // ── Profile ────────────────────────────────────────────────────────────── export const updateProfileSchema = z.object({ name: sanitizedString(100).optional(), username: z.string().min(3).max(40).regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, hyphens, underscores").optional(), bio: sanitizedText(500).optional(), socialLinks: z.record(z.string(), z.string().url()).optional(), image: z.string().url().max(2000).optional(), notifyEmail: z.boolean().optional(), notifyStars: z.boolean().optional(), notifyRemixes: z.boolean().optional(), }) export const syncProfileSchema = z.object({ id: z.string().min(1), email: z.string().email(), name: sanitizedString(100).optional(), image: z.string().url().max(2000).optional(), }) // ── Votes ──────────────────────────────────────────────────────────────── export const voteSchema = z.object({ targetType: z.enum(["imagePrompt", "character"]), targetId: z.string().min(1), value: z.number().int().min(-1).max(1).optional().default(1), }) // ── Comments ───────────────────────────────────────────────────────────── export const createCommentSchema = z.object({ promptId: z.string().min(1), content: z.string().min(1).max(5000).transform(sanitizeHtml), parentId: z.string().optional(), }) export const updateCommentSchema = z.object({ commentId: z.string().min(1), action: z.enum(["like", "unlike", "edit"]), content: z.string().min(1).max(5000).transform(sanitizeHtml).optional(), }) // ── Engagement ─────────────────────────────────────────────────────────── export const engagementSchema = z.object({ targetType: z.enum(["prompt", "imagePrompt", "character", "workflow"]), targetId: z.string().min(1), action: z.enum(["view", "use", "save", "share", "copy"]), }) // ── Forum ──────────────────────────────────────────────────────────────── const forumCategories = ["general", "help", "showcase", "feature-request", "tips"] as const export const createForumPostSchema = z.object({ title: z.string().min(1).max(300).transform(sanitizeHtml), content: z.string().min(1).max(20000).transform(sanitizeHtml), category: z.enum(forumCategories), }) // ── Messages ───────────────────────────────────────────────────────────── export const sendMessageSchema = z.object({ recipientId: z.string().min(1), content: z.string().min(1).max(5000).transform(sanitizeHtml), }) // ── Search ─────────────────────────────────────────────────────────────── export const searchSchema = z.object({ q: z.string().min(2).max(500), category: z.string().optional(), model: z.string().optional(), semantic: z.coerce.boolean().optional(), limit: z.coerce.number().int().min(1).max(100).optional().default(20), offset: z.coerce.number().int().min(0).optional().default(0), }) // ── Run ────────────────────────────────────────────────────────────────── export const runSchema = z.object({ prompt: z.string().min(1).max(100000), promptId: z.string().optional(), model: z.string().max(100).optional(), variables: z.record(z.string(), z.unknown()).optional(), turnstileToken: z.string().optional(), ollamaUrl: z.string().url().optional(), }) // ── Workflows ──────────────────────────────────────────────────────────── export const createWorkflowSchema = z.object({ name: z.string().min(1).max(200).transform(sanitizeHtml), description: sanitizedText(2000).optional(), steps: z.array(z.record(z.string(), z.unknown())).min(1), variables: z.record(z.string(), z.unknown()).optional(), visibility: z.enum(["public", "unlisted", "private"]).optional(), }) // ── Utility: safe parse helper ─────────────────────────────────────────── import { NextResponse } from "next/server" /** * Parse request body with a Zod schema, returning a typed NextResponse error on failure. */ export function parseBody( schema: T, data: unknown ): { success: true; data: z.infer } | { success: false; response: NextResponse } { const result = schema.safeParse(data) if (!result.success) { return { success: false, response: NextResponse.json( { error: "Validation failed", details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message, })), }, { status: 400 } ), } } return { success: true, data: result.data } } /** * Parse URL search params with a Zod schema. */ export function parseSearchParams( schema: T, searchParams: URLSearchParams ): { success: true; data: z.infer } | { success: false; response: NextResponse } { const obj: Record = {} searchParams.forEach((value, key) => { obj[key] = value }) return parseBody(schema, obj) }