DEV Community

Cover image for I Created A Dev.to Clone With Nodejs, Express & MongoDB.
Kelechi Okoronkwo
Kelechi Okoronkwo

Posted on • Edited on

I Created A Dev.to Clone With Nodejs, Express & MongoDB.

Introduction
In this article I will be showing you how to create a Dev.to clone with Nodejs, Express, MongoDB, with JWT authentication. This API will give ability to users to create, read, update and delete blog alongside adding an authentication layer to it.

Pre-requisite

  1. NODEJS- You Should have node installed.
  2. MongoDB - You Should have mongoDB installed or have an account if you prefer to use the cloud version.

Packages that will be required
You can install the following using npm or its equivalent

 "dependencies": {
    "bcryptjs": "^2.4.3",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "express-mongo-sanitize": "^2.2.0",
    "express-rate-limit": "^6.6.0",
    "helmet": "^6.0.0",
    "hpp": "^0.2.3",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^6.7.0",
    "morgan": "^1.10.0",
    "morgan-json": "^1.1.0",
    "nodemailer": "^6.8.0",
    "nodemon": "^2.0.20",
    "slugify": "^1.6.5",
    "validator": "^13.7.0",
    "winston": "^3.8.2",
    "xss-clean": "^0.1.1"
  }
Enter fullscreen mode Exit fullscreen mode

Step 1 - File Structure
Before we write a line of code, lets create a package.json file by running npm init -y in our terminal.
Now we would be using a file structure like the one below, which follows the MVC architecture.

Image description

Step 2 - Lets create our environmental variables in our .env file

NODE_ENV=Developemt
PORT=8000
DATABASE_LOCAL=mongodb://127.0.0.1:27017/blogDB
DATABASE_PROD=mongodb+srv://blak:<password>@cluster0.1nb5crb.mongodb.net/blogdb?retryWrites=true&w=majority
DATABASE_PASSWORD=l3Ax3f4CaoJrKtjj
jwt_secret=mytokensecretblogapp20221026
jwt_expires=1h
jwt_cookie_expires=2
Enter fullscreen mode Exit fullscreen mode

Notice I have two database URL, One for the local DB and the other for the cloud DB, You can pick your preference.

Step 3 - Lets write some code in server.js to start up our server and help us connect to our Database

const mongoose = require('mongoose');
const app = require('./app');
const dotenv = require('dotenv');
dotenv.config({ path: './config.env' });

//CREATE DB CONNECTION
let DB = process.env.DATABASE_PROD.replace(
  '<password>',
  process.env.DATABASE_PASSWORD
);

if (process.env.NODE_ENV == 'Development') {
  DB = process.env.DATABASE_LOCAL;
}
mongoose
  .connect(DB, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  })
  .then((con) => {
    console.log('DB Connection Successful');
  });

//Connect To Server
const port = process.env.PORT || 8080;
const server = app.listen(port, () => {
  console.log(`App running on port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

Step 4 - Lets write some code in our app.js file
This file contains code where all our middlewares will be registered, middlewares such as our route middlewares, global error handler middleware, some third-party middlewares for security and rate limiting

const express = require('express');
const app = express();
const UserRouter = require('./routes/userRoute');
const blogRouter = require('./routes/blogRoute');
const appError = require('./utils/appError');
const cookieParser = require('cookie-parser');
const globalErrHandler = require('./controllers/errorController');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const cors = require('cors');
const morganMiddleware = require('./utils/morgan');
const logger = require('./utils/logger');

//GLOBAL MIDDLEWARES
//Allow cross-origin access
app.use(cors());

//Set security HTTP headers
app.use(helmet());

const limiter = rateLimit({
  max: 500,
  windowMs: 24 * 60 * 60 * 1000,
  standardHeaders: true,
  message: 'Too Many Request From this IP, please try again in an hour',
});

//Set API Limit
app.use('/api', limiter);

//Data Sanitization against NOSQL query Injection
app.use(mongoSanitize());

//Data Sanitization against XSS
app.use(xss());

//Morgan Middleware
app.use(morganMiddleware);
//Allow views
app.use(express.static(`${__dirname}/views`));
app.use((req, res, next) => {
  req.requestTime = new Date().toISOString();
  next();
});

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use('/api/v1/users', UserRouter);
app.use('/api/v1/articles', blogRouter);

//Global Error Handler
app.all('*', (req, res, next) => {
  return next(
    new appError(404, `${req.originalUrl} cannot be found in this application`)
  );
});

app.use(globalErrHandler);
module.exports = app;

Enter fullscreen mode Exit fullscreen mode

Step 5 - Lets create our data models now.
Now that our server is up and connected to our DB, lets start modelling our data. we would start with user model, so lets head into the models folder and create a file called userModel.js and paste the code below into it.

const mongoose = require('mongoose');
const validator = require('validator');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');

const userSchema = new mongoose.Schema(
  {
    firstname: {
      type: String,
      required: [true, 'Please provide firstname'],
    },
    lastname: {
      type: String,
      required: [true, 'Please provide lastname'],
    },
    email: {
      type: String,
      required: [true, 'Please Provide Email Address'],
      unique: true,
      validate: [validator.isEmail, 'Please a valid email address'],
    },
    username: {
      type: String,
      unique: true,
    },
    password: {
      type: String,
      required: [true, 'Please Provide A Password'],
      minlength: 8,
      select: false,
    },
    passwordConfirm: {
      type: String,
      required: [true, 'Please Fill Password Field'],
      validate: function (el) {
        return el === this.password;
      },
      message: 'Password do not match.',
    },
    resetPasswordToken: String,
    resetTokenExpires: Date,
  },
  {
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  this.passwordConfirm = undefined;
  this.username = this.username.toLowerCase();
  next();
});
userSchema.methods.createResetToken = function () {
  const resetToken = crypto.randomBytes(32).toString('hex');
  this.resetPasswordToken = crypto
    .createHash('sha256')
    .update(resetToken)
    .digest('hex');
  console.log({ resetToken }, this.resetPasswordToken);
  this.resetTokenExpires = Date.now() + 30 * 60 * 1000;
  return resetToken;
};
userSchema.methods.comparePassword = async function (
  signinPassword,
  userPassword
) {
  return await bcrypt.compare(signinPassword, userPassword);
};
//Virtual Properties To Load articles per user
userSchema.virtual('articles', {
  ref: 'Blog',
  foreignField: 'author',
  localField: '_id',
});
const User = mongoose.model('User', userSchema);

module.exports = User;

Enter fullscreen mode Exit fullscreen mode

In this file we create our model using mongoose and also used some mongoose hooks, methods & virtual properties to:

  1. Hash our users password before saving to the DB using bcrypt.js package for hashing & unhashing.
  2. Create a reset token, when a user forgets his/her password.
  3. Add a reference to each blog created by a user.

Step 6 - Lets create the signup and login functionality
Head to the controller folder and create a file called authController.js, and copy the following code

const jwt = require('jsonwebtoken');
const catchAsy = require('./../utils/catchAsync');
const appError = require('./../utils/appError');
const User = require('./../models/userModel');
const sendEmail = require('./../utils/email');
const crypto = require('crypto');
const { promisify } = require('util');
const app = require('../app');
const Logger = require('../utils/logger');
const signToken = (id) => {
  return jwt.sign({ id }, process.env.jwt_secret, {
    expiresIn: process.env.jwt_expires,
  });
};

const createToken = (user, statusCode, res) => {
  const token = signToken(user._id);
  const cookieOptions = {
    expires: new Date(
      Date.now() + process.env.jwt_cookie_expires * 60 * 60 * 1000
    ),
    httpOnly: true,
  };
  if (process.env.NODE_ENV === 'production') cookieOptions.secure = true;

  //Send Token To Client
  res.cookie('jwt', token, cookieOptions);

  //Remove Password From JSON
  user.password = undefined;

  //Send Response To Client
  res.status(statusCode).json({
    status: 'Success',
    data: {
      token,
      user,
    },
  });
};
exports.signup = catchAsy(async (req, res, next) => {
  const user = await User.create(req.body);

  createToken(user, 200, res);
});
exports.login = catchAsy(async (req, res, next) => {
  const { email, password } = req.body;

  //If user does not provide any of the required fields
  if (!email || !password) {
    return next(new appError(401, 'Please Provide Email or Password.'));
  }

  const user = await User.findOne({ email }).select('+password');

  //If Any Field Is Incorrect
  if (!user || !(await user.comparePassword(password, user.password))) {
    const message = 'Email or Password Incorrect';
    return next(new appError(401, message));
  }

  //If everything checks out, send JWT Token.
  Logger.info(`New User Logged in with the ID of ${user.id}`);
  createToken(user, 200, res);
});

exports.forgetPassword = catchAsy(async (req, res, next) => {
  //1.Get User From Posted Email
  const user = await User.findOne({ email: req.body.email });

  if (!user) return next(new appError(404, 'Email Address Not Found!'));

  //2. Create Token and Save to DB
  const resetToken = user.createResetToken();
  await user.save({ validateBeforeSave: false });

  //3. Send to Client
  const resetUrl = `${req.protocol}://${req.get(
    'host'
  )}/api/v1/users/resetpassword/${resetToken}`;
  const message = `You made a request for a password reset, Click on the link to reset your password, reset token is valid for 30mins! ${resetUrl} \n Please Ignore if you did not make this request`;

  try {
    sendEmail({
      email: user.email,
      subject: `Your Password Reset Token(Valid 30min)`,
      message,
    });
    res.status(201).json({
      status: 'Success',
      message: 'Please check inbox for reset token!',
    });
  } catch (err) {
    user.resetPasswordToken = undefined;
    user.resetTokenExpires = undefined;
    return next(
      new appError(
        500,
        'There was an error sending mail, Please try again later!'
      )
    );
  }
});

exports.resetPassword = catchAsy(async (req, res, next) => {
  //1. Grab token from resetUrl
  const hashedToken = crypto
    .createHash('sha256')
    .update(req.params.token)
    .digest('hex');

  const user = await User.findOne({
    resetPasswordToken: hashedToken,
    resetTokenExpires: { $gt: Date.now() },
  });
  //2. Check IF token matches & still valid
  if (!user)
    return next(
      new appError(
        400,
        'Token Invalid or Expired, Try to reset password again!'
      )
    );
  //3. IF Token is valid
  user.password = req.body.password;
  user.passwordConfirm = req.body.passwordConfirm;
  user.resetPasswordToken = undefined;
  user.resetTokenExpires = undefined;
  await user.save();
  //4. Login User
  createToken(user, 200, res);
});

exports.protectRoute = catchAsy(async (req, res, next) => {
  const token = req.cookies.jwt;
  if (!token) return next(new appError(400, 'Please Login Again!'));

  const decoded = await promisify(jwt.verify)(token, process.env.jwt_secret);
  //Check if user exists
  const currentUser = await User.findById(decoded.id);
  //if (decoded.expiresIn > Date.now() + jwt_cookie_expires * 60 * 60 * 1000)
  if (!currentUser)
    return next(new appError(404, 'Session expired, Login again!'));
  //Add user to req object
  req.user = currentUser;
  next();
});

Enter fullscreen mode Exit fullscreen mode

Here we allow users to register and login and also issue a JWT after a successful login.

Step 7 - Lets mount our routes
Head to the route folder and create a file called userRoute.js, copy the following code.

const express = require('express');
const authController = require('./../controllers/authController');
const router = express.Router();

router.post('/signup', authController.signup);
router.post('/login', authController.login);
router.post('/forgotPassword', authController.forgetPassword);
router.patch('/resetpassword/:token', authController.resetPassword);

module.exports = router;

Enter fullscreen mode Exit fullscreen mode

lets test what we have now but lets configure some scripts in package.json file.

"scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
Enter fullscreen mode Exit fullscreen mode

Now we can go ahead to test our project now by using npm start in our terminal and if you wrote the code correctly users should be able to create an account and login now.

Step 8 - Lets create our blog model
Now we are done with the users end, lets work on creating the model for the blog itself. Head to the models folder and create another file called blogModel.js, copy the following code.

const mongoose = require('mongoose');
const validator = require('validator');
const slugify = require('slugify');

const blogSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: [true, 'Your Blog Requires A Title'],
      trim: true,
      unique: true,
    },
    slug: {
      type: String,
    },
    description: {
      type: String,
      required: [true, 'Please add a description'],
      trim: true,
    },
    author: {
      type: mongoose.Schema.ObjectId,
      ref: 'User',
    },
    state: {
      type: String,
      enum: ['draft', 'published'],
      default: 'draft',
    },
    read_count: {
      type: Number,
      default: 0,
    },
    reading_time: {
      type: String,
      default: 0,
    },
    tags: {
      type: [String],
    },
    body: {
      type: String,
      required: [true, 'Your blog must have a body!'],
    },
  },
  {
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);
blogSchema.set('timestamps', true);
blogSchema.pre(/^find/, function (next) {
  this.populate({
    path: 'author',
    select: '-__v -resetPasswordToken -resetTokenExpires -password -email',
  });
  next();
});
blogSchema.pre('save', function (next) {
  this.slug = slugify(this.title, { lower: true });
  this.tags.forEach((el) => {
    el.toLowerCase();
  });
  next();
});
blogSchema.methods.updateRead = async function () {
  this.read_count = this.read_count + 1;
  return this.read_count;
};
blogSchema.methods.calcReadTime = async function () {
  let words = this.title.length + this.body.length;
  let time = words / 200;
  const fullTime = time.toString().split('.');
  const min = fullTime[0];
  const sec = Math.round((fullTime[1] * 60) / 1000);
  return [min, sec];
};
const Blog = mongoose.model('Blog', blogSchema);

module.exports = Blog;

Enter fullscreen mode Exit fullscreen mode

In this file we create our model using mongoose and also used some mongoose hooks, methods to:

  1. Slugify blog titles.
  2. Calculate the read-time of each blog post.
  3. Update the read count of each blog.
  4. Add a reference to the author who created the blog.

Step 9 - Lets create functionalities for the blog
Head to the controller folder and create a file called blogController.js, and copy the following code

const Blog = require('./../models/blogModel');
const User = require('./../models/userModel');
const catchAsy = require('./../utils/catchAsync');
const appError = require('./../utils/appError');
const slugify = require('slugify');

exports.createBlog = catchAsy(async (req, res, next) => {
  req.body.author = req.user._id;
  const blog = await Blog.create(req.body);
  const readTime = await blog.calcReadTime();
  blog.reading_time = `${readTime[0]} min ${readTime[1]} seconds`;
  await blog.save({ validateBeforeSave: false });

  res.status(200).json({
    status: 'Success',
    data: {
      blog,
    },
  });
});

exports.getAllBlogs = catchAsy(async (req, res, next) => {
  //1. Create a query
  let query = Blog.find();

  //2. Check if user queries for any blog in draft state.
  if (req.query.state == 'draft') {
    return next(new appError(403, 'You cannot access unpublished articles!'));
  } else {
    query = Blog.find(req.query);
  }

  //Build query for author
  if (req.query.author) {
    const author = req.query.author;
    const user = await User.findOne({ username: author });
    if (!user)
      return next(
        new appError(403, 'Author does not exists or has written no articles')
      );
    const ID = user.id;
    query = Blog.find({ author: ID });
  }

  //Build query for tags
  if (req.query.tag) {
    const tag = req.query.tag.split(',');
    query = Blog.find({ tags: tag });
  }

  //Build Query For sort
  if (req.query.sort) {
    const sort = req.query.sort || 'createdAt';
    query = query.sort(sort);
  }
  //.Add Pagination
  const page = req.query.page * 1 || 1;
  const limit = req.query.limit * 1 || 20;
  const skip = (page - 1) * limit;
  query = query.skip(skip).limit(limit);

  //Await Query, and filter drafts out.
  const blog = await query;
  let newblog = [];
  if (blog.length == 0) return next(new appError(403, 'No Blog Found'));
  blog.forEach((el) => {
    if (el.state == 'published') {
      newblog.push(el);
    }
  });

  res.status(200).json({
    status: 'success',
    result: newblog.length,
    data: {
      newblog,
    },
  });
});

exports.getBlog = catchAsy(async (req, res, next) => {
  const ID = req.params.id;
  const blog = await Blog.findById(ID);

  if (blog.state == 'draft') {
    return next(new appError(403, 'You cannot access unpublished blog'));
  }
  const count = blog.updateRead();
  await blog.save({ validateBeforeSave: false });
  res.status(200).json({
    status: 'Success',
    data: {
      blog,
    },
  });
});

exports.updateBlog = catchAsy(async (req, res, next) => {
  //1. Get Blog Id and Perform search in DB
  const blogID = req.params.id;
  const blog = await Blog.findById(blogID);

  //2. Return error when blog cannot be found
  if (!blog) return next(new appError(404, 'No Blog Found'));

  //3. Check if user is owner of blog
  if (blog.author.id === req.user.id) {
    //4. If everything checks out, allow user edit blog.
    const newBlog = await Blog.findByIdAndUpdate(blogID, req.body, {
      new: true,
      runValidators: true,
    });
    //5. Return data to user
    res.status(200).json({
      status: 'success',
      data: {
        newBlog,
      },
    });
  } else {
    return next(new appError(403, 'Action Forbidden, You cannot Update blog'));
  }
});

exports.deleteBlog = catchAsy(async (req, res, next) => {
  //1. Get Blog Id and Perform search in DB
  const blogID = req.params.id;
  const blog = await Blog.findById(blogID);

  //2. Return error when blog cannot be found
  if (!blog) return next(new appError(404, 'No Blog Found'));

  //3. Check if user is owner of blog
  if (blog.author.id === req.user.id) {
    //4. If everything checks out, allow user delete blog.
    const newBlog = await Blog.findByIdAndDelete(blogID);
    //5. Return data to user
    res.status(204).json({
      status: 'success',
      data: null,
    });
  } else {
    return next(new appError(403, 'Action Forbidden, You cannot delete blog'));
  }
});

exports.myBlog = catchAsy(async (req, res, next) => {
  const queryObj = { ...req.query };
  const excludedFields = ['page', 'sort', 'limit', 'fields'];
  excludedFields.forEach((el) => delete queryObj[el]);

  //1. Grab user ID from protect route
  const userID = req.user.id;

  //2. Use ID To find Blog where it matches the author ID.
  let query = Blog.find({ author: userID });

  //3. Build Query For Other Query
  if (req.query.state) {
    const state = req.query.state;
    query = Blog.find({ author: userID, state: state });
  }
  //4.Add Pagination
  const page = req.query.page * 1 || 1;
  const limit = req.query.limit * 1 || 5;
  const skip = (page - 1) * limit;
  query = query.skip(skip).limit(limit);
  const blog = await query;

  res.status(200).json({
    status: 'success',
    result: blog.length,
    data: {
      blog,
    },
  });
});

Enter fullscreen mode Exit fullscreen mode

Here we configure all the functionalities of our blog go through the comments in the code body.

Step 10 - Lets mount our routes for the blog*
Head to the route folder and create a file called blogRoute.js, copy the following code.

const express = require('express');
const blogController = require('./../controllers/blogController');
const authController = require('./../controllers/authController');

const router = express.Router();
router
  .route('/myblogs')
  .get(authController.protectRoute, blogController.myBlog);

router
  .route('/')
  .post(authController.protectRoute, blogController.createBlog)
  .get(blogController.getAllBlogs);

router.route('/:id').get(blogController.getBlog);

router
  .route('/update/:id')
  .patch(authController.protectRoute, blogController.updateBlog);

router
  .route('/delete/:id')
  .delete(authController.protectRoute, blogController.deleteBlog);
module.exports = router;

Enter fullscreen mode Exit fullscreen mode

Now the major functionalities of our application are done, lets just add some utilities to make our app perfect.

Error Class Util
Head to the util folder and create a file called appError.js, copy the following code.

class appError extends Error {
  constructor(statusCode, message) {
    super(message);

    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'failed' : 'Server Error';
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = appError;

Enter fullscreen mode Exit fullscreen mode

To make this appError util work we need a corresponding controller file, so lets create a new controller called errorController.js and copy the following code.

const appError = require('./../utils/appError');

const handleCastErrorDB = (err) => {
  const message = `Invalid ${err.path}: ${err.value}`;
  return new appError(400, message);
};
const handleDuplicateKeyDB = (err) => {
  const value = err.keyValue.email;
  const message = `Duplicate field value: '${value}' Please use another value`;
  return new appError(400, message);
};
const handleValidationErrorDB = (err) => {
  const errors = Object.values(err.errors).map((el) => el.message);
  const message = `Invalid field data ${errors.join('. ')} Critical Error`;
  return new appError(400, message);
};
const handleJWTError = (err) =>
  new appError(401, 'Invalid token. Please login again');
const handleJWTExpiredError = (err) =>
  new appError(401, 'Your token is expired, Please login again');

const errorDev = (err, res) => {
  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
  });
};
const errorProd = (err, res) => {
  if (err.isOperational) {
    res.status(err.statusCode).json({
      status: err.status,
      message: err.message,
    });
    //Programming Errors or other unknown Error: Don't leak error details
  } else {
    console.error('ERROR 💣', err);
    res.status(500).json({
      status: 'error',
      message: 'Something went very wrong',
    });
  }
};

module.exports = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'Server Error';
  if (process.env.NODE_ENV == 'Development') {
    console.log(err);
    errorDev(err, res);
  } else if (process.env.NODE_ENV == 'production') {
    let error = { ...err };
    error.message = err.message;
    if (error.name === 'CastError') error = handleCastErrorDB(error);
    if (error.code === 11000) error = handleDuplicateKeyDB(error);
    if (error._message === 'Validation failed')
      error = handleValidationErrorDB(error);
    if (error.name === 'JsonWebTokenError') error = handleJWTError(error);
    if (error.name === 'TokenExpiredError')
      error = handleJWTExpiredError(error);
    errorProd(error, res);
    console.log(err);
  }
};
Enter fullscreen mode Exit fullscreen mode

Now we have successfully setup a robust way of handling both development and production error.

Catch async Util
Head to the util folder and create a file called catchAsync.js, copy the following code.

module.exports = (fn) => {
    return (req, res, next) => {
        fn(req, res, next).catch(next);
    }
}
Enter fullscreen mode Exit fullscreen mode

This code block is to handle the repeated use of try and catch block, thereby making our code more readable.

Email Service Util
Head to the util folder and create a file called email.js, copy the following code.

const nodemailer = require('nodemailer')

const sendEmail = async (options) => {
    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,
        },
    })

    const mailOptions = {
        from: 'Kelechi Okoronkwo <hello@blakcoder.tech>',
        to: options.email,
        subject: options.subject,
        text: options.message
    }
    await transporter.sendMail(mailOptions)
}
module.exports = sendEmail
Enter fullscreen mode Exit fullscreen mode

This util is to help in sending emails to users when requested, for example when users request for a password reset. Finally to make our email service work we would need to update our .env file with the following code.

EMAIL_USERNAME='4602f30010ad95'
EMAIL_PASSWORD='1f4568221277a4'
EMAIL_HOST='smtp.mailtrap.io'
EMAIL_PORT=2525

Enter fullscreen mode Exit fullscreen mode

I made use of mailtrap for email testing, you can get the following details by creating an account on mailtrap.io.

Finally you can test and deploy to your favorite platform

Here is the complete source code Source Code

Cheers
May the force be with you.

Top comments (0)