Lab 5 - Advanced Sails

HTTP File upload & download

File upload

In traditional, we could upload file using HTML form element by specifying the enctype attribute to multipart/form-data together with method="post". However, Sails requires all <input type="file" /> elements placing at the end of a form. Meaning that, we could not mix <input type="file" /> with other input elements within a form.

Thus, here are two different examples. One is the traditional approach, another one uses HTML 5 File API reading the file content and put it in a hidden input element.

Example 1: Upload file using <form enctype="multipart/form-data">

In this example, we would store file path of the uploaded file in User model avatarPath attribute.

Let’s use your comp3047-ls05-2018 Sails project as example. Please clone the project and open it up in Visual Studio Code.

git clone https://github.com/HKBU-COMP3047/comp3047-ls05-2018-your_github_username

Adding avatarPath attribute in api/models/User.js.

We add an avatarPath attribute to User model in order to store the uploaded file path.

avatarPath: {
	type: 'string'
},
// you may also need this id attribute for new version Sails in all of the models
id: {
	type: 'number',
	autoIncrement: true
},

Add a upload action in api/controllers/UserController.js

Then we add a controller action to handle the file upload.

//...
upload: async function(req, res) {

	if (req.method == 'GET')
		return res.view('user/upload');

	req.file('avatarfile').upload({maxBytes: 10000000}, async function whenDone(err, uploadedFiles) {
		if (err) { return res.serverError(err); }
		if (uploadedFiles.length === 0){ return res.badRequest('No file was uploaded'); }

		console.log('req.body.agree = ' + req.body.agree);

		await User.update({username: req.session.username}, {
			avatarPath: uploadedFiles[0].fd
		});

		return res.ok('File uploaded.');
	});
},
//...

The req.file('avatarfile').upload(options, callback) function would handle <input type="file" name="avatarfile" /> file upload control.

Config a route to the upload action

As usual, we need to define a route to the controller action in order to reach it. Please add the following line to config/routes.js.

'/user/upload': 'UserController.upload',

Adding ejs file

In a form element like <form action="..." method="post">, application/x-www-form-urlencoded is the default enctype when not specified. Thus we need to specify it to multipart/form-data by adding the enctype attribute.

Please create views/user/upload.ejs file with the following content.

<form action="/user/upload" method="post" enctype="multipart/form-data">
	<input type="file" accept="image/*" name="avatarfile" />
	<input type="checkbox" name="agree" value="true" />
	<input type="submit" />
</form>

Example 2: Embed file data in a hidden input

In this example, we are going to store the file content in User model avatar attribute.

Adding avatar attribute in api/models/User.js.

avatar: {
	type: 'string'
},

Adding upload2 Controller action

//...
upload2: async function(req, res) {

	if (req.method == 'GET')
		return res.view('user/upload2');

	console.log('req.body.agree = ' + req.body.agree);

	await User.update({username: req.session.username}, {
		avatar: req.body.User.avatar
	});
	
	return res.ok('File uploaded.');
},
//...

Config a route to the upload2 action

As usual, we need to define a route to the controller action in order to reach it. Please add the following line to config/routes.js.

'/user/upload2': 'UserController.upload2',

Adding ejs file

In this example, we need not to specify the enctype attribute of the form element. Instead, we need to add a implement a function, handling onchange event of the file input control with HTML 5 File API.

Please create views/user/upload2.ejs file with the following content.

<form action="/user/upload2" method="post">
	<input type="file" accept="image/*" onchange="handleFile(this.files)" />
	<div id="preview"></div>
	<input type="checkbox" name="agree" value="true" />
	<input type="submit" />
</div>
<script>
function handleFile(files) {

  const file = files[0];
  if (!file.type.startsWith('image/')) return;

  var preview = document.getElementById('preview');
  
  var reader = new FileReader();
  reader.onload = function(e) {
	preview.innerHTML = "";
  
	var img = document.createElement('img');
    img.src = e.target.result;
    preview.appendChild(img);

	var input = document.createElement('input');
	input.type = 'hidden';
	input.name = 'User[avatar]';
	input.value = e.target.result;
	preview.appendChild(input);
  }
  reader.readAsDataURL(file);

}
</script>

File download

Example 1: Stream file content directly in controller action

Adding avatar Controller action

Please add the following avatar action to api/controllers/UserController.js.

avatar: async function(req, res) {
	var user = await User.findOne({username: req.params.username});
	if (!user || !user.avatarPath)
		return res.notFound();
	
	var SkipperDisk = require('skipper-disk');
	var fileAdapter = SkipperDisk();
	// set the filename
	// res.set("Content-disposition", "attachment; filename='avatar.jpg'");
	res.set('Content-type', 'image/jpeg');
	// Stream the file down
	fileAdapter.read(user.avatarPath).on('error', function (err){
		return res.serverError(err);
	}).pipe(res);
},

Adding route to controller action

As usual, we need to define a route to the controller action in order to reach it. Please add the following line to config/routes.js.

'GET /user/:username/avatar.jpg': 'UserController.avatar',

After that, you could view the image by http://localhost:1337/user/admin/avatar.jpg, if you uploaded avatar using admin user.

Usage

<img src="/user/admin/avatar.jpg" />

Example 2: Embed file Data URI in HTML element

Adding a controller action

Please add the following profile action to api/controllers/UserController.js.

profile: async function(req, res) {
	var user = await User.findOne({username: req.session.username});
	if (!user) return res.notFound();

	return res.view('user/profile',{model: user});
},

Adding the route

'GET /user/profile': 'UserController.profile',

Adding view ejs file

Please create view/user/profile.ejs with the following code.

<h1><%=model.username%></h1>
<% if(model.avatar) { %>
<img src="<%=model.avatar%>" />
<a download="avatar.jpg" href="<%= model.avatar%>" target="_blank">Download avatar</a>
<% } %>

For the file we stored its Data URI in mode, we could simply use it in src attribute of an image element. If you would like to make it a download link, you could use <a href> element with download="filename" attribute like <a href="..." download="filename">.

Example 3: Convert file to Data URI in Sails

We may like to convert a local file to Data URI for embedding in HTML. To do so, we could use datauri module.

https://www.npmjs.com/package/datauri

Install datauri module

As usual, we use npm install ... --save command to add this module to our project.

npm install datauri --save

Changing upload action to store Data URI in UserController

Please locate following lines in UserController, which storing the file path in avatarPath attribute.

await User.update({username: req.session.username}, {
	avatarPath: uploadedFiles[0].fd
});

We modify it to store Data URI in avatar attribute by reading the file as Data URI.

const  DataURI  =  require('datauri').sync;
await User.update({username: req.session.username}, {
	avatar: DataURI(uploadedFiles[0].fd)
});

Handling CSV file

CSV stands for comma-separated values. It is a simple file format for storing excel like data table.

fast-csv module

fast-csv is a module for parsing or formatting CSV file written in Javascript. You could found the complete documentation and examples at the following URL.

https://www.npmjs.com/package/fast-csv

Install fast-csv module in project

As usual, we use npm install ... --save command to add this module to our project.

npm isntall fast-csv --save

Export CSV

Example: Stream csv file of Person models

In this example, we construct a controller action for us to download all Person models in CSV format.

Adding a controller action

Let’s add csv controller action with following codes into api/controllers/PersonController.js.

csv: async function(req, res) {
	var csv = require("fast-csv");
	var models = await Person.find();
	if (!models)
		return res.notFound();
	res.set('Content-type', 'text/csv');
	csv.write(models , {headers:  true}).pipe(res);
},

Setting up the route

As usual, we need to define a route to the controller action in order to reach it. Please add the following line to config/routes.js.

'GET /person/export.csv': 'PersonController.csv',

After that let’s checkout the result by navigating to http://localhost:1337/person/export.csv

Import CSV

Example: Upload and import CSV

Let’s combine it with the upload function that we have just learned. In this example, we would construct a upload function for importing the CSV file that we have just downloaded.

Adding controller action

Here are codes for implementing the controller action. Please add it in api/controllers/PersonController.js. The code block for uploading file is similar to previous multipart/form-data example.

