Feb 19, 2019

Creating a Photo Gallery in Phoenix with Arc - Part 1

File uploads are a common requirement of many applications. Today we’ll look at how we can accomplish file uploads in Phoenix with the help of Arc… a flexible file upload and attachment library for Elixir.

What we’ll build

To demonstrate file uploads we’re going to build a very simple photo gallery, at the end of this post we’ll be able to upload and display images, and our application will look like:

If you’d rather grab the completed source code directly rather than follow along, it’s available on GitHub, otherwise let’s get started!

Creating the app

We need to set-up a bit of plumbing as a starting point, so let’s grab a bare-bones skeleton of the application from GitHub.

Clone the Repo

We’ll start by cloning the repo.

Terminal
git clone -b part-0 https://github.com/riebeekn/phx-photo-gallery.git photo_gallery
cd photo_gallery

Let’s create a branch for today’s work:

Terminal
git checkout -b part-1

And then let’s get our dependencies and database set-up.

Terminal
mix deps.get
Terminal
cd assets && npm install

Now we’ll run ecto.setup to create our database. Note: before running ecto.setup you may need to update the username / password database settings in dev.config to match your local postgres settings, i.e.

/config/dev.config …line 69
# Configure your database
config :photo_gallery, PhotoGallery.Repo,
  username: "postgres",
  password: "postgres",
  database: "photo_gallery_dev",
  hostname: "localhost",
  pool_size: 10
Terminal
cd ..
mix ecto.setup

With all that out of the way… let’s see where we are starting from.

Terminal
mix phx.server

If we navigate to http://localhost:4000/ we’ll see our application.

The application we’re starting with is essentially the default application you get when running mix phx.new… just with the addition of Pow for authentication and Bodyguard for authorization.

We’ll use some scaffolding to quickly put together the pages for our gallery functionality. So run the below:

Terminal
mix phx.gen.html Gallery Photo photos photo:string uuid:string

We’re creating a Gallery context and a photos table in the database. We won’t be storing our images in the database, but we will keep track of some general information about them in the database. Specifically we want a unique id associated with each photo and we also want a photo to be linked to the user who uploaded the photo.

Therefore we need to make a few changes to the migration file.

/priv/repo/migrations/timestamp_create_photos.exs
defmodule PhotoGallery.Repo.Migrations.CreatePhotos do
  use Ecto.Migration

  def change do
    create table(:photos) do
      add :photo, :string
      add :uuid, :string
      add :user_id, references(:users, on_delete: :delete_all)

      timestamps()
    end

    create index(:photos, [:user_id])
  end
end

We’ve added a reference to our users table; indicating on user deletion we should also clean any associated photo records (via the :delete_all option). We’ve also created an index on user_id.

Let’s run the migration.

Terminal
mix ecto.migrate

We now need to update our router to include our photo routes.

/lib/images_web/router.ex …line 30
scope "/", PhotoGalleryWeb do
  pipe_through [:browser, :protected]

  # Add your protected routes here
  resources "/photos", PhotoController, except: [:edit, :update]
end

We only want authenticated users to access the photo functionality, thus we are utilizing the protected scope in our router file. It also doesn’t make much sense for a photo to be edited (i.e. replaced), so we’re excluding edit and update from the routing. We’ll be removing the update / edit functionality from the controller and context later on.

Now we’ll add a navigation link to the main Photos page in our layout.

/lib/images_web/templates/layout/app.html.eex …line 13
<nav role="navigation">
  <ul>
    <%= if @current_user do %>
      <li>Signed in as: <%= @current_user.email %></li>
      <li><%= link "Photos", to: Routes.photo_path(@conn, :index) %></li>
      <li><%= link "Sign out", to: Routes.pow_session_path(@conn, :delete), method: :delete %></li>
      <li><%= link "Edit account", to: Routes.pow_registration_path(@conn, :edit) %></li>
    <% else %>
    ...
    ...

This would be a good time to fire up the application and create a user. Since we’re going to restrict our functionality to authenticated users we’ll need to sign up a user for testing out our code… and it would also be good to do a quick spot check to make sure the changes we’ve made so far haven’t broken anything.

Terminal
mix phx.server

Note we’ll need to grab the registration confirmation link from our console:

Once we paste the confirmation link into our browser, we can log in.

If we follow the Photos link we’ll see the default index page created by our scaffolding.

We’ll be changing these pages a fair bit; now that we’ve confirmed our application is running we can look at how to integrate Arc.

Adding and configuring Arc

