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, };