In this article we will see how to implement a basic social network similar to Instagram with ExpressJS.
Requirements
Users will need to be able to register, log in, edit their profile, create posts and follow other users. Upon registration, a welcome email must be sent to them.
Database
We will use MongoDB with two collections: users
and posts
. We will manage the connection and interaction with the database through the Mongoose module.
The users
collection will have this scheme for documents:
'use strict';
const mongoose = require('mongoose');
const { Schema } = mongoose;
const UserSchema = new Schema({
name: String,
username: String,
email: String,
password: String,
url: { type: String, default: '' },
image: { type: String, default: 'default.png' },
description: { type: String, default: '' },
posts: Array,
followers: Array,
following: Array
},{collection: 'users'});
module.exports = mongoose.model('users', UserSchema);
Users will be linked to their posts via the posts
array which will contain a series of Object IDs so that posts can be retrieved with the $in
operator.
In turn, followers
and following
will contain a series of Object IDs relating to users.
The posts
collection has the following scheme instead:
'use strict';
const mongoose = require('mongoose');
const { Schema } = mongoose;
const PostSchema = new Schema({
description: String,
image: String,
user: String,
date: Date
},{collection: 'posts'});
module.exports = mongoose.model('posts', PostSchema);
The user
field will contain the username of the user who created the post. If desired, this value can be replaced with an Object ID type.
Sending emails
We will use NodeMailer and Mailtrap for sending emails under development. Mailtrap configuration is as follows:
mail: {
from: 'noreply@express.localhost',
settings: {
host: 'smtp.mailtrap.io',
port: 2525,
auth: {
user: '',
pass: ''
}
}
}
Enter your credentials that you find in the mailbox you created on Mailtrap.
Registration
After validating the input data with the validator module, we create the user account and send the welcome email.
router.post('/register', async (req, res, next) => {
const { name, email, username, password, password_confirmation } = req.body;
const errors = [];
if(validator.isEmpty(name)) {
errors.push({
param: 'name',
msg: 'Name is a required field.'
});
}
if(!validator.isEmail(email)) {
errors.push({
param: 'email',
msg: 'Invalid e-mail address.'
});
}
if(!validator.isAlphanumeric(username)) {
errors.push({
param: 'username',
msg: 'Invalid username.'
});
}
if(validator.isEmpty(password)) {
errors.push({
param: 'password',
msg: 'Password is a required field.'
});
}
if(password !== password_confirmation) {
errors.push({
param: 'password_confirmation',
msg: 'Passwords do not match.'
});
}
try {
const usernameExists = await users.countDocuments({ username: username });
const emailExists = await users.countDocuments({ email: email });
if(usernameExists === 1) {
errors.push({
param: 'username',
msg: 'Invalid username.'
});
}
if(emailExists === 1) {
errors.push({
param: 'email',
msg: 'Invalid e-mail address.'
});
}
} catch(err) {
res.json({ error: err });
}
if(errors.length > 0) {
res.json({ errors });
} else {
const encPwd = crypto.createHash('sha256').update(password).digest('hex');
const newUser = new users({
name,
email,
username,
password: encPwd
});
const mailer = new Mail({
from,
settings
});
try {
await newUser.save();
await mailer.send({ to: email, subject: 'Welcome to Express Instagram', body: `Welcome ${username}!` });
} catch(err) {
res.json({ error: err });
}
res.json({ success: true });
}
});
Login
When the user successfully logs in, a user
object is created in the current session.
router.post('/login', async (req, res, next) => {
const { email, password } = req.body;
const encPwd = crypto.createHash('sha256').update(password).digest('hex');
try {
const user = await users.findOne({ email: email, password: encPwd });
if(user) {
req.session.user = user;
res.json({ success: true, username: user.username });
} else {
res.json({ success: false });
}
} catch(err) {
res.json({ success: false });
}
});
Editing the profile
This route has multer as an add-on module to manage the upload of files.
router.post('/profile/:username/edit', async (req, res, next) => {
if(req.session.user) {
const { username } = req.params;
try {
const upload = new Upload({
filename: 'image',
destination: UPLOAD_PATH + 'profiles',
newName: crypto.createHash('sha256').update(Date.now().toString()).digest('hex')
});
const uploaded = await upload.save(req, res);
if(uploaded.done) {
const { url, description } = uploaded.body;
const { file } = uploaded;
const data = {
url,
description,
image: file.filename
};
await users.findOneAndUpdate({ username: username }, { $set: data });
res.json({ updated: true, username });
} else {
res.json({ updated: false });
}
} catch(err) {
res.json(err);
}
} else {
res.sendStatus(403);
}
});
Creating a post
The creation of a post also involves uploading an image associated with the post, so in this case also the multer module will be used.
router.post('/posts/create', async (req, res, next) => {
if(req.session.user) {
try {
const upload = new Upload({
filename: 'image',
destination: UPLOAD_PATH + 'posts',
newName: crypto.createHash('sha256').update(Date.now().toString()).digest('hex')
});
const uploaded = await upload.save(req, res);
if(uploaded.done) {
const { description } = uploaded.body;
const { file } = uploaded;
const errors = [];
if(validator.isEmpty(description)) {
errors.push({
param: 'description',
msg: 'Description is a required field.'
});
}
if(errors.length > 0) {
fs.unlinkSync(file.path);
res.json({ errors });
} else {
const newPost = new posts({
description,
image: file.filename,
user: req.session.user.username,
date: new Date()
});
newPost.save().then(post => {
users.findOneAndUpdate({ _id: req.session.user._id}, { $push: { posts: post._id } }).then(result => {
res.json({ created: true, postid: post._id });
});
});
}
} else {
res.json({ created: false });
}
} catch(err) {
res.json(err);
}
} else {
res.sendStatus(403);
}
});
When the document is created, its Object ID is inserted in the posts
field of the associated user.
Follow / Unfollow
This feature distinguishes between the user following another user (follower) and the user being followed (following). When the user clicks on "Follow" the client-side code sends the Object IDs of the follower and the following to the route.
In the view in EJS we will have this situation:
<% if(isLoggedIn) { %>
<% if(user && user._id != currentUser._id) { %>
<% const following = user.following;
const action = following.includes(currentUser._id) ? 'unfollow' : 'follow';
%>
<button class="btn btn-primary ml-4" data-action="<%= action %>" data-follower="<%= user._id %>" data-following="<%= currentUser._id %>" id="follow-btn">Follow</button>
<% } %>
<% } %>
currentUser
is the user retrieved from the database, while user
is the user present in the session (the user who logged in).
If the user is not viewing his profile but that of another user, then the button will be displayed and the action of the button
will be follow
or unfollow
depending on whether the Object ID of the profile displayed is present or not in the following
array
of the user viewing this profile.
Our route takes into account the action sent via AJAX in this way:
router.post('/follow', async (req, res, next) => {
const { follower, following, action } = req.body;
try {
switch(action) {
case 'follow':
await Promise.all([
users.findByIdAndUpdate(follower, { $push: { following: following }}),
users.findByIdAndUpdate(following, { $push: { followers: follower }})
]);
break;
case 'unfollow':
await Promise.all([
users.findByIdAndUpdate(follower, { $pull: { following: following }}),
users.findByIdAndUpdate(following, { $pull: { followers: follower }})
]);
break;
default:
break;
}
res.json({ done: true });
} catch(err) {
res.json({ done: false });
}
});
Client-side code simply adds the logic of dynamic button text change and follower number increment based on the current action.
var $followBtn = $( "#follow-btn" );
if( $followBtn.length ) {
$followBtn.click(function() {
var data = {
follower: $followBtn.data( "follower" ),
following: $followBtn.data( "following" ),
action: $followBtn.data( "action" )
};
$.post( "/follow", data, function( res ) {
if( res.done ) {
var text = $followBtn.data( "action" ) === "follow" ? "Unfollow" : "Follow";
var count = parseInt( $( "#followers" ).text(), 10 );
if( text === "Unfollow" ) {
$( "#followers" ).text( count + 1 );
$followBtn.data( "action", "unfollow" ).text( text );
} else {
$( "#followers" ).text( count - 1 );
$followBtn.data( "action", "follow" ).text( text );
}
}
});
});
}