From 0ffebd54c9e05c3b203229f1a27e66ecf02b10f1 Mon Sep 17 00:00:00 2001 From: chaaanuwu Date: Thu, 23 Apr 2026 23:13:31 +0530 Subject: [PATCH] add functionality to share review images Co-authored-by: Copilot --- server/app.js | 2 + server/config/env.js | 3 + .../generateReviewImage.controller.js | 53 +++ server/package-lock.json | 430 +++++++++++++++++- server/package.json | 1 + .../features/generateReviewImage.route.js | 10 + server/utils/generateReviewImage.util.js | 237 ++++++++++ 7 files changed, 723 insertions(+), 13 deletions(-) create mode 100644 server/controllers/features/generateReviewImage.controller.js create mode 100644 server/routes/features/generateReviewImage.route.js create mode 100644 server/utils/generateReviewImage.util.js diff --git a/server/app.js b/server/app.js index 8c9e2be..5372882 100644 --- a/server/app.js +++ b/server/app.js @@ -13,6 +13,7 @@ import commentRouter from './routes/comment.route.js'; import profileRouter from './routes/features/profile.route.js'; import movieRouter from './routes/movie.route.js'; import tmdbRouter from './routes/tmdb.routes.js'; +import shareRouter from './routes/features/generateReviewImage.route.js'; import "./cron/trending.cron.js"; import { fetchAndStoreTrending } from './services/trending.service.js'; import { fetchAndStoreTopRated } from './services/topRated.service.js'; @@ -38,6 +39,7 @@ app.use(`${BASE_URL}/movies`, tmdbRouter); app.use(`${BASE_URL}/movies`, movieRouter); app.use(`${BASE_URL}/history`, historyRouter); app.use(`${BASE_URL}/watchlist`, watchListRouter); +app.use(`${BASE_URL}`, shareRouter); app.use(`${BASE_URL}`, profileRouter); app.use(`${BASE_URL}`, commentRouter); app.use(`${BASE_URL}`, reviewRouter); diff --git a/server/config/env.js b/server/config/env.js index 271689a..9e4d618 100644 --- a/server/config/env.js +++ b/server/config/env.js @@ -5,10 +5,13 @@ config({ path: `.env.${env}.local` }); export const { PORT, BASE_URL, + CLIENT_URL, NODE_ENV, DB_URI, JWT_SECRET, JWT_EXPIRES_IN, TMDB_BASE_URL, + TMDB_BACKDROP_BASE_URL, + TMDB_POSTER_BASE_URL, TMDB_KEY } = process.env; \ No newline at end of file diff --git a/server/controllers/features/generateReviewImage.controller.js b/server/controllers/features/generateReviewImage.controller.js new file mode 100644 index 0000000..dbf0913 --- /dev/null +++ b/server/controllers/features/generateReviewImage.controller.js @@ -0,0 +1,53 @@ +import generateReviewImage from '../../utils/generateReviewImage.util.js'; +import Review from '../../models/review.model.js'; +import History from '../../models/history.model.js'; +import { CLIENT_URL, TMDB_BACKDROP_BASE_URL, TMDB_POSTER_BASE_URL } from '../../config/env.js'; + +export const shareReviewImage = async (req, res) => { + try { + const { reviewId } = req.params; + const clientURL = CLIENT_URL; + + const review = await Review.findById(reviewId) + .populate('movieId', '_id title posterPath backdropPath releaseDate genreNames') + .populate('userId', '_id firstName lastName pfp'); + + if (!review) { + return res.status(404).json({ + success: false, + error: "Review not found" + }); + } + + const history = await History.findOne({ userId: review.userId._id, movieId: review.movieId._id }); + + const reviewLink = `${clientURL}/reviews/${reviewId}`; + + const imageBuffer = await generateReviewImage({ + backdropUrl: TMDB_BACKDROP_BASE_URL + review.movieId.backdropPath, + posterUrl: TMDB_POSTER_BASE_URL + review.movieId.posterPath, + title: review.movieId.title, + genres: review.movieId.genreNames, + year: review.movieId.releaseDate ? review.movieId.releaseDate.split("-")[0] : "N/A", + reviewText: review.review, + likeCount: review.likedBy.length.toString(), + username: review.userId.firstName + " " + review.userId.lastName, + userAvatarUrl: review.userId.pfp, + rating: history.rating ? history.rating.toString() : "0", + reviewDate: review.createdAt.toDateString().split(" ").slice(1, 3).join(" ") + ", " + review.createdAt.getFullYear(), + linkToReview: reviewLink + }); + + res.set('Content-Type', 'image/png'); + res.set(`Content-Disposition`, `attachment; filename="${reviewId}.png"`); + res.send(imageBuffer); + + } catch (error) { + console.error("Share review image error:", error); + + return res.status(500).json({ + success: false, + error: error.message, + }); + } +} \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 5623f0b..d265e8e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.13.2", "bcryptjs": "^3.0.3", + "canvas": "^3.2.3", "cookie-parser": "~1.4.4", "cors": "^2.8.6", "debug": "~2.6.9", @@ -3479,14 +3480,14 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-jest": { @@ -3637,6 +3638,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", @@ -3690,6 +3711,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -3791,6 +3823,30 @@ "node": ">=20.19.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3902,6 +3958,20 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3967,6 +4037,12 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4369,6 +4445,21 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4384,6 +4475,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4465,6 +4565,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4570,6 +4679,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4991,6 +5109,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", @@ -5191,9 +5318,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -5295,6 +5422,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5465,6 +5598,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5728,6 +5867,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5879,6 +6038,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7545,6 +7710,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7558,6 +7735,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -7568,6 +7754,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mongodb": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", @@ -7706,6 +7898,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7738,6 +7936,36 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -8033,7 +8261,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8387,6 +8614,33 @@ "node": ">= 0.4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8439,10 +8693,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -8451,6 +8708,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8516,6 +8783,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8523,6 +8814,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9040,6 +9345,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -9159,6 +9509,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9518,6 +9877,34 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9614,6 +10001,18 @@ "license": "0BSD", "optional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9903,6 +10302,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -10195,7 +10600,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/server/package.json b/server/package.json index 566510e..5da8201 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.13.2", "bcryptjs": "^3.0.3", + "canvas": "^3.2.3", "cookie-parser": "~1.4.4", "cors": "^2.8.6", "debug": "~2.6.9", diff --git a/server/routes/features/generateReviewImage.route.js b/server/routes/features/generateReviewImage.route.js new file mode 100644 index 0000000..ed36459 --- /dev/null +++ b/server/routes/features/generateReviewImage.route.js @@ -0,0 +1,10 @@ +import { Router } from "express"; + +import authorize from "../../middlewares/auth.middleware.js"; +import { shareReviewImage } from "../../controllers/features/generateReviewImage.controller.js"; + +const shareRouter = Router(); + +shareRouter.get('/reviews/:reviewId/share', authorize, shareReviewImage); + +export default shareRouter; \ No newline at end of file diff --git a/server/utils/generateReviewImage.util.js b/server/utils/generateReviewImage.util.js new file mode 100644 index 0000000..938a76b --- /dev/null +++ b/server/utils/generateReviewImage.util.js @@ -0,0 +1,237 @@ +import { createCanvas, loadImage } from 'canvas'; + +/** + * Helper function to wrap text for the review body + */ +function wrapText(ctx, text, maxWidth) { + const words = text.split(' '); + let line = ''; + const lines = []; + + for (let n = 0; n < words.length; n++) { + const testLine = line + words[n] + ' '; + const metrics = ctx.measureText(testLine); + + if (metrics.width > maxWidth && n > 0) { + lines.push(line.trim()); + line = words[n] + ' '; + } else { + line = testLine; + } + } + if (line.trim()) lines.push(line.trim()); + return lines; +} + +/** + * Draw rounded rectangle + */ +function drawRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +/** + * Draw a circular image (Avatar) + */ +function drawCircularImage(ctx, img, x, y, radius) { + ctx.save(); + ctx.beginPath(); + ctx.arc(x + radius, y + radius, radius, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(img, x, y, radius * 2, radius * 2); + ctx.restore(); +} + +export default async function generateReviewImage(data) { + try { + const { + backdropUrl = "", + posterUrl = "", + title = "Untitled", + reviewText = "No review text provided", + year = "2025", + rating = "8.5", + genres = ["Action", "Adventure"], + username = "Reviewer", + userAvatarUrl = "https://via.placeholder.com/200", + reviewDate = "Today", + likeCount = "0", + commentCount = "0", + linkToReview="#" + } = data; + + const width = 1920; + const height = 1080; + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + const safeLoad = async (url) => { + try { return await loadImage(url); } + catch (e) { return await loadImage('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='); } + }; + + const [bg, poster, avatar] = await Promise.all([ + safeLoad(backdropUrl), + safeLoad(posterUrl), + safeLoad(userAvatarUrl) + ]); + + // Layout Constants + const paddingLeft = 140; + const posterWidth = 470; + const posterHeight = 700; + const posterY = (height - posterHeight) / 2; + const contentX = paddingLeft + posterWidth + 100; + const maxContentWidth = width - contentX - 140; + + // --- 1. Background --- + ctx.drawImage(bg, 0, 0, width, height); + const overlay = ctx.createLinearGradient(0, 0, 0, height); + overlay.addColorStop(0, 'rgba(0, 0, 0, 0.5)'); + overlay.addColorStop(1, 'rgba(0, 0, 0, 0.9)'); + ctx.fillStyle = overlay; + ctx.fillRect(0, 0, width, height); + + // --- 2. Poster --- + ctx.save(); + ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; + ctx.shadowBlur = 60; + ctx.shadowOffsetY = 30; + drawRoundedRect(ctx, paddingLeft, posterY, posterWidth, posterHeight, 25); + ctx.clip(); + ctx.drawImage(poster, paddingLeft, posterY, posterWidth, posterHeight); + ctx.restore(); + + // --- 3. Header Row (Title, Year, Rating) --- + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const titleY = posterY + 10; + + // Title: Bold and White + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 100px "Arial", sans-serif'; + ctx.fillText(title, contentX, titleY); + const titleWidth = ctx.measureText(title).width; + + // Year: Light and Faded + let currentX = contentX + titleWidth + 30; + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.font = '300 100px "Arial", sans-serif'; + ctx.fillText(year, currentX, titleY); + const yearWidth = ctx.measureText(year).width; + + // Rating Badge + const ratingX = currentX + yearWidth + 45; + const ratingY = titleY + 20; + const ratingText = `⭐ ${rating}`; + + ctx.font = 'bold 36px "Arial", sans-serif'; + const badgeWidth = ctx.measureText(ratingText).width + 44; + const badgeHeight = 70; + + ctx.fillStyle = 'rgba(255, 193, 7, 0.15)'; + drawRoundedRect(ctx, ratingX, ratingY, badgeWidth, badgeHeight, 15); + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 193, 7, 0.4)'; + ctx.lineWidth = 2; + ctx.stroke(); + + ctx.fillStyle = '#FFC107'; + ctx.textBaseline = 'middle'; + ctx.fillText(ratingText, ratingX + 22, ratingY + (badgeHeight / 2)); + ctx.textBaseline = 'top'; + + // --- 4. Genres --- + const genresY = titleY + 130; + let genresX = contentX; + genres.forEach(g => { + ctx.font = '30px "Arial", sans-serif'; + const gTextWidth = ctx.measureText(g).width; + const gW = gTextWidth + 40; + const gH = 55; + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + drawRoundedRect(ctx, genresX, genresY, gW, gH, 12); + ctx.fill(); + ctx.fillStyle = '#FFFFFF'; + ctx.textBaseline = 'middle'; + ctx.fillText(g, genresX + 20, genresY + gH / 2); + genresX += gW + 15; + }); + ctx.textBaseline = 'top'; + + // --- 5. User Profile --- + const userY = genresY + 110; + drawCircularImage(ctx, avatar, contentX, userY, 45); + ctx.font = 'bold 40px "Arial", sans-serif'; + ctx.fillStyle = '#FFFFFF'; + ctx.fillText(username, contentX + 115, userY + 8); + ctx.font = '28px "Arial", sans-serif'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.fillText(reviewDate, contentX + 115, userY + 55); + + // --- 6. Review Text (Limited to 4 lines with Ellipsis) --- + const reviewY = userY + 130; + const lineSpacing = 55; + ctx.font = '34px "Arial", sans-serif'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + + let lines = wrapText(ctx, reviewText, maxContentWidth); + const maxLines = 4; + + lines.slice(0, maxLines).forEach((line, i) => { + let textToDraw = line; + + // If we are on the 4th line and there are more lines remaining + if (i === maxLines - 1 && lines.length > maxLines) { + // Remove characters until the line + "..." fits + while (ctx.measureText(textToDraw + "...").width > maxContentWidth && textToDraw.length > 0) { + textToDraw = textToDraw.substring(0, textToDraw.length - 1); + } + textToDraw += "..."; + } + + ctx.fillText(textToDraw, contentX, reviewY + (i * lineSpacing)); + }); + + // --- 7. Interaction Bar --- + const barY = posterY + posterHeight - 90; + const drawButton = (icon, count, x) => { + const label = `${icon} ${count}`; + ctx.font = 'bold 30px "Arial", sans-serif'; + const bWidth = ctx.measureText(label).width + 60; + ctx.fillStyle = 'rgba(255, 255, 255, 0.08)'; + drawRoundedRect(ctx, x, barY, bWidth, 90, 45); + ctx.fill(); + ctx.fillStyle = '#FFFFFF'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, x + 30, barY + 45); + ctx.textBaseline = 'top'; + return bWidth + 20; + }; + + let btnX = contentX; + btnX += drawButton('❤️', likeCount, btnX); + btnX += drawButton('💬', commentCount, btnX); + + // --- 8. Link to Review (Small Text at the Bottom) --- + ctx.font = '18px "Arial", sans-serif'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.fillText(`🔗 ${linkToReview}`, paddingLeft, height - 80); + + return canvas.toBuffer('image/png'); + + } catch (error) { + console.error('Error generating image:', error); + throw error; + } +} \ No newline at end of file