We’ll be making use of the file transformation features of Arc so the first thing to do is install ImageMagick as this is what Arc uses to perform the transformations.

If on a Mac and using Homebrew this is a snap!

Terminal
brew install imagemagick

If not on a Mac I suggest checking out the ImageMagick download page or using a package manager compatible with your OS. For Windows I like chocolatey.

Now onto Arc. We need two new dependencies in mix.exs; arc and arc_ecto. arc is the primary dependency and arc_ecto provides the database tie in with arc.

/mix.exs
defp deps do
  [
    {:phoenix, "~> 1.4.0"},
    {:phoenix_pubsub, "~> 1.1"},
    {:phoenix_ecto, "~> 4.0"},
    {:ecto_sql, "~> 3.0"},
    {:postgrex, ">= 0.0.0"},
    {:phoenix_html, "~> 2.11"},
    {:phoenix_live_reload, "~> 1.2", only: :dev},
    {:gettext, "~> 0.11"},
    {:jason, "~> 1.0"},
    {:plug_cowboy, "~> 2.0"},
    {:arc, "~> 0.11.0"},
    {:arc_ecto, "~> 0.11.1"}
  ]
end

Let’s update our dependencies.

Terminal
mix deps.get

We’ll see that arc and arc-ecto along with a number of other packages have been added.

That’s it for installation, let’s configure things up!

Configuration

Configuration is pretty straight forward. We need to add an Arc specific configuration entry in config.exs. For now we’ll be storing images locally, so the initial configuration is very simple.

/config/config.exs …line 39
# Arc config
config :arc,
  storage: Arc.Storage.Local

# Import environment specific config. This must remain at the bottom
...
...

We also need to add a second static plug in endpoint.ex to indicate that we will be serving static resources from the /uploads directory. This is the directory we will be saving and serving our uploaded files from.

plug Plug.Static,
  at: "/",
  from: :images,
  gzip: false,
  only: ~w(css fonts images js favicon.ico robots.txt)

plug Plug.Static,
  at: "/uploads",
  from: Path.expand("./uploads"),
  gzip: false

We also likely want to add the ./uploads folder to our .gitignore file as we don’t want to be checking our uploads into source control.

/.gitignore
# ignore the file uploads directory
/uploads

And that’s it for basic installation and configuration, let’s get into using Arc!

Using Arc

The first step in getting started with Arc is to generate an uploader module. This module is what determines the basic behaviour we want from Arc when an upload occurs.

The uploader module can be generated via a mix task.

Terminal
mix arc.g photo

The generated file contains pretty good commenting so it should be pretty obvious what the various components of the file do, feel free to have a read through the generated comments.

We’ll replace the contents of the file with the below.

defmodule PhotoGallery.Photo do
  use Arc.Definition
  use Arc.Ecto.Definition

  @extension_whitelist ~w(.jpg .jpeg .gif .png)

  # To add a thumbnail version:
  @versions [:original, :thumb]

  # Whitelist file extensions:
  def validate({file, _}) do
    file_extension = file.file_name |> Path.extname() |> String.downcase()
    Enum.member?(@extension_whitelist, file_extension)
  end

  # Define a thumbnail transformation:
  def transform(:thumb, _) do
    {:convert, "-strip -thumbnail 150x150^ -gravity center -extent 150x150"}
  end

  # Override the persisted filenames:
  def filename(version, {file, scope}) do
    "#{scope.uuid}_#{version}"
  end

  # Override the storage directory:
  def storage_dir(version, {file, scope}) do
    "uploads/photos/"
  end
end

Pretty self explanatory.

We start off by defining a white-list of allowed file types. We then indicate we’ll be uploading both the original image and a thumbnail. After this we have a few functions; one to validate against our whitelist; another to perform the thumbnail transform; and then two functions which override the default directory and file name of our images. Note in our filename function we are making use of a uuid value. This references the uuid column we added in the database. In this way we will have an easy way to associate an uploaded file with a database record as they will both have the same uuid.

We now need to update our photo schema to use Arc.

/lib/photo_gallery/gallery/photo.ex
defmodule PhotoGallery.Gallery.Photo do
  use Ecto.Schema
  use Arc.Ecto.Schema
  import Ecto.Changeset

  schema "photos" do
    field :photo, PhotoGallery.Photo.Type
    field :uuid, :string

    belongs_to :user, PhotoGallery.Users.User

    timestamps()
  end

  def changeset(image, attrs) do
    image
    |> Map.update(:uuid, Ecto.UUID.generate, fn val -> val || Ecto.UUID.generate end)
    |> cast_attachments(attrs, [:photo])
    |> validate_required([:photo])
  end
