AndreaNotes1 commited on
Commit
7e1247d
Β·
verified Β·
1 Parent(s): 7154447

Upload 36 files

Browse files
src/controllers/content.controller.js CHANGED
@@ -161,32 +161,48 @@ async function uploadLesson(req, res, next) {
161
  filenameHint: title,
162
  });
163
 
164
- section.lessons.push({
165
- title,
166
- description: description || "",
167
- type: lessonType,
168
- order: order !== undefined ? Number(order) : section.lessons.length,
169
- isPreview: isPreview === "true" || isPreview === true,
170
- cloudinaryPublicId: result.public_id,
171
- cloudinaryResourceType: result.resource_type,
172
- format: result.format || "",
173
- bytes: result.bytes || 0,
174
- durationSeconds: result.duration ? Math.round(result.duration) : 0,
175
- });
176
- await course.save();
177
-
178
- await AuditLog.create({
179
- actor: req.user._id,
180
- actorEmail: req.user.email,
181
- action: "admin.lesson_uploaded",
182
- targetType: "Course",
183
- targetId: course._id,
184
- ip: req.ip,
185
- userAgent: req.headers["user-agent"],
186
- metadata: { sectionId: section._id.toString(), title },
187
- });
188
-
189
- res.status(201).json({ curriculum: course.toCurriculumJSON() });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  } catch (err) {
191
  next(err);
192
  } finally {
 
161
  filenameHint: title,
162
  });
163
 
164
+ // From here on, the asset exists in Cloudinary. If anything below
165
+ // fails (DB validation, a save error, etc.), we must delete it β€”
166
+ // otherwise a failed attempt leaves an orphaned file in storage
167
+ // forever, since nothing in the database ever references it to
168
+ // clean it up later.
169
+ try {
170
+ section.lessons.push({
171
+ title,
172
+ description: description || "",
173
+ type: lessonType,
174
+ order: order !== undefined ? Number(order) : section.lessons.length,
175
+ isPreview: isPreview === "true" || isPreview === true,
176
+ cloudinaryPublicId: result.public_id,
177
+ cloudinaryResourceType: result.resource_type,
178
+ format: result.format || "",
179
+ bytes: result.bytes || 0,
180
+ durationSeconds: result.duration ? Math.round(result.duration) : 0,
181
+ });
182
+ await course.save();
183
+
184
+ await AuditLog.create({
185
+ actor: req.user._id,
186
+ actorEmail: req.user.email,
187
+ action: "admin.lesson_uploaded",
188
+ targetType: "Course",
189
+ targetId: course._id,
190
+ ip: req.ip,
191
+ userAgent: req.headers["user-agent"],
192
+ metadata: { sectionId: section._id.toString(), title },
193
+ });
194
+
195
+ res.status(201).json({ curriculum: course.toCurriculumJSON() });
196
+ } catch (err) {
197
+ await deleteAsset(result.public_id, result.resource_type).catch((cleanupErr) =>
198
+ logger.error("Failed to clean up orphaned Cloudinary asset after failed save", {
199
+ error: cleanupErr.message,
200
+ publicId: result.public_id,
201
+ resourceType: result.resource_type,
202
+ })
203
+ );
204
+ throw err;
205
+ }
206
  } catch (err) {
207
  next(err);
208
  } finally {
src/utils/cloudinaryUpload.js CHANGED
@@ -1,19 +1,26 @@
 
1
  const cloudinary = require("../config/cloudinary");
2
 
3
  const FOLDER = "aynwerd-courses";
4
 
 
 
 
 
 
 
 
 
 
 
5
  /**
6
  * Uploads a file from a local temp path to Cloudinary under private,
7
  * signed-only access. `type: "authenticated"` means the asset can
8
  * NEVER be fetched by its plain URL or by guessing the public ID β€”
9
  * every request must carry a valid signature that we only ever mint
10
  * for a verified, paying learner (see getSignedPlaybackUrl below).
11
- *
12
- * Videos use `upload_large`, which chunks the upload β€” required for
13
- * anything beyond a small size and generally more reliable for video
14
- * regardless of size. Everything else uses a normal upload.
15
  */
16
- function uploadFromPath(filePath, { resourceType, filenameHint }) {
17
  const options = {
18
  folder: FOLDER,
19
  resource_type: resourceType, // "video" | "image" | "raw"
@@ -23,10 +30,25 @@ function uploadFromPath(filePath, { resourceType, filenameHint }) {
23
  filename_override: filenameHint,
24
  };
25
 
26
- if (resourceType === "video") {
27
- return cloudinary.uploader.upload_large(filePath, { ...options, chunk_size: 6 * 1024 * 1024 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
- return cloudinary.uploader.upload(filePath, options);
 
30
  }
31
 
32
  function deleteAsset(publicId, resourceType) {
 
1
+ const fs = require("fs");
2
  const cloudinary = require("../config/cloudinary");
3
 
4
  const FOLDER = "aynwerd-courses";
5
 
6
+ // Cloudinary's chunked `upload_large` is meant for genuinely large
7
+ // files (their own docs point to it for anything that risks a single
8
+ // request timing out or exceeding size limits β€” 100MB+ in practice).
9
+ // Using it unconditionally for every video was a bug: for small files
10
+ // its resolved result can come back missing fields (public_id,
11
+ // resource_type) that the plain, non-chunked upload always includes,
12
+ // which surfaced as a confusing Mongoose "required" validation error
13
+ // rather than a clear upload error. Only use it above this threshold.
14
+ const LARGE_UPLOAD_THRESHOLD_BYTES = 100 * 1024 * 1024; // 100MB
15
+
16
  /**
17
  * Uploads a file from a local temp path to Cloudinary under private,
18
  * signed-only access. `type: "authenticated"` means the asset can
19
  * NEVER be fetched by its plain URL or by guessing the public ID β€”
20
  * every request must carry a valid signature that we only ever mint
21
  * for a verified, paying learner (see getSignedPlaybackUrl below).
 
 
 
 
22
  */
23
+ async function uploadFromPath(filePath, { resourceType, filenameHint }) {
24
  const options = {
25
  folder: FOLDER,
26
  resource_type: resourceType, // "video" | "image" | "raw"
 
30
  filename_override: filenameHint,
31
  };
32
 
33
+ const { size } = fs.statSync(filePath);
34
+ const useLargeUpload = resourceType === "video" && size > LARGE_UPLOAD_THRESHOLD_BYTES;
35
+
36
+ const result = useLargeUpload
37
+ ? await cloudinary.uploader.upload_large(filePath, { ...options, chunk_size: 20 * 1024 * 1024 })
38
+ : await cloudinary.uploader.upload(filePath, options);
39
+
40
+ // Fail loudly and specifically here rather than letting an
41
+ // incomplete result silently reach the database, where it would
42
+ // otherwise surface as an opaque Mongoose validation error instead
43
+ // of a clear "the upload didn't actually succeed" message.
44
+ if (!result || !result.public_id || !result.resource_type) {
45
+ throw new Error(
46
+ `Cloudinary upload did not return the expected result (got: ${JSON.stringify(result)}). ` +
47
+ "The file was not saved β€” please try again."
48
+ );
49
  }
50
+
51
+ return result;
52
  }
53
 
54
  function deleteAsset(publicId, resourceType) {