const fs = require("fs"); const cloudinary = require("../config/cloudinary"); const FOLDER = "aynwerd-courses"; // Cloudinary's chunked `upload_large` is meant for genuinely large // files (their own docs point to it for anything that risks a single // request timing out or exceeding size limits — 100MB+ in practice). // Using it unconditionally for every video was a bug: for small files // its resolved result can come back missing fields (public_id, // resource_type) that the plain, non-chunked upload always includes, // which surfaced as a confusing Mongoose "required" validation error // rather than a clear upload error. Only use it above this threshold. const LARGE_UPLOAD_THRESHOLD_BYTES = 100 * 1024 * 1024; // 100MB /** * Uploads a file from a local temp path to Cloudinary under private, * signed-only access. `type: "authenticated"` means the asset can * NEVER be fetched by its plain URL or by guessing the public ID — * every request must carry a valid signature that we only ever mint * for a verified, paying learner (see getSignedPlaybackUrl below). */ async function uploadFromPath(filePath, { resourceType, filenameHint }) { const options = { folder: FOLDER, resource_type: resourceType, // "video" | "image" | "raw" type: "authenticated", use_filename: true, unique_filename: true, filename_override: filenameHint, }; const { size } = fs.statSync(filePath); const useLargeUpload = resourceType === "video" && size > LARGE_UPLOAD_THRESHOLD_BYTES; const result = useLargeUpload ? await cloudinary.uploader.upload_large(filePath, { ...options, chunk_size: 20 * 1024 * 1024 }) : await cloudinary.uploader.upload(filePath, options); // Fail loudly and specifically here rather than letting an // incomplete result silently reach the database, where it would // otherwise surface as an opaque Mongoose validation error instead // of a clear "the upload didn't actually succeed" message. if (!result || !result.public_id || !result.resource_type) { throw new Error( `Cloudinary upload did not return the expected result (got: ${JSON.stringify(result)}). ` + "The file was not saved — please try again." ); } return result; } function deleteAsset(publicId, resourceType) { return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" }); } /** * Mints a short-lived, signed URL for a specific lesson asset. Valid for * a couple of hours only, so a leaked link stops working quickly and * cannot be redistributed as a permanent download. */ function getSignedPlaybackUrl(publicId, resourceType, { format, expiresInSeconds = 2 * 60 * 60 } = {}) { const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds; const url = cloudinary.url(publicId, { resource_type: resourceType, type: "authenticated", sign_url: true, secure: true, expires_at: expiresAt, format: format || undefined, }); return { url, expiresAt }; } /** Maps an uploaded file's mimetype to the Cloudinary resource_type bucket it belongs in. */ function resourceTypeForMimetype(mimetype) { if (mimetype.startsWith("video/")) return "video"; if (mimetype.startsWith("image/")) return "image"; return "raw"; // PDFs, docs, slides, zips, etc. } module.exports = { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype };