Jumping into React, Node, MongoDB, and AWS S3

So I finally got around to working with some technologies that have been on my hot-list for quite some time. NodeJS, React, and MongoDB see a lot of use in the application development industry and it was high time I put my nose into them. I decided to toss in some AWS as I also wanted to learn how to work with S3 buckets. Why not, right?

The gist of this little project was to create a front-end interface along with a back-end API for it to interact with. As far as the front-end goes, my main goals were to consume enough of React to understand its asynchronous nature and how to provide a decent interactive experience with regards to its functionality. The back-end would handle uploading, deleting, and creating signed urls within the context of AWS’ S3 service in addition to interacting with a MongoDB database that would keep track of any uploaded objects.

The following diagram summarizes the interactions of each component.

You can find all of the code featured in this blog post in my Github account at the following URL. I’ve included all of the requests the back-end API accepts (aside from a call to “/uploadFile” — which can be captured with a proxy) in text files so you can pull them into Burp, Zap, Fiddler, or Postmon.

https://github.com/ryan-wendel/react-node-mongo-s3-app

All things considered, nothing in this post is overwhelmingly technical or impressive. It’s a simplistic app that interacts with AWS S3 and DynamoDB. Hopefully, someone will take something from it should they happen to run across my little portion of the web.

A gratuitous screenshot because we all know waiting until the end would be far too excruciating!

Disclaimer: I am not a Node, React, or Mongo expert and it’s highly likely I’ll use some terminology that doesn’t quite fit and do things in an unconventional manner. By all means, leave a comment and inform me of something I’ve missed or got wrong should you be so inclined. I’m always open to constructive comments.

To kick things off I’ll start by explaining that I wanted the application to allow for multiple file types that carry different characteristics.

  1. Restrictions on mime-types
  2. Restrictions on file extensions
  3. A specific S3 bucket
  4. A specific S3 bucket path
  5. A few pieces of info to populate the front-end with

Basically, I wanted to approach the design by making the application extensible via the use of JSON shoved into a NoSQL database. More on that later…

Knowing there would be multiple file types I went ahead and created two different buckets to illustrate this baked-in extensibility. Yes, a single bucket would have sufficed but I wanted to demonstrate using more than one. The application is designed to hand out download links to users via S3 pre-signed URLs. As such, a bucket used by a given file-type does not need to be made public.

For the sake of this proof-of-concept, the following buckets will be utilized:

  • foo.ryanwendel.com
  • bar.ryanwendel.com

The following AWS S3 policy was placed in-line for an AWS IAM user with programmatic access. If I were hosting this in AWS, I would create an assumable role, but I’ll not get into that and save it for another day.

From here we’ll set up our MongoDB database. Assuming that you’ve just installed MongoDB, let’s lock it down in terms of requiring password authentication. With a fresh install, access the command-line interface using the following:

Then configure a password for an administrative user using the following.

Exit the cli and edit the main MongoDB configuration file (mine was /etc/mongod.conf). Add the following two lines to the bottom:

Once done with editing the config file, restart the server.

Log back in as the administrative account and create an application user. You’d use the following to do this.

Once logged in as an admin user, execute the following commands to create a service account and database for your application.

Log out and then back in using your newly created application user.

Start off by creating three collections (tables).

Next, we’ll create two “fileType” documents (rows). Each file type document will control how the application will classify, filter, and upload files. You’ll see how these are used later on.

We’ll also create an “origin” document that we’ll use to programmatically configure the CORS policy for the back-end API. Nope, we’re not setting a wildcard and walking away. Remember to abide by the Principle of Least Privilege!

This document implies that my front-end’s origin is “http://192.168.0.160:3000” and that we want the back-end to allow this origin to read its responses. Check out the Same Origin Policy if you don’t understand this.

With our database set up, we’ll now focus on the back-end API. After installing Node, create a working directory for the back-end api. Traverse into this directory and run the following to set up your environment.

All of this should be pretty self-explanatory. If not, visit some of the following links to read up on each package.

