A forum's not much without images. Let's enable our users to upload images to an S3 bucket and include them in their posts easily.

Project

Project Setup (off-video)

We're starting with the dailydrip/firestorm repo tagged before this episode.

I've implemented S3 Direct Uploads in the past, so we won't dwell on the backend code in this episode as much as we otherwise might. We'll add a route and an API controller:

vim lib/firestorm_web/web/router.ex
defmodule FirestormWeb.Web.Router do
  # ...
  # API routes
  scope "/api/v1", FirestormWeb.Web.Api.V1 do
    # ...
    resources "/upload_signature", UploadSignatureController, only: [:create]
  end
  # ...
end
vim lib/firestorm_web/web/controllers/api/v1/upload_signature_controller.ex

We're going to introduce a new Uploads context that just handles uploads for now by signing them. We're adding a new context because this uploaded file is completely unrelated to the Forums themselves. This is just another thing we can do. We'll write the code as if we already had this context function:

defmodule FirestormWeb.Web.Api.V1.UploadSignatureController do
  use FirestormWeb.Web, :controller

  # We'll accept an upload with a filename and mimetype, and sign it. If we sign
  # it successfully, we'll provide a capability to the frontend to upload this
  # file to our bucket.
  def create(conn, %{"upload" => %{"filename" => filename, "mimetype" => mimetype}}) do
    case FirestormWeb.Uploads.sign(filename, mimetype) do
      {:ok, signature} ->
        conn
        |> put_status(201)
        |> render("show.json", upload_signature: signature)

      {:error, errors} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render("error.json", errors: errors)
    end
  end
end

We need to make the view for our controller:

vim lib/firestorm_web/web/views/api/v1/upload_signature_view.ex

We output the data our frontend wants.

defmodule FirestormWeb.Web.Api.V1.UploadSignatureView do
  use FirestormWeb.Web, :view

  def render("show.json", %{upload_signature: upload_signature}) do
    %{data: render_one(upload_signature, __MODULE__, "upload_signature.json")}
  end

  def render("upload_signature.json", %{upload_signature: upload_signature}) do
    upload_signature
    |> Map.take([
      :key,
      :date,
      :content_type,
      :acl,
      :success_action_status,
      :action,
      :aws_access_key_id,
      :credential,
      :policy,
      :signature
    ])
  end

  def render("error.json", %{errors: errors}) do
    %{errors: %{detail: errors}}
  end
end

Next, we need to introduce the Uploads context:

mkdir lib/firestorm_web/uploads
vim lib/firestorm_web/uploads/uploads.ex

It has a single sign function that's public, which takes the filename and mimetype:

defmodule FirestormWeb.Uploads do
  alias FirestormWeb.Uploads.AWS.UploadSignature

  def sign(filename, mimetype) do
    signature =
      filename
      |> file_path()
      |> UploadSignature.signature(mimetype)

    {:ok, signature}
  end

  defp file_path(filename) do
    uuid = UUID.uuid4()
    "uploads/#{uuid}/#{filename}"
  end
end

We'll break out one more module here, the actual logic for signing an AWS upload. We've covered it before, so I'll simply paste it in.

mkdir lib/firestorm_web/uploads/aws
vim lib/firestorm_web/uploads/aws/upload_signature.ex
defmodule FirestormWeb.Uploads.AWS.UploadSignature do
  @service "s3"
  @aws_request "aws4_request"

  def signature(filename, mimetype) do
    policy = policy(filename, mimetype)

    %{
      key: filename,
      date: get_date(),
      content_type: mimetype,
      acl: "public-read",
      success_action_status: "201",
      action: bucket_url(),
      aws_access_key_id: aws_config()[:access_key_id],
      policy: policy,
      credential: credential(),
      signature: sign(policy)
    }
  end

  def get_date() do
    datetime = Timex.now
    {:ok, t} = Timex.format(datetime, "%Y%m%d", :strftime)
    t
  end

  defp credential() do
    credential(aws_config()[:access_key_id], get_date())
  end

  defp credential(key, date) do
    key <> "/" <> date <> "/" <> region() <> "/" <> @service <> "/" <> @aws_request
  end

  defp policy(key, mimetype, expire_after_min \\ 60) do
    %{
      expiration: min_from_now(expire_after_min),
      conditions: [
        %{bucket: bucket_name()},
        %{acl: "public-read"},
        ["starts-with", "$Content-Type", mimetype],
        ["starts-with", "$key", key],
        %{success_action_status: "201"}
      ]
    }
    |> Poison.encode!()
    |> Base.encode64()
  end

  defp min_from_now(minutes) do
    import Timex

    now()
    |> shift(minutes: minutes)
    |> format!("{ISO:Extended:Z}")
  end

  defp sign(policy) do
    :sha
    |> :crypto.hmac(secret_access_key(), policy)
    |> Base.encode64()
  end

  defp bucket_name() do
    aws_config()[:bucket]
  end

  defp region() do
    aws_config()[:region]
  end

  defp secret_access_key() do
    aws_config()[:secret_access_key]
  end

  defp bucket_url() do
    "https://s3-#{region()}.amazonaws.com/#{bucket_name()}"
  end

  defp aws_config() do
    Application.get_env(:firestorm_web, :aws)
  end
end

Next, we'll add the UUID package and Timex.

vim mix.exs
defmodule FirestormWeb.Mixfile do
  # ...
  defp deps do
    [
      # ...
      {:uuid, "~> 1.1"},
      {:timex, "~> 3.1.15"},
      # ...
    ]
  end
end
mix deps.get

We also need to configure AWS:

# ...
# Configuration for AWS integration
config :firestorm_web, :aws,
  access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
  secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"),
  bucket: System.get_env("AWS_S3_BUCKET"),
  region: System.get_env("AWS_S3_REGION")
# ...

Now we should be able to sign a file. I've got a basic test ready to verify that we can create a signature:

vim test/web/controllers/api/v1/upload_signature_controller_test.exs
defmodule FirestormWeb.Web.Api.V1.UploadSignatureControllerTest do
  use FirestormWeb.Web.ConnCase

  test "POST /", %{conn: conn} do
    upload = %{
      "filename" => "image.jpg",
      "mimetype" => "image/jpeg"
    }
    conn = post conn, "/api/v1/upload_signature", upload: upload
    response = json_response(conn, 201)["data"]
    assert response
  end
end

Interacting with our API to upload content (video starts)

Since I've previously released an episode covering S3 Direct Uploads with Phoenix I have already prepared an API endpoint for us as well as a new context for managing the uploads. I also work through all of the code changes to support this in the script for this episode.

Consequently, the video's starting with the dailydrip/firestorm repo tagged with mid_episode_009.3.

With the code outlined in the script, we can ask our backend to provide us with the capability to upload a file to their S3 bucket. We'll write a bit of JavaScript to request an upload via the API:

vim assets/js/api.js
// ...
const UploadSignature = {
  create: (filename, mimetype) => {
    const payload = {
      upload: {
        filename,
        mimetype
      }
    };
    const fetch = createFetch(
      commonStack,
      method("POST"),
      body(JSON.stringify(payload), "application/json")
    );

    return fetch("/upload_signature");
  }
};

const Api = {
  Preview,
  UploadSignature
};

export default Api;

Next, we'll wire it up to the frontend. We'll create a new component:

vim assets/js/components/attachments.js
// We'll bring in jQuery and the API
const $ = require("jquery");
import Api from "../api";

// We'll construct some selectors to find elements we want to affect.
const fileSelector = ".add-attachment input[type=file]";
const textAreaSelector = ".post-editor textarea";

// When we upload to S3, we need to construct a form to submit to their
// endpoint.
const uploadToS3 = (el, file, response) => {
  const data = response.jsonData.data;

  const $el = $(el);
  const $parent = $el.parent();
  let fd = new FormData();
  fd.append("key", data.key);
  fd.append("AWSAccessKeyId", data.aws_access_key_id);
  fd.append("acl", data.acl);
  fd.append("success_action_status", data.success_action_status);
  fd.append("policy", data.policy);
  fd.append("signature", data.signature);
  fd.append("Content-Type", data.content_type);
  fd.append("file", file);
  return $.ajax({
    type: "POST",
    url: data.action,
    data: fd,
    dataType: "xml",
    processData: false, // tell jQuery not to convert to form data
    contentType: false // tell jQuery not to set contentType
  });
};

// Once we upload the file, we'll receive the URL for it on S3. We'd like to
// inject some markdown that will either show it, if it's an image, or link to
// it if it isn't.
const makeMarkdown = (filename, mimeType, location) => {
  let val;
  switch (mimeType) {
    case "image/png":
    case "image/jpeg":
    case "image/jpg":
    case "image/gif":
      val = `![${filename}](${location})`;
      break;
    default:
      val = `[${filename}](${location})`;
      break;
  }
  return val;
};

// We'll write a quick function to append our markdown to the textarea using the
// makeMarkdown function.
const appendToTextArea = (filename, mimeType, xml) => {
  const uriEncodedLocation = $(xml).find("PostResponse Location").text();
  const location = decodeURIComponent(uriEncodedLocation);
  const $textArea = $(textAreaSelector);
  const fileMarkdown = makeMarkdown(filename, mimeType, location);
  $textArea.val(`${$textArea.val()}\n\n${fileMarkdown}`);
  $textArea.change();
};

// Finally, we'll build our only public function, which mounts on the
// fileselector and injects our behaviour.
const mount = () => {
  $(fileSelector).on("change", function() {
    const file = this.files[0];

    // Get an upload signature from the backend
    Api.UploadSignature
      .create(file.name, file.type)
      // Then upload it to S3
      .then(response => uploadToS3(this, file, response))
      // Then append it to the textarea
      .then(xml => appendToTextArea(file.name, file.type, xml));
  });
};

const Attachments = {
  mount
};

export default Attachments;

Now we need to run our mount function when the page load and we should get the behaviour we're after.

vim assets/js/app.js
// ...
// == COMPONENTS ==
// ...
import Attachments from "./components/attachments";
// == END COMPONENTS ==

// == USING COMPONENTS ==
// ...
// Handle attachments for posts
Attachments.mount();
// ==== END POSTS ====
// == END USING COMPONENTS ==

Now if we try it out, it works!

Summary

In today's episode we introduced an endpoint for signing requests to upload to S3 directly. We then introduced a small JavaScript component to handle first getting a signature from our backend, then uploading the file to S3, and finally appending the reference to the file to the post. See you soon!

Resources