← 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/')
}
/**
* 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
}