diff --git a/.eslintrc b/.eslintrc index 137d893825..22dddfeffb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -135,7 +135,7 @@ "import/no-extraneous-dependencies": "off", "no-unused-vars": "off", "import/no-default-export": "warn", - "no-underscore-dangle": "warn", + "no-underscore-dangle": "off", "react/require-default-props": "off", "no-shadow": "off", "@typescript-eslint/no-shadow": "error" diff --git a/client/tsconfig.json b/client/tsconfig.json index 9e63548a60..3c03827e78 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -6,6 +6,6 @@ "lib": ["DOM", "ESNext"], "jsx": "react", }, - "include": ["./**/*"], + "include": ["./**/*", "../types"], "exclude": ["../node_modules", "../server"] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cfd1a3c504..7ba157a1e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -153,6 +153,7 @@ "@svgr/webpack": "^6.2.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", + "@types/bcryptjs": "^2.4.6", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/node": "^16.18.126", @@ -13993,6 +13994,13 @@ "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz", "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -50416,6 +50424,12 @@ "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz", "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==" }, + "@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/package.json b/package.json index 989ef2a75c..d989d79e8d 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "@svgr/webpack": "^6.2.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", + "@types/bcryptjs": "^2.4.6", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/node": "^16.18.126", diff --git a/server/config/passport.js b/server/config/passport.js index 4a6f0c461f..cd1b057173 100644 --- a/server/config/passport.js +++ b/server/config/passport.js @@ -8,7 +8,7 @@ import LocalStrategy from 'passport-local'; import GoogleStrategy from 'passport-google-oauth20'; import { BasicStrategy } from 'passport-http'; -import User from '../models/user'; +import { User } from '../models/user'; const accountSuspensionMessage = 'Account has been suspended. Please contact privacy@p5js.org if you believe this is an error.'; @@ -61,7 +61,8 @@ passport.use( await user.save(); return done(null, user); - } else { // eslint-disable-line + } else { + // eslint-disable-line return done(null, false, { msg: 'Invalid email or password' }); } } catch (err) { diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index c5d6daed03..286c57cc08 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -9,7 +9,7 @@ import { } from '@aws-sdk/client-s3'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const { ObjectId } = mongoose.Types; diff --git a/server/controllers/collection.controller/collectionForUserExists.js b/server/controllers/collection.controller/collectionForUserExists.js index 8b6bb05f87..3eb75c445d 100644 --- a/server/controllers/collection.controller/collectionForUserExists.js +++ b/server/controllers/collection.controller/collectionForUserExists.js @@ -1,5 +1,5 @@ import Collection from '../../models/collection'; -import User from '../../models/user'; +import { User } from '../../models/user'; /** * @param {string} username diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js index 055a22ac4f..e1ee11d28f 100644 --- a/server/controllers/collection.controller/listCollections.js +++ b/server/controllers/collection.controller/listCollections.js @@ -1,5 +1,5 @@ import Collection from '../../models/collection'; -import User from '../../models/user'; +import { User } from '../../models/user'; async function getOwnerUserId(req) { if (req.params.username) { diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index 18611eafb5..397173083a 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -7,7 +7,7 @@ import isAfter from 'date-fns/isAfter'; import axios from 'axios'; import slugify from 'slugify'; import Project from '../models/project'; -import User from '../models/user'; +import { User } from '../models/user'; import { resolvePathToFile } from '../utils/filePath'; import { generateFileSystemSafeName } from '../utils/generateFileSystemSafeName'; diff --git a/server/controllers/project.controller/__test__/deleteProject.test.js b/server/controllers/project.controller/__test__/deleteProject.test.js index 54d45a6d55..05ee2bb64b 100644 --- a/server/controllers/project.controller/__test__/deleteProject.test.js +++ b/server/controllers/project.controller/__test__/deleteProject.test.js @@ -4,7 +4,7 @@ import { Request, Response } from 'jest-express'; import Project from '../../../models/project'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import deleteProject from '../deleteProject'; import { deleteObjectsFromS3 } from '../../aws.controller'; diff --git a/server/controllers/project.controller/__test__/getProjectsForUser.test.js b/server/controllers/project.controller/__test__/getProjectsForUser.test.js index 9b7b8e128f..d612c0a054 100644 --- a/server/controllers/project.controller/__test__/getProjectsForUser.test.js +++ b/server/controllers/project.controller/__test__/getProjectsForUser.test.js @@ -3,7 +3,7 @@ */ import { Request, Response } from 'jest-express'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import getProjectsForUser, { apiGetProjectsForUser } from '../getProjectsForUser'; diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js index 072ae3b50b..21eafc6370 100644 --- a/server/controllers/project.controller/getProjectsForUser.js +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -1,5 +1,5 @@ import Project from '../../models/project'; -import User from '../../models/user'; +import { User } from '../../models/user'; import { toApi as toApiProjectObject } from '../../domain-objects/Project'; /** diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js deleted file mode 100644 index da5f4c615c..0000000000 --- a/server/controllers/user.controller.js +++ /dev/null @@ -1,389 +0,0 @@ -import crypto from 'crypto'; - -import User from '../models/user'; -import mail from '../utils/mail'; -import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; - -export * from './user.controller/apiKey'; - -export function userResponse(user) { - return { - email: user.email, - username: user.username, - preferences: user.preferences, - apiKeys: user.apiKeys, - verified: user.verified, - id: user._id, - totalSize: user.totalSize, - github: user.github, - google: user.google, - cookieConsent: user.cookieConsent - }; -} - -/** - * Create a new verification token. - * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback - * @return Promise - */ -async function generateToken() { - return new Promise((resolve, reject) => { - crypto.randomBytes(20, (err, buf) => { - if (err) { - reject(err); - } else { - const token = buf.toString('hex'); - resolve(token); - } - }); - }); -} - -export async function createUser(req, res) { - try { - const { username, email, password } = req.body; - const emailLowerCase = email.toLowerCase(); - const existingUser = await User.findByEmailAndUsername(email, username); - if (existingUser) { - const fieldInUse = - existingUser.email.toLowerCase() === emailLowerCase - ? 'Email' - : 'Username'; - res.status(422).send({ error: `${fieldInUse} is in use` }); - return; - } - - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - const token = await generateToken(); - const user = new User({ - username, - email: emailLowerCase, - password, - verified: User.EmailConfirmation.Sent, - verifiedToken: token, - verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME - }); - - await user.save(); - - req.logIn(user, async (loginErr) => { - if (loginErr) { - console.error(loginErr); - res.status(500).json({ error: 'Failed to log in user.' }); - 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 - }); - - try { - await mail.send(mailOptions); - res.json(userResponse(user)); - } catch (mailErr) { - console.error(mailErr); - res.status(500).json({ error: 'Failed to send verification email.' }); - } - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err }); - } -} - -export async function duplicateUserCheck(req, res) { - const checkType = req.query.check_type; - const value = req.query[checkType]; - const options = { caseInsensitive: true, valueType: checkType }; - const user = await User.findByEmailOrUsername(value, options); - if (user) { - return res.json({ - exists: true, - message: `This ${checkType} is already taken.`, - type: checkType - }); - } - return res.json({ - exists: false, - type: checkType - }); -} - -export async function updatePreferences(req, res) { - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - // Shallow merge the new preferences with the existing. - user.preferences = { ...user.preferences, ...req.body.preferences }; - await user.save(); - res.json(user.preferences); - } catch (err) { - res.status(500).json({ error: err }); - } -} - -export async function resetPasswordInitiate(req, res) { - try { - const token = await generateToken(); - const user = await User.findByEmail(req.body.email); - if (!user) { - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - return; - } - user.resetPasswordToken = token; - user.resetPasswordExpires = Date.now() + 3600000; // 1 hour - - await user.save(); - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderResetPassword({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/reset-password/${token}` - }, - to: user.email - }); - - await mail.send(mailOptions); - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - } catch (err) { - console.log(err); - res.json({ success: false }); - } -} - -export async function validateResetPasswordToken(req, res) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - res.json({ success: true }); -} - -export async function emailVerificationInitiate(req, res) { - try { - const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - if (user.verified === User.EmailConfirmation.Verified) { - res.status(409).json({ error: 'Email already verified' }); - 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: user.email - }); - try { - await mail.send(mailOptions); - } catch (mailErr) { - res.status(500).send({ error: 'Error sending mail' }); - return; - } - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation.Resent; - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours - await user.save(); - - res.json(userResponse(req.user)); - } catch (err) { - res.status(500).json({ error: err }); - } -} - -export async function verifyEmail(req, res) { - const token = req.query.t; - const user = await User.findOne({ - verifiedToken: token, - verifiedTokenExpires: { $gt: new Date() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Token is invalid or has expired.' - }); - return; - } - user.verified = User.EmailConfirmation.Verified; - user.verifiedToken = null; - user.verifiedTokenExpires = null; - await user.save(); - res.json({ success: true }); -} - -export async function updatePassword(req, res) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - - user.password = req.body.password; - user.resetPasswordToken = undefined; - user.resetPasswordExpires = undefined; - - await user.save(); - req.logIn(user, (loginErr) => res.json(userResponse(req.user))); - // eventually send email that the password has been reset -} - -/** - * @param {string} username - * @return {Promise} - */ -export async function userExists(username) { - const user = await User.findByUsername(username); - return user != null; -} - -/** - * Updates the user object and sets the response. - * Response is the user or a 500 error. - * @param res - * @param user - */ -export async function saveUser(res, user) { - try { - await user.save(); - res.json(userResponse(user)); - } catch (error) { - res.status(500).json({ error }); - } -} - -export async function updateSettings(req, res) { - try { - const user = await User.findById(req.user.id); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - user.username = req.body.username; - - if (req.body.newPassword) { - if (user.password === undefined) { - user.password = req.body.newPassword; - saveUser(res, user); - } - if (!req.body.currentPassword) { - res.status(401).json({ error: 'Current password is not provided.' }); - return; - } - } - if (req.body.currentPassword) { - const isMatch = await user.comparePassword(req.body.currentPassword); - if (!isMatch) { - res.status(401).json({ error: 'Current password is invalid.' }); - return; - } - user.password = req.body.newPassword; - await saveUser(res, user); - } else if (user.email !== req.body.email) { - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation.Sent; - - user.email = req.body.email; - - const token = await generateToken(); - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; - - await saveUser(res, user); - - 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: user.email - }); - - await mail.send(mailOptions); - } else { - await saveUser(res, user); - } - } catch (err) { - res.status(500).json({ error: err }); - } -} - -export async function unlinkGithub(req, res) { - if (req.user) { - req.user.github = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'github' - ); - await saveUser(res, req.user); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -} - -export async function unlinkGoogle(req, res) { - if (req.user) { - req.user.google = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'google' - ); - await saveUser(res, req.user); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -} - -export async function updateCookieConsent(req, res) { - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - const { cookieConsent } = req.body; - user.cookieConsent = cookieConsent; - await saveUser(res, user); - } catch (err) { - res.status(500).json({ error: err }); - } -} diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index 49d23697cb..87dc1320e8 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -3,7 +3,7 @@ import { last } from 'lodash'; import { Request, Response } from 'jest-express'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.js index e826810a08..d614a27324 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import User from '../../models/user'; +import { User } from '../../models/user'; /** * Generates a unique token to be used as a Personal Access Token diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts new file mode 100644 index 0000000000..5a428c0f5c --- /dev/null +++ b/server/controllers/user.controller/authManagement.ts @@ -0,0 +1,214 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express'; +import { User } from '../../models/user'; +import { saveUser, generateToken, userResponse } from './helpers'; +import mail from '../../utils/mail'; +import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; + +// POST /reset-password +export interface ResetPasswordInitiateRequestBody { + email: string; +} +export const resetPasswordInitiate: RequestHandler< + {}, + any, + ResetPasswordInitiateRequestBody +> = async (req, res) => { + try { + const token = await generateToken(); + const user = await User.findByEmail(req.body.email); + if (!user) { + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + return; + } + user.resetPasswordToken = token; + user.resetPasswordExpires = Date.now() + 3600000; // 1 hour + + await user.save(); + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderResetPassword({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/reset-password/${token}` + }, + to: user.email + }); + + await mail.send(mailOptions); + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + } catch (err) { + console.log(err); + res.json({ success: false }); + } +}; + +// GET /reset-password/:token +export interface ValidateResetPasswordTokenRequestParams { + token: string; +} +export const validateResetPasswordToken: RequestHandler = async ( + req, + res +) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + res.json({ success: true }); +}; + +// POST /reset-password/:token +export interface UpdatePasswordRequestParams { + token: string; +} +export interface UpdatePasswordRequestBody { + password: string; +} +export const updatePassword: RequestHandler< + UpdatePasswordRequestParams, + any, + UpdatePasswordRequestBody +> = async (req, res) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; + + await user.save(); + req.logIn(user, (loginErr: any) => res.json(userResponse(user))); + // eventually send email that the password has been reset +}; + +// PUT /account +export interface UpdateUserSettingsRequestBody { + username: string; + newPassword: string; + currentPassword: string; + email: string; +} +export const updateSettings: RequestHandler< + {}, + any, + UpdateUserSettingsRequestBody +> = async (req, res) => { + if (!req.user) { + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); + } + + try { + const user = await User.findById(req.user?.id); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + user.username = req.body.username; + + if (req.body.newPassword) { + if (user.password === undefined) { + user.password = req.body.newPassword; + saveUser(res, user); + } + if (!req.body.currentPassword) { + res.status(401).json({ error: 'Current password is not provided.' }); + return; + } + } + if (req.body.currentPassword) { + const isMatch = await user.comparePassword(req.body.currentPassword); + if (!isMatch) { + res.status(401).json({ error: 'Current password is invalid.' }); + return; + } + user.password = req.body.newPassword; + await saveUser(res, user); + } else if (user.email !== req.body.email) { + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation.Sent; + + user.email = req.body.email; + + const token = await generateToken(); + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; + + await saveUser(res, user); + + 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: user.email + }); + + await mail.send(mailOptions); + } else { + await saveUser(res, user); + } + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +// DELETE /auth/github +export const unlinkGithub: RequestHandler = async (req, res) => { + if (req.user) { + req.user.github = undefined; + req.user.tokens = req.user.tokens.filter( + (token) => token.kind !== 'github' + ); + await saveUser(res, req.user); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; + +// DELETE /auth/google +export const unlinkGoogle: RequestHandler = async (req, res) => { + if (req.user) { + req.user.google = undefined; + req.user.tokens = req.user.tokens.filter( + (token: { kind: string }) => token.kind !== 'google' + ); + await saveUser(res, req.user); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts new file mode 100644 index 0000000000..7d99d51449 --- /dev/null +++ b/server/controllers/user.controller/helpers.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto'; +import { Response } from 'express'; +import { UserDocument, PublicUserDocument } from '../../types'; +import { User } from '../../models/user'; + +/** Sanitised user response without sensitive data */ +export function userResponse( + user: UserDocument | PublicUserDocument +): PublicUserDocument { + return { + email: user.email, + username: user.username, + preferences: user.preferences, + apiKeys: user.apiKeys, + verified: user.verified, + id: '_id' in user ? String(user._id) : user.id, + totalSize: user.totalSize, + github: user.github, + google: user.google, + cookieConsent: user.cookieConsent + }; +} + +/** + * Create a new verification token. + * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback + * @return Promise + */ +export async function generateToken(): Promise { + return new Promise((resolve, reject) => { + crypto.randomBytes(20, (err, buf) => { + if (err) { + reject(err); + } else { + const token = buf.toString('hex'); + resolve(token); + } + }); + }); +} + +/** + * @param {string} username + * @return {Promise} + */ +export async function userExists(username: string) { + const user = await User.findByUsername(username); + return user != null; +} + +/** + * Updates the user object and sets the response. + * Response is the user or a 500 error. + * @param res + * @param user + */ +export async function saveUser(res: Response, user: UserDocument) { + try { + await user.save(); + res.json(userResponse(user)); + } catch (error) { + res.status(500).json({ error }); + } +} diff --git a/server/controllers/user.controller/index.ts b/server/controllers/user.controller/index.ts new file mode 100644 index 0000000000..ac23dfcd4b --- /dev/null +++ b/server/controllers/user.controller/index.ts @@ -0,0 +1,4 @@ +export * from './signup'; +export * from './apiKey'; +export * from './userPreferences'; +export * from './authManagement'; diff --git a/server/controllers/user.controller/signup.ts b/server/controllers/user.controller/signup.ts new file mode 100644 index 0000000000..252d7b841c --- /dev/null +++ b/server/controllers/user.controller/signup.ts @@ -0,0 +1,177 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express-serve-static-core'; +import { User } from '../../models/user'; +import { generateToken, userResponse } from './helpers'; +import { renderEmailConfirmation } from '../../views/mail'; +import mail from '../../utils/mail'; + +// POST /signup +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +export const createUser: RequestHandler< + {}, + any, + CreateUserRequestBody +> = async (req, res) => { + try { + const { username, email, password } = req.body; + const emailLowerCase = email.toLowerCase(); + + const existingUser = await User.findByEmailAndUsername(email, username); + if (existingUser) { + const fieldInUse = + existingUser.email.toLowerCase() === emailLowerCase + ? 'Email' + : 'Username'; + return res.status(422).send({ error: `${fieldInUse} is in use` }); + } + + const token = await generateToken(); + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + + const user = new User({ + username, + email: emailLowerCase, + password, + verified: User.EmailConfirmation.Sent, + verifiedToken: token, + verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME + }); + + await user.save(); + + req.logIn(user, async (loginErr) => { + if (loginErr) { + console.error(loginErr); + return res.status(500).json({ error: 'Failed to log in user.' }); + } + + 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 + }); + + try { + await mail.send(mailOptions); + res.json(userResponse(user)); + } catch (mailErr) { + console.error(mailErr); + res.status(500).json({ error: 'Failed to send verification email.' }); + } + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err }); + } +}; + +// GET /signup/duplicate_check +export interface DuplicateUserCheckQuery { + // eslint-disable-next-line camelcase + check_type: 'email' | 'string'; + email: string; + username: string; +} +export const duplicateUserCheck: RequestHandler< + {}, + any, + any, + DuplicateUserCheckQuery +> = async (req, res) => { + const checkType = req.query.check_type; + const value = req.query[checkType as 'email' | 'username']; + const options = { + caseInsensitive: true, + valueType: checkType as 'email' | 'username' + }; + const user = await User.findByEmailOrUsername(value, options); + if (user) { + return res.json({ + exists: true, + message: `This ${checkType} is already taken.`, + type: checkType + }); + } + return res.json({ + exists: false, + type: checkType + }); +}; + +// POST /verify/send +export const emailVerificationInitiate: RequestHandler = async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + try { + const token = await generateToken(); + const user = await User.findById(req.user.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + if (user.verified === User.EmailConfirmation.Verified) { + res.status(409).json({ error: 'Email already verified' }); + 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: user.email + }); + try { + await mail.send(mailOptions); + } catch (mailErr) { + res.status(500).send({ error: 'Error sending mail' }); + return; + } + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation.Resent; + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours + await user.save(); + + res.json(userResponse(req.user)); + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +// GET /verify +export interface VerifyEmailRequestQuery { + t: string; +} +export const verifyEmail: RequestHandler< + {}, + any, + any, + VerifyEmailRequestQuery +> = async (req, res) => { + const token = req.query.t; + const user = await User.findOne({ + verifiedToken: token, + verifiedTokenExpires: { $gt: new Date() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Token is invalid or has expired.' + }); + return; + } + user.verified = User.EmailConfirmation.Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + await user.save(); + res.json({ success: true }); +}; diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts new file mode 100644 index 0000000000..6bd4b14909 --- /dev/null +++ b/server/controllers/user.controller/userPreferences.ts @@ -0,0 +1,54 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express'; +import { CookieConsentOptions, UserPreferences } from '../../types'; +import { User } from '../../models/user'; +import { saveUser } from './helpers'; + +// PUT /preferences +export interface UpdateUserPreferencesRequestBody { + preferences: Partial; +} +export const updatePreferences: RequestHandler< + {}, + any, + UpdateUserPreferencesRequestBody +> = async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + try { + const user = await User.findById(req.user.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + // Shallow merge the new preferences with the existing. + user.preferences = { ...user.preferences, ...req.body.preferences }; + await user.save(); + res.json(user.preferences); + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +// PUT /cookie-consent +export interface UpdateCookieConsentRequestBody { + cookieConsent: CookieConsentOptions; +} +export const updateCookieConsent: RequestHandler< + {}, + any, + UpdateCookieConsentRequestBody +> = async (req, res) => { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + + try { + const user = await User.findById(req.user.id).exec(); + if (!user) return res.status(404).json({ error: 'User not found' }); + + user.cookieConsent = req.body.cookieConsent; + await saveUser(res, user); + } catch (err) { + res.status(500).json({ error: err }); + } +}; diff --git a/server/migrations/db_reformat.js b/server/migrations/db_reformat.js index 50284c1f74..0d4ad8f8e7 100644 --- a/server/migrations/db_reformat.js +++ b/server/migrations/db_reformat.js @@ -2,16 +2,18 @@ import mongoose from 'mongoose'; import path from 'path'; import { uniqWith, isEqual } from 'lodash'; -require('dotenv').config({path: path.resolve('.env')}); +require('dotenv').config({ path: path.resolve('.env') }); const ObjectId = mongoose.Types.ObjectId; mongoose.connect('mongodb://localhost:27017/p5js-web-editor'); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); import Project from '../models/project'; -import User from '../models/user'; +import { User } from '../models/user'; import s3 from '@auth0/s3'; @@ -19,36 +21,35 @@ let 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) + 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}` - }, + } }); let s3Files = []; -Project.find({}) - .exec((err, projects) => { - projects.forEach((project, projectIndex) => { - project.files.forEach((file, fileIndex) => { - if (file.url && !file.url.includes("https://rawgit.com/")) { - s3Files.push(file.url.split('/').pop()); - } - }); +Project.find({}).exec((err, projects) => { + projects.forEach((project, projectIndex) => { + project.files.forEach((file, fileIndex) => { + if (file.url && !file.url.includes('https://rawgit.com/')) { + s3Files.push(file.url.split('/').pop()); + } }); - console.log(s3Files.length); - s3Files = uniqWith(s3Files, isEqual); - console.log(s3Files.length); }); + console.log(s3Files.length); + s3Files = uniqWith(s3Files, isEqual); + console.log(s3Files.length); +}); const uploadedFiles = []; -const params = {'s3Params': {'Bucket': `${process.env.S3_BUCKET}`}}; +const params = { s3Params: { Bucket: `${process.env.S3_BUCKET}` } }; let objectsResponse = client.listObjects(params); -objectsResponse.on('data', function(objects) { - objects.Contents.forEach(object => { +objectsResponse.on('data', function (objects) { + objects.Contents.forEach((object) => { uploadedFiles.push(object.Key); }); }); @@ -56,18 +57,18 @@ objectsResponse.on('data', function(objects) { const filesToDelete = []; objectsResponse.on('end', () => { console.log(uploadedFiles.length); - uploadedFiles.forEach(fileKey => { + uploadedFiles.forEach((fileKey) => { if (s3Files.indexOf(fileKey) === -1) { //delete file - filesToDelete.push({Key: fileKey}); + filesToDelete.push({ Key: fileKey }); // console.log("would delete file: ", fileKey); } }); let params = { Bucket: `${process.env.S3_BUCKET}`, Delete: { - Objects: filesToDelete, - }, + Objects: filesToDelete + } }; // let del = client.deleteObjects(params); // del.on('err', (err) => { @@ -76,9 +77,9 @@ objectsResponse.on('end', () => { // del.on('end', () => { // console.log('deleted extra S3 files!'); // }); - console.log("To delete: ", filesToDelete.length); - console.log("Total S3 files: ", uploadedFiles.length); - console.log("Total S3 files in mongo: ", s3Files.length); + console.log('To delete: ', filesToDelete.length); + console.log('Total S3 files: ', uploadedFiles.length); + console.log('Total S3 files in mongo: ', s3Files.length); }); // let projectsNotToUpdate; diff --git a/server/migrations/emailConsolidation.js b/server/migrations/emailConsolidation.js index 01af84943a..19a141eee5 100644 --- a/server/migrations/emailConsolidation.js +++ b/server/migrations/emailConsolidation.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import fs from 'fs'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import Collection from '../models/collection'; import { @@ -31,7 +31,8 @@ mongoose.connection.on('error', () => { * https://mongodb.github.io/node-mongodb-native */ -const agg = [ // eslint-disable-line +const agg = [ + // eslint-disable-line { $project: { email: { diff --git a/server/migrations/moveBucket.js b/server/migrations/moveBucket.js index fa833e3845..10ef1a38f4 100644 --- a/server/migrations/moveBucket.js +++ b/server/migrations/moveBucket.js @@ -2,13 +2,15 @@ import s3 from '@auth0/s3'; import path from 'path'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import async from 'async'; -require('dotenv').config({path: path.resolve('.env')}); +require('dotenv').config({ path: path.resolve('.env') }); mongoose.connect('mongodb://localhost:27017/p5js-web-editor'); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); @@ -16,8 +18,8 @@ mongoose.connection.on('error', () => { // maxAsyncS3: 20, // s3RetryCount: 3, // s3RetryDelay: 1000, -// multipartUploadThreshold: 20971520, // this is the default (20 MB) -// multipartUploadSize: 15728640, // this is the default (15 MB) +// 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}`, @@ -27,39 +29,57 @@ mongoose.connection.on('error', () => { const CHUNK = 100; Project.count({}) -.exec().then((numProjects) => { - console.log(numProjects); - let index = 0; - async.whilst( - () => { - return index < numProjects; - }, - (whilstCb) => { - Project.find({}).skip(index).limit(CHUNK).exec((err, projects) => { - async.eachSeries(projects, (project, cb) => { - console.log(project.name); - async.eachSeries(project.files, (file, fileCb) => { - if (file.url && file.url.includes('s3-us-west-2.amazonaws.com/')) { - file.url = file.url.replace('s3-us-west-2.amazonaws.com/', ''); - project.save((err, newProject) => { - console.log(`updated file ${file.url}`); - fileCb(); - }); - } else { - fileCb(); - } - }, () => { - cb(); + .exec() + .then((numProjects) => { + console.log(numProjects); + let index = 0; + async.whilst( + () => { + return index < numProjects; + }, + (whilstCb) => { + Project.find({}) + .skip(index) + .limit(CHUNK) + .exec((err, projects) => { + async.eachSeries( + projects, + (project, cb) => { + console.log(project.name); + async.eachSeries( + project.files, + (file, fileCb) => { + if ( + file.url && + file.url.includes('s3-us-west-2.amazonaws.com/') + ) { + file.url = file.url.replace( + 's3-us-west-2.amazonaws.com/', + '' + ); + project.save((err, newProject) => { + console.log(`updated file ${file.url}`); + fileCb(); + }); + } else { + fileCb(); + } + }, + () => { + cb(); + } + ); + }, + () => { + index += CHUNK; + whilstCb(); + } + ); }); - }, () => { - index += CHUNK; - whilstCb(); - }); - }); - }, - () => { - console.log('finished processing all documents.') - process.exit(0); - } - ); -}); + }, + () => { + console.log('finished processing all documents.'); + process.exit(0); + } + ); + }); diff --git a/server/migrations/populateTotalSize.js b/server/migrations/populateTotalSize.js index 19e9f62345..370843e7c4 100644 --- a/server/migrations/populateTotalSize.js +++ b/server/migrations/populateTotalSize.js @@ -1,29 +1,38 @@ /* eslint-disable */ import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import { listObjectsInS3ForUser } from '../controllers/aws.controller'; // Connect to MongoDB mongoose.Promise = global.Promise; mongoose.connect(process.env.MONGO_URL, { useMongoClient: true }); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); -User.find({}, {}, { timeout: true }).cursor().eachAsync((user) => { - console.log(user.id); - if (user.totalSize !== undefined) { - console.log('Already updated size for user: ' + user.username); - return Promise.resolve(); - } - return listObjectsInS3ForUser(user.id).then((objects) => { - return User.findByIdAndUpdate(user.id, { $set: { totalSize: objects.totalSize } }); - }).then(() => { - console.log('Updated new total size for user: ' + user.username); +User.find({}, {}, { timeout: true }) + .cursor() + .eachAsync((user) => { + console.log(user.id); + if (user.totalSize !== undefined) { + console.log('Already updated size for user: ' + user.username); + return Promise.resolve(); + } + return listObjectsInS3ForUser(user.id) + .then((objects) => { + return User.findByIdAndUpdate(user.id, { + $set: { totalSize: objects.totalSize } + }); + }) + .then(() => { + console.log('Updated new total size for user: ' + user.username); + }); + }) + .then(() => { + console.log('Done iterating over every user'); + process.exit(0); }); -}).then(() => { - console.log('Done iterating over every user'); - process.exit(0); -}); \ No newline at end of file diff --git a/server/migrations/s3UnderUser.js b/server/migrations/s3UnderUser.js index 0657b0edc3..55254d7cf7 100644 --- a/server/migrations/s3UnderUser.js +++ b/server/migrations/s3UnderUser.js @@ -6,7 +6,7 @@ import { } from '@aws-sdk/client-s3'; import path from 'path'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import async from 'async'; import dotenv from 'dotenv'; diff --git a/server/models/project.js b/server/models/project.js index 1cbcc78483..182b9fe16d 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -3,7 +3,7 @@ import shortid from 'shortid'; import slugify from 'slugify'; // Register User model as it's referenced by Project -import './user'; +import { User } from './user'; const { Schema } = mongoose; diff --git a/server/models/user.js b/server/models/user.ts similarity index 86% rename from server/models/user.js rename to server/models/user.ts index b825971747..5b8d4735f4 100644 --- a/server/models/user.js +++ b/server/models/user.ts @@ -1,15 +1,14 @@ -import mongoose from 'mongoose'; +/* eslint-disable no-underscore-dangle */ +import mongoose, { Schema } from 'mongoose'; import bcrypt from 'bcryptjs'; - -const EmailConfirmationStates = { - Verified: 'verified', - Sent: 'sent', - Resent: 'resent' -}; - -const { Schema } = mongoose; - -const apiKeySchema = new Schema( +import { + ApiKeyDocument, + UserDocument, + UserModel, + CookieConsentOptions +} from '../types'; + +const apiKeySchema = new Schema( { label: { type: String, default: 'API Key' }, lastUsedAt: { type: Date }, @@ -27,7 +26,7 @@ apiKeySchema.virtual('id').get(function getApiKeyId() { * should never be exposed to the client. So we only return * a safe list of fields when toObject and toJSON are called. */ -function apiKeyMetadata(doc, ret, options) { +function apiKeyMetadata(doc: ApiKeyDocument): Partial { return { id: doc.id, label: doc.label, @@ -45,7 +44,7 @@ apiKeySchema.set('toJSON', { transform: apiKeyMetadata }); -const userSchema = new Schema( +const userSchema = new Schema( { name: { type: String, default: '' }, username: { type: String, required: true, unique: true }, @@ -79,13 +78,13 @@ const userSchema = new Schema( totalSize: { type: Number, default: 0 }, cookieConsent: { type: String, - enum: ['none', 'essential', 'all'], - default: 'none' + enum: Object.values(CookieConsentOptions), + default: CookieConsentOptions.NONE }, banned: { type: Boolean, default: false }, lastLoginTimestamp: { type: Date } }, - { timestamps: true, usePushEach: true } + { timestamps: true } ); /** @@ -102,6 +101,10 @@ userSchema.pre('save', function checkPassword(next) { next(err); return; } + if (!user.password) { + next(new Error('Password is missing')); + return; + } bcrypt.hash(user.password, salt, (innerErr, hash) => { if (innerErr) { next(innerErr); @@ -127,7 +130,7 @@ userSchema.pre('save', function checkApiKey(next) { let pendingTasks = 0; let nextCalled = false; - const done = (err) => { + const done = (err?: mongoose.CallbackError) => { if (nextCalled) return; if (err) { nextCalled = true; @@ -179,7 +182,7 @@ userSchema.set('toJSON', { * @return {Promise} */ userSchema.methods.comparePassword = async function comparePassword( - candidatePassword + candidatePassword: string ) { if (!this.password) { return false; @@ -197,8 +200,8 @@ userSchema.methods.comparePassword = async function comparePassword( * Helper method for validating a user's api key */ userSchema.methods.findMatchingKey = async function findMatchingKey( - candidateKey -) { + candidateKey: string +): Promise<{ isMatch: boolean; keyDocument: UserDocument | null }> { let keyObj = { isMatch: false, keyDocument: null }; /* eslint-disable no-restricted-syntax */ for (const k of this.apiKeys) { @@ -227,7 +230,9 @@ userSchema.methods.findMatchingKey = async function findMatchingKey( * @callback [cb] - Optional error-first callback that passes User document * @return {Object} - Returns User Object fulfilled by User document */ -userSchema.statics.findByEmail = async function findByEmail(email) { +userSchema.statics.findByEmail = async function findByEmail( + email: string | string[] +): Promise { const user = this; const query = Array.isArray(email) ? { email: { $in: email } } : { email }; @@ -247,7 +252,9 @@ userSchema.statics.findByEmail = async function findByEmail(email) { * @param {string[]} emails - Array of email strings * @return {Promise} - Returns Promise fulfilled by User document */ -userSchema.statics.findAllByEmails = async function findAllByEmails(emails) { +userSchema.statics.findAllByEmails = async function findAllByEmails( + emails: string[] +): Promise { const user = this; const query = { email: { $in: emails } @@ -271,19 +278,15 @@ userSchema.statics.findAllByEmails = async function findAllByEmails(emails) { * @return {Object} - Returns User Object fulfilled by User document */ userSchema.statics.findByUsername = async function findByUsername( - username, - options -) { + username: string, + options: { caseInsensitive: boolean } +): Promise { const user = this; const query = { username }; - if ( - arguments.length === 2 && - typeof options === 'object' && - options.caseInsensitive - ) { + if (options?.caseInsensitive) { const foundUser = await user .findOne(query) .collation({ locale: 'en', strength: 2 }) @@ -310,9 +313,9 @@ userSchema.statics.findByUsername = async function findByUsername( * @return {Object} - Returns User Object fulfilled by User document */ userSchema.statics.findByEmailOrUsername = async function findByEmailOrUsername( - value, - options -) { + value: string, + options: { caseInsensitive: boolean; valueType: 'email' | 'username' } +): Promise { const user = this; const isEmail = options && options.valueType @@ -352,9 +355,9 @@ userSchema.statics.findByEmailOrUsername = async function findByEmailOrUsername( * @return {Object} - Returns User Object fulfilled by User document */ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsername( - email, - username -) { + email: string, + username: string +): Promise { const user = this; const query = { $or: [{ email }, { username }] @@ -367,9 +370,9 @@ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsernam return foundUser; }; -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.models.User || mongoose.model('User', userSchema); +export const User = + (mongoose.models.User as UserModel) || + mongoose.model('User', userSchema); diff --git a/server/routes/user.routes.js b/server/routes/user.routes.ts similarity index 68% rename from server/routes/user.routes.js rename to server/routes/user.routes.ts index a507c328bd..71f160f3b3 100644 --- a/server/routes/user.routes.js +++ b/server/routes/user.routes.ts @@ -2,28 +2,53 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; import isAuthenticated from '../utils/isAuthenticated'; -const router = new Router(); +const router = Router(); +/** + * =============== + * SIGN UP + * =============== + */ +// POST /signup router.post('/signup', UserController.createUser); +// GET /signup/duplicate_check router.get('/signup/duplicate_check', UserController.duplicateUserCheck); -router.put('/preferences', isAuthenticated, UserController.updatePreferences); +// POST /verify/send +router.post('/verify/send', UserController.emailVerificationInitiate); + +// GET /verify +router.get('/verify', UserController.verifyEmail); +/** + * =============== + * AUTH MANAGEMENT + * =============== + */ +// POST /reset-password router.post('/reset-password', UserController.resetPasswordInitiate); +// GET /reset-password/:token router.get('/reset-password/:token', UserController.validateResetPasswordToken); +// POST /reset-password/:token router.post('/reset-password/:token', UserController.updatePassword); +// PUT /account router.put('/account', isAuthenticated, UserController.updateSettings); -router.put( - '/cookie-consent', - isAuthenticated, - UserController.updateCookieConsent -); +// DELETE /auth/github +router.delete('/auth/github', UserController.unlinkGithub); + +// DELETE /auth/google +router.delete('/auth/google', UserController.unlinkGoogle); +/** + * =============== + * API KEYS + * =============== + */ router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); router.delete( @@ -32,11 +57,19 @@ router.delete( UserController.removeApiKey ); -router.post('/verify/send', UserController.emailVerificationInitiate); - -router.get('/verify', UserController.verifyEmail); +/** + * =============== + * USER PREFERENCES + * =============== + */ +// PUT /preferences +router.put('/preferences', isAuthenticated, UserController.updatePreferences); -router.delete('/auth/github', UserController.unlinkGithub); -router.delete('/auth/google', UserController.unlinkGoogle); +// PUT /cookie-consent +router.put( + '/cookie-consent', + isAuthenticated, + UserController.updateCookieConsent +); export default router; diff --git a/server/scripts/examples-gg-latest.js b/server/scripts/examples-gg-latest.js index 422bdb4cbe..f9b9453677 100644 --- a/server/scripts/examples-gg-latest.js +++ b/server/scripts/examples-gg-latest.js @@ -5,7 +5,7 @@ import objectID from 'bson-objectid'; import shortid from 'shortid'; import eachSeries from 'async/eachSeries'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; // TODO: change to true when testing! @@ -197,23 +197,31 @@ function getSketchItems(sketchList) { // const completeSketchPkg = []; /* eslint-disable */ - return Q.all(sketchList[0].map(async sketch => Q.all(sketch.tree.map((item) => { - if (item.name === 'data') { - const options = { - url: `https://api.github.com/repos/generative-design/Code-Package-p5.js/contents/${item.path}${branchRef}`, - method: 'GET', - headers: { - ...headers, - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` - } - }; - - const { data } = axios.request(options); - sketch.data = data; - return sketch; - } - // pass - })))).then(() => sketchList[0]); + return Q.all( + sketchList[0].map(async (sketch) => + Q.all( + sketch.tree.map((item) => { + if (item.name === 'data') { + const options = { + url: `https://api.github.com/repos/generative-design/Code-Package-p5.js/contents/${item.path}${branchRef}`, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from( + `${clientId}:${clientSecret}` + ).toString('base64')}` + } + }; + + const { data } = axios.request(options); + sketch.data = data; + return sketch; + } + // pass + }) + ) + ) + ).then(() => sketchList[0]); /* eslint-enable */ } @@ -378,8 +386,11 @@ function formatAllSketches(sketchList) { // get all the sketch data content and download to the newProjects array function getAllSketchContent(newProjectList) { /* eslint-disable */ - return Q.all(newProjectList.map(newProject => Q.all(newProject.files.map(async (sketchFile, i) => { - /* + return Q.all( + newProjectList.map((newProject) => + Q.all( + newProject.files.map(async (sketchFile, i) => { + /* sketchFile.name.endsWith(".mp4") !== true && sketchFile.name.endsWith(".ogg") !== true && sketchFile.name.endsWith(".otf") !== true && @@ -390,42 +401,49 @@ function getAllSketchContent(newProjectList) { sketchFile.name.endsWith(".svg") !== true */ - if (sketchFile.fileType === 'file' && - sketchFile.content != null && - sketchFile.name.endsWith('.html') !== true && - sketchFile.name.endsWith('.css') !== true && - sketchFile.name.endsWith('.js') === true - ) { - const options = { - url: newProject.files[i].content, - method: 'GET', - headers: { - ...headers, - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` - } - }; - - // console.log("CONVERT ME!") - const { data } = await axios.request(options); - newProject.files[i].content = data; - return newProject; - } - if (newProject.files[i].url) { - return new Promise((resolve, reject) => { - // "https://raw.githubusercontent.com/generative-design/Code-Package-p5.js/gg4editor/01_P/P_3_2_1_01/data/FreeSans.otf", - // https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@master/01_P/P_4_3_1_01/data/pic.png - // const rawGitRef = `https://raw.githack.com/${newProject.files[i].url.split('.com/')[1]}`; - const cdnRef = `https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@${branchName}${newProject.files[i].url.split(branchName)[1]}` - // console.log("🌈🌈🌈🌈🌈", sketchFile.name); - // console.log("🌈🌈🌈🌈🌈", cdnRef); - sketchFile.content = cdnRef; - sketchFile.url = cdnRef; - // newProject.files[1].content = newProject.files[1].content.replace(`'data/${sketchFile.name}'`, `'${rawGitRef}'`); - resolve(newProject); - }); - } - })) - )).then(() => newProjectList); + if ( + sketchFile.fileType === 'file' && + sketchFile.content != null && + sketchFile.name.endsWith('.html') !== true && + sketchFile.name.endsWith('.css') !== true && + sketchFile.name.endsWith('.js') === true + ) { + const options = { + url: newProject.files[i].content, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from( + `${clientId}:${clientSecret}` + ).toString('base64')}` + } + }; + + // console.log("CONVERT ME!") + const { data } = await axios.request(options); + newProject.files[i].content = data; + return newProject; + } + if (newProject.files[i].url) { + return new Promise((resolve, reject) => { + // "https://raw.githubusercontent.com/generative-design/Code-Package-p5.js/gg4editor/01_P/P_3_2_1_01/data/FreeSans.otf", + // https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@master/01_P/P_4_3_1_01/data/pic.png + // const rawGitRef = `https://raw.githack.com/${newProject.files[i].url.split('.com/')[1]}`; + const cdnRef = `https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@${branchName}${ + newProject.files[i].url.split(branchName)[1] + }`; + // console.log("🌈🌈🌈🌈🌈", sketchFile.name); + // console.log("🌈🌈🌈🌈🌈", cdnRef); + sketchFile.content = cdnRef; + sketchFile.url = cdnRef; + // newProject.files[1].content = newProject.files[1].content.replace(`'data/${sketchFile.name}'`, `'${rawGitRef}'`); + resolve(newProject); + }); + } + }) + ) + ) + ).then(() => newProjectList); /* eslint-enable */ } diff --git a/server/scripts/examples.js b/server/scripts/examples.js index 39e974a873..9ea067e845 100644 --- a/server/scripts/examples.js +++ b/server/scripts/examples.js @@ -4,7 +4,7 @@ import mongoose from 'mongoose'; import objectID from 'bson-objectid'; import shortid from 'shortid'; import { defaultCSS, defaultHTML } from '../domain-objects/createDefaultFiles'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const clientId = process.env.GITHUB_ID; diff --git a/server/tsconfig.json b/server/tsconfig.json index 3cd303094b..3242826595 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -6,6 +6,6 @@ "lib": ["ES2022"], "types": ["node"], }, - "include": ["./**/*"], + "include": ["./**/*", "../types"], "exclude": ["../node_modules", "../client"] } diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts new file mode 100644 index 0000000000..7703f7e010 --- /dev/null +++ b/server/types/apiKey.ts @@ -0,0 +1,11 @@ +import { DocumentWithTimestampAndVirtualId } from './mongoose'; + +export interface ApiKey { + label: string; + lastUsedAt?: Date; + hashedKey: string; +} + +export interface ApiKeyDocument + extends ApiKey, + DocumentWithTimestampAndVirtualId {} diff --git a/server/types/email.ts b/server/types/email.ts new file mode 100644 index 0000000000..fd36d7cad8 --- /dev/null +++ b/server/types/email.ts @@ -0,0 +1,5 @@ +export enum EmailConfirmationStates { + Verified = 'verified', + Sent = 'sent', + Resent = 'resent' +} diff --git a/server/types/express.d.ts b/server/types/express.d.ts new file mode 100644 index 0000000000..128c9faa29 --- /dev/null +++ b/server/types/express.d.ts @@ -0,0 +1,13 @@ +import { UserDocument } from './user'; + +declare global { + namespace Express { + interface User extends UserDocument {} + interface Request { + user?: UserDocument; + logIn: (user: UserDocument, callback: (err?: any) => void) => void; + logOut: (callback: (err?: any) => void) => void; + isAuthenticated: () => boolean; + } + } +} diff --git a/server/types/index.ts b/server/types/index.ts new file mode 100644 index 0000000000..6efd8a00fb --- /dev/null +++ b/server/types/index.ts @@ -0,0 +1,5 @@ +export * from './apiKey'; +export * from './email'; +export * from './mongoose'; +export * from './user'; +export * from './userPreferences'; diff --git a/server/types/mongoose.ts b/server/types/mongoose.ts new file mode 100644 index 0000000000..eba38c599e --- /dev/null +++ b/server/types/mongoose.ts @@ -0,0 +1,16 @@ +import mongoose, { Document, Types } from 'mongoose'; + +export interface DocumentTimestamp { + updatedAt?: Date; + createdAt?: Date; +} + +export interface VirtualId { + id: string | mongoose.ObjectId; +} + +/** Mongoose document with virtual id and timestamps enabled */ +export interface DocumentWithTimestampAndVirtualId + extends Omit, 'id'>, + DocumentTimestamp, + VirtualId {} diff --git a/server/types/user.ts b/server/types/user.ts new file mode 100644 index 0000000000..86a0eaa713 --- /dev/null +++ b/server/types/user.ts @@ -0,0 +1,69 @@ +import { Model } from 'mongoose'; +import { UserPreferences, CookieConsentOptions } from './userPreferences'; +import { EmailConfirmationStates } from './email'; +import { ApiKeyDocument } from './apiKey'; +import { DocumentWithTimestampAndVirtualId } from './mongoose'; + +export interface UserInterface { + name: string; + username: string; + password?: string; + resetPasswordToken?: string; + resetPasswordExpires?: number; + verified?: string; + verifiedToken?: string | null; + verifiedTokenExpires?: number | null; + github?: string; + google?: string; + email: string; + tokens: { kind: string }[]; + apiKeys: ApiKeyDocument[]; + preferences: UserPreferences; + totalSize: number; + cookieConsent: CookieConsentOptions; + banned: boolean; + lastLoginTimestamp?: Date; +} + +/** Sanitised version of the user document without sensitive info */ +export interface PublicUserDocument + extends Pick< + UserDocument, + | 'email' + | 'username' + | 'preferences' + | 'apiKeys' + | 'verified' + | 'id' + | 'totalSize' + | 'github' + | 'google' + | 'cookieConsent' + > {} + +export interface UserDocument + extends UserInterface, + DocumentWithTimestampAndVirtualId { + comparePassword(candidatePassword: string): Promise; + findMatchingKey( + candidateKey: string + ): Promise<{ isMatch: boolean; keyDocument: UserDocument | null }>; +} + +export interface UserModel extends Model { + findByEmail(email: string | string[]): Promise; + findAllByEmails(emails: string[]): Promise; + findByUsername( + username: string, + options?: { caseInsensitive: boolean } + ): Promise; + findByEmailOrUsername( + value: string, + options?: { caseInsensitive: boolean; valueType: 'email' | 'username' } + ): Promise; + findByEmailAndUsername( + email: string, + username: string + ): Promise; + EmailConfirmation: typeof EmailConfirmationStates; +} diff --git a/server/types/userPreferences.ts b/server/types/userPreferences.ts new file mode 100644 index 0000000000..20fe3b116f --- /dev/null +++ b/server/types/userPreferences.ts @@ -0,0 +1,28 @@ +export enum AppThemeOptions { + LIGHT = 'light', + DARK = 'dark', + CONTRAST = 'contrast' +} + +export interface UserPreferences { + fontSize: number; + lineNumbers: boolean; + indentationAmount: number; + isTabIndent: boolean; + autosave: boolean; + linewrap: boolean; + lintWarning: boolean; + textOutput: boolean; + gridOutput: boolean; + theme: AppThemeOptions; + autorefresh: boolean; + language: string; + autocloseBracketsQuotes: boolean; + autocompleteHinter: boolean; +} + +export enum CookieConsentOptions { + NONE = 'none', + ESSENTIAL = 'essential', + ALL = 'all' +} diff --git a/server/views/404Page.js b/server/views/404Page.js index 7c864f4009..50f40ea206 100644 --- a/server/views/404Page.js +++ b/server/views/404Page.js @@ -1,9 +1,10 @@ -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const insertErrorMessage = (htmlFile) => { const html = htmlFile.split(''); - const metaDescription = 'A web editor for p5.js, a JavaScript library with the goal of making coding accessible to artists, designers, educators, and beginners.'; // eslint-disable-line + const metaDescription = + 'A web editor for p5.js, a JavaScript library with the goal of making coding accessible to artists, designers, educators, and beginners.'; // eslint-disable-line html[0] = ` ${html[0]} diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000000..458c5808f2 --- /dev/null +++ b/types/index.ts @@ -0,0 +1 @@ +export * from '../server/types';