- Node
- Modules
- Express
- Environment Variables
- MongoDB
- Mongoose
- Connection
- Schemas and Models
- Mongoose Crud Documents From Models
- Simple Querying Functionality
- Advanced Filtering
- Sorting
- Projection (Field Limiting)
- Pagination
- Aggregation Pipelining - Matching & Grouping
- Virtual Properties
- Mongoose Middlewares (pre & post hooks)
- Data Validation
- Custom Validators
- Handling unhandled Routes
- Merging Error Creation Using Classes
- Catching All
try... catch
Errors in a Go - Unhandled Promise Rejections
- Uncaught Exceptions
- Advanced Error Handling
- Authentication, Authorization & Security
- Other Security Concerns
- Data Modelling
- Server Side Rendering With Pug
const fs = require('fs');
// Readinng
const textRead = fs.readFileSync(
'./path/to/file/to/read.txt',
'utf-8'
);
// Writing
const textWrite = `Some read info...`;
fs.writeFileSync('./path/to/file/to/write.txt', textWrite);
fs.readFile('./path/to/file/to/read.txt', 'utf-8', (err, data1) => {
// View data... If error, do sth.
}
// Callback is always optional
fs.writeFile('./path/to/file/to/write.txt', `${textWrite}`, 'utf8', (err /*No data*/) => {
console.log('File written asyncly 😁');
});
const http = require('http');
const server = http.createServer((req, res) => {
res.end('Response from server!');
});
// * Callback is optional but shows listening has started successfully
server.listen(8000, '127.0.0.1', () => {
console.log('Server listening started...');
});
const server = http.createServer((req, res) => {
const path = req.url;
if (path === '/' || path === '/home') {
res.end('Welcome home');
} else if (path === '/exp') {
res.end('Experience page');
} else {
// res.writeHead(404);
// res.end('Page not found!');
// ? You can include your own headers as status message
res.writeHead(404, {
'Content-type': 'text/html',
'custom-header': 'some own header text',
});
res.end('<h3>Page not found pal!</h3>');
}
});
const EventEmmiter = require('events');
class Project extends EventEmmiter {
constructor() {
super();
}
}
const myEmmiter = new Project();
myEmmiter.on('newProject', () => {
console.log('Start of a new project');
});
// Several liteners can be set on the same event
myEmmiter.on('newProject', (type) => {
console.log(`The project is on ${type}`);
});
// Other arguments after emmiter are parameters to the listeners
myEmmiter.emit('newProject', 'Computer Science');
Is an efficient way of data transmision in which info is read in chunks and sent to client without waiting for the entire read process to end, save it to a variable then respond. It clears the memory whenever a readable stream
is complete and sends it back as data
event.
const fs = require('fs');
const server = require('http').createServer();
server.on('request', (req, res) => {
const readable = fs.createReadStream('big-file.txt');
readable.on('data', (chunk) => {
res.write(chunk);
});
readable.on('end', () => {
res.end();
});
readable.on('error', (err) => {
console.log(err);
res.statusCode = 500;
res.end('File not found!');
});
});
server.listen(8000, '127.0.0.1', () => {
console.log('Listening...');
});
Thou this works, Backpressure
can occur as the readable stream is much faster than the writable and therefore overwhelms it. The solution is using the pipe()
where, readableSource.pipe(writableDestination)
, where it regulates how fast the process happens. Eg for the above:
const fs = require('fs');
const server = require('http').createServer();
server.on('request', (req, res) => {
const readable = fs.createReadStream('test-file.txt');
readable.pipe(res);
});
server.listen(8000, '127.0.0.1', () => {
console.log('Listening...');
});
On requiring a module, it's wrapped in a IEFE
as below which is seen in logging require('module').wrapper
.
[
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];
Exports can be done individually or as a group.
class Calculator {
add(a, b) {
return a + b;
}
multiply(a, b) {
return a * b;
}
}
module.exports = Calculator;
Then imported and used as:
const Calc = require('./Calculator');
const calc1 = new Calc();
calc1.add(1, 2);
exports.subtract = (a, b) => a - b;
exports.divide = (a, b) => a / b;
const { subtract, divide } = require('./02-multipleExports');
divide(9, 3);
Build on top of Node.
const express = require('express');
const fs = require('fs');
const app = express();
// Middleware
app.use(express.json());
const tours = JSON.parse(
fs.readFileSync(`${__dirname}/dev-data/data/tours-simple.json`)
);
app.get('/api/v1/tours', (req, res) => {
res.status(200).json({
status: 'success',
results: tours.length,
data: { tours },
});
});
app.post('/api/v1/tours', (req, res) => {
const newId = tours[tours.length - 1].id + 1;
const newTour = Object.assign({ id: newId }, req.body);
tours.push(newTour);
fs.writeFile(
`${__dirname}/dev-data/data/tours-simple.json`,
JSON.stringify(tours),
(err) => {
console.log(err);
}
);
// 201 for creation
res.status(201).json({
status: 'success',
data: {
tour: newTour,
},
});
});
app.listen(3000, () => {
console.log('App running on port 3000...');
});
Middleware is a function can be used in post
to modify incoming request data. It's created as:
app.use(express.json());
app.get('/api/v1/tours/:id', (req, res) => {
const id = req.params.id * 1;
const tour = tours.find((el) => el.id === id);
if (!tour) {
return res.status(404).json({
status: 'fail',
message: 'Invalid ID!',
});
}
res.status(200).json({
status: 'success',
data: { tour },
});
});
app.patch('/api/v1/tours/:id', (req, res) => {
// Search ID and update contents
if (req.params.id * 1 >= tours.length) {
return res.status(404).json({
status: 'fail',
message: 'Invalid ID!',
});
}
res.status(200).json({
status: 'success',
data: {
tour: '<Updated tour>',
},
});
});
app.delete('/api/v1/tours/:id', (req, res) => {
// Search ID and update contents
// 204 No Content
res.status(204).json({
status: 'success',
data: null,
});
});
The above could be separated into functions and further combine related paths using app.route
app.route('/api/v1/tours').get(getAllTours).post(createTour);
app
.route('/api/v1/tours/:id')
.get(getTour)
.patch(updateTour)
.delete(deleteTour);
They are like subfunctions in express. Almost everything is a middleware including the routing and data transfer that all make up the request-response cycle
. Every middleware has access to a next()
function which must be called unless it is a final middleware which sends data back res.send
. If not called, the middlewares coming after it wont be run. If a .send()
is called before other middlewares of the same or non-specified paths, then thats where middleware execution stops.
Middlewares without specific paths are run for every code execution.
app.use((req, res, next) => {
console.log('Middleware called 👋');
next();
});
It can also me used to add properties to the request to use later, eg adding time like:
app.use((req, res, next) => {
req.timeRequested = new Date().toISOString();
next();
});
Mounting an express router
on another in order to distribute workload.
const tourRouter = express.Router();
tourRouter.route('/').get(getAllTours).post(createTour);
tourRouter
.route('/:id')
.get(getTour)
.patch(updateTour)
.delete(deleteTour);
app.use('/api/v1/tours', tourRouter);
Middleware that runs only for a particular parameter specified in request url. Eg for id
parameter:
router.param('id', (req, res, next, val) => {
console.log(`ID is ${val}`);
next();
});
It has the 4th argument which holds the value of the param. Can be used to validate the id for every router that needs it.
exports.checkID = (req, res, next, val) => {
console.log(`ID is ${val}`);
if (req.params.id * 1 >= tours.length) {
return res.status(404).json({
status: 'fail',
message: 'Invalid ID!',
});
}
router
.route('/')
.get(getAllTours)
.post(tourControllers.checkBody, createTour);
The tourControllers.checkBody
is an extra middleware checking if the createTour
request body has all necessary parameters before it being created.
exports.checkBody = (req, res, next) => {
const tourBody = req.body;
const { id, price } = tourBody;
if (!id || !price) {
return res.status(400).json({
status: 'fail',
message: 'Tour price and ID are required!!',
});
}
next();
};
Are files that can't be accessed from the url. express.static
middleware is used to provide the dir with the static files which will then act as root. So to access any file inside the dir, go to localhost then the path to file without including the parent dir.
app.use(express.static(`${__dirname}/public`));
To access: 127.0.0.1:<portNum>/fileName.txt, html...
if fileName
is a file in public
folder.
These are outside of express
and are created by node. To view the env var by express:
console.log(app.get('env')); // Returns 'environment
console.log(process.env);
Variables created by node can be seen through:
console.log(process.env);
env vars
are configured in the config.env
file and injected in nodes env vars using dotenv
package.
- In
config.env
NODE_ENV = development;
PORT = 3000;
- In
server.js
before requiringapp.js
in order to have access
const dotenv = require('dotenv');
dotenv.config({ path: './config.env' });
Depending on set envs, different codes can be run at different places eg in the package.json
s scripts, 2 npm starts can be set for either in production
or development
.
"start:dev": "nodemon server.js",
"start:prod": "NODE_ENV=production nodemon server.js"
And back in app.js
:
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
- Create db
use dbName
- Create collections within a
document
. The objects are input like inJSON
but will be stored asBSON
.db.docName.insertMany({}, {});
-
See all dbs or collections
show dbs db.docName.find()
-
Search for specific collection
db.docName.find({property<SearchParam>: "value"})
-
Query for specific range in collection
db.docName.find({ price: { $lte: 300 } }); // Will return all prices <=300
-
And query
db.docName.find({ price: { $lte: 300 }, age: { $gt: 20 } });
-
Or query
db.docName.find({ $or: [{ price: { $lte: 300 } }, { age: { $gt: 20 } }], });
-
Object projection - Selecting specific fields from the output. Eg to get only the names:
db.docName.find( { price: { $lte: 300 }, age: { $gt: 20 } }, { name: 1 } );
You can use updateOne
or updateMany
as needed, and use $set
var for setting.
db.docName.updateOne(
{ name: 'Some Namme' },
{ $set: { age: 'Age to set' } }
);
Replacing a collection is similar to update. Pass in the search criteria and its done.
db.docName.replaceOne({<SearchQuery>}, {<NewData>});
Use deleteOne
or deleteMany
together with search query.
db.docName.deleteOne({<SearchQuery>});
To delete all, search query is left empty:
db.docName.deleteMany({});
mongoose
.connect(process.env.ENCODED_DATABASE_PASSWORD, {
useNewUrlParser: true,
})
.then((con) => {
// console.log(con.connection.name); DB name
console.log('DB connection successful...');
});
A schema is a baseline for describing a model. It contains the properties and data types with other specifications. A model is like a blueprint made out of schema objects used in creation of a database.
const tourSchema = mongoose.Schema({
name: {
type: String,
required: [true, 'A tour needs a name'],
unique: true,
},
rating: {
type: Number,
default: 4.5,
},
price: {
type: Number,
required: [true, 'A tour needs a price'],
},
difficulty: String,
});
const Tour = mongoose.model('Tour', tourSchema);
The docs are created as insatnces of models. The new Tour()
returns a promise that resolves with the document if no errors are encountered.
const testTour = new Tour({
name: 'The Park Camper',
price: 997,
});
testTour
.save()
.then((doc) => {
console.log(doc);
})
.catch((err) => console.log('Error 💥💥', err));
exports.createTour = async (req, res) => {
try {
const newTour = await Tour.create(req.body);
res.status(201).json({
status: 'success',
data: {
tour: newTour,
},
});
} catch (err) {
res.status(400).json({
status: 'fail',
message: err,
});
}
};
Only the query changes:
const tour = await Tour.findById(req.params.id);
Several methods can be used including findIdAndUpdate
which takes the ID to search for, the body to update to and options eg new
returns a new tour after the update, runValidators
will run the update through the described validations in the schema
.
const tour = await Tour.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
const tour = await Tour.findOneAndDelete(req.params.id);
To query a parameter sent through the url:
const tours = await Tour.find(req.query);
const tours = await Tour.find({
key: value,
key2: val2,
});
const tours = await Tour.find()
.where('duration')
.gte(5)
.where('difficulty')
.equals('easy');
Sample query url:
127.0.0.1:3000/api/v1/tours/?difficulty=easy&duration[gte]=5&page=1&duration[lt]=9
Parameters such as greater than
and the likes are put in square brackets. But upon calling the req.query
, they miss the $
as it is to be in mongoDB
queries. So the addition is done manually as:
let queryStr = JSON.stringify(queryObj);
queryStr = queryStr.replace(
/\b(gt|lt|gte|lte)\b/g,
(match) => `$${match}`
);
console.log(JSON.parse(queryStr));
let query = Tour.find(JSON.parse(queryStr));
const tours = await query;
Plus the await is done on the complete query after manipulation.
Url with such formatting:
127.0.0.1:3000/api/v1/tours/?sort=-price,ratingsAverage
The sorting is done first on prices then ratings. Since the query func expects them space separated, split them up based on comma then rejoin with space to pass them in as parameters.
if (req.query.sort) {
const sortBy = req.query.sort.split(',').join(' ');
console.log(sortBy);
query = query.sort(sortBy); // sort(param1, param2) === sort(param1 param2)
}
A negative before the param makes it sorted in desc.
Sample url
127.0.0.1:3000/api/v1/tours/?fields=name,price,duration,difficulty
A negative before excludes it from the selection.
if (req.query.fields) {
const fields = req.query.fields.split(',').join(' ');
query = query.select(fields);
} else {
query = query.select('-__v');
}
Sensitive info can be excluded from the schema by setting select
to false
.
createdAt: {
type: Date,
default: Date.now(),
select: false,
}
Specified using skip()
and limit()
.
const limit = req.query.limit || 100;
const page = req.query.page || 1;
const skip = (page - 1) * limit;
query = query.skip(skip).limit(limit);
if (req.query.page) {
const totalTours = await Tour.countDocuments();
if (limit * page > totalTours)
throw new Error("Page doesn't exist!!!");
}
Pipelining involves passing a route through several filters executed in the order of appearance and each stage returning specified fields in matching, grouping etc.
router
.route('/tour-stats')
.get(tourControllers.tourStats, getAllTours);
exports.tourStats = async (req, res) => {
try {
const stats = await Tour.aggregate([
{ $match: { ratingsAverage: { $gte: 4.5 } } },
{
$group: {
_id: { $toUpper: '$difficulty' },
numTours: { $sum: 1 },
numRatings: { $sum: '$ratingsQuantity' },
avgRating: { $avg: '$ratingsAverage' },
avgPrice: { $avg: '$price' },
minPrice: { $min: '$price' },
maxPrice: { $max: '$price' },
},
},
{ $sort: { avgPrice: 1 } },
]);
res.status(200).json({
status: 'success',
data: {
stats,
},
});
} catch (err) {
res.status(400).json({
status: 'fail',
message: err,
});
}
};
Unwinding
involves breaking a document into different separate documents each with one value of a property that was an array of the original one document. Eg
exports.getMonthlyPlan = async (req, res) => {
try {
const year = req.params.year * 1;
const plan = await Tour.aggregate([
{ $unwind: '$startDates' },
{
$match: {
startDates: {
$gte: new Date(`${year}-01-01`),
$lte: new Date(`${year}-12-31`),
},
},
},
{
$group: {
_id: { $month: '$startDates' },
numOfTours: { $sum: 1 },
tours: { $push: '$name' },
avgRating: { $avg: '$ratingsAverage' },
},
},
{
$addFields: {
month: '$_id',
},
},
{
$project: {
_id: 0,
},
},
{
$sort: {
numOfTours: -1,
},
},
{
$limit: 12,
},
]);
res.status(200).json({
status: 'success',
data: {
plan,
},
});
} catch (err) {
res.status(400).json({
status: 'fail',
message: err,
});
}
};
Are calculated properties and thus not stored in the database. They also can't be used in queries. In order to be displayed, a second parameter is passed to the parent schema.
const mongoose = require('mongoose');
const tourSchema = mongoose.Schema(
{
name: {
type: String,
required: [true, 'A tour needs a name'],
unique: true,
trim: true,
},
duration: {
type: Number,
required: [true, 'A tour needs a duration'],
},
price: {
type: Number,
required: [true, 'A tour needs a price'],
},
difficulty: {
type: String,
required: [true, 'A tour needs a difficulty'],
},
description: {
type: String,
trim: true,
},
},
{
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
tourSchema.virtual('durationWeeks').get(function () {
return this.duration / 7;
});
This code:
toJSON: { virtuals: true },
toObject: { virtuals: true },
... makes sure that these calculated properties (virtual) that arent stored in the DB always show up whenever a query is made.
They can be used to run a fuctionality between 2 events eg before saving a doc and after. They are defined on the schema & are 4 types:
- Document
- Query
- Aggregate
- Model
Can act on currently processed middleware. They include save
, delete
, remove
and such doc manipulators. The pre
has access to this
which points to the current document being worked on. They all have next()
same as any other middleware.
// Middleware only runs for .create() and .save() but not update()
tourSchema.pre('save', function (next) {
this.slug = slugify(this.name, { lower: true });
next();
});
tourSchema.post('save', function (doc, next) {
console.log(doc);
next();
});
Acts between the request for a query and the time the query is presented as results. Includes find
, findOne
, and such. Here the this
points to a query object and therefore chaining with other queries is posible. A simple pre middleware one for find
would be like:
tourSchema.pre('find', function (next) {
this.find({ secretTour: { $ne: true } });
next();
});
Using regular expressions to track all queries with word find
would be:
tourSchema.pre(/^find/, function (next) {
this.find({ secretTour: { $ne: true } });
this.start = Date.now();
next();
});
tourSchema.post(/^find/, function (docs, next) {
console.log(`Query took ${Date.now() - this.start} milliseconds.`);
next();
});
Run between agregate actions and pipelines. this
refers to any aggregate action. Sample:
tourSchema.pre('aggregate', function (next) {
this.pipeline().unshift({ $match: { secretTour: { $ne: true } } });
console.log(this.pipeline());
next();
});
Logging this.pipeline()
returns the aggregate that was executed (like below) and thus other properties can be added using the unshift
to add to start or shift
.
[
{ $match: { ratingsAverage: [Object] } },
{
$group: {
_id: [Object],
numTours: [Object],
numRatings: [Object],
avgRating: [Object],
avgPrice: [Object],
minPrice: [Object],
maxPrice: [Object],
},
},
{ $sort: { avgPrice: 1 } },
];
They are specified in the model in the schema. Examples are minLength
& maxLength
for strings, min
& max
for nums and dates, enum
for a range of accepted values eg
difficulty: {
type: String,
required: [true, 'A tour needs a difficulty'],
enum: {
values: ['easy', 'medium', 'difficult'],
message: 'Difficulty can only be easy, medium or difficult!',
},
},
To note is the this
only points to current doc on NEW DOC creation and not the same in updating.
priceDiscount: {
type: Number,
validate: {
validator: function (val) {
return this.price > val;
},
message: 'Discount value ({VALUE}) must be below regular price!',
},
}
A third party library likevalidator.js
could be used for most of these.
The app.all()
route is used to handle all requests and *
for all routes.
app.all('*', (req, res, next) => {
res.status(404).json({
status: 'fail',
message: `The url ${req.originalUrl} couldnt be found on server.`,
});
next();
});
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperationalError = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
With this, whenever a new error is to be created, the following is passed to the next
middleware function:
next(
new AppError(
`The url ${req.originalUrl} couldnt be found on server!!!`,
404
)
);
To achieve this, all async funcs/middlewares with try catch
block are called using a high order function that takes this async func as a parameter and calls it, then add a .catch(next)
statement on it so that any unandled promises are caught and passed to the next()
.
The longer syntax for calling the catch
is:
fn(req, res, next).catch((err) => next(err));
The high order function:
module.exports = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
Back at the error generators:
exports.createTour = catchAsync(async(...) => {...}
They can be captured globally using the listener process.on
. The 1
in exit
implies an unhandled exeption.
process.on('unhandledRejection', (err) => {
console.log(err.name, err.message);
console.log('Unandled Rejection, shutting down...');
server.close(() => {
process.exit(1);
});
});
Should be put in server and above most code to listen to all. They include undefined vars and such.
process.on('uncaughtException', (err) => {
console.log(err.name, err.message);
console.log('Uncaught Exception, shutting down...');
process.exit(1);
});
Sending different errors in prod and development could be implemented in the errorcontroller
. This would be as:
if (process.env.NODE_ENV === 'development') {
sendErrorDev(err, res);
} else if (process.env.NODE_ENV === 'development') {
sendErrorProd(err, res);
}
The funcs just serve different levels of info
const sendErrorDev = (err, res) => {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
error: err,
stack: err.stack,
});
};
const sendErrorProd = (err, res) => {
// Operational error
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
// Programming error
} else {
res.status(500).json({
status: 'error',
message: 'Something went wrong!!!',
});
}
};
Made use of bcryptjs
. Salt value is 12 but default is 10. The higher the more cpu intensive. Implemented using a middleware before record save. Only re-encrypted if it has changed and passwordConfirm
is not retained in the database.
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
this.passwordConfirm = undefined;
});
The jwt.sign
takes in the identifier
to use to create the token, the secret key you define to be used that should be atleast 32 chars long for higher sec, and optional options as an object.
exports.signup = catchAsync(async (req, res, next) => {
const newUser = await User.create({
// User creation
});
const token = jwt.sign(
{ id: newUser._id },
process.env.JWT_SECRET_KEY,
{
expiresIn: process.env.JWT_EXPIRES_IN,
}
);
res.status(201).json({
status: 'success',
token,
data: {
user: newUser,
},
});
});
An instance
method is available on alldocs of a certail collection. They are created using schemaName.methods.instanceMethodName
. It is implemented in the model where the schema is also defined. This is used to compare the passwords.
In this method, this.password
could have been used but password is set as not selected and so this wont work and the password now has to be passed in as a parameter.
userSchema.methods.correctPassword = async (
enteredPass,
userPass
) => {
// Could have used this.
return await bcrypt.compare(enteredPass, userPass);
};
Then back to the authController
...
const signToken = (id) => {
return jwt.sign({ id }, process.env.JWT_SECRET_KEY, {
expiresIn: process.env.JWT_EXPIRES_IN,
});
};
exports.login = catchAsync(async (req, res, next) => {
const { email, password } = req.body;
// If details are provided
if (!email || !password) {
return next(
new AppError('Please provide email and password', 404)
);
}
//? If user exists and password is correct
const user = await User.findOne({ email }).select('+password');
console.log(user);
// const correct = await user.correctPassword(password, user.password);
if (
!user ||
!(await user.correctPassword(password, user.password))
) {
return next(new AppError('Incorrect username or password', 401));
}
// Everything OK
const token = signToken(user._id);
res.status(200).json({
status: 'success',
token,
});
});
401 status is for unauthorized
.
In the router
for the resource to protect, add a middleware before the middleware handler so it is only run after authentication is approved.
router.route('/').get(protect, getAllTours).post(createTour);
Then in the authController
, the logic for the protect
middleware is applied. First the auth token is read from the request header
named authorization
and its value starts with Bearer
followed by the token.
const auth = req.headers.authorization;
let token;
if (auth && auth.startsWith('Bearer')) {
// authorization: Bearer sometokenstring
token = auth.split(' ')[1];
}
if (!token)
return next(new AppError('Please log in to continue', 401));
If the token exists, it is verified by the func jwt.verify()
which takes in the token and the secret key. The third param is a callback to handle its response but the function can be promisified
and just await the response. The util
is an inbuilt node module.
const { promisify } = require('util');
// Later
const decoded = await promisify(jwt.verify)(
token,
process.env.JWT_SECRET_KEY
);
The promise resolves to an object with the id
used to create the token, its issue time
as iat
and expiration time
as exp
.
{ id: '63e8adc3ed5a9deeac7ea6a9', iat: 1676193417, exp: 1676452617 }
You can then check to see if the user still exists by finding them using the id. An extra verification may be to ensure that from the time the token was issued to the time it's being used, the user password has not been changed. This can be accomplished by another user defined method in the schema like userModel
.
userSchema.methods.changedPasswordAfter = function (JWTTimeStamp) {
if (this.passwordChangedAt) {
const timeChanged = parseFloat(
this.passwordChangedAt.getTime() / 1000
);
console.log(JWTTimeStamp, timeChanged);
return timeChanged > JWTTimeStamp;
}
return false;
};
The func returns true
if the password was changed recently. This func is then called in the auth controller.
if (currUser.changedPasswordAfter(decoded.iat)) {
return next(
new AppError(
'User password changed recently. Please login again.'
)
);
}
If all goes well, the user can access the route. The general structure would look as below.
exports.protect = catchAsync(async (req, res, next) => {
// Check if token is included in header
const auth = req.headers.authorization;
let token;
if (auth && auth.startsWith('Bearer')) {
// authorization: Bearer sometokenstring
token = auth.split(' ')[1];
}
if (!token)
return next(new AppError('Please log in to continue', 401));
// console.log(token);
// Authenticity of token - Verification
const decoded = await promisify(jwt.verify)(
token,
process.env.JWT_SECRET_KEY
);
// console.log(decoded);
// If user still exists
const currUser = await User.findById(decoded.id);
if (!currUser) {
return next(
new AppError(
'The owner for this token no longer exists!!!',
401
)
);
}
// If user changed password after token was issued
if (currUser.changedPasswordAfter(decoded.iat)) {
return next(
new AppError(
'User password changed recently. Please login again.'
)
);
}
// Grant access
req.user = currUser;
next();
});
First add user roles in the schema for the user model.
role: {
type: String,
enum: {
values: ['user', 'admin', 'guide'],
message: 'Roles can either be user, admin or guide!!',
},
default: 'user',
},
Then implements the middleware in the route after the protect
so it can access the user
object set in the protection stage on the req.user
property.
router.delete(protect, restrictTo('admin'), deleteTour);
Now creating the middleware function restrictTo()
on the authController
to implement the details.
exports.restrictTo = (...roles) => {
// Roles can be an array of permitted users/roles passed into this function
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(
new AppError(
'You are not aythorized to perform this operation!!!',
403
)
);
}
next();
};
};
Important to note that this step depends on the previous middleware else the role of the user could not be read.
Create a route for forgotPassword
where an email for the account to recover in sent.
router.post('/forgotPassword', forgotPassword);
Then handle the middleware in the authController with steps as:
- Check if User Exists
- Generate random token to be used as a reset token.
This token is sent to the email and a copy of its encrypted version is kept in the database on a field passwordResetToken
, and an expiration time passwordResetExpires
. Thus a method in the model will be useful.
userSchema.methods.createPasswordResetToken = function () {
const resetToken = crypto.randomBytes(32).toString('hex');
this.passwordResetToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
console.log({ resetToken }, this.passwordResetToken);
this.passwordResetExpires = Date.now() + 10 * 60 * 1000; // 10 mins
return resetToken;
};
The method creates a 32 bit long token, of which its encrypted version is stored in the db. It makes use of an inbuilt encryption module:
const crypto = require('crypto');
- Save the document with the current edited fields. the
validateBeforeSave
temporarily clears the rules set that a request must have certain fields.
exports.forgotPassword = catchAsync(async (req, res, next) => {
// Check if user exists using email
const user = await User.findOne({ email: req.body.email });
if (!user) {
return next(new AppError('No user found with that email!!', 404));
}
// Generate random reset token
const resetToken = user.createPasswordResetToken();
await user.save({ validateBeforeSave: false });
});
- Send the token to the described email.
Gonna be using nodemailer
and mailtrap
. It's quite a process.
const nodemailer = require('nodemailer');
const sendEmail = async (options) => {
// Create transporter
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
authMethod: process.env.EMAIL_AUTH_METHOD,
});
// Define email options
const mailOptions = {
from: 'Hassan Shakur <[email protected]',
to: options.email,
subject: options.subject,
text: options.message,
};
// Send the email
await transporter.sendMail(mailOptions);
};
module.exports = sendEmail;
- Create the url to be sent for user to send a patch request to.
- Create the message to send
- Send the email with options.
const sendEmail = require('../utils/email');
// Later ...
// Send url to user's email
const resetURL = `${req.protocol}://${req.get(
'host'
)}/api/v1/users/resetPassword/${resetToken}`;
const message = `We've received a password reset request. Please submit a PATCH request to ${resetURL} with the new password and confirm password.\nIf you didn't forget your password just ignore this email.`;
try {
await sendEmail({
email: user.email,
subject: `Password reset token, (valid for 10 mins)`,
message,
});
res.status(200).json({
status: 'success',
message: 'Check your email for the token!',
});
} catch (err) {
user.passwordResetExpires = undefined;
user.passwordResetToken = undefined;
await user.save({ validateBeforeSave: false });
console.log(err);
return next(
new AppError('Something went wrong! Please try again later.', 500)
);
}
Email is sent containing a url to make a patch
request for reseting the password. The request has the client token appended as a param and it's captured in the router as:
router.patch('/resetPassword/:token', resetPassword);
The resetPassword
function in the auth
has 4 steps:
- Get the user using by using the hashed token received as it was saved before.
- Check if token is valid or has expired.
- Update
passwordChangedAt
prop from themodel
userSchema.pre('save', function (next) {
if (!this.isModified('password') || this.isNew) return next();
this.passwordChangedAt = Date.now() - 1000;
next();
});
The minus ensures that the token is always created after the password changed time so the user need not login again after this. removing it will require a login with the new password.
- Send token and login the user. The procedure would be as:
exports.resetPassword = catchAsync(async (req, res, next) => {
// Get user based on token
const hashedToken = crypto
.createHash('sha256')
.update(req.params.token)
.digest('hex');
const user = await User.findOne({
passwordResetToken: hashedToken,
passwordResetExpires: { $gt: Date.now() },
});
// Token hasnt expired and there is a user, set new password
if (!user) {
return next(
new AppError('Token is invalid or has expired!', 400)
);
}
user.password = req.body.password;
user.passwordConfirm = req.body.passwordConfirm;
user.passwordResetExpires = undefined;
user.passwordResetToken = undefined;
await user.save();
// Update passwordChangedAt property
// Log user in and send jwt token
const token = signToken(user._id);
res.status(200).json({
status: 'success',
token,
});
});
Done when user is already logged in so the request will be protected.
router.patch('/updateMyPassword', protect, updatePassword);
User enters passqordCurrent
, newPassword
and confirmPassword
in a patch
request. The curr pass in compared with db curr pass using the earlier method correctPassword
. If true, the new password is saved and token sent.
const sendTokenResponse = (userId, statusCode, res) => {
const token = signToken(userId);
res.status(statusCode).json({
status: 'success',
token,
});
};
// Later
exports.updatePassword = catchAsync(async (req, res, next) => {
// Find user based on passed id and user from protect middleware
//? Should Never Use FindByIdAndUpdate as it doesnt run Validatiors
//? Validators only run on SAVE and NEW doc
const user = await User.findById(req.user.id).select('+password');
// console.log(user);
// Check if POSTed pass is correct
const correct = await user.correctPassword(
req.body.passwordCurrent,
user.password
);
if (!correct) {
return next(new AppError('Wrong password!', 401));
}
// Save new password
user.password = req.body.password;
user.passwordConfirm = req.body.passwordConfirm;
await user.save();
// Send token
sendTokenResponse(user._id, 200, res);
});
router.patch('/updateme', protect, userControllers.updateMe);
exports.updateMe = catchAsync(async (req, res, next) => {
// Send error if user is tryng to change password
if (req.body.password || req.body.passwordconfirm) {
return next(
new AppError(
'Please use /updateMyPassword for password update!',
400
)
);
}
// select only fields user is allowed to change
const filteredBody = filterBody(req.body, 'name', 'email');
console.log(filterBody);
// Use direct update as password will be required if you try to save
const updatedUser = await User.findByIdAndUpdate(
req.user.id,
filteredBody,
{
new: true,
runValidators: true,
}
);
res.status(200).json({
status: 'success',
data: {
user: updatedUser,
},
});
});
The user is set to inactive as a property in the user model. A query middleware
is then used to ensure all queries do not select the inactive users.
router.delete('/deleteMe', protect, userControllers.deleteMe);
userSchema.pre(/^find/, function (next) {
// This points to current query
this.find({ isActive: { $ne: false } });
//Or
this.find({ isActive: true });
next();
});
exports.deleteMe = catchAsync(async (req, res, next) => {
await User.findByIdAndUpdate(req.user.id, { isActive: false });
res.status(204).json({
status: 'Success',
data: null,
});
});
JWTs can be send through browser cookies to prevent XSS as would happen when sending it via the response as a json string. It is included in the res
as a cookie. Secure
ensures the cookie is only sent in https
, and so on.
const sendTokenResponse = (userId, statusCode, res) => {
const token = signToken(userId);
const cookieOptions = {
expires: new Date(
Date.now() +
process.env.JWT_COOKIE_EXPIRES_IN * 24 * 60 * 60 * 1000
),
// Only send via https
// secure: false,
// Ensures the browser or any other person cannot manipulate this cookie
httpOnly: true,
};
if (process.env.NODE_ENV === 'production')
cookieOptions.secure = true;
res.cookie('jwt', token, cookieOptions);
res.status(statusCode).json({
status: 'success',
token,
});
};
Helps prevent DOS
and Brute Force Attacks
by limiting the amount of requests from an IP within a period of time. express-rate-limit
package can be used. Its applied as a global middleware to the app and the highest global route.
const rateLimit = require('express-rate-limit');
// Then later in same file
const limiter = rateLimit({
max: 100,
windowMs: 60 * 60 * 1000,
message:
'Too many requests from this IP. Please try again in 1 hour!',
});
app.use('/api', limiter);
The max
is max number of requests within the period windowMs
in milliseconds. The message
is sent if the limit is reached. The remaining requests and other limit details are in the header of the response.
helmet
package is used at the topmost of middlewares to ensure all headers have it.
const helmet = require('helmet');
// Later
app.use(helmet());
Logging in with a valid password and email as:
{
"email":{"$gt": ""},
"password":"correct"
}
The email injection will always return true for all emails and as long as one of the email passwords is used, access is granted.
Sanitization can be applied using xss-clean
& express-mongo-sanitize
packages.
The sanitize looks at request body
, queries
and params
and filters all $
and dots
. Xss cleans malicious html/js code, by converting html symbols.
// Protection from NoSQL and query injections
app.use(mongoSanitize());
// Data sanitization against XSS
app.use(xss());
Eg specifying the sort parameter twice on the same url with different vslues. hpp
package is used, HTTP Parameter Pollution
. You can whitelist the fields that multiple queries are allowed.
// Parameter pollution
app.use(
hpp({
whitelist: ['duration'],
})
);
Including data in data can be done by reference
or embedding
. All depends on accesibity of the data and their relationship.
To embed data eg keeping tour guides ids in the tour model can be implemented such that on tour creation, guides' ids are passed to a property guides: Array
of the tour and in the backend, the guides' details is fetched from the user model.
// Before in the same doc for schema
...
guides: Array,
// Later
tourSchema.pre('save', async function (next) {
const guidesPromises = this.guides.map(
async (id) => await User.findById(id)
);
this.guides = await Promise.all(guidesPromises);
next();
});
The guidesPromises
is an array of promises returned from the search and therefore all have to be awaited for resolve before including them in the tour data. Embedding in this case is not best as user details may later change.
Creating a reference to a tour using the tour id can be done as:
guides: [
{
type: mongoose.Schema.ObjectId,
ref: 'User',
},
],
This is included in the tours schema
model. Whenever a query is made, this reference will be responsible for populating
the results of the query with the relevant user details from the user model.
To populate, a small addition is added to the getTour
query handler:
exports.getTour = catchAsync(async (req, res, next) => {
const tour = await Tour.findById(req.params.id).populate('guides');
if (!tour) {
return next(new AppError('No tour found with that ID', 404));
}
res.status(200).json({
status: 'success',
data: { tour },
});
});
To exclude some fields about the user, extra options are added to the populate
function.
const tour = await Tour.findById(req.params.id).populate({
path: 'guides',
select: '-__v -passwordChangedAt',
});
This will therefore only work for the routes where the populate is specified. A query middleware can be used to populate all queries hitting the tours route.
tourSchema.pre(/^find/, function (next) {
this.populate({
path: 'guides',
select: '-__v -passwordChangedAt',
});
next();
});
As in parent referencing the parent doesnt keep track of the children, getting the chicldren info when querying the parent can be accomplished using virtual populate
, just like a virtual property. Keeping a list of all children in the parent would only make this list longer and redundant.
So in the tour model, to return reviews for the tour:
tourSchema.virtual('reviews', {
ref: 'Review',
foreignField: 'tour',
localField: '_id',
});
The ref
is the source of the properties - children, foreignField
the like foreign-key
and localField
the primary-key
. This therefore matches the id passed in the review
at the tour
property to the id of the current queried tour.
const reviewSchema = mongoose.Schema(
{
review: {
type: String,
required: [true, 'A review cannot be empty!'],
},
...
tour: {
type: mongoose.Schema.ObjectId,
ref: 'Tour',
required: [true, 'A review must belong to a tour!'],
},
user: {
type: mongoose.Schema.ObjectId,
ref: 'User',
required: [true, 'A review must be written by a user!'],
},
},
...
);
The the route/query to populate in toursController:
const tour = await Tour.findById(req.params.id)?.populate('reviews');
When dealing with multiple db changes that it's either all are commited or if one fails, all are reverted, sessions
and transactions
are needed. A session is created and diring this period, all transactions are made asynchronously and there after the session closed.
try {
const sess = await mongooose.startSession();
sess.startTransaction();
await Model1.save({ session: sess });
Model2.bookmarks.push(newBookmark);
await Model2.save({ session: sess });
await sess.commitTransaction();
} catch (err) {
throw err;
}
fetchedUser.toObject({ getters: true });
The syntax :/id
is still used in the router:
router
.route('/:tourId/reviews')
.post(protect, restrictTo('user'), createReview);
Then in the route controller, createReview
, if the user id or tour id is not specified in the body, tour is fetched from the params.id
and user from user.id
set at the protect
middleware.
exports.createReview = catchAsync(async (req, res, next) => {
// For nested routes
if (!req.body.tour) req.body.tour = req.params.tourId;
if (!req.body.user) req.body.user = req.user.id;
const newReview = await Review.create(req.body);
...
});
The above code works but its quite a repetition, plus a route related to reviews is kept in the tourRouter
. To fix this, such a route as it starts with tour
, eg /tour/someId/reviews
, is first received in the tour router but then fowarded to the reviews router as all user, tour routes were redirected by the app.js
. Therefore in tourRouter
:
router.use('/:tourId/reviews', reviewRouter);
Then the review will remain the same but its router will be configured to accept params from redirected routes:
const router = express.Router({ mergeParams: true });
If an id is specified in the url and its a GET
request for reviews, then the id can be picked by the getAllTours
controller and use it to find only the reviews for the particular tour. This is again ensured by the mergeParams prop.
exports.getAllReviews = catchAsync(async (req, res, next) => {
let filter = {};
if (req.params.tourId) filter = { tour: req.params.tourId };
const reviews = await Review.find(filter);
...
});
Are funcs that return functions. As certain operations are similar, eg the delete
for reviews, users and tours, the procedure can be automated by a factory function that receives a Model
to operate on eg User
and then finds the doc and handle responses and operations. A new factory file can be used in the controllers to handle this:
// Example for deleting docs
exports.deleteOne = (Model) =>
catchAsync(async (req, res, next) => {
const doc = await Model.findByIdAndDelete(req.params.id);
if (!doc) {
return next(new AppError('Document not found', 404));
}
res.status(204).json({
status: 'success',
data: null,
});
});
Then for example in the tourController, to delete, only this is required:
const factory = require('./handlerFactory');
// Later
exports.deleteTour = factory.deleteOne(Tour);
Allow user to access their details:
router.get('/me', protect, userControllers.getMe, getUser);
The getMe
middleware in user controller sets current user id to the params as it is required by the factory getOne
to get details.
exports.getMe = (req, res, next) => {
req.params.id = req.user.id;
next();
};
exports.getUser = factory.getOne(User);
exports.getOne = (Model, populateOptions) =>
catchAsync(async (req, res, next) => {
let query = Model.findById(req.params.id);
if (populateOptions) query = query.populate(populateOptions);
const doc = await query;
if (!doc) {
return next(
new AppError('No document found with that ID', 404)
);
}
res.status(200).json({
status: 'success',
data: { doc },
});
});
For queries that are anticipated to be queried most, it'll be hectic if all documents have to be examined every time a similar query is issued. Indices help with this as after a query, the designed index will sort the docs in the given order such that as few docs as possible are examined whenever the same query is issued. All this can be viewed by adding a .explain()
method to a query:
//Form
const doc = await features.query;
//to
const doc = await features.query.explain();
This will be part of results: compare totalDocsExamined
and nReturned
.
"executionStats": {
"executionSuccess": true,
"nReturned": 2,
"executionTimeMillis": 7,
"totalKeysExamined": 0,
"totalDocsExamined": 9,
"executionStages": {
"allPlansExecution": []
},
To add an index, go to the schema file:
tourSchema.index({ price: 1, ratingsAverage: -1 });
This creates 2 indices, (1 compound index), where prices are sorted in asc. Indices use some space but with large scale data reading,it would be worth it.
A single user can only review a tour once. An index can be used to ensure the tour
user
combination is always unique. The second param in the index
method is options and this can be set here. The nums 1
in the tour and user arent that important.
reviewSchema.index({ tour: 1, user: 1 }, { unique: true });
An aggregate function is used to match all ids that match the current query id. This is a static
method that can therefore be called by the model itself since aggregate funcs can only be used on a model not instances. The this
in a static method always points to the model.
The second part checks if the stats
array is empty, ie no docs matching it were found, ie, no reviews exist, and sets the ratingsQuantity
& ratingsAverage
as per the newly calculated results.
reviewSchema.statics.calcAvgRatings = async function (tourId) {
const stats = await this.aggregate([
{
$match: { tour: tourId },
},
{
$group: {
_id: '$tour',
numRatings: { $sum: 1 },
avgRatings: { $avg: '$rating' },
},
},
]);
console.log(stats);
if (stats.length > 0) {
await Tour.findByIdAndUpdate(tourId, {
ratingsAverage: stats[0].avgRatings,
ratingsQuantity: stats[0].numRatings,
});
} else {
await Tour.findByIdAndUpdate(tourId, {
ratingsAverage: 4.5,
ratingsQuantity: 0,
});
}
};
The static func is then called in a post
save middleware to ensure current changes are reflected. Yhe post
doesn't get access to the next
method. As this
in such a middleware points to an instancce of the model, the current query, it's constructor
, the model is used to call the previously created method.
reviewSchema.post('save', function () {
// Review.calcAvgRatings(this.tour) => Cant work as Review is not yet defined. Moving it down also wont work as the `post` wont be there
this.constructor.calcAvgRatings(this.tour);
});
The calculations will ocly work for new ratings but to ensure updates
and deletes
are also reflected, something more is added.
//findByIdAndUpdate
//findByIdAndDelete
reviewSchema.pre(/^findOneAnd/, async function (next) {
this.r = await this.findOne();
console.log(this.r);
next();
});
reviewSchema.post(/^findOneAnd/, async function () {
await this.r.constructor.calcAvgRatings(this.r);
});
The pre
middleware is just there to include the current tour that has been accessed by the review in the this
so that it can be called at post
after the query is done and doc is saved. This is posiible as whenever a query is made, eg await this.findOne()
, it always returns the current document which has the tour id
in it on a prop tour
. Thus this current tour is includec in this this
and passed on to the next middleware in post
where it is accessed: this.r
, called constructor on to access the model: this.r.constructor
, then call the calc method using the same tour id this.r
A router is set to receive the radius distance, starting point as latitude and longitude, and the usits for the distance: can be miles or km.
router
.route('/tours-within/:distance/center/:latlng/unit/:unit')
.get(tourControllers.tourWithin);
The a control handler tourWithin
is defined where it captures these params, and fetch tours that match the descriptions.
exports.tourWithin = catchAsync(async (req, res, next) => {
const { distance, latlng, unit } = req.params;
const [lat, lng] = latlng.split(',');
const radiantRadius =
unit === 'mi' ? distance / 3963.2 : distance / 6378.1;
if (!lat || !lng) {
next(new AppError('Please specify your lat & lng!', 400));
}
const tours = await Tour.find({
startLocation: {
$geoWithin: {
$centerSphere: [[lng, lat], radiantRadius],
},
},
});
res.status(200).json({
status: 'success',
results: tours.length,
data: {
data: tours,
},
});
});
For this, a geoWithin
query is issued with an option centerSphere
which is an array with the first an array of longitude then latitude, and the second the distance from that point in radiants
. An index can be added to the model for such 2d locations as:
tourSchema.index({ startLocation: '2dsphere' });
Router:
router
.route('/distances/center/:latlng/unit/:unit')
.get(tourControllers.distances);
Controller
exports.distances = catchAsync(async (req, res, next) => {
const { latlng, unit } = req.params;
const [lat, lng] = latlng.split(',');
// Converting distance to miles or km from default meters
const multiplier = unit === 'mi' ? 0.000621371 : 0.001;
if (!lat || !lng) {
next(new AppError('Please specify your lat & lng!', 400));
}
const distances = await Tour.aggregate([
{
$geoNear: {
near: {
type: 'Point',
coordinates: [lng * 1, lat * 1],
},
distanceField: 'distance',
distanceMultiplier: multiplier,
},
},
{
$project: {
name: 1,
distance: 1,
},
},
]);
res.status(200).json({
status: 'success',
data: {
data: distances,
},
});
});
The geoNear
query is the only one in aggregation concerning geospatials and should always be the first in the aggregate else it will cause an error. Its parameters near
specifies type of reference point and its location, distanceField
is the field to be added to the data and distanceMultiplier
is multiplied by the distanceField
for convertions as needed.
To instruct express
to use pug
as the view engine:
// Server side pug config
app.set('view engine', 'pug');
// views of MVC location
app.set('views', path.join(__dirname, 'views'));
The second part shows express the location for the views
that are to be used as template in the rendering.
Items in the pug
files are dependent on the statics
file location defined in the app
. Example, using such a link in pug:
link((rel = 'stylesheet'), (href = 'css/style.css'));
The above href
will be searched from the defined base point for static urls ie in this case:
app.use(express.static(path.join(__dirname, 'public')));
//OR
// app.use(express.static(`${__dirname}/public`));
So the path will direct to the public
folder then try to locate the css
folder. The route will be implemented in the app as:
app.use('/', (req, res) => {
res.status(200).render('base');
});
... where base
is the pug file named base.pug
and is located in the views
folder as described above when setting the views
path.
It is code visible to the output html. Involves using data set in the router, in the pug file. it's a second parameter as options when describing the routes.
app.use('/', (req, res) => {
res.status(200).render('base', {
random: 'Some random text',
});
});
Then consumed with tagName= value
syntax
doctype html
html
head
link(rel="stylesheet", href="css/style.css")
link(rel="shortcut icon", href="img/favicon.png", type="image/png")
title Website
body
h1 The Homepage
h2= random
// HTML comment
//-Pug comment => Not visible in output html
Not visible. Can be used as calculators.
- const x = 10
h4= x * 2
Like es6 template strings
// where tour is a passed data
title Natours | #{tour}