https://www.npmjs.com/package/fastify
https://www.npmjs.com/package/fastify-multipart
https://www.npmjs.com/package/fastify-cors
https://www.npmjs.com/package/mongoose
https://www.npmjs.com/package/dotenv
https://www.npmjs.com/package/nodemon
https://www.npmjs.com/package/@aws-sdk/client-s3
https://www.npmjs.com/package/@aws-sdk/s3-request-presigner

I could have installed the entire @aws-sdk package, but seeing as how everyone likes leaner/faster code, I chose to only install the portions of the SDK that we plan on using. Perhaps we could call this the Principle of Least Stuffs?

A quick note about “nodemon”.

nodemon is a tool that helps develop node.js based applications by automatically restarting the node application when file changes in the directory are detected.

To set up nodemon, we need to add the following line of code to our package.json file, in the scripts object:

Your package.json file should end up looking something like the following:

Now after issuing an “npm start” in the root directory of your project, nodemon will automatically restart node when any of your files change.

Note: Do not use nodemon in production.

From here we’ll get into the code utilized by the back-end. To start, we’ll look at index.js and pulling in modules required by the application. The added comments should suffice regarding the reasons behind inclusion.

Worth mentioning is the “dotenv” module. This allows you to place variables in a “.env” file in the root of your project that will store environment variables to use in your code. This provides portability and some added security in that we’re not hard-coding secrets in our code-base. Read up on using dotenv at the following URL.

https://medium.com/the-node-js-collection/making-your-node-js-work-everywhere-with-environment-variables-2da8cdf6e786

Make note that you probably don’t want to include this in any git commits. Best to add it to your gitignore file. Check out the following URL for more info.

http://git-scm.com/docs/gitignore

My .env file looks like the following:

Next, in our index file we’ll pull in our environment variables, instantiate a Fastify object, register the multipart module, and create an object to interact with the AWS S3 service.

Then we’ll connect to the MongoDB we set up earlier. Note how we’re pulling in environment variables.

Next, we pull in our controllers which also encompass our database models.

It’s here that I should point out that database models were placed in “./src/models” and the logic that controls them is found in “./src/controllers”. The controllers include the models. By including the controllers in our index file we also pull in the models.

Take a quick look at the “file” model (./src/models/file.js).

This is pretty straight forward. We define a schema that we want Mongoose to work with. The “versionKey” attribute tells Mongoose whether we want to keep track of versions within the database. This was not required for this POC.

Next, we’ll take a quick look at the file controller (./src/controllers/fileController.js).

Everything should also be pretty self-explanatory. We’re creating functions to get, create, update, and delete file objects.

The “fileType” model/controller are very similar. The “origin” model and controller were dumbed down in that I needed something simple to pull origins from the database to help configure CORS. Regardless, there is a lot of overlap between all of them.

At this point, we go back to our index file and work on handling the routes our API will work with. They are as follows:

I won’t go over every piece of code that comprises each route and, instead, will choose to highlight various fragments.

The following is some logic I utilized to sanity check inputs for the “/newFileType” route. It’s always prudent to handle erroneous input gracefully and provide some meaningful feedback to help with debugging.

You’ll see several variations of this throughout the route logic. Perhaps some slick Node package exists to perform this task more gracefully. I didn’t look for one but wouldn’t be surprised if someone has already put something together.

There is also some sanity checking done with regards to uploaded files and their file type information. The back-end API makes sure that the mime-type and extensions specified in fileType objects are adhered to.

The following is how a file is inserted into the database. We use the “fileController” object to call the “newFile” method to insert a document/row into the “files” collection/table.

The insertion of the file document into the files collection is preceded by parsing the uploaded multipart file and uploading it to the file type’s specific S3 bucket. Note that this code checks to see if an object with the same S3 “key” already exists and bails if this is the case.

And then there is the task of obtaining a pre-signed URL from the S3 service.

And lastly (regarding the back-end’s index file), we have to handle CORS so that the foreign origin of the front-end can read the responses provided by the back-end API. The lazy way to handle this is to set a wild-card but I strongly urge against this. Again, abide by the Principle of Least Privilege and only allow approved origins to read responses from any API you create. Granted, unless you want to open your API up to the world.

