Finnian Anderson

My hackerspace

Uploading files to S3 with React Native and Ruby on Rails

Intro

I've recently been working on a top secret React Native project, powered by a Rails backend deployed to Heroku.

The app allows users to upload images/videos to our backend as part of one of the flows. Originally, I was using a multipart/form-data request to upload the data + files directly to our backend, which would then upload them to S3.

This approach has a couple of main disadvantages:

  • Heroku timeouts will abort the request if it takes longer than 30s (can happen if uploading lots of, or big files)
  • it increases the load on your backend

This post will be specifically talking about AWS S3 but GCP has a Signed URLs feature too.

Presigned URLs in S3 allow us to generate a signed request from our backend which we can give to the client, enabling them to upload to S3 directly. This solves both issues outlined above, with the added benefits of not exposing AWS credentials. Nice.

This all sounds great in theory, so I started hacking.

Generating a Presigned URL

The first step is to generate a URL that the client can upload to.
For this to happen, we need to know a few things about what the client intends to upload:

  • filename
  • size (bytes)
  • type (mimetype)
  • checksum

This is so that S3 can verify the client is uploading what they told us they would. Otherwise, they could upload any file they wished, which may or may not be an issue in your application.

This code takes the fields from the request and creates an ActiveStorage::Blob from them.
Then it returns a JSON object which contains all the information needed for the client to upload the file to S3. Example response:

{
  "data": {
    "url": "https://bucket.s3.region.amazonaws.com/etc",
    "headers": {
      "Content-Type": "image/jpeg",
      "Content-MD5": "3Tbhfs6EB0ukAPTziowN0A=="
    },
    "signed_id": "signedidoftheblob"
  }
}

So far, so good.

Uploading the file to S3

I wrote some code in the mobile app to request a signed URL and got a successful response, so started refactoring the actual upload to use the new URL. This was where the problems started.

Every single time I tried to upload a file to S3 using the presigned URL, I got one of the following errors, no matter what I tried:

  • SignatureDoesNotMatch
  • InvalidDigest
  • BadDigest

I also tried manually sending the file through Postman to rule out any library-specific issues, but still no go. At one point, I got it to create a file but it had no content - still not sure how that happened.
I was running this in my terminal (macOS) to generate a MD5 checksum of the file:

$ md5 test.jpg
MD5 (test.jpg) = a98c48553050f5d651cde8a46ee364ff  

After at least two days of head banging, I stumbled upon this post from Cloudway.
As it turns out, the checksum header (Content-MD5) needs to be a base64-encoded 128-bit MD5 digest. This was the root of my problems - I wasn't encoding the checksum as base64 before sending it to our backend, or S3.

Converting to base64 (again, macOS):

$ cat app.json | openssl dgst -md5 -binary | openssl enc -base64
qYxIVTBQ9dZRzeikbuNk/w==  

Aha! That looks better.

I tried the whole cycle again (request presigned URL from our backend, then send the file to that), using the new base64-encoded checksum. Voilá! That worked.

It would be nice if S3 could be a little more specific about what the issue with the request actually is (BadDigest isn't particularly helpful). Hey ho, one can dream.

App code

Here is the completed file upload code for the mobile app (React Native, TypeScript):

Obviously, you must have already retrieved the presigned URL, headers and signed_id from your backend before hitting this code. I won't cover that here as it's pretty straightforward.

The only tricky part was getting the correct checksum of the file, so I've included that below:

Next, you just loop through each file and (in parallel):

  • retrieve a presigned URL for each (using the checksum from above, size of the file, filename and mimetype)
  • store the presigned URL, headers and signed_id somewhere
  • send a request to AWS for each file (note: it needs to be a PUT request, not POST)

If you're intending on creating some kind of record on your server to attach the files to (in our case, it's called a Report), you need to get hold of all the signed_ids.
When you create the record on your backend, you need to pass it the signed_ids which ActiveStorage will understand, and automatically link the Blobs we created above.

Linking the Blob(s) to the record

Here's the controller for creating a Report in our backend:

To make this work, just add an attachments field to the request creating the record which is an array of the signed_ids, ActiveStorage will handle the rest 🥰

That should be it!

Conclusion

This whole process took a lot longer than I was expecting, I'd originally budgeted a morning's work, but it ended up taking up a solid two days.

Here's all the code in one go: https://gist.github.com/developius/1fa35f2192b886dfce4e7f4eaed8b923

I read every post under the sun about RN + S3 uploads, the entire Rails docs for Direct Uploads, plus a slew of other random posts too. Here are the ones that helped me out:

Hopefully this is all helpful to someone 😁

Finnian Anderson

Node.js, Rails, Raspberry Pi, iOS and other such things. Sailing instructor and Docker fan!

Queenstown, New Zealandhttps://finnian.io

Subscribe to Finnian Anderson

Get the latest posts delivered right to your inbox.