Now we’ve done all of the configuration and can start writing our upload code. Fortunately, we don’t have to make any changes to the model, though we do have to alter the database schema. We will be making all of our changes in /routes/photos.js.
The major tasks remaining in this file are significant. When the user makes a POST to /photos/upload, we want the following actions to be taken:
1. User input is validated (user ID, album ID, and image are required).
2. Image is written to /tmp folder.
3. Image is uploaded from /tmp folder to S3 bucket.
4. Image is deleted from /tmp folder.
5. Final image URL is generated.
6. Entry is created in database, including URL.
7. User receives success message.
Along the way, there are a number of things that could go wrong, which we want to plan for: the user could include invalid input; there could be a problem reading/writing the image on the EC2 instance; or there could be a failure to upload to S3 or write to the database. The bad news is that proper error handling in complex Node.js apps can look a bit messy to read. The good news is that we can accomplish all of these steps with relatively little code.
First, we will have to enable access to globals to the router, as well as the fs module. Though the file system module is built into Node.js, you must declare it to access it directly. The top of the router will now look like the following:
var express = require('express'); var router = express.Router();
var model = require('./../lib/model/model-photos'); var globals = require('./../lib/globals');
var fs = require('fs');
Next, the route for /upload requires a complete rewrite. Replace it with Listing 5-7.
Listing 5-7. New POST/upload Route
router.post('/upload', function(req, res) {
if(req.param('albumID') && req.param('userID') && req.files.photo){ var params = { userID : req.param('userID'), albumID : req.param('albumID') } if(req.param('caption')){ params.caption = req.param('caption'); } fs.exists(req.files.photo.path, function(exists) { if(exists) {
133
params.newFilename = params.userID + '/' + params.filePath.replace('tmp/', timestamp); uploadPhoto(params, function(err, fileObject){
if(err){
res.status(400).send({error: 'Invalid photo data'}); } else {
params.url = fileObject.url; delete params.filePath; delete params.newFilename;
model.createPhoto(params, function(err, obj){ if(err){
res.status(400).send({error: 'Invalid photo data'}); } else { res.send(obj); } }); } }); } else {
res.status(400).send({error: 'Invalid photo data'}); }
}); } else {
res.status(400).send({error: 'Invalid photo data'}); }
});
There’s a lot happening here, so let’s take it down step-by-step. First, the form data is validated. In addition to albumID and userID being required, we are now requiring a file with the imaginative name photo
to be submitted. Most of the code is wrapped in this condition, and if it fails, an HTTP 400 error is sent in response to the request.
As we often do, we next construct a params object based on the request parameters. The required
albumID and userID are included, and if a caption is found, it is also included. Captions are optional, and we never access them directly in this route. Because we’re using multer, when a file is included in the POST, it is automatically written to the /tmp folder (which we specified in server.js). The copy stored in the /tmp
folder does not retain its original name but is instead assigned a random identifier, to alleviate concerns of duplicate file names. It’s not inconceivable that two users uploading photos from the same smartphone could have identical image names. Any files included in requests are automatically assigned a path property, pointing to their location on the server. This saves us quite a bit of trouble!
Next, we begin using the fs module. First, we use fs.exists() to check that the file is indeed located at the path we expect, accessible via req.files.photo.path. If it cannot be found here, an error is sent to the user and our route is stopped. If the file is found, then we add the file’s path to our params objects. We also create a params property called newFilename, which will be the final file name when the file is uploaded to S3. Because our app is running on several instances simultaneously, even with the random file name, there is still a chance of file names conflicting. To alleviate this, we prepend a timestamp to the file name, making the names even more unique. Additionally, we are also including a directory with the user’s ID in the path. The chances of a file name collision with these techniques are astronomically small.
Now that our params object is ready, we send it to the uploadPhoto() method, which we’ve yet to review. If that is successful, our image will be written to S3, and our params object will be assigned a url
property. Finally, we delete the params properties we no longer need and send the finished object to the
model.createPhoto() function. If that operation is successful, we return an HTTP 200 status to the user with a photo ID.
In /routes/photos.js, scroll down to the end of the routes, but before the module.exports
declaration at the bottom. We will add private functions here, for use only in this file. First, we will add the
uploadPhoto() function, shown in Listing 5-8.
Listing 5-8. uploadPhoto() Function
function uploadPhoto(params, callback){
fs.readFile(params.filePath, function (err, imgData) { if(err){
callback(err); } else {
var contentType = 'image/jpeg';
var uploadPath = 'uploads/' + params.newFilename; var uploadData = { Bucket: globals.awsVariables().bucket, Key: uploadPath, Body: imgData, ACL:'public-read', ContentType: contentType }
putS3Object(uploadData, function(err, data){ if(err){
callback(err); } else {
fs.unlink(params.filePath, function (err) { if (err){
callback(err); } else {
callback(null, {url: uploadPath}); } }); } }); } }); }
First and foremost, this function reads the file from the /tmp directory. Then an upload path is set, using the file name from the params object. An object named uploadData is constructed, using the key-values required by the AWS SDK. We construct this object in preparation for uploading the image to S3, at which point it will be referred to as an object.
The Bucket key uses the bucket declared in our globals, which were ultimately set in an OpsWorks Environment Variable. The Key is simply the path in the S3 bucket. The Body contains the image data we retrieved with fs.readFile(). ACL stands for Access Control List and represents the permissions for the object when it’s created on S3. Last is the ContentType, which is hard-coded to 'image/jpeg'.
As an additional exercise, you could set the ContentType dynamically, by reading it with fs and passing it to this function in the params object.
135
We’ll add the last function, putS3Object(), below uploadPhoto(). This function (see Listing 5-9) simply handles the upload to S3, using the AWS SDK. Add the following function to /routes/photos.js:
Listing 5-9. putS3Object() Function
function putS3Object(uploadData, callback){ var aws = require('aws-sdk');
if(globals.awsVariables().key){
aws.config.update({ accessKeyId: globals.awsVariables().key, secretAccessKey: globals. awsVariables().secret });
}
var s3 = new aws.S3();
s3.putObject(uploadData, function(err, data) { if(err){ callback(err); } else { callback(null, data); } }); }
Let’s break it down line by line. First, the aws-sdk is loaded. Then, we check whether globals. awsVariables().key is defined. You’ll recall that it’s only defined locally, for the use case wherein we use IAM user credentials. If you never want to use this approach, you could eliminate this if statement entirely. But if you’re using an IAM user for S3 permissions, then the key and secret must be passed to aws.config. update(). If we’re instead relying on the IAM role of the instance, then the AWS SDK obtains the credentials automatically, and we never have to call aws.config.update().
Then, we simply call s3.putObject(). As mentioned previously, an S3 bucket’s contents are referred to ambiguously as objects, regardless of type. We already constructed the necessary parameters prior to this function, so it’s short and simple.
Just to be clear on how this all works, let’s take a quick look at model.createPhoto(). Open /lib/ model/model-photos.js. Near the top of the file, you should see the code in Listing 5-10.
Listing 5-10. Model createPhoto() Function
function createPhoto(params, callback){ var query = 'INSERT INTO photos SET ? ';
connection.query(query, params, function(err, rows, fields){ if(err){ callback(err); } else { var response = { id : rows.insertId }; callback(null, response); } }); }
We have not made any changes to this function. Because it sets values based on the contents of the
params object parameter, any changes to the controller and database will automatically be reflected here. You can see that the value returned is simply the ID of the photo.
However, if you look at the other methods in the model, you’ll see that we are selecting specific fields for output to the user. We will have to make a few changes to our other SQL statements. After all, it would be ridiculous to have a photo album web app that didn’t actually show any photos. It could probably raise $50 million in VC funding anyway.
First, find function getPhotoByID(). Add url to the query variable, so the function now appears as in Listing 5-11.
Listing 5-11. Model getPhotoByID() Function
function getPhotoByID(params, callback){
var query = 'SELECT photoID, caption, url, albumID, userID FROM photos WHERE published=1 AND photoID=' + connection.escape(params.photoID);
connection.query(query, function(err, rows, fields){ if(err){ callback(err); } else { if(rows.length > 0){ callback(null, rows); } else { callback(null, []); } } }); }
Likewise, we want to include the URL when photos are selected by album ID. Once again, update the SQL query only (see Listing 5-12).
Listing 5-12. Model getPhotosByAlbumID() Function
function getPhotosByAlbumID(params, callback){
var query = 'SELECT photoID, caption, url, albumID, userID FROM photos WHERE published=1 AND albumID=' + connection.escape(params.albumID);
connection.query(query, function(err, rows, fields){ if(err){ callback(err); } else { if(rows.length > 0){ callback(null, rows); } else { callback(null, []); } } }); }
137
Figure 5-11. RDS instance tables
Listing 5-13. Model getPhotosSearch() Function
function getPhotosSearch(params, callback){
var query = 'SELECT photoID, caption, url, albumID, userID FROM photos WHERE caption LIKE "%' + params.query + '%"';
connection.query(query, function(err, rows, fields){ if(err){ callback(err); } else { if(rows.length > 0){ callback(null, rows); } else { callback(null, []); } } }); }
And we’re now done coding! Commit your changes to your repository. Return to OpsWorks and deploy your app. You can do that without directions by now. The deployment process has a lot of work to do this time. It’s adding your OpsWorks Environment Variables to each instance, updating the IAM role, and running your Chef JSON. When your code is retrieved from your repository, OpsWorks will find the new dependencies listed in your package.json file and automatically install them along with the rest of your app. In the meantime, we have a few more tasks to finish before this lesson is complete.