diff --git a/.env.template b/.env.template index a028982e..15f68f87 100644 --- a/.env.template +++ b/.env.template @@ -3,3 +3,10 @@ DATABASE_PASS=escapp DATABASE_NAME=escapp DATABASE_HOST=localhost DATABASE_URL=postgres://escapp:escapp@localhost:5432/escapp +SMTP_HOST=smtp.resend.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USERNAME=resend +SMTP_PASSWORD= +SMTP_FROM=noreply@example.com +UPLOADS_PATH=public/uploads diff --git a/Dockerfile b/Dockerfile index 6e482b87..6bd7bdb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ RUN npm ci COPY . . +RUN mkdir -p public/uploads + ENV APP_NAME='localhost' ENV APP_SSL='false' ENV DATABASE_USER='escapp' @@ -15,6 +17,11 @@ ENV DATABASE_PASS='escapp' ENV DATABASE_NAME='escapp' ENV DATABASE_HOST='db' ENV DATABASE_SSL='false' +ENV SMTP_HOST='smtp.resend.com' +ENV SMTP_PORT='465' +ENV SMTP_SECURE='true' +ENV SMTP_USERNAME='resend' +ENV UPLOADS_PATH='public/uploads' ENV NODE_ENV='development' ENV PORT='3000' diff --git a/controllers/assets_controller.js b/controllers/assets_controller.js index 6213352d..d76743e2 100644 --- a/controllers/assets_controller.js +++ b/controllers/assets_controller.js @@ -41,7 +41,7 @@ exports.uploadAssets = async (req, res) => { try { await attHelper.checksCloudinaryEnv(); // Save the new asset into Cloudinary: - uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options_zip); + uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options_zip, req.file); } catch (err) { res.status(500); res.send(err); @@ -103,4 +103,3 @@ exports.browse = async (req, res, next) => { next(err); } }; - diff --git a/controllers/escapeRoom_controller.js b/controllers/escapeRoom_controller.js index a29964c8..57f08bcb 100644 --- a/controllers/escapeRoom_controller.js +++ b/controllers/escapeRoom_controller.js @@ -2,7 +2,6 @@ const Sequelize = require("sequelize"); const {QueryTypes} = require("sequelize"); const sequelize = require("../models"); const {models} = sequelize; -const cloudinary = require("cloudinary"); const query = require("../queries"); const attHelper = require("../helpers/attachments"); const {nextStep, prevStep} = require("../helpers/progress"); @@ -88,7 +87,7 @@ exports.index = async (req, res, next) => { } else { const pageArray = paginate(page, pages, 5); - res.render("escapeRooms/index.ejs", {escapeRooms, cloudinary, user, count, page, pages, pageArray}); + res.render("escapeRooms/index.ejs", {escapeRooms, "cloudinary": attHelper, user, count, page, pages, pageArray}); } } catch (error) { next(error); @@ -107,9 +106,9 @@ exports.show = async (req, res) => { const howManyRetos = await models.retosSuperados.count({"where": {"success": true, "teamId": team.id }}); const finished = howManyRetos === escapeRoom.puzzles.length; - res.render("escapeRooms/showStudent", {escapeRoom, cloudinary, participant, team, finished}); + res.render("escapeRooms/showStudent", {escapeRoom, "cloudinary": attHelper, participant, team, finished}); } else { - res.render("escapeRooms/show", {escapeRoom, cloudinary, hostName, "email": req.session.user.username}); + res.render("escapeRooms/show", {escapeRoom, "cloudinary": attHelper, hostName, "email": req.session.user.username}); } }; @@ -142,7 +141,7 @@ exports.create = async (req, res) => { try { await attHelper.checksCloudinaryEnv(); - const uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options); + const uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options, req.file); try { await models.attachment.create({ @@ -219,7 +218,7 @@ exports.update = async (req, res) => { try { // Save the new attachment into Cloudinary: await attHelper.checksCloudinaryEnv(); - const uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options); + const uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options, req.file); // Remember the public_id of the old image. const old_public_id = er.attachment ? er.attachment.public_id : null; let attachment = await er.getAttachment(); diff --git a/controllers/hint_controller.js b/controllers/hint_controller.js index e6acd115..64d52c5e 100644 --- a/controllers/hint_controller.js +++ b/controllers/hint_controller.js @@ -103,7 +103,7 @@ exports.pistasUpdate = async (req, res) => { } else { try { await attHelper.checksCloudinaryEnv(); - const uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options_zip); + const uploadResult = await attHelper.uploadResource(req.file.path, attHelper.cloudinary_upload_options_zip, req.file); const old_public_id = escapeRoom.hintApp ? escapeRoom.hintApp.public_id : null; // Update the attachment into the data base. const att = await escapeRoom.getHintApp(); diff --git a/deploy/.env.template b/deploy/.env.template index c4532a94..cc0629b9 100644 --- a/deploy/.env.template +++ b/deploy/.env.template @@ -3,4 +3,10 @@ DATABASE_NAME= DATABASE_USER= DATABASE_PASS= CLOUDINARY_URL= -SENDGRID_API_KEY= +SMTP_HOST=smtp.resend.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USERNAME=resend +SMTP_PASSWORD= +SMTP_FROM= +UPLOADS_PATH=public/uploads diff --git a/helpers/attachments.js b/helpers/attachments.js index d57d086b..276fe812 100644 --- a/helpers/attachments.js +++ b/helpers/attachments.js @@ -3,6 +3,14 @@ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); +const getUploadPath = () => process.env.UPLOADS_PATH || path.join("public", "uploads"); + +const safeExtension = (file) => { + const extension = path.extname(file && file.originalname || "").toLowerCase(); + + return extension.match(/^\.[a-z0-9]+$/) ? extension : ""; +}; + const uploadResourceToCloudinary = (src, options) => new Promise((resolve, reject) => { cloudinary.v2.uploader.upload( src, @@ -20,33 +28,50 @@ const uploadResourceToCloudinary = (src, options) => new Promise((resolve, rejec ); }); -const uploadResourceToFileSystem = (src) => new Promise(function (resolve, reject) { +const uploadResourceToFileSystem = (src, file) => new Promise(function (resolve, reject) { const salt = `${Math.round(new Date().valueOf() * Math.random())}`; - const public_id = crypto.createHmac("sha256", salt).update(src). - digest("hex"); + const public_id = `${crypto.createHmac("sha256", salt).update(src). + digest("hex")}${safeExtension(file)}`; - const url = path.join("/uploads", public_id); + const url = path.posix.join("/uploads", public_id); + + const destination = path.join(getUploadPath(), public_id); + + fs.mkdir(path.dirname(destination), {"recursive": true}, function (dirError) { + if (dirError) { + reject(dirError); + return; + } + fs.copyFile(src, destination, fs.constants.COPYFILE_EXCL, function (error) { + if (error) { + reject(error); + } else { + resolve({ + public_id, + url + }); + } + }); + }); +}); - const destination = path.join("public", "uploads", public_id); +const deleteResourceFromFileSystem = (public_id) => new Promise(function (resolve) { + const destination = path.join(getUploadPath(), public_id); - fs.copyFile(src, destination, fs.constants.COPYFILE_EXCL, function (error) { + fs.unlink(destination, function (error) { if (error) { - reject(error); - } else { - resolve({ - public_id, - url - }); + console.error("Failed to delete file from filesystem", error); } + resolve(); }); }); -exports.uploadResource = function (src, options) { +exports.uploadResource = function (src, options, file) { return new Promise(function (resolve) { if (process.env.CLOUDINARY_URL) { resolve(uploadResourceToCloudinary(src, options)); } else { - resolve(uploadResourceToFileSystem(src)); + resolve(uploadResourceToFileSystem(src, file)); } }); }; @@ -61,11 +86,7 @@ exports.deleteResource = async function (public_id, model) { // Delete from Cloudinary. cloudinary.api.delete_resources(public_id); } else { - const destination = path.join("public", "uploads", public_id); // Delete from local file system. - - fs.unlink(destination, function (error) { - console.error("Failed to delete file from filesystem", error); - }); + await deleteResourceFromFileSystem(public_id); } }; @@ -110,13 +131,7 @@ exports.video = function (public_id, options) { ""; }; -exports.checksCloudinaryEnv = () => new Promise((resolve, reject) => { - if (process.env.CLOUDINARY_URL) { - resolve(); - } else { - reject(new Error("The CLOUDINARY_URL env variable is not defined.")); - } -}); +exports.checksCloudinaryEnv = () => Promise.resolve(); exports.cloudinary_upload_options = { "folder": "/escapeRoom/attachments", diff --git a/helpers/mailer.js b/helpers/mailer.js index fd2d4740..8018295e 100644 --- a/helpers/mailer.js +++ b/helpers/mailer.js @@ -1,9 +1,27 @@ -const sgMail = require("@sendgrid/mail"); +const nodemailer = require("nodemailer"); -sgMail.setApiKey(process.env.SENDGRID_API_KEY); +const getSmtpPort = () => parseInt(process.env.SMTP_PORT || "465", 10); + +const isSmtpSecure = () => { + if (process.env.SMTP_SECURE === undefined) { + return getSmtpPort() === 465; + } + + return process.env.SMTP_SECURE === "true"; +}; + +const getTransporter = () => nodemailer.createTransport({ + "host": process.env.SMTP_HOST || "smtp.resend.com", + "port": getSmtpPort(), + "secure": isSmtpSecure(), + "auth": { + "user": process.env.SMTP_USERNAME || "resend", + "pass": process.env.SMTP_PASSWORD + } +}); exports.resetPasswordEmail = (to, subject = "escapp", text, html) => { - const from = `noreply@${process.env.APP_NAME || "escapp.dit.upm.es"}`; + const from = process.env.SMTP_FROM || `noreply@${process.env.APP_NAME || "escapp.dit.upm.es"}`; const msg = { to, from, @@ -12,6 +30,5 @@ exports.resetPasswordEmail = (to, subject = "escapp", text, html) => { html }; - return sgMail.send(msg); + return getTransporter().sendMail(msg); }; - diff --git a/helpers/utils.js b/helpers/utils.js index d9e013bc..cf107940 100644 --- a/helpers/utils.js +++ b/helpers/utils.js @@ -3,7 +3,7 @@ const {Op} = Sequelize; const sequelize = require("../models"); const {models} = sequelize; const {nextStep, prevStep} = require("./progress"); -const cloudinary = require("cloudinary"); +const attHelper = require("../helpers/attachments"); const ejs = require("ejs"); const queries = require("../queries"); const {OK, NOT_A_PARTICIPANT, PARTICIPANT, NOK, NOT_ACTIVE, NOT_STARTED, TOO_LATE, AUTHOR, ERROR} = require("../helpers/apiCodes"); @@ -54,7 +54,7 @@ exports.playInterface = async (name, req, res, next) => { if (isAdmin || isAuthor) { res.render("escapeRooms/play/play", { "escapeRoom": req.escapeRoom, - cloudinary, + "cloudinary": attHelper, "team": { "turno": req.turn, "retos": [] @@ -113,7 +113,7 @@ exports.playInterface = async (name, req, res, next) => { await exports.automaticallySetAttendance(team, req.session.user.id, req.escapeRoom.automaticAttendance); const hints = await models.requestedHint.findAll({"where": {"teamId": team.id, "success": true}, "include": [{"model": models.hint, "include": [{"model": models.puzzle, "attributes": ["order"]}]}], "order": [["createdAt", "ASC"]]}); - res.render("escapeRooms/play/play", {"escapeRoom": req.escapeRoom, "hostName": process.env.APP_NAME ? `https://${process.env.APP_NAME}` : "http://localhost:3000", cloudinary, "teams": [], team, token, "userId": req.session.user.id, "turnoId": team.turno.id, "teamId": team.id, "isStudent": true, "hints": hints || [], "endPoint": name, "layout": false }); + res.render("escapeRooms/play/play", {"escapeRoom": req.escapeRoom, "hostName": process.env.APP_NAME ? `https://${process.env.APP_NAME}` : "http://localhost:3000", "cloudinary": attHelper, "teams": [], team, token, "userId": req.session.user.id, "turnoId": team.turno.id, "teamId": team.id, "isStudent": true, "hints": hints || [], "endPoint": name, "layout": false }); } catch (err) { next(err); } diff --git a/package-lock.json b/package-lock.json index 8e79afc0..d3a05dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "escapp", "version": "1.0.0", "dependencies": { - "@sendgrid/mail": "^8.1.4", "body-parser": "^1.19.2", "cloudinary": "^1.28.1", "compression": "^1.7.4", @@ -1447,41 +1446,6 @@ "node": ">=14" } }, - "node_modules/@sendgrid/client": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", - "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", - "dependencies": { - "@sendgrid/helpers": "^8.0.0", - "axios": "^1.12.0" - }, - "engines": { - "node": ">=12.*" - } - }, - "node_modules/@sendgrid/helpers": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", - "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", - "dependencies": { - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@sendgrid/mail": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", - "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", - "dependencies": { - "@sendgrid/client": "^8.1.5", - "@sendgrid/helpers": "^8.0.0" - }, - "engines": { - "node": ">=12.*" - } - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -1888,16 +1852,6 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/babel-jest": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.3.tgz", @@ -2753,6 +2707,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -3699,25 +3654,6 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==" }, - "node_modules/follow-redirects": { - "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", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3752,21 +3688,6 @@ "node": "*" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/formidable": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", @@ -6353,14 +6274,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "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==", - "engines": { - "node": ">=10" - } - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", diff --git a/package.json b/package.json index cabcf38c..e7bb1c03 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "unused": "unused-exports --path ./ --ignore node_modules/ --ignore public --ignore migrations" }, "dependencies": { - "@sendgrid/mail": "^8.1.4", "body-parser": "^1.19.2", "cloudinary": "^1.28.1", "compression": "^1.7.4", diff --git a/views/attachments/_show.ejs b/views/attachments/_show.ejs index 607cf483..14f65d1f 100644 --- a/views/attachments/_show.ejs +++ b/views/attachments/_show.ejs @@ -7,11 +7,11 @@ <% } else if ( attachment.mime === 'video/mp4') { %> <%- cloudinary.video(locals.attachment.public_id, {controls: false, secure: true})%> <% } else if ( attachment.mime === 'application/pdf') { %> - <%- cloudinary.image(locals.attachment.public_id, {format: 'jpg', secure: true}) %> + <%= locals.attachment.filename %> <% } else { %> None <% } %> <% } else { %> Placeholder -<% } %> \ No newline at end of file +<% } %>