//...
upload: async function(req, res) {

	if (req.method == 'GET')
		return res.view('person/upload');

	req.file('csv').upload({maxBytes: 10000000}, async function whenDone(err, uploadedFiles) {
		if (err) { return res.serverError(err); }
		if (uploadedFiles.length === 0){ return res.badRequest('No file was uploaded'); }

		var csv = require("fast-csv");
		csv.fromPath(uploadedFiles[0].fd, {headers :  true}).on("data", async function(data){

			await Person.create(data);
	
		}).on("end",  function(){
			return res.ok('csv file imported.');
		});

	});
},
//...

After we have validate the uploaded file, we construct a fast-csv object using require('fast-csv'). Then we use its .fromPath(path, options) to parse the CSV file that have just uploaded. .on('data') event callback would give us actual data of each row in the CSV file. The .on('end') event callback tells us the parsing have been completed.

Adding ejs file of upload form

Please create views/person/upload.ejs file with the following content.

<form action="/person/upload" method="post" enctype="multipart/form-data">
	<input type="file" accept="text/csv" name="csv" />
	<input type="submit" />
</form>

Adding route to controller action

As usual, we need to define a route to the controller action in order to reach it. Please add the following line to config/routes.js.

'/person/upload': 'PersonController.upload',

After that let’s checkout the result by navigating to http://localhost:1337/person/upload

Sending Email

Setting up Mailgun, a free email service

Mailgun is a powerful transactional Email APIs that enable you to send, receive, and track emails.

Register a Mailgun account

Please register a Mailgun account at https://www.mailgun.com/.

enter image description here

Optional: Add a Authorized Recipients

Without providing credit card information, we could only send email to authorized recipients. Please add an authorized recipient email address for testing.

enter image description here

Using Mailgun in Sails

Mailgun comes with a Javascript module. we may use it directly in our Sails app.

Install Mailgun module

As usual, we use npm install ... --save command to add this module to our project.

npm install mailgun-js --save

Add Mailgun account information to config/custom.js

Please add your Mailgun account information according to your dashboard. mailgunDomain is your Mailgun domain, for example sandboxef5f56337bca4a36a491e6a91e8cd296.mailgun.org. mailgunSecret is the API Key showing on the domain dashboard. mailgunFrom is the email address showing in From of an email.

mailgunDomain: 'yourmailgundomain',
mailgunSecret: 'yourmailgunkey',
mailgunFrom:'yourmailgunfrommail',

Generate a helper function

We would like to construct a helper function, which would enable us to use the function globally in Sails. Please use the following command to create a blank helper function with name send-single-email.

sails generate helper send-single-email

Adding code to helper

Please replace the codes in api/helpers/send-single-email.js as follows.

var mailgun = require('mailgun-js')({apiKey: sails.config.custom.mailgunSecret, domain: sails.config.custom.mailgunDomain});

module.exports = {
	friendlyName: 'Send single email',
	description: '',
	inputs: {
		options:{
			type:'json'
		}
	},
	exits: {
	},
	fn: async function (inputs, exits) {
		mailgun.messages().send(inputs.options, function (error, body) {
		
			if(error) return exits.error(error)
			return exits.success(body);
		});
	}
};

Using the helper function

We could add the following to config/bootstrap.js for testing. Each time we sails lift, there is an email sending to destination@email.com. Please modify destination@email.com to the authorized email you have just added.

await sails.helpers.sendSingleEmail({
	to: 'destination@email.com',
	from: sails.config.custom.mailgunFrom,
	subject: 'Subject',
	text: 'Your message'
});

After that, just run sails lift to see result.

Example: Render ejs file as email content

We could use ejs rendering result as email content. sails.renderView(pathToView, templateData) function could render the ejs file content with dynamic data, and return the result as string. We could use option layout: false to exclude layout.ejs. Please add the following code to api/controllers/UserController.js.


userlistemail: async function(req, res) {
	var models = await User.find();
	var html = await sails.reanderView('user/email_userlist', {
		users: models,
		layout: false
	});
	await sails.helpers.sendSingleEmail({
		to: 'change_to_your@email.com',
		from: sails.config.custom.mailgunFrom,
		subject: 'Subject',
		html: html
	});
	return res.ok('users list sent!');
},

Please create views/user/email_userlist.ejs with the following content.

<% users.forEach(function(user) { %>
<div><%= user.username %></div>
<% }); %>