File Uploads


File Uploads

One common task for web applications is uploading files. These files might be images, videos, PDFs, or files of any other type. In order to upload files through an HTML interface, we need a file input tag in a multipart form.

Plug provides a Plug.Upload struct to hold the data from the file input. A Plug.Upload struct will automatically appear in our request parameters if a user has selected a file when they submit the form.

Let’s take this one piece at a time.

In the Ecto Guide, we generated an HTML resource for users. We can reuse the form we generated there in order to demonstrate how file uploads work in Phoenix. Please see that guide for instructions on generating the users resource we’ll be using here.

The first thing we need to do is change our form into a multipart form. The form_for/4 function accepts a keyword list of options where we can specify this.

Here is the form from lib/hello_web/templates/user/form.html.eex with that change in place.

<%= form_for @changeset, @action, [multipart: true], fn f -> %>
. . .

Once we have a multipart form, we need a file input. Here’s how we would do that, also in form.html.eex.

. . .
  <div class="form-group">
    <label>Photo</label>
    <%= file_input f, :photo, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= submit "Submit", class: "btn btn-primary" %>
  </div>
<% end %>

When rendered, here’s what the HTML for that input looks like.

<div class="form-group">
  <label>Photo</label>
  <input class="form-control" id="user_photo" name="user[photo]" type="file">
</div>

Note the name attribute of our file input. This will create the "photo" key in the user_params map which will be available in our controller action.

That’s it from the form side. Now when users submit the form, a POST request will route to our Hello.UserController create/2 action.

Note: This photo input does not need to be part of our model for it to come across in the user_params. If we want to persist any properties of the photo in a database, however, we would need to add it to our Hello.User model’s schema.

Before we begin, let’s add IO.inspect user_params to the top of our Hello.create/2 action in lib/hello_web/controllers/user_controller.ex. This will show the user_params in our development log so we can better see what’s happening.

. . .
  def create(conn, %{"user" => user_params}) do
    IO.inspect user_params
. . .

Since we generated an HTML resource, we can now start our server with mix phoenix.server, visit http://localhost:4000/users/new, and create a new user with a photo.

When we do that, this is what our user_params look like in the log.

%{"bio" => "Guitarist", "email" => "dweezil@example.com", "name" => "Dweezil Zappa", "number_of_pets" => "3",
"photo" => %Plug.Upload{content_type: "image/jpg", filename: "cute-kitty.jpg", path: "/var/folders/_6/xbsnn7tx6g9dblyx149nrvbw0000gn/T//plug-1434/multipart-558399-917557-1"}}

We have a “photo” key which maps to the pre-populated Plug.Upload struct representing our uploaded photo.

To make this easier to read, let’s just focus on the struct itself.

%Plug.Upload{content_type: "image/jpg", filename: "cute-kitty.jpg", path: "/var/folders/_6/xbsnn7tx6g9dblyx149nrvbw0000gn/T//plug-1434/multipart-558399-917557-1"}

Plug.Upload provides the file’s content type, original filename, and path to the temporary file which Plug created for us. In our case, "/var/folders/_6/xbsnn7tx6g9dblyx149nrvbw0000gn/T//plug-1434/" is the directory which Plug created to put uploaded files in. It will persist across requests. "multipart-558399-917557-1" is the name Plug gave to our uploaded file. If we had multiple file inputs and if the user selected photos for all of them, we would have multiple files scattered in temporary directories. Plug will make sure all the filenames are unique.

Note: This file is temporary, and Plug will remove it from the directory as the request completes. If we need to do anything with this file, we need to do it before then.

Once we have the Plug.Upload struct available in our controller, we can perform any operation on it we want. We can check to make sure the file exists with File.exists?/1, copy it somewhere else on the filesystem with File.cp/2, send it to S3 with an external library, or even send it back to the client with Plug.Conn.send_file/5.

For example, in production system, we may want to copy the file to a root directory, such as /media. When doing so, it is important to guarantee the names are unique. For instance, if we are allowing users to upload profile pictures, we could use the user id to generate a unique name:

if upload = user_params["photo"] do
  extension = Path.extname(upload.filename)
  File.cp(upload.path, "/media/#{user.id}-profile#{extension}")
end

Then a Plug.Static plug could be set in your lib/my_app/endpoint.ex to serve the files at “/media”:

plug Plug.Static, at: "/uploads", from: "/media"

The uploaded file can now be accessed from your browsers using a path such as “/uploads/1-profile.jpg”. In practice, there are other concerns you want to handle when uploading files, such validating extensions, encoding names and so on. Many times, using a library that already handles such cases, is prefered.

Finally, notice that when there is no data from the file input, we get neither the “photo” key nor a Plug.Upload struct. Here are the user_params from the log.

%{"bio" => "Guitarist", "email" => "dweezil@example.com", "name" => "Dweezil Zappa", "number_of_pets" => "3"}

Configuring upload limits

The conversion from the data being sent by the form to an actual Plug.Upload is done by the Plug.Parsers plug which we can find inside HelloWeb.Endpoint:

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Poison

Besides the options above, Plug.Parsers accepts other options to control data upload:

  • :length - sets the max body length to read, defaults to 8_000_000 bytes
  • :read_length - set the amount of bytes to read at one time, defaults to 1_000_000 bytes
  • :read_timeout - set the timeout for each chunk received, defaults to 15_000 ms

The first option configures the maximum data allowed. The remaining ones configure how much data we expect to read and its frequency. If the client cannot push data fast enough, the connection will be terminated. Phoenix ships with reasonable defaults but you may want to customize it under special circumstances, for example, if you are expecting really slow clients to send large chunks of data.

It is also worth pointing out those limits are important as a security mechanism. For example, if we don’t set a limit for data upload, attackers could open up thousands of connections to your application and send one byte every 2 minutes, which would take very long to complete while using up all connections to your server. The limits above expect at least a reasonable amount of progress, making attackers’ lives a bit harder.