Skip to content

Commit 45be7a1

Browse files
committed
feat(backend): add cloudinary service for uploading images
1 parent 82a76a6 commit 45be7a1

File tree

7 files changed

+488
-57
lines changed

7 files changed

+488
-57
lines changed

backend/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ BACKEND_URL=http://localhost:3000
9090
# -----------------------------------------------------------------------------
9191
UPLOAD_PATH=./uploads
9292

93+
# -----------------------------------------------------------------------------
94+
# Cloudinary Configuration (for image uploads in production/serverless)
95+
# Get from: https://console.cloudinary.com/settings/api-keys
96+
# Required for Vercel deployment (serverless has read-only filesystem)
97+
# -----------------------------------------------------------------------------
98+
CLOUDINARY_CLOUD_NAME=your-cloud-name
99+
CLOUDINARY_API_KEY=your-api-key
100+
CLOUDINARY_API_SECRET=your-api-secret
101+
93102
# -----------------------------------------------------------------------------
94103
# Google Gemini API Configuration (for AI Quiz Generator)
95104
# Get from: https://aistudio.google.com/apikey

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"bcrypt": "^6.0.0",
3232
"class-transformer": "^0.5.1",
3333
"class-validator": "^0.14.3",
34+
"cloudinary": "^2.8.0",
3435
"cookie-parser": "^1.4.7",
3536
"multer": "^2.0.2",
3637
"nodemailer": "^7.0.11",
@@ -84,7 +85,7 @@
8485
"tsconfig-paths": "^4.2.0",
8586
"tsx": "^4.21.0",
8687
"typescript": "^5.9.3",
87-
"typescript-eslint": "^8.48.0"
88+
"typescript-eslint": "^8.48.1"
8889
},
8990
"jest": {
9091
"moduleFileExtensions": [
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { Readable } from 'stream';
2+
3+
import { Injectable, BadRequestException } from '@nestjs/common';
4+
import { ConfigService } from '@nestjs/config';
5+
import {
6+
v2 as cloudinary,
7+
UploadApiResponse,
8+
UploadApiErrorResponse,
9+
} from 'cloudinary';
10+
11+
export interface CloudinaryUploadResult {
12+
publicId: string;
13+
url: string;
14+
secureUrl: string;
15+
format: string;
16+
width?: number;
17+
height?: number;
18+
bytes: number;
19+
resourceType: string;
20+
}
21+
22+
@Injectable()
23+
export class CloudinaryService {
24+
private readonly isConfigured: boolean;
25+
26+
constructor(private readonly configService: ConfigService) {
27+
const cloudName = this.configService.get<string>('CLOUDINARY_CLOUD_NAME');
28+
const apiKey = this.configService.get<string>('CLOUDINARY_API_KEY');
29+
const apiSecret = this.configService.get<string>('CLOUDINARY_API_SECRET');
30+
31+
this.isConfigured = !!(cloudName && apiKey && apiSecret);
32+
33+
if (this.isConfigured) {
34+
cloudinary.config({
35+
cloud_name: cloudName,
36+
api_key: apiKey,
37+
api_secret: apiSecret,
38+
secure: true,
39+
});
40+
}
41+
}
42+
43+
/**
44+
* Check if Cloudinary is configured
45+
*/
46+
isAvailable(): boolean {
47+
return this.isConfigured;
48+
}
49+
50+
/**
51+
* Upload a file buffer to Cloudinary
52+
*/
53+
async uploadBuffer(
54+
buffer: Buffer,
55+
options: {
56+
folder?: string;
57+
publicId?: string;
58+
resourceType?: 'image' | 'video' | 'raw' | 'auto';
59+
transformation?: Record<string, unknown>;
60+
} = {},
61+
): Promise<CloudinaryUploadResult> {
62+
if (!this.isConfigured) {
63+
throw new BadRequestException(
64+
'Cloudinary is not configured. Please set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET environment variables.',
65+
);
66+
}
67+
68+
return new Promise((resolve, reject) => {
69+
const uploadStream = cloudinary.uploader.upload_stream(
70+
{
71+
folder: options.folder ?? 'learnix',
72+
public_id: options.publicId,
73+
resource_type: options.resourceType ?? 'auto',
74+
transformation: options.transformation,
75+
},
76+
(
77+
error: UploadApiErrorResponse | undefined,
78+
result: UploadApiResponse | undefined,
79+
) => {
80+
if (error) {
81+
reject(
82+
new BadRequestException(
83+
`Cloudinary upload failed: ${error.message}`,
84+
),
85+
);
86+
} else if (result) {
87+
resolve({
88+
publicId: result.public_id,
89+
url: result.url,
90+
secureUrl: result.secure_url,
91+
format: result.format,
92+
width: result.width,
93+
height: result.height,
94+
bytes: result.bytes,
95+
resourceType: result.resource_type,
96+
});
97+
} else {
98+
reject(new BadRequestException('Cloudinary upload failed'));
99+
}
100+
},
101+
);
102+
103+
// Convert buffer to readable stream and pipe to upload
104+
const readableStream = new Readable();
105+
readableStream.push(buffer);
106+
readableStream.push(null);
107+
readableStream.pipe(uploadStream);
108+
});
109+
}
110+
111+
/**
112+
* Upload a Multer file to Cloudinary
113+
*/
114+
async uploadFile(
115+
file: Express.Multer.File,
116+
options: {
117+
folder?: string;
118+
transformation?: Record<string, unknown>;
119+
} = {},
120+
): Promise<CloudinaryUploadResult> {
121+
// For memory storage, file.buffer is available
122+
// For disk storage, we'd need to read the file
123+
const buffer = file.buffer;
124+
125+
if (!buffer) {
126+
throw new BadRequestException(
127+
'File buffer not available. Ensure memory storage is used.',
128+
);
129+
}
130+
131+
// Determine resource type from mimetype
132+
let resourceType: 'image' | 'video' | 'raw' | 'auto' = 'auto';
133+
if (file.mimetype.startsWith('image/')) {
134+
resourceType = 'image';
135+
} else if (file.mimetype.startsWith('video/')) {
136+
resourceType = 'video';
137+
}
138+
139+
return this.uploadBuffer(buffer, {
140+
...options,
141+
resourceType,
142+
});
143+
}
144+
145+
/**
146+
* Upload an avatar image with automatic optimization
147+
*/
148+
async uploadAvatar(file: Express.Multer.File): Promise<CloudinaryUploadResult> {
149+
return this.uploadFile(file, {
150+
folder: 'learnix/avatars',
151+
transformation: {
152+
width: 400,
153+
height: 400,
154+
crop: 'fill',
155+
gravity: 'face',
156+
quality: 'auto',
157+
fetch_format: 'auto',
158+
},
159+
});
160+
}
161+
162+
/**
163+
* Upload a course image with optimization
164+
*/
165+
async uploadCourseImage(
166+
file: Express.Multer.File,
167+
): Promise<CloudinaryUploadResult> {
168+
return this.uploadFile(file, {
169+
folder: 'learnix/courses',
170+
transformation: {
171+
width: 1200,
172+
height: 675,
173+
crop: 'fill',
174+
quality: 'auto',
175+
fetch_format: 'auto',
176+
},
177+
});
178+
}
179+
180+
/**
181+
* Delete a file from Cloudinary by public ID
182+
*/
183+
async deleteFile(
184+
publicId: string,
185+
resourceType: 'image' | 'video' | 'raw' = 'image',
186+
): Promise<boolean> {
187+
if (!this.isConfigured) {
188+
throw new BadRequestException('Cloudinary is not configured');
189+
}
190+
191+
try {
192+
const result = await cloudinary.uploader.destroy(publicId, {
193+
resource_type: resourceType,
194+
});
195+
return result.result === 'ok';
196+
} catch {
197+
return false;
198+
}
199+
}
200+
201+
/**
202+
* Extract public ID from Cloudinary URL
203+
*/
204+
extractPublicId(url: string): string | null {
205+
try {
206+
// Cloudinary URLs look like:
207+
// https://res.cloudinary.com/{cloud_name}/{resource_type}/upload/{version}/{public_id}.{format}
208+
const urlObj = new URL(url);
209+
const pathParts = urlObj.pathname.split('/');
210+
211+
// Find the index of 'upload' and get everything after version
212+
const uploadIndex = pathParts.indexOf('upload');
213+
if (uploadIndex === -1) return null;
214+
215+
// Skip 'upload' and version (vXXXXXXX)
216+
const publicIdParts = pathParts.slice(uploadIndex + 2);
217+
const publicIdWithExt = publicIdParts.join('/');
218+
219+
// Remove file extension
220+
return publicIdWithExt.replace(/\.[^.]+$/, '');
221+
} catch {
222+
return null;
223+
}
224+
}
225+
}

0 commit comments

Comments
 (0)