I handled the CORS configuration and started the Fastify application in the following manner. Figuring this out was kind of a pain-in-the-ass. Perhaps someone out there knows a more elegant way to handle this?

And now we’re on to the React front-end. As far as React goes, the main points I took from this front-end technology were:

Great links to read up on React are:

https://reactjs.org/docs/
https://react-tutorial.app

Admittedly, I could learn to use components and props better than I did. Always room for improvement!

I’m not going to go over everything I did in the front-end as coming to understand React is something a lot of online resources already deal with. Tons of publicly available info exist that can help you with this.

What I will detail is constructing some of the upload form via use of back-end API calls and how the upload form is used to transmit files to the back-end.

Let’s first create our application’s environment. Execute the following as non-root user.

To start, we’ll look at the select object that helps build form data to send to the back-end to communicate what file type is being uploaded. Various member variables need to be initialized in the app’s constructor to help out with this.

Take a look at the app’s constructor.

Also, note the use of “dotenv” to assist with making the back-end API portable. My .env file was pretty simple.

As far as dynamically building a select element goes, I used the “fileTypeSelectOptions”, “fileTypeSelectOptionsValue”, and “fileTypeInfo” member variables for this. Let’s look at how this went down.

When fetching data needed to help construct objects for an interface, it’s best to do so in the “componentDidMount” lifecycle event. Without going off on a tangent about why this is optimal, go ahead and peruse the following link for specifics. Dave does a good job at explaining why this is.

https://daveceddia.com/where-fetch-data-componentwillmount-vs-componentdidmount/

And now on to mine:

Something to note, the “Axios” HTTP client is being utilized by the front-end application to make API calls to the back-end.

The “componentDidMount” lifecycle event encompasses making a call to the back-end “/getFileTypes” endpoint and setting a few state variables. The file type info gets stored in “fileTypeInfo” while “fileTypeHelp” stores some text designated by the “fileTypeSelectOptionsValue” integer.

With the file type info captured and available for repeated use in other portions of the app, a call to “setFileTable” is made that builds a list of files already uploaded to S3 that allows a user to download or delete each file. This list of files gets stored in a state variable as we want to re-render the list when a file is deleted. State variables are utilized for this purpose.

Note that we make use of the “setState” function instead of returning a React Component. React has a pretty slick convention for handling an application’s state. A call to “setState” that updates an app’s state-specific member variables will force a call to “render()” which will utilize the updated state variables and rebuild the DOM.

The “setFilesTable” method makes a call to the “/getFiles” back-end API target whose response is then used to build a table of files. Handlers are attached to two buttons that handle the downloading and deletion of files. Both handlers are very similar in that they ingest a file ID to use when making calls to the back-end.

Take a look at the download handler.

Pretty straight-forward. The “onDownloadClick” handler makes a call to the back-end to obtain a pre-signed URL from S3 and opens a new window using the generated URL.

I haven’t featured it here, but I chose to send JSON to the delete handler simply because I wanted to work through using both scenarios of using query parameters and a request body. You can see the query parameter version commented out in the code-base.

Now on to how the upload form is utilized to transmit files to the back-end.

Go back to the constructor and make note of the “selectedFile” member variable. This is the object that will comprise the file that will eventually be uploaded to S3. A file input is tied to this state variable via the “onFileChange” handler.

The “onFileChange” handler simply sets the “selectedFile” state variable.

The upload button calls another handler that performs the actual upload.

Take a look at the “onSubmit” class method. The provided comments should be self-explanatory.

The “onSubmit” method uses various state variables to construct a form object that is used to post the file to the “/uploadFile” endpoint. Pretty easy after it’s all said and done.

There is definitely some room for improvement within my little proof-of-concept. Granted, I’m not too concerned with going much further with any of the coding as I want to push on to other tasks. I plan on using this code-base for at least two more blog posts that encompass CI/CD pipelines. One using AWS’ CodeCommit as a starting point and another that works with ECR after I push this entire project into containers. I plan to integrate both with BeanStalk with the former using EC2 instances and the latter ECS. Stay tuned!

Ethical Hacking Consultant. @ryanwendel

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store