← back to Handbag Auth Nextjs

src/lib/validation.ts

170 lines

/**
 * Input Validation Schemas
 * Uses Zod for runtime validation to prevent injection attacks
 * OWASP: Input Validation (https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/07-Input_Validation_Testing/README)
 */

import { z } from 'zod'

// Common validation patterns
const patterns = {
  // Alphanumeric with spaces, hyphens, and apostrophes (for names/brands)
  safeText: /^[a-zA-Z0-9\s\-'&]+$/,
  // Email pattern
  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  // External ID pattern (alphanumeric with hyphens/underscores)
  externalId: /^[a-zA-Z0-9\-_]+$/,
  // URL pattern
  url: /^https?:\/\/.+$/,
}

// Pagination schema
export const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).max(1000).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

// Search and filter schema for listings
export const listingSearchSchema = z.object({
  // Global search - sanitized for SQL LIKE queries
  search: z.string().max(100).optional().transform(val =>
    val ? val.replace(/[%_\\]/g, '\\$&') : undefined
  ),
  // Brand filter - whitelist approach
  brand: z.enum(['all', 'hermes', 'chanel', 'lv', 'louis vuitton', 'dior', 'prada', 'fendi', 'gucci', 'celine', 'balenciaga']).optional(),
  // Model search - sanitized
  model: z.string().max(50).regex(patterns.safeText).optional(),
  // Price filters
  minPrice: z.coerce.number().min(0).max(1000000).optional(),
  maxPrice: z.coerce.number().min(0).max(1000000).optional(),
  // Type filter
  type: z.enum(['auction', 'buyItNow', 'all']).optional(),
  // Boolean flags
  dealOnly: z.coerce.boolean().optional(),
  showNew: z.coerce.boolean().optional(),
  // Deal percentage filter
  minDeal: z.coerce.number().min(0).max(100).optional(),
  // Sort options - whitelist approach
  sort: z.enum(['deal', 'price-high', 'price-low', 'date']).optional(),
}).merge(paginationSchema)

// Deal API schema
export const dealSearchSchema = z.object({
  minDeal: z.coerce.number().min(0).max(100).default(20),
  limit: z.coerce.number().int().min(1).max(100).default(50),
})

// Price alert schemas
export const priceAlertGetSchema = z.object({
  listingId: z.coerce.number().int().positive().optional(),
  externalId: z.string().regex(patterns.externalId).max(100).optional(),
  email: z.string().email().optional(),
})

export const priceAlertCreateSchema = z.object({
  listingId: z.number().int().positive(),
  externalId: z.string().regex(patterns.externalId).max(100),
  userEmail: z.string().email().optional(),
  targetPrice: z.number().min(0).max(1000000),
  alertType: z.enum(['below', 'above', 'change']).default('below'),
})

export const priceAlertDeleteSchema = z.object({
  id: z.coerce.number().int().positive(),
})

// Price history schema
export const priceHistoryGetSchema = z.object({
  externalId: z.string().regex(patterns.externalId).max(100).optional(),
  listingId: z.coerce.number().int().positive().optional(),
  limit: z.coerce.number().int().min(1).max(365).default(30),
})

export const priceHistoryCreateSchema = z.object({
  listingId: z.number().int().positive(),
  externalId: z.string().regex(patterns.externalId).max(100),
  source: z.string().max(50),
  priceJpy: z.number().min(0).max(100000000),
  priceUsd: z.number().min(0).max(1000000).optional(),
  condition: z.string().max(100).optional(),
  listingType: z.string().max(50).optional(),
  isAvailable: z.number().int().min(0).max(1).default(1),
})

// eBay sold listings schema
export const ebaySoldSchema = z.object({
  query: z.string().max(200).optional(),
  brand: z.string().regex(patterns.safeText).max(50).optional(),
  model: z.string().regex(patterns.safeText).max(50).optional(),
  daysBack: z.coerce.number().int().min(1).max(365).default(90),
  maxResults: z.coerce.number().int().min(1).max(100).default(50),
})

// Image analysis schema
export const imageAnalysisSchema = z.object({
  image: z.string().optional(), // Base64 encoded image
  files: z.array(z.object({
    data: z.string(), // Base64 encoded
    type: z.string().regex(/^(image|video)\/(jpeg|png|gif|mp4|webm)$/),
  })).optional(),
}).refine(data => data.image || (data.files && data.files.length > 0), {
  message: "Either 'image' or 'files' must be provided",
})

// Crawler control schema
export const crawlerActionSchema = z.enum(['start', 'stop', 'status'])

// Marketplace API schema
export const marketplaceFilterSchema = z.object({
  brand: z.enum(['all', 'hermes', 'chanel', 'lv', 'dior', 'prada', 'fendi']).optional(),
})

// Stats API - no input params needed, but we validate anyway
export const statsSchema = z.object({})

// Admin dashboard - requires authentication (to be implemented)
export const adminDashboardSchema = z.object({
  // Will add auth token validation here
  token: z.string().optional(), // Placeholder for JWT token
})

/**
 * Validation helper function with error handling
 */
export function validateInput<T>(
  schema: z.ZodSchema<T>,
  data: unknown
): { success: true; data: T } | { success: false; errors: z.ZodError } {
  try {
    const validatedData = schema.parse(data)
    return { success: true, data: validatedData }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { success: false, errors: error }
    }
    throw error
  }
}

/**
 * Sanitize HTML to prevent XSS attacks
 */
export function sanitizeHtml(input: string): string {
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .replace(/\//g, '&#x2F;')
}

/**
 * Validate and sanitize SQL identifiers (table names, column names)
 */
export function validateSqlIdentifier(identifier: string): string {
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
    throw new Error('Invalid SQL identifier')
  }
  return identifier
}