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 = ``;
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!
Comments for S3 Direct Upload