open-prompt / src /lib /validations.ts
anky2002's picture
fix: add HTML sanitization to prevent XSS in user-generated content
23949b2 verified
/**
* 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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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<T extends z.ZodType>(
schema: T,
data: unknown
): { success: true; data: z.infer<T> } | { 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<T extends z.ZodType>(
schema: T,
searchParams: URLSearchParams
): { success: true; data: z.infer<T> } | { success: false; response: NextResponse } {
const obj: Record<string, string> = {}
searchParams.forEach((value, key) => {
obj[key] = value
})
return parseBody(schema, obj)
}