From 4f4f28ad678848cb7eeb2b75d1200421193bbbd8 Mon Sep 17 00:00:00 2001 From: Hoang Date: Thu, 23 Sep 2021 02:42:45 +0700 Subject: [PATCH] Validate request body with ajv (#131) * Fix withUser of undefined * Add validation using ajv * fix --- README.md | 3 +- api-lib/constants.js | 15 ++++ api-lib/db/post.js | 2 +- api-lib/middlewares/ajv.js | 18 ++++ api-lib/middlewares/index.js | 1 + package.json | 1 + pages/api/posts/[postId]/comments/index.js | 54 ++++++----- pages/api/posts/index.js | 38 ++++---- pages/api/user/index.js | 58 +++++++----- pages/api/user/password/index.js | 56 +++++++----- pages/api/user/password/reset.js | 100 +++++++++++---------- pages/api/users.js | 80 +++++++++-------- 12 files changed, 261 insertions(+), 165 deletions(-) create mode 100644 api-lib/constants.js create mode 100644 api-lib/middlewares/ajv.js diff --git a/README.md b/README.md index 81b2027..923e357 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ This project uses the following dependencies: - `next-connect` - recommended if you want to use Express/Connect middleware and easier method routing. - `next-session`, `connect-mongo` - required for session, may be replaced with other session libraries such as `cookie-session`, `next-iron-session`, or `express-session` (`express-session` is observed not to work properly on Next.js 11+). - `bcryptjs` - optional, may be replaced with any password-hashing library. `argon2` recommended. -- `validator` - optional but recommended. +- `validator` - optional but recommended, to validate email. +- `ajv` - optional but recommended, to validate request body. - `multer` - may be replaced with any middleware that handles `multipart/form-data` - `cloudinary` - optional, **only if** you are using [Cloudinary](https://cloudinary.com) for image upload. diff --git a/api-lib/constants.js b/api-lib/constants.js new file mode 100644 index 0000000..9c88dcd --- /dev/null +++ b/api-lib/constants.js @@ -0,0 +1,15 @@ +export const ValidateProps = { + user: { + username: { type: 'string', minLength: 4, maxLength: 20 }, + name: { type: 'string', minLength: 1, maxLength: 50 }, + password: { type: 'string', minLength: 8 }, + email: { type: 'string', minLength: 1 }, + bio: { type: 'string', minLength: 0, maxLength: 160 }, + }, + post: { + content: { type: 'string', minLength: 1, maxLength: 280 }, + }, + comment: { + content: { type: 'string', minLength: 1, maxLength: 280 }, + }, +}; diff --git a/api-lib/db/post.js b/api-lib/db/post.js index 6e0dd29..9cb9edd 100644 --- a/api-lib/db/post.js +++ b/api-lib/db/post.js @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid'; import { dbProjectionUsers } from './user'; -export async function findPostById(db, id) { +export async function findPostById(db, id, withUser) { const post = await db.collection('posts').findOne({ _id: id }); if (!post) return null; post.creator = await db diff --git a/api-lib/middlewares/ajv.js b/api-lib/middlewares/ajv.js new file mode 100644 index 0000000..330a0e4 --- /dev/null +++ b/api-lib/middlewares/ajv.js @@ -0,0 +1,18 @@ +import Ajv from 'ajv'; + +export function validateBody(schema) { + const ajv = new Ajv(); + const validate = ajv.compile(schema); + return (req, res, next) => { + const valid = validate(req.body); + if (valid) { + return next(); + } else { + console.log(validate.errors); + const error = validate.errors[0]; + return res + .status(400) + .end(`"${error.instancePath.substring(1)}" ${error.message}`); + } + }; +} diff --git a/api-lib/middlewares/index.js b/api-lib/middlewares/index.js index 5bb1954..3b15914 100644 --- a/api-lib/middlewares/index.js +++ b/api-lib/middlewares/index.js @@ -1,3 +1,4 @@ +export { validateBody } from './ajv'; export { default as all } from './all'; export { default as auth } from './auth'; export { default as database } from './database'; diff --git a/package.json b/package.json index 5a54053..33c3dd1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "homepage": "https://github.com/hoangvvo/nextjs-mongodb-app#readme", "dependencies": { + "ajv": "^8.6.3", "bcryptjs": "^2.4.3", "cloudinary": "^1.27.0", "connect-mongo": "^4.6.0", diff --git a/pages/api/posts/[postId]/comments/index.js b/pages/api/posts/[postId]/comments/index.js index 5e9f929..6094f05 100644 --- a/pages/api/posts/[postId]/comments/index.js +++ b/pages/api/posts/[postId]/comments/index.js @@ -1,6 +1,7 @@ +import { ValidateProps } from '@/api-lib/constants'; import { findPostById } from '@/api-lib/db'; import { findComments, insertComment } from '@/api-lib/db/comment'; -import { all } from '@/api-lib/middlewares'; +import { all, validateBody } from '@/api-lib/middlewares'; import { ncOpts } from '@/api-lib/nc'; import nc from 'next-connect'; @@ -25,28 +26,35 @@ handler.get(async (req, res) => { return res.send({ comments }); }); -handler.post(async (req, res) => { - if (!req.user) { - return res.status(401).send('unauthenticated'); +handler.post( + validateBody({ + type: 'object', + properties: { + content: ValidateProps.comment.content, + }, + required: ['content'], + additionalProperties: false, + }), + async (req, res) => { + if (!req.user) { + return res.status(401).send('unauthenticated'); + } + + const content = req.body.content; + + const post = await findPostById(req.db, req.query.postId); + + if (!post) { + return res.status(404).send('post not found'); + } + + const comment = await insertComment(req.db, post._id, { + creatorId: req.user._id, + content, + }); + + return res.json({ comment }); } - - const content = req.body.content; - if (!content) { - return res.status(400).send('You must write something'); - } - - const post = await findPostById(req.db, req.query.postId); - - if (!post) { - return res.status(404).send('post not found'); - } - - const comment = await insertComment(req.db, post._id, { - creatorId: req.user._id, - content, - }); - - return res.json({ comment }); -}); +); export default handler; diff --git a/pages/api/posts/index.js b/pages/api/posts/index.js index 5660ef0..eb2f40d 100644 --- a/pages/api/posts/index.js +++ b/pages/api/posts/index.js @@ -1,5 +1,6 @@ +import { ValidateProps } from '@/api-lib/constants'; import { findPosts, insertPost } from '@/api-lib/db'; -import { all } from '@/api-lib/middlewares'; +import { all, validateBody } from '@/api-lib/middlewares'; import { ncOpts } from '@/api-lib/nc'; import nc from 'next-connect'; @@ -18,20 +19,27 @@ handler.get(async (req, res) => { res.send({ posts }); }); -handler.post(async (req, res) => { - if (!req.user) { - return res.status(401).send('unauthenticated'); +handler.post( + validateBody({ + type: 'object', + properties: { + content: ValidateProps.post.content, + }, + required: ['content'], + additionalProperties: false, + }), + async (req, res) => { + if (!req.user) { + return res.status(401).send('unauthenticated'); + } + + const post = await insertPost(req.db, { + content: req.body.content, + creatorId: req.user._id, + }); + + return res.json({ post }); } - - if (!req.body.content) - return res.status(400).send('You must write something'); - - const post = await insertPost(req.db, { - content: req.body.content, - creatorId: req.user._id, - }); - - return res.json({ post }); -}); +); export default handler; diff --git a/pages/api/user/index.js b/pages/api/user/index.js index f910d52..a1f1317 100644 --- a/pages/api/user/index.js +++ b/pages/api/user/index.js @@ -1,5 +1,6 @@ +import { ValidateProps } from '@/api-lib/constants'; import { updateUserById } from '@/api-lib/db'; -import { all } from '@/api-lib/middlewares'; +import { all, validateBody } from '@/api-lib/middlewares'; import { ncOpts } from '@/api-lib/nc'; import { v2 as cloudinary } from 'cloudinary'; import multer from 'multer'; @@ -30,30 +31,41 @@ handler.get(async (req, res) => { return res.json({ user: u }); }); -handler.patch(upload.single('profilePicture'), async (req, res) => { - if (!req.user) { - req.status(401).end(); - return; - } - let profilePicture; - if (req.file) { - const image = await cloudinary.uploader.upload(req.file.path, { - width: 512, - height: 512, - crop: 'fill', - }); - profilePicture = image.secure_url; - } - const { name, bio } = req.body; +handler.patch( + validateBody({ + type: 'object', + properties: { + name: ValidateProps.user.name, + bio: ValidateProps.user.bio, + }, + additionalProperties: false, + }), + upload.single('profilePicture'), + async (req, res) => { + if (!req.user) { + req.status(401).end(); + return; + } + let profilePicture; + if (req.file) { + const image = await cloudinary.uploader.upload(req.file.path, { + width: 512, + height: 512, + crop: 'fill', + }); + profilePicture = image.secure_url; + } + const { name, bio } = req.body; - const user = await updateUserById(req.db, req.user._id, { - ...(name && { name }), - ...(typeof bio === 'string' && { bio }), - ...(profilePicture && { profilePicture }), - }); + const user = await updateUserById(req.db, req.user._id, { + ...(name && { name }), + ...(typeof bio === 'string' && { bio }), + ...(profilePicture && { profilePicture }), + }); - res.json({ user }); -}); + res.json({ user }); + } +); export const config = { api: { diff --git a/pages/api/user/password/index.js b/pages/api/user/password/index.js index 81e14aa..c38fb40 100644 --- a/pages/api/user/password/index.js +++ b/pages/api/user/password/index.js @@ -1,5 +1,6 @@ +import { ValidateProps } from '@/api-lib/constants'; import { UNSAFE_findUserForAuth, updateUserById } from '@/api-lib/db'; -import { all } from '@/api-lib/middlewares'; +import { all, validateBody } from '@/api-lib/middlewares'; import { ncOpts } from '@/api-lib/nc'; import bcrypt from 'bcryptjs'; import nc from 'next-connect'; @@ -7,29 +8,40 @@ import nc from 'next-connect'; const handler = nc(ncOpts); handler.use(all); -handler.put(async (req, res) => { - if (!req.user) { - res.json(401).send('you need to be authenticated'); - return; - } - const { oldPassword, newPassword } = req.body; +handler.put( + validateBody({ + type: 'object', + properties: { + oldPassword: ValidateProps.user.password, + newPassword: ValidateProps.user.password, + }, + required: ['oldPassword', 'newPassword'], + additionalProperties: false, + }), + async (req, res) => { + if (!req.user) { + res.json(401).send('you need to be authenticated'); + return; + } + const { oldPassword, newPassword } = req.body; - if ( - !(await bcrypt.compare( - oldPassword, - ( - await UNSAFE_findUserForAuth(req.db, req.user._id, true) - ).password - )) - ) { - res.status(401).send('The password you has entered is incorrect.'); - return; - } - const password = await bcrypt.hash(newPassword, 10); + if ( + !(await bcrypt.compare( + oldPassword, + ( + await UNSAFE_findUserForAuth(req.db, req.user._id, true) + ).password + )) + ) { + res.status(401).send('The password you has entered is incorrect.'); + return; + } + const password = await bcrypt.hash(newPassword, 10); - await updateUserById(req.db, req.user._id, { password }); + await updateUserById(req.db, req.user._id, { password }); - res.end('ok'); -}); + res.end('ok'); + } +); export default handler; diff --git a/pages/api/user/password/reset.js b/pages/api/user/password/reset.js index f8343bd..abfbf09 100644 --- a/pages/api/user/password/reset.js +++ b/pages/api/user/password/reset.js @@ -1,11 +1,7 @@ -import { - createToken, - findAndDeleteTokenByIdAndType, - findUserByEmail, - updateUserById, -} from '@/api-lib/db'; +import { ValidateProps } from '@/api-lib/constants'; +import { createToken, findUserByEmail, updateUserById } from '@/api-lib/db'; import { CONFIG as MAIL_CONFIG, sendMail } from '@/api-lib/mail'; -import { database } from '@/api-lib/middlewares'; +import { database, validateBody } from '@/api-lib/middlewares'; import { ncOpts } from '@/api-lib/nc'; import bcrypt from 'bcryptjs'; import nc from 'next-connect'; @@ -14,54 +10,68 @@ const handler = nc(ncOpts); handler.use(database); -handler.post(async (req, res) => { - const user = await findUserByEmail(req.db, req.body.email); - if (!user) { - res.status(401).send('The email is not found'); - return; - } +handler.post( + validateBody({ + type: 'object', + properties: { + email: ValidateProps.user.email, + }, + required: ['email'], + additionalProperties: false, + }), + async (req, res) => { + const user = await findUserByEmail(req.db, req.body.email); + if (!user) { + res.status(401).send('The email is not found'); + return; + } - const token = await createToken(req.db, { - creatorId: user._id, - type: 'passwordReset', - expireAt: new Date(Date.now() + 1000 * 60 * 20), - }); + const token = await createToken(req.db, { + creatorId: user._id, + type: 'passwordReset', + expireAt: new Date(Date.now() + 1000 * 60 * 20), + }); - await sendMail({ - to: user.email, - from: MAIL_CONFIG.from, - subject: '[nextjs-mongodb-app] Reset your password.', - html: ` + await sendMail({ + to: user.email, + from: MAIL_CONFIG.from, + subject: '[nextjs-mongodb-app] Reset your password.', + html: `

Hello, ${user.name}

Please follow this link to reset your password.

`, - }); - - res.end('ok'); -}); + }); -handler.put(async (req, res) => { - // password reset - if (!req.body.password) { - res.status(400).send('Password not provided'); - return; + res.end('ok'); } +); - const deletedToken = await findAndDeleteTokenByIdAndType( - req.db, - req.body.token, - 'passwordReset' - ); - - if (!deletedToken) { - res.status(403).send('This link may have been expired.'); - return; +handler.put( + validateBody({ + type: 'object', + properties: { + password: ValidateProps.user.password, + token: { type: 'string', minLength: 0 }, + }, + required: ['password', 'token'], + additionalProperties: false, + }), + async (req, res) => { + const deletedToken = await findAndDeleteTokenByIdAndType( + req.db, + req.body.token, + 'passwordReset' + ); + if (!deletedToken) { + res.status(403).send('This link may have been expired.'); + return; + } + const password = await bcrypt.hash(req.body.password, 10); + await updateUserById(req.db, deletedToken.creatorId, { password }); + res.end('ok'); } - const password = await bcrypt.hash(req.body.password, 10); - await updateUserById(req.db, deletedToken.creatorId, { password }); - res.end('ok'); -}); +); export default handler; diff --git a/pages/api/users.js b/pages/api/users.js index f06d1fd..ccd9feb 100644 --- a/pages/api/users.js +++ b/pages/api/users.js @@ -1,5 +1,6 @@ +import { ValidateProps } from '@/api-lib/constants'; import { findUserByEmail, findUserByUsername, insertUser } from '@/api-lib/db'; -import { all } from '@/api-lib/middlewares'; +import { all, validateBody } from '@/api-lib/middlewares'; import { ncOpts } from '@/api-lib/nc'; import { slugUsername } from '@/lib/user'; import bcrypt from 'bcryptjs'; @@ -11,40 +12,49 @@ const handler = nc(ncOpts); handler.use(all); -handler.post(async (req, res) => { - let { username, name, email, password } = req.body; - if (!username || !email || !password || !name) { - res.status(400).send('Missing field(s)'); - return; - } - username = slugUsername(req.body.username); - email = normalizeEmail(req.body.email); - if (!isEmail(email)) { - res.status(400).send('The email you entered is invalid.'); - return; - } - if (await findUserByEmail(req.db, email)) { - res.status(403).send('The email has already been used.'); - return; - } - if (await findUserByUsername(req.db, username)) { - res.status(403).send('The username has already been taken.'); - return; - } - const hashedPassword = await bcrypt.hash(password, 10); - const user = await insertUser(req.db, { - email, - password: hashedPassword, - bio: '', - name, - username, - }); - req.logIn(user, (err) => { - if (err) throw err; - res.status(201).json({ - user, +handler.post( + validateBody({ + type: 'object', + properties: { + username: ValidateProps.user.username, + name: ValidateProps.user.name, + password: ValidateProps.user.password, + email: ValidateProps.user.email, + }, + required: ['username', 'name', 'password', 'email'], + additionalProperties: false, + }), + async (req, res) => { + let { username, name, email, password } = req.body; + username = slugUsername(req.body.username); + email = normalizeEmail(req.body.email); + if (!isEmail(email)) { + res.status(400).send('The email you entered is invalid.'); + return; + } + if (await findUserByEmail(req.db, email)) { + res.status(403).send('The email has already been used.'); + return; + } + if (await findUserByUsername(req.db, username)) { + res.status(403).send('The username has already been taken.'); + return; + } + const hashedPassword = await bcrypt.hash(password, 10); + const user = await insertUser(req.db, { + email, + password: hashedPassword, + bio: '', + name, + username, + }); + req.logIn(user, (err) => { + if (err) throw err; + res.status(201).json({ + user, + }); }); - }); -}); + } +); export default handler;