auth-app-api / src /utils /cloudinaryUpload.js
AndreaNotes1's picture
Upload 46 files
4c6b1e8 verified
Raw History Blame Contribute Delete
6.91 kB
const fs = require("fs");
const cloudinary = require("../config/cloudinary");
const FOLDER = "aynwerd-courses";
// Public ID of the white Aynwerd logo, uploaded once (see
// scripts/uploadWatermarkLogo.js) as a plain, non-secret image asset β€”
// it's only ever used as an overlay layer, never served on its own.
const WATERMARK_LOGO_PUBLIC_ID = process.env.CLOUDINARY_WATERMARK_LOGO_PUBLIC_ID || "aynwerd_watermark_logo";
// 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" });
}
const THUMBNAIL_FOLDER = "aynwerd-course-thumbnails";
/**
* Uploads a course thumbnail image. Deliberately PUBLIC (type: "upload",
* the Cloudinary default) β€” unlike lesson videos/documents, a
* thumbnail's whole purpose is to be visible to any visitor browsing
* the storefront before they've bought anything, so there's nothing to
* gate here.
*/
async function uploadThumbnail(filePath, { filenameHint }) {
const result = await cloudinary.uploader.upload(filePath, {
folder: THUMBNAIL_FOLDER,
resource_type: "image",
type: "upload",
use_filename: true,
unique_filename: true,
filename_override: filenameHint,
// Keep thumbnails reasonably sized regardless of what the admin
// uploads, so course cards load fast on the storefront.
transformation: [{ width: 1024, height: 768, crop: "limit" }],
});
if (!result || !result.public_id || !result.secure_url) {
throw new Error(`Cloudinary thumbnail upload did not return the expected result (got: ${JSON.stringify(result)}).`);
}
return result;
}
function deleteThumbnail(publicId) {
return cloudinary.uploader.destroy(publicId, { resource_type: "image", type: "upload" });
}
/**
* Builds the overlay layers that stamp a video with the Aynwerd logo
* plus the viewing learner's email, so any leaked/recorded copy is
* both clearly branded and traceable back to the account it leaked
* from. Applied at request time, per viewer β€” nothing pre-baked, so
* the same source video is watermarked differently for every learner
* who opens it.
*
* Logo sits top-right (matching a standard, clearly-visible corner
* bug like most watermarked platform videos), email sits bottom-left
* β€” opposite corner, so the two marks don't overlap and a crop that
* removes one is unlikely to remove both.
*/
function buildWatermarkTransformation({ email }) {
return [
{
overlay: { public_id: WATERMARK_LOGO_PUBLIC_ID, resource_type: "image" },
width: 160,
opacity: 90,
gravity: "north_east",
x: 24,
y: 24,
},
{
overlay: {
font_family: "Arial",
font_size: 22,
font_weight: "bold",
text: email,
},
color: "white",
opacity: 85,
gravity: "south_west",
x: 20,
y: 20,
},
];
}
/**
* 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.
*
* When `viewer` is provided and the asset is a video, the URL also
* carries a per-viewer watermark (Aynwerd logo + the learner's email β€”
* see buildWatermarkTransformation above) β€” Cloudinary renders that
* watermarked derivative on the fly and signs the transformed URL, so
* the overlay can't be stripped without the original signed request.
* Watermarking only applies to video; document/raw lessons (PDFs,
* slides) are returned unmodified since Cloudinary can't overlay text
* on a raw file delivery the same way.
*/
function getSignedPlaybackUrl(
publicId,
resourceType,
{ format, expiresInSeconds = 2 * 60 * 60, viewer } = {}
) {
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,
transformation: resourceType === "video" && viewer ? buildWatermarkTransformation(viewer) : 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,
uploadThumbnail,
deleteThumbnail,
};