Skip to content

Commit e63212f

Browse files
committed
feat: add direct Cloudinary upload for videos to bypass Vercel body limit
- Add /upload/cloudinary-config endpoint to provide frontend with upload credentials - Modify uploadVideo to first attempt direct Cloudinary upload, then fallback to API - Direct uploads bypass Vercel's 4.5MB serverless function body size limit - Requires creating an unsigned upload preset 'learnix_unsigned' in Cloudinary dashboard
1 parent 44066b5 commit e63212f

File tree

2 files changed

+112
-0
lines changed

2 files changed

+112
-0
lines changed

apps/api/src/upload/upload.controller.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Controller,
33
Post,
44
Delete,
5+
Get,
56
Param,
67
UseGuards,
78
UseInterceptors,
@@ -21,6 +22,12 @@ import { UploadService } from './upload.service';
2122

2223
import type { FileUploadResult } from './upload.service';
2324

25+
interface CloudinaryConfig {
26+
cloudName: string;
27+
uploadPreset: string;
28+
folder: string;
29+
}
30+
2431
@Controller('upload')
2532
@UseGuards(JwtAuthGuard)
2633
export class UploadController {
@@ -35,6 +42,29 @@ export class UploadController {
3542
this.useCloudinary = this.cloudinaryService.isAvailable();
3643
}
3744

45+
/**
46+
* Get Cloudinary configuration for direct frontend uploads.
47+
* This allows large files (videos) to be uploaded directly to Cloudinary,
48+
* bypassing Vercel's 4.5MB serverless function body limit.
49+
*/
50+
@Get('cloudinary-config')
51+
getCloudinaryConfig(): CloudinaryConfig {
52+
const cloudName = this.configService.get<string>('CLOUDINARY_CLOUD_NAME');
53+
const uploadPreset = this.configService.get<string>(
54+
'CLOUDINARY_UPLOAD_PRESET',
55+
);
56+
57+
if (!cloudName) {
58+
throw new BadRequestException('Cloudinary is not configured');
59+
}
60+
61+
return {
62+
cloudName,
63+
uploadPreset: uploadPreset ?? 'learnix_unsigned',
64+
folder: 'learnix/videos',
65+
};
66+
}
67+
3868
/**
3969
* Upload a single avatar image (max 5MB)
4070
*/

apps/web/src/lib/upload-api.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,44 @@ interface ErrorResponse {
1212
message?: string;
1313
}
1414

15+
interface CloudinaryConfig {
16+
cloudName: string;
17+
uploadPreset: string;
18+
folder: string;
19+
}
20+
21+
interface CloudinaryUploadResponse {
22+
secure_url: string;
23+
public_id: string;
24+
format: string;
25+
bytes: number;
26+
resource_type: string;
27+
}
28+
1529
/**
1630
* Upload API functions for file handling
1731
*/
1832
export const uploadApi = {
33+
/**
34+
* Get Cloudinary configuration for direct uploads
35+
*/
36+
getCloudinaryConfig: async (): Promise<CloudinaryConfig> => {
37+
const response = await fetch(`${config.apiUrl}/upload/cloudinary-config`, {
38+
method: 'GET',
39+
credentials: 'include',
40+
});
41+
42+
if (!response.ok) {
43+
const error = await response
44+
.json()
45+
.then((data: unknown) => data as ErrorResponse)
46+
.catch((): ErrorResponse => ({}));
47+
throw new Error(error.message ?? 'Failed to get Cloudinary config');
48+
}
49+
50+
return response.json() as Promise<CloudinaryConfig>;
51+
},
52+
1953
/**
2054
* Upload an avatar image (max 5MB)
2155
*/
@@ -116,8 +150,20 @@ export const uploadApi = {
116150

117151
/**
118152
* Upload a video (max 100MB)
153+
* Attempts direct Cloudinary upload first to bypass serverless body limits,
154+
* falls back to API upload if Cloudinary config is unavailable.
119155
*/
120156
uploadVideo: async (file: File): Promise<UploadResult> => {
157+
// Try direct Cloudinary upload first (for serverless environments with body size limits)
158+
try {
159+
const cloudinaryConfig = await uploadApi.getCloudinaryConfig();
160+
return await uploadVideoDirectToCloudinary(file, cloudinaryConfig);
161+
} catch {
162+
// Fallback to API upload if Cloudinary config is not available
163+
// This works for local development without Cloudinary
164+
}
165+
166+
// Fallback: upload through API
121167
const formData = new FormData();
122168
formData.append('file', file);
123169

@@ -157,6 +203,42 @@ export const uploadApi = {
157203
},
158204
};
159205

206+
/**
207+
* Upload video directly to Cloudinary (bypasses API body size limits)
208+
*/
209+
async function uploadVideoDirectToCloudinary(
210+
file: File,
211+
config: CloudinaryConfig,
212+
): Promise<UploadResult> {
213+
const formData = new FormData();
214+
formData.append('file', file);
215+
formData.append('upload_preset', config.uploadPreset);
216+
formData.append('folder', config.folder);
217+
218+
const response = await fetch(
219+
`https://api.cloudinary.com/v1_1/${config.cloudName}/video/upload`,
220+
{
221+
method: 'POST',
222+
body: formData,
223+
},
224+
);
225+
226+
if (!response.ok) {
227+
const errorText = await response.text();
228+
throw new Error(`Cloudinary upload failed: ${errorText}`);
229+
}
230+
231+
const result = (await response.json()) as CloudinaryUploadResponse;
232+
233+
return {
234+
filename: result.public_id,
235+
originalName: file.name,
236+
mimetype: file.type,
237+
size: result.bytes,
238+
url: result.secure_url,
239+
};
240+
}
241+
160242
/**
161243
* Helper function to validate file before upload
162244
*/

0 commit comments

Comments
 (0)