Spaces:
Running
Running
File size: 6,907 Bytes
7e1247d 6e13570 980ecdf 7e1247d 6e13570 7e1247d 6e13570 7e1247d 6e13570 7e1247d 6e13570 4c6b1e8 980ecdf db9f9f5 980ecdf db9f9f5 980ecdf db9f9f5 980ecdf db9f9f5 980ecdf db9f9f5 980ecdf 6e13570 980ecdf db9f9f5 980ecdf 6e13570 980ecdf 6e13570 980ecdf 6e13570 4c6b1e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | 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,
};
|