Skip to content

Commit 3730bd2

Browse files
CopilotAshrafAbir
andcommitted
Address code review feedback: fix variant updates, security validations
Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com>
1 parent 74ca066 commit 3730bd2

File tree

3 files changed

+134
-43
lines changed

3 files changed

+134
-43
lines changed

src/app/api/products/import/route.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ import { NextRequest, NextResponse } from 'next/server';
55
import { getServerSession } from 'next-auth/next';
66
import { authOptions } from '@/lib/auth';
77
import { ProductService } from '@/lib/services/product.service';
8+
import { z } from 'zod';
9+
10+
// Zod schema for CSV record validation
11+
const csvRecordSchema = z.object({
12+
name: z.string().min(1, 'Name is required'),
13+
sku: z.string().min(1, 'SKU is required'),
14+
price: z.union([z.string(), z.number()]).transform((val) => {
15+
const num = typeof val === 'string' ? parseFloat(val) : val;
16+
if (isNaN(num) || num < 0) throw new Error('Invalid price');
17+
return num;
18+
}),
19+
description: z.string().optional(),
20+
categoryId: z.string().optional(),
21+
brandId: z.string().optional(),
22+
inventoryQty: z.union([z.string(), z.number()]).optional().transform((val) => {
23+
if (val === undefined || val === '') return 0;
24+
const num = typeof val === 'string' ? parseInt(val) : val;
25+
return isNaN(num) ? 0 : Math.max(0, num);
26+
}),
27+
status: z.string().optional(),
28+
images: z.string().optional(),
29+
}).passthrough(); // Allow additional columns
830

931
// Simple CSV parser (handles quoted fields and commas within quotes)
1032
function parseCSV(text: string): Array<Record<string, string>> {
@@ -127,10 +149,8 @@ export async function POST(request: NextRequest) {
127149
);
128150
}
129151

130-
// Import products
131-
const productService = ProductService.getInstance();
132-
// Cast records to the expected type - validation happens in bulkImport
133-
const result = await productService.bulkImport(storeId, records as Array<{
152+
// Validate and transform records using Zod schema
153+
const validatedRecords: Array<{
134154
name: string;
135155
sku: string;
136156
price: number | string;
@@ -140,13 +160,47 @@ export async function POST(request: NextRequest) {
140160
inventoryQty?: number | string;
141161
status?: string;
142162
images?: string;
143-
}>);
163+
}> = [];
164+
const validationErrors: Array<{ row: number; error: string }> = [];
165+
166+
for (let i = 0; i < records.length; i++) {
167+
const result = csvRecordSchema.safeParse(records[i]);
168+
if (result.success) {
169+
validatedRecords.push(result.data);
170+
} else {
171+
// Zod 4 uses issues instead of errors
172+
const errorMessages = result.error.issues
173+
? result.error.issues.map((issue: { message: string }) => issue.message).join(', ')
174+
: 'Validation failed';
175+
validationErrors.push({
176+
row: i + 1,
177+
error: errorMessages,
178+
});
179+
}
180+
}
181+
182+
// If all records failed validation, return early
183+
if (validatedRecords.length === 0) {
184+
return NextResponse.json({
185+
success: false,
186+
imported: 0,
187+
total: records.length,
188+
errors: validationErrors,
189+
});
190+
}
191+
192+
// Import validated products
193+
const productService = ProductService.getInstance();
194+
const result = await productService.bulkImport(storeId, validatedRecords);
195+
196+
// Combine validation errors with import errors
197+
const allErrors = [...validationErrors, ...result.errors];
144198

145199
return NextResponse.json({
146-
success: true,
200+
success: result.imported > 0,
147201
imported: result.imported,
148202
total: records.length,
149-
errors: result.errors,
203+
errors: allErrors,
150204
});
151205
} catch (error) {
152206
console.error('POST /api/products/import error:', error);

src/app/api/products/upload/route.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,20 @@ const ALLOWED_TYPES = [
2222
'image/svg+xml',
2323
];
2424

25+
// Validate storeId to prevent directory traversal attacks
26+
function isValidStoreId(storeId: string): boolean {
27+
// storeId should be alphanumeric with optional hyphens (cuid format)
28+
return /^[a-zA-Z0-9-_]+$/.test(storeId) && storeId.length <= 100;
29+
}
30+
2531
// Generate a unique filename
2632
function generateUniqueFileName(originalName: string): string {
2733
const timestamp = Date.now();
2834
const random = Math.random().toString(36).substring(2, 8);
2935
const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg';
30-
return `${timestamp}-${random}.${extension}`;
36+
// Sanitize extension to prevent path traversal
37+
const safeExtension = extension.replace(/[^a-z0-9]/g, '');
38+
return `${timestamp}-${random}.${safeExtension || 'jpg'}`;
3139
}
3240

3341
// POST /api/products/upload - Upload product image
@@ -61,6 +69,14 @@ export async function POST(request: NextRequest) {
6169
);
6270
}
6371

