diff --git a/.gitignore b/.gitignore index e36b805f..6fdf06c6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ localhost.key privkey.pem storybook-static +duplicates.json diff --git a/client/i18n.js b/client/i18n.js index 7d79bf8c..3cdf5991 100644 --- a/client/i18n.js +++ b/client/i18n.js @@ -7,7 +7,7 @@ const fallbackLng = ['en-US']; const availableLanguages = ['en-US', 'es-419']; const options = { - loadPath: 'locales/{{lng}}/translations.json', + loadPath: '/locales/{{lng}}/translations.json', requestOptions: { // used for fetch, can also be a function (payload) => ({ method: 'GET' }) mode: 'no-cors' }, diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 9055d484..2909742f 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -16,6 +16,8 @@ import { import { clearState, saveState } from '../../../persistState'; const ROOT_URL = getConfig('API_URL'); +const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE'); +const S3_BUCKET = getConfig('S3_BUCKET'); export function setProject(project) { return { @@ -287,7 +289,7 @@ export function cloneProject(id) { // duplicate all files hosted on S3 each(newFiles, (file, callback) => { - if (file.url && file.url.includes('amazonaws')) { + if (file.url && (file.url.includes(S3_BUCKET_URL_BASE) || file.url.includes(S3_BUCKET))) { const formParams = { url: file.url }; diff --git a/package-lock.json b/package-lock.json index 710be64b..04592b99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "p5.js-web-editor", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -32207,17 +32207,15 @@ "integrity": "sha1-nYqSjH8sN1E8LQZOV7Pjw1bp+rs=" }, "react-redux": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.1.2.tgz", - "integrity": "sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz", + "integrity": "sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA==", "requires": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.5.5", "hoist-non-react-statics": "^3.3.0", - "invariant": "^2.2.4", - "loose-envify": "^1.1.0", - "prop-types": "^15.6.1", - "react-is": "^16.6.0", - "react-lifecycles-compat": "^3.0.0" + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.9.0" }, "dependencies": { "hoist-non-react-statics": { @@ -32227,6 +32225,11 @@ "requires": { "react-is": "^16.7.0" } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, diff --git a/package.json b/package.json index 6c7c6f5b..ba2680a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p5.js-web-editor", - "version": "1.0.5", + "version": "1.0.6", "description": "The web editor for p5.js.", "scripts": { "clean": "rimraf dist", @@ -200,7 +200,7 @@ "react-helmet": "^5.1.3", "react-hot-loader": "^4.12.19", "react-i18next": "^11.5.0", - "react-redux": "^5.1.2", + "react-redux": "^7.2.0", "react-router": "^3.2.5", "react-split-pane": "^0.1.89", "react-tabs": "^2.3.1", diff --git a/server/config/passport.js b/server/config/passport.js index 4c739138..163db374 100644 --- a/server/config/passport.js +++ b/server/config/passport.js @@ -24,7 +24,7 @@ passport.deserializeUser((id, done) => { * Sign in using Email/Username and Password. */ passport.use(new LocalStrategy({ usernameField: 'email' }, (email, password, done) => { - User.findByMailOrName(email.toLowerCase()) + User.findByEmailOrUsername(email) .then((user) => { // eslint-disable-line consistent-return if (!user) { return done(null, false, { msg: `Email ${email} not found.` }); @@ -43,7 +43,7 @@ passport.use(new LocalStrategy({ usernameField: 'email' }, (email, password, don * Authentificate using Basic Auth (Username + Api Key) */ passport.use(new BasicStrategy((userid, key, done) => { - User.findOne({ username: userid }, (err, user) => { // eslint-disable-line consistent-return + User.findByUsername(userid, (err, user) => { // eslint-disable-line consistent-return if (err) { return done(err); } if (!user) { return done(null, false); } user.findMatchingKey(key, (innerErr, isMatch, keyDocument) => { @@ -98,9 +98,7 @@ passport.use(new GitHubStrategy({ const emails = getVerifiedEmails(profile.emails); const primaryEmail = getPrimaryEmail(profile.emails); - User.findOne({ - email: { $in: emails }, - }, (findByEmailErr, existingEmailUser) => { + User.findByEmail(emails, (findByEmailErr, existingEmailUser) => { if (existingEmailUser) { existingEmailUser.email = existingEmailUser.email || primaryEmail; existingEmailUser.github = profile.id; @@ -141,11 +139,9 @@ passport.use(new GoogleStrategy({ const primaryEmail = profile._json.emails[0].value; - User.findOne({ - email: primaryEmail, - }, (findByEmailErr, existingEmailUser) => { + User.findByEmail(primaryEmail, (findByEmailErr, existingEmailUser) => { let username = profile._json.emails[0].value.split('@')[0]; - User.findOne({ username }, (findByUsernameErr, existingUsernameUser) => { + User.findByUsername(username, (findByUsernameErr, existingUsernameUser) => { if (existingUsernameUser) { const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)]; username = slugify(`${username} ${adj}`); diff --git a/server/controllers/__mocks__/aws.controller.js b/server/controllers/__mocks__/aws.controller.js index 9e5709c4..061072a1 100644 --- a/server/controllers/__mocks__/aws.controller.js +++ b/server/controllers/__mocks__/aws.controller.js @@ -1,5 +1,5 @@ export const getObjectKey = jest.mock(); export const deleteObjectsFromS3 = jest.fn(); export const signS3 = jest.fn(); -export const copyObjectInS3 = jest.fn(); +export const copyObjectInS3RequestHandler = jest.fn(); export const listObjectsInS3ForUser = jest.fn(); diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index 0b7d5d85..a315f1d4 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -28,7 +28,7 @@ function getExtension(filename) { export function getObjectKey(url) { const urlArray = url.split('/'); let objectKey; - if (urlArray.length === 6) { + if (urlArray.length === 5) { const key = urlArray.pop(); const userId = urlArray.pop(); objectKey = `${userId}/${key}`; @@ -98,24 +98,72 @@ export function signS3(req, res) { res.json(result); } -export function copyObjectInS3(req, res) { - const { url } = req.body; - const objectKey = getObjectKey(url); - const fileExtension = getExtension(objectKey); - const newFilename = uuid.v4() + fileExtension; - const userId = req.user.id; - const params = { - Bucket: `${process.env.S3_BUCKET}`, - CopySource: `${process.env.S3_BUCKET}/${objectKey}`, - Key: `${userId}/${newFilename}`, - ACL: 'public-read' - }; - const copy = client.copyObject(params); - copy.on('err', (err) => { - console.log(err); +export function copyObjectInS3(url, userId) { + return new Promise((resolve, reject) => { + const objectKey = getObjectKey(url); + const fileExtension = getExtension(objectKey); + const newFilename = uuid.v4() + fileExtension; + const headParams = { + Bucket: `${process.env.S3_BUCKET}`, + Key: `${objectKey}` + }; + client.s3.headObject(headParams, (headErr) => { + if (headErr) { + reject(new Error(`Object with key ${process.env.S3_BUCKET}/${objectKey} does not exist.`)); + return; + } + const params = { + Bucket: `${process.env.S3_BUCKET}`, + CopySource: `${process.env.S3_BUCKET}/${objectKey}`, + Key: `${userId}/${newFilename}`, + ACL: 'public-read' + }; + const copy = client.copyObject(params); + copy.on('err', (err) => { + reject(err); + }); + copy.on('end', (data) => { + resolve(`${s3Bucket}${userId}/${newFilename}`); + }); + }); }); - copy.on('end', (data) => { - res.json({ url: `${s3Bucket}${userId}/${newFilename}` }); +} + +export function copyObjectInS3RequestHandler(req, res) { + const { url } = req.body; + copyObjectInS3(url, req.user.id).then((newUrl) => { + res.json({ url: newUrl }); + }); +} + +export function moveObjectToUserInS3(url, userId) { + return new Promise((resolve, reject) => { + const objectKey = getObjectKey(url); + const fileExtension = getExtension(objectKey); + const newFilename = uuid.v4() + fileExtension; + const headParams = { + Bucket: `${process.env.S3_BUCKET}`, + Key: `${objectKey}` + }; + client.s3.headObject(headParams, (headErr) => { + if (headErr) { + reject(new Error(`Object with key ${process.env.S3_BUCKET}/${objectKey} does not exist.`)); + return; + } + const params = { + Bucket: `${process.env.S3_BUCKET}`, + CopySource: `${process.env.S3_BUCKET}/${objectKey}`, + Key: `${userId}/${newFilename}`, + ACL: 'public-read' + }; + const move = client.moveObject(params); + move.on('err', (err) => { + reject(err); + }); + move.on('end', (data) => { + resolve(`${s3Bucket}${userId}/${newFilename}`); + }); + }); }); } diff --git a/server/controllers/collection.controller/collectionForUserExists.js b/server/controllers/collection.controller/collectionForUserExists.js index e2881fd4..4dd2d40f 100644 --- a/server/controllers/collection.controller/collectionForUserExists.js +++ b/server/controllers/collection.controller/collectionForUserExists.js @@ -11,7 +11,7 @@ export default function collectionForUserExists(username, collectionId, callback } function findUser() { - return User.findOne({ username }); + return User.findByUsername(username); } function findCollection(owner) { diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js index c71041b3..b1a60ee9 100644 --- a/server/controllers/collection.controller/listCollections.js +++ b/server/controllers/collection.controller/listCollections.js @@ -3,7 +3,8 @@ import User from '../../models/user'; async function getOwnerUserId(req) { if (req.params.username) { - const user = await User.findOne({ username: req.params.username }); + const user = + await User.findByUsername(req.params.username); if (user && user._id) { return user._id; } diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index ab4d4a63..7f118c23 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -64,7 +64,7 @@ export function updateProject(req, res) { export function getProject(req, res) { const { project_id: projectId, username } = req.params; - User.findOne({ username }, (err, user) => { // eslint-disable-line + User.findByUsername(username, (err, user) => { // eslint-disable-line if (!user) { return res.status(404).send({ message: 'Project with that username does not exist' }); } @@ -141,7 +141,7 @@ export function projectExists(projectId, callback) { } export function projectForUserExists(username, projectId, callback) { - User.findOne({ username }, (err, user) => { + User.findByUsername(username, (err, user) => { if (!user) { callback(false); return; diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js index 1d9a0e34..7b563b2c 100644 --- a/server/controllers/project.controller/getProjectsForUser.js +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -7,7 +7,7 @@ const UserNotFoundError = createApplicationErrorClass('UserNotFoundError'); function getProjectsForUserName(username) { return new Promise((resolve, reject) => { - User.findOne({ username }, (err, user) => { + User.findByUsername(username, (err, user) => { if (err) { reject(err); return; diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index d326e373..0ff791c6 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -30,71 +30,63 @@ const random = (done) => { }; export function findUserByUsername(username, cb) { - User.findOne( - { username }, - (err, user) => { - cb(user); - } - ); + User.findByUsername(username, (err, user) => { + cb(user); + }); } export function createUser(req, res, next) { + const { username, email } = req.body; + const { password } = req.body; + const emailLowerCase = email.toLowerCase(); const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + (3600000 * 24); // 24 hours random((tokenError, token) => { const user = new User({ - username: req.body.username, - email: req.body.email, - password: req.body.password, + username, + email: emailLowerCase, + password, verified: User.EmailConfirmation.Sent, verifiedToken: token, verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME, }); - User.findOne( - { - $or: [ - { email: req.body.email }, - { username: req.body.username } - ] - }, - (err, existingUser) => { - if (err) { - res.status(404).send({ error: err }); - return; - } + User.findByEmailAndUsername(email, username, (err, existingUser) => { + if (err) { + res.status(404).send({ error: err }); + return; + } - if (existingUser) { - const fieldInUse = existingUser.email === req.body.email ? 'Email' : 'Username'; - res.status(422).send({ error: `${fieldInUse} is in use` }); + if (existingUser) { + const fieldInUse = existingUser.email.toLowerCase() === emailLowerCase ? 'Email' : 'Username'; + res.status(422).send({ error: `${fieldInUse} is in use` }); + return; + } + user.save((saveErr) => { + if (saveErr) { + next(saveErr); return; } - user.save((saveErr) => { - if (saveErr) { - next(saveErr); + req.logIn(user, (loginErr) => { + if (loginErr) { + next(loginErr); return; } - req.logIn(user, (loginErr) => { - if (loginErr) { - next(loginErr); - return; - } - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: req.user.email, - }); + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: req.user.email, + }); - mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars - res.json(userResponse(req.user)); - }); + mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars + res.json(userResponse(req.user)); }); }); - } - ); + }); + }); }); } @@ -103,7 +95,10 @@ export function duplicateUserCheck(req, res) { const value = req.query[checkType]; const query = {}; query[checkType] = value; - User.findOne(query, (err, user) => { + // Don't want to use findByEmailOrUsername here, because in this case we do + // want to use case-insensitive search for usernames to prevent username + // duplicates, which overrides the default behavior. + User.findOne(query).collation({ locale: 'en', strength: 2 }).exec((err, user) => { if (user) { return res.json({ exists: true, @@ -147,7 +142,7 @@ export function resetPasswordInitiate(req, res) { async.waterfall([ random, (token, done) => { - User.findOne({ email: req.body.email }, (err, user) => { + User.findByEmail(req.body.email, (err, user) => { if (!user) { res.json({ success: true, message: 'If the email is registered with the editor, an email has been sent.' }); return; @@ -277,7 +272,7 @@ export function updatePassword(req, res) { } export function userExists(username, callback) { - User.findOne({ username }, (err, user) => ( + User.findByUsername(username, (err, user) => ( user ? callback(true) : callback(false) )); } diff --git a/server/migrations/emailConsolidation.js b/server/migrations/emailConsolidation.js new file mode 100644 index 00000000..8343377c --- /dev/null +++ b/server/migrations/emailConsolidation.js @@ -0,0 +1,237 @@ +import mongoose from 'mongoose'; +import fs from 'fs'; +import User from '../models/user'; +import Project from '../models/project'; +import Collection from '../models/collection'; +import { moveObjectToUserInS3, copyObjectInS3 } from '../controllers/aws.controller'; +import mail from '../utils/mail'; +import { renderAccountConsolidation } from '../views/mail'; + + +const mongoConnectionString = process.env.MONGO_URL; +const { ObjectId } = mongoose.Types; +// Connect to MongoDB +mongoose.Promise = global.Promise; +mongoose.connect(mongoConnectionString, { useNewUrlParser: true, useUnifiedTopology: true }); +mongoose.set('useCreateIndex', true); +mongoose.connection.on('error', () => { + console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + process.exit(1); +}); + +/* + * Requires the MongoDB Node.js Driver + * https://mongodb.github.io/node-mongodb-native + */ + +const agg = [ + { + $project: { + email: { + $toLower: [ + '$email' + ] + } + } + }, { + $group: { + _id: '$email', + total: { + $sum: 1 + } + } + }, { + $match: { + total: { + $gt: 1 + } + } + }, { + $sort: { + total: -1 + } + } +]; + + +// steps to make this work +// iterate through the results +// check if any files are on AWS +// if so, move them to the right user bucket +// then, update the user to currentUser +// then, after updating all of the projects +// also update the collections +// delete other users +// update user email so it is all lowercase +// then, send the email +// then, figure out how to iterate through all of the users. + +// create list of duplicate users +// User.aggregate(agg).then((result) => { +// return fs.writeFile('duplicates.json', JSON.stringify(result), () => { +// console.log('File written.'); +// process.exit(0); +// }); +// }); + +let currentUser = null; +let duplicates = null; + +fs.readFile('duplicates.json', async (err, file) => { + const result = JSON.parse(file); + for (let i = 3000; i < result.length; i += 1) { + console.log('Index: ', i); + const email = result[i]._id; + console.log(email); + await consolidateAccount(email); // eslint-disable-line + } + process.exit(0); +}); + +async function consolidateAccount(email) { + return User.find({ email }).collation({ locale: 'en', strength: 2 }) + .sort({ createdAt: 1 }).exec() + .then((result) => { + [currentUser, ...duplicates] = result; + console.log('Current User: ', currentUser._id, ' ', currentUser.email); + duplicates = duplicates.map(dup => dup._id); + console.log('Duplicates: ', duplicates); + return Project.find({ + user: { $in: duplicates } + }).exec(); + }).then((sketches) => { + const saveSketchPromises = []; + sketches.forEach((sketch) => { + console.log('SketchId: ', sketch._id); + console.log('UserId: ', sketch.user); + const moveSketchFilesPromises = []; + sketch.files.forEach((file) => { + // if the file url contains sketch user + if (file.url && file.url.includes(process.env.S3_BUCKET_URL_BASE) && !file.url.includes(currentUser._id)) { + if (file.url.includes(sketch.user)) { + const fileSavePromise = moveObjectToUserInS3(file.url, currentUser._id) + .then((newUrl) => { + file.url = newUrl; + }).catch((err) => { + console.log('Move Error:'); + console.log(err); + }); + moveSketchFilesPromises.push(fileSavePromise); + } else { + const fileSavePromise = copyObjectInS3(file.url, currentUser._id) + .then((newUrl) => { + file.url = newUrl; + }).catch((err) => { + console.log('Copy Error:'); + console.log(err); + }); + moveSketchFilesPromises.push(fileSavePromise); + } + } + }); + const sketchSavePromise = Promise.all(moveSketchFilesPromises).then(() => { + sketch.user = ObjectId(currentUser._id); + return sketch.save(); + }); + saveSketchPromises.push(sketchSavePromise); + }); + return Promise.all(saveSketchPromises); + }).then(() => { + console.log('Moved and updated all sketches.'); + return Collection.updateMany( + { owner: { $in: duplicates } }, + { $set: { owner: ObjectId(currentUser.id) } } + ); + }).then(() => { + console.log('Moved and updated all collections.'); + return User.deleteMany({ _id: { $in: duplicates } }); + }).then(() => { + console.log('Deleted other user accounts.'); + currentUser.email = currentUser.email.toLowerCase(); + return currentUser.save(); + }).then(() => { + console.log('Migrated email to lowercase.'); + // const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderAccountConsolidation({ + body: { + domain: 'https://editor.p5js.org', + username: currentUser.username, + email: currentUser.email + }, + to: currentUser.email, + }); + + return new Promise((resolve, reject) => { + mail.send(mailOptions, (mailErr, result) => { + console.log('Sent email.'); + if (mailErr) { + return reject(mailErr); + } + return resolve(result); + }); + }); + }).catch((err) => { + console.log(err); + process.exit(1); + }); +} + + +// let duplicates = [ +// "5ce3d936e0f9df0022d8330c", +// "5cff843f091745001e83c070", +// "5d246f5db489e6001eaee6e9" +// ]; +// let currentUser = null; +// User.deleteMany({ _id: { $in: duplicates } }).then(() => { +// return User.findOne({ "email": "Silverstar09@hotmail.com" }) +// }).then((result) => { +// currentUser = result; +// console.log('Deleted other user accounts.'); +// currentUser.email = currentUser.email.toLowerCase(); +// return currentUser.save(); +// }).then(() => { +// const mailOptions = renderAccountConsolidation({ +// body: { +// domain: 'https://editor.p5js.org', +// username: currentUser.username, +// email: currentUser.email +// }, +// to: currentUser.email, +// }); + +// return new Promise((resolve, reject) => { +// mail.send(mailOptions, (mailErr, result) => { +// console.log('Sent email.'); +// if (mailErr) { +// return reject(mailErr); +// } +// return resolve(result); +// }); +// }); +// }); + + +// import s3 from '@auth0/s3'; + +// const client = s3.createClient({ +// maxAsyncS3: 20, +// s3RetryCount: 3, +// s3RetryDelay: 1000, +// multipartUploadThreshold: 20971520, // this is the default (20 MB) +// multipartUploadSize: 15728640, // this is the default (15 MB) +// s3Options: { +// accessKeyId: `${process.env.AWS_ACCESS_KEY}`, +// secretAccessKey: `${process.env.AWS_SECRET_KEY}`, +// region: `${process.env.AWS_REGION}` +// }, +// }); + +// const headParams = { +// Bucket: `${process.env.S3_BUCKET}`, +// Key: "5c9de807f6bccf0017da7927/8b9d95ae-7ddd-452a-b398-672392c4ac43.png" +// }; +// client.s3.headObject(headParams, (err, data) => { +// console.log(err); +// console.log(data); +// }); diff --git a/server/migrations/start.js b/server/migrations/start.js index a3127620..2e0e3d88 100644 --- a/server/migrations/start.js +++ b/server/migrations/start.js @@ -1,7 +1,8 @@ require('@babel/register'); require('@babel/polyfill'); const path = require('path'); -require('dotenv').config({ path: path.resolve('.env') }); -require('./populateTotalSize'); +require('dotenv').config({ path: path.resolve('.env.production') }); +require('./emailConsolidation'); +// require('./populateTotalSize'); // require('./moveBucket'); // require('./truncate'); diff --git a/server/models/user.js b/server/models/user.js index 98c0e1fd..c4097e87 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -141,17 +141,86 @@ userSchema.methods.findMatchingKey = function findMatchingKey(candidateKey, cb) if (!foundOne) cb('Matching API key not found !', false, null); }; -userSchema.statics.findByMailOrName = function findByMailOrName(email) { +/** + * + * Queries User collection by email and returns one User document. + * + * @param {string|string[]} email - Email string or array of email strings + * @callback [cb] - Optional error-first callback that passes User document + * @return {Promise} - Returns Promise fulfilled by User document + */ +userSchema.statics.findByEmail = function findByEmail(email, cb) { + let query; + if (Array.isArray(email)) { + query = { + email: { $in: email } + }; + } else { + query = { + email + }; + } + // Email addresses should be case-insensitive unique + // In MongoDB, you must use collation in order to do a case-insensitive query + return this.findOne(query).collation({ locale: 'en', strength: 2 }).exec(cb); +}; + +/** + * + * Queries User collection by username and returns one User document. + * + * @param {string} username - Username string + * @callback [cb] - Optional error-first callback that passes User document + * @return {Promise} - Returns Promise fulfilled by User document + */ +userSchema.statics.findByUsername = function findByUsername(username, cb) { const query = { - $or: [{ - email, - }, { - username: email, - }], + username }; - return this.findOne(query).exec(); + return this.findOne(query, cb); +}; + +/** + * + * Queries User collection using email or username with optional callback. + * This function will determine automatically whether the data passed is + * a username or email. + * + * @param {string} value - Email or username + * @callback [cb] - Optional error-first callback that passes User document + * @return {Promise} - Returns Promise fulfilled by User document + */ +userSchema.statics.findByEmailOrUsername = function findByEmailOrUsername(value, cb) { + const isEmail = value.indexOf('@') > -1; + if (isEmail) { + return this.findByEmail(value, cb); + } + return this.findByUsername(value, cb); +}; + +/** + * + * Queries User collection, performing a MongoDB logical or with the email + * and username (i.e. if either one matches, will return the first document). + * + * @param {string} email + * @param {string} username + * @callback [cb] - Optional error-first callback that passes User document + * @return {Promise} - Returns Promise fulfilled by User document + */ +userSchema.statics.findByEmailAndUsername = function findByEmailAndUsername(email, username, cb) { + const query = { + $or: [ + { email }, + { username } + ] + }; + return this.findOne(query).collation({ locale: 'en', strength: 2 }).exec(cb); }; userSchema.statics.EmailConfirmation = EmailConfirmationStates; +userSchema.index({ username: 1 }, { collation: { locale: 'en', strength: 2 } }); +userSchema.index({ email: 1 }, { collation: { locale: 'en', strength: 2 } }); + export default mongoose.model('User', userSchema); diff --git a/server/routes/aws.routes.js b/server/routes/aws.routes.js index 850bb190..6bbf3795 100644 --- a/server/routes/aws.routes.js +++ b/server/routes/aws.routes.js @@ -5,7 +5,7 @@ import isAuthenticated from '../utils/isAuthenticated'; const router = new Router(); router.post('/S3/sign', isAuthenticated, AWSController.signS3); -router.post('/S3/copy', isAuthenticated, AWSController.copyObjectInS3); +router.post('/S3/copy', isAuthenticated, AWSController.copyObjectInS3RequestHandler); router.delete('/S3/:userId?/:objectKey', isAuthenticated, AWSController.deleteObjectFromS3); router.get('/S3/objects', AWSController.listObjectsInS3ForUserRequestHandler); diff --git a/server/server.js b/server/server.js index b78469a6..d82aa6d8 100644 --- a/server/server.js +++ b/server/server.js @@ -75,7 +75,18 @@ app.use(corsMiddleware); app.options('*', corsMiddleware); // Body parser, cookie parser, sessions, serve public assets -app.use('/locales', Express.static(path.resolve(__dirname, '../dist/static/locales'), { cacheControl: false })); +app.use( + '/locales', + Express.static( + path.resolve(__dirname, '../dist/static/locales'), + { + // Browsers must revalidate for changes to the locale files + // It doesn't actually mean "don't cache this file" + // See: https://jakearchibald.com/2016/caching-best-practices/ + setHeaders: res => res.setHeader('Cache-Control', 'no-cache') + } + ) +); app.use(Express.static(path.resolve(__dirname, '../dist/static'), { maxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === 'production' ? '1d' : '0') })); diff --git a/server/views/consolidationMailLayout.js b/server/views/consolidationMailLayout.js new file mode 100644 index 00000000..fa986024 --- /dev/null +++ b/server/views/consolidationMailLayout.js @@ -0,0 +1,78 @@ +export default ({ + domain, + headingText, + greetingText, + messageText, + username, + email, + message2Text, + resetPasswordLink, + directLinkText, + resetPasswordText, + noteText, + meta +}) => ( + ` + + + + + + + + + + + + + + + + + + + + ${headingText} + + + + + + + + ${greetingText} + + + ${messageText} + + + Username: ${username} + + + Email: ${email} + + + ${message2Text} + + + ${resetPasswordText} + + + + + + + + ${directLinkText} + + ${domain}/${resetPasswordLink} + + ${noteText} + + + + + + +` +); diff --git a/server/views/mail.js b/server/views/mail.js index 43aaf3cb..07e5c5e5 100644 --- a/server/views/mail.js +++ b/server/views/mail.js @@ -1,5 +1,47 @@ import renderMjml from '../utils/renderMjml'; import mailLayout from './mailLayout'; +import consolidationMailLayout from './consolidationMailLayout'; + +export const renderAccountConsolidation = (data) => { + const subject = 'p5.js Web Editor Account Consolidation'; + const templateOptions = { + domain: data.body.domain, + headingText: 'Account Consolidation', + greetingText: 'Hello,', + messageText: `You're receiving this message because you previous registered for the + p5.js Web Editor + using the same email address multiple times. In order to fix bugs and prevent future bugs, + your accounts have been consolidated to the first account you created. You can login with + the following email and username:`, + username: data.body.username, + email: data.body.email, + message2Text: `All of your sketches and collections have been preserved and have not been modified. + If you have forgotten your password you can reset it:`, + resetPasswordLink: 'reset-password', + resetPasswordText: 'Reset Password', + directLinkText: 'Or copy and paste the following URL into your browser:', + noteText: `We are grateful for your patience and understanding. Thank you for supporting p5.js and the + p5.js Web Editor!`, + meta: { + keywords: 'p5.js, p5.js web editor, web editor, processing, code editor', + description: 'A web editor for p5.js, a JavaScript library with the goal' + + ' of making coding accessible to artists, designers, educators, and beginners.' + } + }; + + // Return MJML string + const template = consolidationMailLayout(templateOptions); + + // Render MJML to HTML string + const html = renderMjml(template); + + // Return options to send mail + return Object.assign( + {}, + data, + { html, subject }, + ); +}; export const renderResetPassword = (data) => { const subject = 'p5.js Web Editor Password Reset';