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