72+
// Validate storeId to prevent directory traversal attacks
73+
if (!isValidStoreId(storeId)) {
74+
return NextResponse.json(
75+
{ error: 'Invalid storeId format' },
76+
{ status: 400 }
77+
);
78+
}
79+
6480
// Validate file size
6581
if (file.size > MAX_FILE_SIZE) {
6682
return NextResponse.json(
@@ -145,6 +161,14 @@ export async function PUT(request: NextRequest) {
145161
);
146162
}
147163

164+
// Validate storeId to prevent directory traversal attacks
165+
if (!isValidStoreId(storeId)) {
166+
return NextResponse.json(
167+
{ error: 'Invalid storeId format' },
168+
{ status: 400 }
169+
);
170+
}
171+
148172
// Limit number of files per upload
149173
if (files.length > 10) {
150174
return NextResponse.json(

src/lib/services/product.service.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export const createProductSchema = z.object({
107107
metaKeywords: z.string().optional().nullable(),
108108
status: z.nativeEnum(ProductStatus).default(ProductStatus.DRAFT),
109109
isFeatured: z.boolean().default(false),
110-
// Variants support (min 1, max 100)
110+
// Variants support (min 0, max 100)
111111
variants: z.array(variantSchema).min(0).max(100).optional(),
112112
});
113113

@@ -582,42 +582,55 @@ export class ProductService {
582582
.filter(v => !variantIdsToKeep.has(v.id))
583583
.map(v => v.id);
584584

585-
// Build the variant update operations
586-
updateData.variants = {
585+
// Use a transaction to handle variant updates individually
586+
await prisma.$transaction(async (tx) => {
587587
// Delete variants that are no longer in the list
588-
deleteMany: variantIdsToDelete.length > 0 ? { id: { in: variantIdsToDelete } } : undefined,
589-
// Update existing variants
590-
updateMany: variantsToUpdate.map(variant => ({
591-
where: { id: variant.id! },
592-
data: {
593-
name: variant.name,
594-
sku: variant.sku,
595-
barcode: variant.barcode,
596-
price: variant.price,
597-
compareAtPrice: variant.compareAtPrice,
598-
inventoryQty: variant.inventoryQty ?? 0,
599-
lowStockThreshold: variant.lowStockThreshold ?? 5,
600-
weight: variant.weight,
601-
image: variant.image,
602-
options: typeof variant.options === 'string' ? variant.options : JSON.stringify(variant.options),
603-
isDefault: variant.isDefault ?? false,
604-
},
605-
})),
588+
if (variantIdsToDelete.length > 0) {
589+
await tx.productVariant.deleteMany({
590+
where: { id: { in: variantIdsToDelete } },
591+
});
592+
}
593+
594+
// Update existing variants individually (updateMany can't handle different data per record)
595+
for (const variant of variantsToUpdate) {
596+
await tx.productVariant.update({
597+
where: { id: variant.id! },
598+
data: {
599+
name: variant.name,
600+
sku: variant.sku,
601+
barcode: variant.barcode,
602+
price: variant.price,
603+
compareAtPrice: variant.compareAtPrice,
604+
inventoryQty: variant.inventoryQty ?? 0,
605+
lowStockThreshold: variant.lowStockThreshold ?? 5,
606+
weight: variant.weight,
607+
image: variant.image,
608+
options: typeof variant.options === 'string' ? variant.options : JSON.stringify(variant.options),
609+
isDefault: variant.isDefault ?? false,
610+
},
611+
});
612+
}
613+
606614
// Create new variants
607-
create: variantsToCreate.map((variant, index) => ({
608-
name: variant.name,
609-
sku: variant.sku,
610-
barcode: variant.barcode,
611-
price: variant.price,
612-
compareAtPrice: variant.compareAtPrice,
613-
inventoryQty: variant.inventoryQty ?? 0,
614-
lowStockThreshold: variant.lowStockThreshold ?? 5,
615-
weight: variant.weight,
616-
image: variant.image,
617-
options: typeof variant.options === 'string' ? variant.options : JSON.stringify(variant.options),
618-
isDefault: variant.isDefault ?? (variantsToUpdate.length === 0 && index === 0),
619-
})),
620-
};
615+
if (variantsToCreate.length > 0) {
616+
await tx.productVariant.createMany({
617+
data: variantsToCreate.map((variant, index) => ({
618+
productId,
619+
name: variant.name,
620+
sku: variant.sku,
621+
barcode: variant.barcode,
622+
price: variant.price,
623+
compareAtPrice: variant.compareAtPrice,
624+
inventoryQty: variant.inventoryQty ?? 0,
625+
lowStockThreshold: variant.lowStockThreshold ?? 5,
626+
weight: variant.weight,
627+
image: variant.image,
628+
options: typeof variant.options === 'string' ? variant.options : JSON.stringify(variant.options),
629+
isDefault: variant.isDefault ?? (variantsToUpdate.length === 0 && index === 0),
630+
})),
631+
});
632+
}
633+
});
621634
}
622635

623636
// Remove id from update data

0 commit comments

Comments
 (0)