Spaces:
Running
Running
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 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
-
|
|
|
|
| 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) {
|