Spaces:
Configuration error
Configuration error
| /** | |
| * 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) | |
| } | |