Overview
The E-commerce API uses Cloudinary for image storage and management. This guide covers the complete implementation of image uploads for product images.
Architecture
Image upload flow:
Client sends multipart/form-data request with image file
Express file upload middleware validates and parses the file
Cloudinary utility validates file type and size
Image is uploaded to Cloudinary via streaming API
Secure URL is returned and stored in database
Configuration
Environment Variables
Configure Cloudinary credentials in backend/.env:
CLOUDINARY_CLOUD_NAME = "your-cloud-name"
CLOUDINARY_API_KEY = "your-api-key"
CLOUDINARY_API_SECRET = "your-api-secret"
See Environment Setup for details on obtaining these credentials.
Cloudinary Client
The Cloudinary client is configured in backend/src/config/cloudinary.ts:1-18:
import { v2 as cloudinary } from "cloudinary" ;
const requiredVars = [ "CLOUDINARY_CLOUD_NAME" , "CLOUDINARY_API_KEY" , "CLOUDINARY_API_SECRET" ] as const ;
for ( const varName of requiredVars ) {
if ( ! process . env [ varName ]) {
throw new Error ( `Variable de entorno ${ varName } no definida` );
}
}
cloudinary . config ({
cloud_name: process . env . CLOUDINARY_CLOUD_NAME ! ,
api_key: process . env . CLOUDINARY_API_KEY ! ,
api_secret: process . env . CLOUDINARY_API_SECRET ! ,
});
export default cloudinary ;
The configuration validates all required variables at startup and throws an error if any are missing.
Express File Upload Middleware
Configured in backend/src/app.ts:31-39:
import fileUpload from 'express-fileupload' ;
app . use ( fileUpload ({
limits: {
fileSize: 5 * 1024 * 1024 // 5MB maximum
},
abortOnLimit: true ,
safeFileNames: true ,
preserveExtension: true ,
createParentPath: true
}));
Upload Utility
The core upload logic is in backend/src/utils/cloudinary.ts:49-73:
import cloudinary from "../config/cloudinary.js" ;
import { UploadedFile } from "express-fileupload" ;
const ALLOWED_MIME_TYPES = [
"image/jpeg" ,
"image/jpg" ,
"image/png" ,
"image/webp" ,
"image/gif" ,
];
const ALLOWED_EXTENSIONS = [ ".jpg" , ".jpeg" , ".png" , ".webp" , ".gif" ];
export const uploadImage = async ( image : UploadedFile ) : Promise < string > => {
validateImageFile ( image );
const safeName = sanitizeFileName ( image . name );
const publicId = ` ${ Date . now () } _ ${ safeName . replace ( / \. [ ^ . ] + $ / , "" ) } ` ;
return new Promise < string >(( resolve , reject ) => {
const stream = cloudinary . uploader . upload_stream (
{
folder: "/ECOMMERCE/PRODUCTS" ,
public_id: publicId ,
resource_type: "image" ,
},
( error , result ) => {
if ( error ) return reject ( error );
if ( ! result ) return reject ( new Error ( "Cloudinary no devolvió resultado" ));
resolve ( result . secure_url );
},
);
stream . end ( image . data );
});
};
Validation
File Type Validation
From backend/src/utils/cloudinary.ts:17-23:
function validateImageFile ( file : UploadedFile ) : void {
// Validate MIME type
if ( ! ALLOWED_MIME_TYPES . includes ( file . mimetype )) {
throw new Error (
`Invalid file type: ${ file . mimetype } . Allowed types: ${ ALLOWED_MIME_TYPES . join ( ", " ) } ` ,
);
}
// Validate file extension
const fileExtension = file . name
. substring ( file . name . lastIndexOf ( "." ))
. toLowerCase ();
if ( ! ALLOWED_EXTENSIONS . includes ( fileExtension )) {
throw new Error (
`Invalid file extension: ${ fileExtension } . Allowed extensions: ${ ALLOWED_EXTENSIONS . join ( ", " ) } ` ,
);
}
// File size limit (5MB)
const maxSize = 5 * 1024 * 1024 ;
if ( file . size > maxSize ) {
throw new Error ( "El archivo es demasiado grande. Tamaño máximo: 5MB" );
}
}
Allowed File Types
MIME types : image/jpeg, image/jpg, image/png, image/webp, image/gif
Extensions : .jpg, .jpeg, .png, .webp, .gif
Maximum size : 5MB
File Name Sanitization
From backend/src/utils/cloudinary.ts:42-47:
function sanitizeFileName ( name : string ) : string {
// Extract only the file name (prevent path traversal)
const baseName = name . replace ( / ^ . * [ \\\/ ] / , "" );
// Remove special characters, keep only alphanumerics, dashes, dots, underscores
return baseName . replace ( / [ ^ a-zA-Z0-9._- ] / g , "_" );
}
This prevents:
Path traversal attacks
Special characters in file names
Security vulnerabilities
Controller Implementation
Creating Products with Images
From backend/src/controllers/product.controller.ts:11-23:
import { UploadedFile } from "express-fileupload" ;
import { ProductService } from "../services/product.services.js" ;
export class ProductController {
constructor ( private productService = new ProductService ()) {}
create = async ( req : Request , res : Response ) => {
const file = req . files ?. image as UploadedFile | undefined ;
if ( ! file ) {
return res . status ( 400 ). json ({
error: "La imagen del producto es obligatoria (campo 'image')"
});
}
const dto = plainToInstance ( CreateProductDto , req . body , {
enableImplicitConversion: true
});
await validateOrReject ( dto );
const product = await this . productService . createProduct ( dto , file );
res . status ( 201 ). json ( product );
};
}
Updating Products with Images
From backend/src/controllers/product.controller.ts:25-36:
update = async ( req : Request , res : Response ) => {
const { id } = req . params ;
const data = { ... req . body };
const file = req . files ?. image ;
if ( file ) {
data . imageUrl = file ;
}
const product = await this . productService . updateProduct ( Number ( id ), data );
res . json ( product );
};
Service Layer
Create Product
From backend/src/services/product.services.ts:20-28:
import { uploadImage , deleteImage , extractPublicId } from "../utils/cloudinary.js" ;
import type { UploadedFile } from "express-fileupload" ;
export class ProductService {
constructor ( private productRepository = new ProductRepository ()) {}
async createProduct ( dto : CreateProductDto , image : UploadedFile ) {
const imageUrl = await uploadImage ( image );
return this . productRepository . create ({
... dto ,
imageUrl ,
});
}
}
Update Product
From backend/src/services/product.services.ts:31-69:
async updateProduct ( id : number , data : UpdateProductData ) {
const existing = await this . productRepository . findById ( id );
if ( ! existing ) throw new NotFoundError ( "Producto no encontrado" );
// If there's a new image as UploadedFile, process it
if ( data . imageUrl && typeof data . imageUrl !== "string" ) {
const file = data . imageUrl as UploadedFile ;
// Delete old image from Cloudinary
if ( existing . imageUrl ) {
try {
const oldPublicId = extractPublicId ( existing . imageUrl );
if ( oldPublicId ) {
await deleteImage ( oldPublicId );
}
} catch ( err ) {
console . warn ( "Error eliminando imagen anterior de Cloudinary:" , err );
}
}
data . imageUrl = await uploadImage ( file );
}
// Build clean object for repository
const { imageUrl , stock , categoryId , ... rest } = data ;
const updateData : Record < string , unknown > = { ... rest };
if ( typeof imageUrl === "string" ) {
updateData . imageUrl = imageUrl ;
}
if ( stock !== undefined ) {
updateData . stock = Number ( stock );
}
if ( categoryId !== undefined ) {
updateData . categoryId = Number ( categoryId );
}
return this . productRepository . update ( id , updateData );
}
Delete Product
From backend/src/services/product.services.ts:71-88:
async deleteProduct ( id : number ) {
const product = await this . productRepository . findById ( id );
if ( ! product ) throw new NotFoundError ( "Producto no encontrado" );
// Delete image from Cloudinary
if ( product . imageUrl ) {
try {
const publicId = extractPublicId ( product . imageUrl );
if ( publicId ) {
await deleteImage ( publicId );
}
} catch ( err ) {
console . warn ( "Error eliminando imagen de Cloudinary:" , err );
}
}
return this . productRepository . delete ( id );
}
Cloudinary Utilities
Delete Image
From backend/src/utils/cloudinary.ts:75-77:
export const deleteImage = async ( publicId : string ) : Promise < void > => {
await cloudinary . uploader . destroy ( publicId );
};
From backend/src/utils/cloudinary.ts:79-83:
export const extractPublicId = ( cloudinaryUrl : string ) : string | null => {
// URL format: https://res.cloudinary.com/<cloud>/image/upload/v<version>/<public_id>.<ext>
const match = cloudinaryUrl . match ( / \/ upload \/ v \d + \/ ( . + ) \. [ ^ . ] + $ / );
return match ?.[ 1 ] ?? null ;
};
This extracts the public ID from a Cloudinary URL for deletion:
Input: https://res.cloudinary.com/demo/image/upload/v1234567890/ECOMMERCE/PRODUCTS/12345_product.jpg
Output: ECOMMERCE/PRODUCTS/12345_product
API Usage
Create Product with Image
curl -X POST http://localhost:3000/products \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "name=Premium Headphones" \
-F "description=High-quality wireless headphones" \
-F "price=199.99" \
-F "stock=50" \
-F "categoryId=1" \
-F "image=@/path/to/image.jpg"
Update Product Image
curl -X PUT http://localhost:3000/products/1 \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "name=Updated Product Name" \
-F "image=@/path/to/new-image.jpg"
When updating a product with a new image, the old image is automatically deleted from Cloudinary.
Successful upload returns product with Cloudinary URL:
{
"id" : 1 ,
"name" : "Premium Headphones" ,
"description" : "High-quality wireless headphones" ,
"price" : "199.99" ,
"stock" : 50 ,
"imageUrl" : "https://res.cloudinary.com/demo/image/upload/v1234567890/ECOMMERCE/PRODUCTS/1234567890_product.jpg" ,
"categoryId" : 1 ,
"createdAt" : "2024-01-15T10:30:00.000Z" ,
"updatedAt" : "2024-01-15T10:30:00.000Z"
}
Storage Location
Images are uploaded to:
Cloudinary folder: /ECOMMERCE/PRODUCTS
Public ID format: {timestamp}_{sanitized_filename}
Example:
/ECOMMERCE/PRODUCTS/1705318200000_premium_headphones
Error Handling
Missing Image
Request : Create product without image
Response : 400 Bad Request
{
"error" : "La imagen del producto es obligatoria (campo 'image')"
}
Invalid File Type
Request : Upload .pdf file
Response : 500 Internal Server Error
{
"error" : "Invalid file type: application/pdf. Allowed types: image/jpeg, image/jpg, image/png, image/webp, image/gif"
}
File Too Large
Request : Upload 10MB image
Response : 500 Internal Server Error
{
"error" : "El archivo es demasiado grande. Tamaño máximo: 5MB"
}
Cloudinary Error
Cause : Invalid credentials or Cloudinary service down
Response : 500 Internal Server Error
{
"error" : "Upload to Cloudinary failed"
}
Best Practices
Client-Side Validation
Validate before upload to improve UX:
function validateImage ( file ) {
const allowedTypes = [ 'image/jpeg' , 'image/jpg' , 'image/png' , 'image/webp' , 'image/gif' ];
const maxSize = 5 * 1024 * 1024 ; // 5MB
if ( ! allowedTypes . includes ( file . type )) {
throw new Error ( 'Invalid file type. Please upload a JPEG, PNG, WebP, or GIF image.' );
}
if ( file . size > maxSize ) {
throw new Error ( 'File too large. Maximum size is 5MB.' );
}
return true ;
}
Image Optimization
Cloudinary provides automatic optimization. Use transformation URLs:
// Original URL
const originalUrl = product . imageUrl ;
// Optimized thumbnail (200x200)
const thumbnailUrl = originalUrl . replace (
'/upload/' ,
'/upload/w_200,h_200,c_fill/'
);
// Responsive with quality adjustment
const responsiveUrl = originalUrl . replace (
'/upload/' ,
'/upload/w_auto,q_auto,f_auto/'
);
Progress Tracking
For large files, show upload progress:
const xhr = new XMLHttpRequest ();
xhr . upload . addEventListener ( 'progress' , ( e ) => {
if ( e . lengthComputable ) {
const percentComplete = ( e . loaded / e . total ) * 100 ;
console . log ( `Upload: ${ percentComplete . toFixed ( 2 ) } %` );
}
});
xhr . addEventListener ( 'load' , () => {
const product = JSON . parse ( xhr . responseText );
console . log ( 'Upload complete:' , product );
});
xhr . open ( 'POST' , 'http://localhost:3000/products' );
xhr . setRequestHeader ( 'Authorization' , `Bearer ${ token } ` );
xhr . send ( formData );
Security Considerations
Always validate files on the server side. Client-side validation can be bypassed.
Implemented Security Measures
File type validation : MIME type and extension checking
File size limits : 5MB maximum enforced by Express and validation function
Filename sanitization : Prevents path traversal attacks
Safe file names : express-fileupload option enabled
Abort on limit : Request terminated if file exceeds size limit
Additional Recommendations
Use HTTPS in production to encrypt file transfers
Implement rate limiting on upload endpoints
Scan uploaded files for malware (if dealing with user-generated content)
Use signed upload URLs for direct client-to-Cloudinary uploads
Set up Cloudinary upload presets with restrictions
Troubleshooting
Images not uploading
Check :
Cloudinary credentials in .env
File size < 5MB
File type is allowed
Request includes Content-Type: multipart/form-data
Old images not deleted
Symptom : Cloudinary storage keeps growing
Solution : The extractPublicId function may fail on malformed URLs. Check the URL format:
// Expected format:
// https://res.cloudinary.com/cloud/image/upload/v1234/ECOMMERCE/PRODUCTS/123_name.jpg
const publicId = extractPublicId ( imageUrl );
console . log ( 'Extracted:' , publicId );
// Should output: ECOMMERCE/PRODUCTS/123_name
“Cannot read property ‘image’ of undefined”
Symptom : req.files is undefined
Solution : Ensure express-fileupload middleware is registered in app.ts before routes.
Next Steps
Environment Setup Configure Cloudinary credentials
Products API Complete product creation API reference