AndreaNotes1 commited on
Commit
980ecdf
Β·
verified Β·
1 Parent(s): 7e1247d

Upload 38 files

Browse files
package.json CHANGED
@@ -8,7 +8,8 @@
8
  "scripts": {
9
  "start": "node src/server.js",
10
  "dev": "nodemon src/server.js",
11
- "seed": "node src/seed/seed.js"
 
12
  },
13
  "engines": {
14
  "node": ">=18.0.0"
 
8
  "scripts": {
9
  "start": "node src/server.js",
10
  "dev": "nodemon src/server.js",
11
+ "seed": "node src/seed/seed.js",
12
+ "upload-watermark-logo": "node scripts/uploadWatermarkLogo.js"
13
  },
14
  "engines": {
15
  "node": ">=18.0.0"
scripts/assets/aynwerd-watermark-logo.png ADDED
scripts/uploadWatermarkLogo.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * One-time setup script: uploads the white Aynwerd logo to Cloudinary
3
+ * as a plain (public) image asset, so it can be referenced as an
4
+ * overlay layer when watermarking lesson videos (see
5
+ * src/utils/cloudinaryUpload.js -> buildWatermarkTransformation).
6
+ *
7
+ * This is NOT a course asset β€” it's not sensitive and isn't uploaded
8
+ * with `type: "authenticated"` β€” it just needs a stable public_id that
9
+ * the video-overlay transformation can reference.
10
+ *
11
+ * Usage (run once, from server/):
12
+ * node scripts/uploadWatermarkLogo.js
13
+ *
14
+ * Requires the same CLOUDINARY_* env vars as the rest of the app
15
+ * (loaded via .env / your host's secrets, same as normal).
16
+ *
17
+ * If you ever want to swap the logo image, just re-run this script β€”
18
+ * `overwrite: true` means it replaces the existing asset at the same
19
+ * public_id, so no code changes are needed afterwards.
20
+ */
21
+ require("dotenv").config();
22
+ const path = require("path");
23
+ const cloudinary = require("../src/config/cloudinary");
24
+
25
+ const LOGO_PATH = path.join(__dirname, "assets", "aynwerd-watermark-logo.png");
26
+ const PUBLIC_ID = process.env.CLOUDINARY_WATERMARK_LOGO_PUBLIC_ID || "aynwerd_watermark_logo";
27
+
28
+ async function main() {
29
+ console.log(`Uploading ${LOGO_PATH} to Cloudinary as "${PUBLIC_ID}"...`);
30
+
31
+ const result = await cloudinary.uploader.upload(LOGO_PATH, {
32
+ public_id: PUBLIC_ID,
33
+ resource_type: "image",
34
+ type: "upload", // public delivery β€” this is just a logo, not course content
35
+ overwrite: true,
36
+ invalidate: true,
37
+ });
38
+
39
+ console.log("Done. Watermark logo is live at public_id:", result.public_id);
40
+ console.log(
41
+ "If you set a custom CLOUDINARY_WATERMARK_LOGO_PUBLIC_ID, make sure it matches this value in your server env."
42
+ );
43
+ }
44
+
45
+ main()
46
+ .then(() => process.exit(0))
47
+ .catch((err) => {
48
+ console.error("Failed to upload watermark logo:", err.message);
49
+ process.exit(1);
50
+ });
src/controllers/content.controller.js CHANGED
@@ -51,6 +51,7 @@ async function getLessonAccess(req, res, next) {
51
 
52
  const { url, expiresAt } = getSignedPlaybackUrl(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType, {
53
  format: lesson.format,
 
54
  });
55
 
56
  await AuditLog.create({
 
51
 
52
  const { url, expiresAt } = getSignedPlaybackUrl(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType, {
53
  format: lesson.format,
54
+ viewer: { name: req.user.name, email: req.user.email },
55
  });
56
 
57
  await AuditLog.create({
src/utils/cloudinaryUpload.js CHANGED
@@ -3,6 +3,11 @@ const cloudinary = require("../config/cloudinary");
3
 
4
  const FOLDER = "aynwerd-courses";
5
 
 
 
 
 
 
6
  // Cloudinary's chunked `upload_large` is meant for genuinely large
7
  // files (their own docs point to it for anything that risks a single
8
  // request timing out or exceeding size limits β€” 100MB+ in practice).
@@ -55,12 +60,67 @@ function deleteAsset(publicId, resourceType) {
55
  return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" });
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  /**
59
  * Mints a short-lived, signed URL for a specific lesson asset. Valid for
60
  * a couple of hours only, so a leaked link stops working quickly and
61
  * cannot be redistributed as a permanent download.
 
 
 
 
 
 
 
 
 
62
  */
63
- function getSignedPlaybackUrl(publicId, resourceType, { format, expiresInSeconds = 2 * 60 * 60 } = {}) {
 
 
 
 
64
  const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
65
  const url = cloudinary.url(publicId, {
66
  resource_type: resourceType,
@@ -69,6 +129,7 @@ function getSignedPlaybackUrl(publicId, resourceType, { format, expiresInSeconds
69
  secure: true,
70
  expires_at: expiresAt,
71
  format: format || undefined,
 
72
  });
73
  return { url, expiresAt };
74
  }
 
3
 
4
  const FOLDER = "aynwerd-courses";
5
 
6
+ // Public ID of the white Aynwerd logo, uploaded once (see
7
+ // scripts/uploadWatermarkLogo.js) as a plain, non-secret image asset β€”
8
+ // it's only ever used as an overlay layer, never served on its own.
9
+ const WATERMARK_LOGO_PUBLIC_ID = process.env.CLOUDINARY_WATERMARK_LOGO_PUBLIC_ID || "aynwerd_watermark_logo";
10
+
11
  // Cloudinary's chunked `upload_large` is meant for genuinely large
12
  // files (their own docs point to it for anything that risks a single
13
  // request timing out or exceeding size limits β€” 100MB+ in practice).
 
60
  return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" });
61
  }
62
 
63
+ /**
64
+ * Builds the overlay layers that stamp a video with the viewing
65
+ * learner's identity plus the Aynwerd logo, so any leaked/recorded
66
+ * copy is traceable back to the account it leaked from and is visibly
67
+ * branded as Aynwerd's property. Applied at request time, per viewer β€”
68
+ * nothing pre-baked, so the same source video is watermarked
69
+ * differently for every learner who opens it.
70
+ *
71
+ * The identity text is stamped in two opposite corners (not just one)
72
+ * so a casual crop/re-frame of the recording doesn't remove it
73
+ * entirely.
74
+ */
75
+ function buildWatermarkTransformation({ name, email }) {
76
+ const label = `${name} β€’ ${email}`;
77
+ const textLayer = (gravity) => ({
78
+ overlay: {
79
+ font_family: "Arial",
80
+ font_size: 20,
81
+ font_weight: "bold",
82
+ text: label,
83
+ },
84
+ color: "white",
85
+ opacity: 45,
86
+ gravity,
87
+ x: 18,
88
+ y: 18,
89
+ });
90
+
91
+ return [
92
+ textLayer("north_west"),
93
+ textLayer("south_east"),
94
+ {
95
+ overlay: { public_id: WATERMARK_LOGO_PUBLIC_ID, resource_type: "image" },
96
+ width: 130,
97
+ opacity: 40,
98
+ gravity: "south_west",
99
+ x: 18,
100
+ y: 18,
101
+ },
102
+ ];
103
+ }
104
+
105
  /**
106
  * Mints a short-lived, signed URL for a specific lesson asset. Valid for
107
  * a couple of hours only, so a leaked link stops working quickly and
108
  * cannot be redistributed as a permanent download.
109
+ *
110
+ * When `viewer` is provided and the asset is a video, the URL also
111
+ * carries a per-viewer watermark transformation (see
112
+ * buildWatermarkTransformation above) β€” Cloudinary renders that
113
+ * watermarked derivative on the fly and signs the transformed URL, so
114
+ * the overlay can't be stripped without the original signed request.
115
+ * Watermarking only applies to video; document/raw lessons (PDFs,
116
+ * slides) are returned unmodified since Cloudinary can't overlay text
117
+ * on a raw file delivery the same way.
118
  */
119
+ function getSignedPlaybackUrl(
120
+ publicId,
121
+ resourceType,
122
+ { format, expiresInSeconds = 2 * 60 * 60, viewer } = {}
123
+ ) {
124
  const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
125
  const url = cloudinary.url(publicId, {
126
  resource_type: resourceType,
 
129
  secure: true,
130
  expires_at: expiresAt,
131
  format: format || undefined,
132
+ transformation: resourceType === "video" && viewer ? buildWatermarkTransformation(viewer) : undefined,
133
  });
134
  return { url, expiresAt };
135
  }