end

We’ve added a use statement for Arc.Ecto.Changeset and have updated the type of the photo field to be of type PhotoGallery.Photo (i.e. the uploader module we just created).

In the changeset, we are generating a uuid value if one does not already exist. We then call cast_attachments on the photo, which is what handles the upload via Arc.

Updating the context and adding a Bodyguard policy

I know we’re vomiting out a lot of code here without seeing anything in action… we’re getting close thou, so hang on!

Before updating our context we want to create a simple Bodyguard policy around image deletion. We want only the user who uploaded an image to be able to delete the image. So let’s create the policy.

Terminal
touch lib/photo_gallery/gallery/policy.ex
/lib/
defmodule PhotoGallery.Gallery.Policy do
  @behaviour Bodyguard.Policy

  def authorize(:delete_photo, user, photo), do: user.id == photo.user_id
end

Pretty simple, we’re just checking that the user ids match.

Now onto the gallery context changes, we need to do the following:

  • Update the delete function to delete the stored image in addition to the database record.
  • Make use of the policy we created when it comes to image deletion.
  • Update the create and delete functions to include the current user id.
  • Remove the edit function.

So given the above, our context code becomes:

/lib/photo_gallery/gallery/gallery.ex
defmodule PhotoGallery.Gallery do
  import Ecto.Query, warn: false
  alias PhotoGallery.Users.User
  alias PhotoGallery.Repo
  alias PhotoGallery.Gallery.Photo
  alias PhotoGallery.Gallery.Policy

  defdelegate authorize(action, user, params), to: Policy

  def list_photos do
    Repo.all(Photo)
  end

  def get_photo!(id), do: Repo.get!(Photo, id)

  def create_photo(%User{} = user, attrs \\ %{}) do
    %Photo{}
    |> Photo.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:user, user)
    |> Repo.insert()
  end

  def delete_photo(%User{} = user, %Photo{} = photo) do
    with :ok <- Bodyguard.permit(PhotoGallery.Gallery, :delete_photo, user, photo) do
      PhotoGallery.Photo.delete({photo.photo, photo})
      Repo.delete(photo)
    end
  end

  def change_photo(%Photo{} = photo) do
    Photo.changeset(photo, %{})
  end
end

Nothing complicated, we’ve added a bunch of alias statements, and a defdelegate statement that delegates to our policy file.

Other than that, we’ve updated the function signatures to include the current user, and updated the delete code to include a call to our Bodyguard.permit policy function. In the delete function we also added a call to Photo.delete so the actual image gets deleted as well as the database record. Note we’ve also added a put_assoc call in the create_photo function so that our user gets associated with the photo record when it is inserted in the database.

Now we need to make some controller updates to accomodate the above context changes.

Controller updates

With our controller code, we can remove the edit and update actions as we are not making use of them. Other changes we need are:

  • Add an action_fallback to handle unauthorized deletes.
  • Includ conn.assigns.current_user in the calls to our context functions in the create and delete actions.
  • Redirect to the index action instead of the show action when an image is created.
  • Use a with statement in the delete action so that our fallback controller is triggered when an invalid delete occurs.

The above results in the following code:

defmodule PhotoGalleryWeb.PhotoController do
  use PhotoGalleryWeb, :controller

  alias PhotoGallery.Gallery
  alias PhotoGallery.Gallery.Photo

  action_fallback PhotoGalleryWeb.FallbackController

  def index(conn, _params) do
    photos = Gallery.list_photos()
    render(conn, "index.html", photos: photos)
  end

  def new(conn, _params) do
    changeset = Gallery.change_photo(%Photo{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"photo" => photo_params}) do
    case Gallery.create_photo(conn.assigns.current_user, photo_params) do
      {:ok, _photo} ->
        conn
        |> put_flash(:info, "Photo created successfully.")
        |> redirect(to: Routes.photo_path(conn, :index))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    photo = Gallery.get_photo!(id)
    render(conn, "show.html", photo: photo)
  end

  def delete(conn, %{"id" => id}) do
    photo = Gallery.get_photo!(id)

    with {:ok, _photo} <- Gallery.delete_photo(conn.assigns.current_user, photo) do
      conn
      |> put_flash(:info, "Photo deleted successfully.")
      |> redirect(to: Routes.photo_path(conn, :index))
    end
  end
end

The final changes we need to make before trying things out is to update our template files.

Template updates

The first thing we can do is remove the edit template since we are no longer using it.

Terminal
rm lib/photo_gallery_web/templates/photo/edit.html.eex

