Spaces:
Sleeping
Sleeping
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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|