Next let’s update form.html.eex to allow us to upload photos:

/lib/images_web/templates/image/form.html.eex
<%= form_for @changeset, @action, [multipart: true], fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :photo %>
  <%= file_input f, :photo %>
  <%= error_tag f, :photo %>

  <div>
    <%= submit "Upload" %>
  </div>
<% end %>

Note that we needed to add multipart: true to allow for files to be processed. Other than that, everything is straight forward, we have a single file_input control for selecting a file and then a submit button.

Next let’s update index.html.eex.

/lib/images_web/templates/image/index.html.eex
<%= link "Add new photo", to: Routes.photo_path(@conn, :new) %>
<h1>Photos</h1>
<%= for photo <- @photos do %>
  <%=
    PhotoGallery.Photo.url({photo.photo, photo}, :thumb)
    |> img_tag()
    |> link(to: Routes.photo_path(@conn, :show, photo))
  %>
<% end %>

Super simple, we’re just iterating thru any photo records and displaying each photo. We’re enclosing each individual photo in a link tag so that selecting the image navigates to the show template. Note that the PhotoGallery.Photo.url function is an Arc specific function for retrieving the url of an upload. We’re taking the result of this function and piping it into img_tag in order to display each photo.

Finally lets update the show template. All we are going to do is display the uuid of the photo, the thumbnail and the original image. We also include a delete button if the user has authorization to delete the image (i.e. if they are the one who uploaded the image).

/lib/images_web/templates/image/show.html.eex
<span><%= link "Back", to: Routes.photo_path(@conn, :index) %></span>
<div>
  <strong>Uuid:</strong>
  <%= @photo.uuid %>
  <%= if Bodyguard.permit?(PhotoGallery.Gallery, :delete_photo, @current_user, @photo) do %>
    <%= button("Delete", to: Routes.photo_path(@conn, :delete, @photo), method: :delete, data: [confirm: "Are you sure?"]) %>
  <% end %>
</div>
<div>
  <img src="<%= PhotoGallery.Photo.url({@photo.photo, @photo}, :thumb) %>"/>
</div>
<div>
  <img src="<%= PhotoGallery.Photo.url({@photo.photo, @photo}) %>"/>
</div>

And that’s it for our templates, we’re finally ready to try things out!

Terminal
mix phx.server

We can now upload photos:

After the photo is uploaded, we see it is present in the directory we specified in our uploader module. Both the original and a thumbnail version have been uploaded.

We also have a record in the database:

We can view the images on our index page:

Selecting a photo takes us to the details for that photo where we can also delete the photo if we have permission to do so.

So that pretty much wraps things up!

One final thing

One final thing before we stop for the day however. Currently if we click the upload button without selecting a file, we get a nasty error.

To remedy this we just need another create function in the image_controller.

NOTE: this second function needs to be placed after the original create method otherwise it will always be what gets matched on and we won’t be able to upload anything!

/lib/images_web/controllers/image_controller.ex …line 31
def create(conn, params) do
  msg = if (Map.get(params, "photo") == nil) do
    "No photo selected"
  else
    "Please try again, something went wrong"
  end

  conn
  |> put_flash(:info, msg)
  |> redirect(to: Routes.photo_path(conn, :new))
end

This second create method will match on any value for params and thus we can check if the map passed into params contains the photo key. If not we know a photo was not selected so can indicate that in the error message. Otherwise we just spit out a generic message.

With that we get a much better result when clicking Upload with no file selected.

A quick word about tests

I haven’t been sure how to handle testing for the purposes of this post. Testing is obviously very important but at the same time can require a fair bit of explanation which can add significantly to the length of a post and distract from the core subject.

Initially I was going to completely ignore testing, and indeed if you run mix test you’ll see a number of tests will fail with the updates we’ve made to the code base.

This felt a little janky… so as a compromise I’ve updated the existing tests in the master branch of the GitHub repo so they pass; but there is no discussion of the tests and I haven’t added any new tests. Needless to say, with a real application this is not the strategy you would want to follow! I might post an addendum at one point to discuss the tests as I found it a little tricky getting things set up with Arc… but for now we won’t be discussing testing.

Summary

I try to keep these posts a reasonable length, I think I failed this time out, but I just couldn’t see a good way of splitting things up 🙁, hopefully it wasn’t too painful!

In any case we now have a decent idea about Arc’s general functionality. Next time we’ll look at how we can upload multiple photos at a time and also look at how to upload to AWS S3 instead of a local directory.

Thanks for reading and I hope you enjoyed the post!



Comment on this post!