2019-07-13 20:25

Using Cloudinary Without the Cloudinary Ruby Gem

I always try to keep the number of gems I use in projects as small as possible. If you're not careful you end up adding tens of thousands of lines of code that you don't know, that could harbor strange side effects–or worse–introduce security flaws.

This article by Thoughtbot puts it well:

Adding another gem is adding liability for code I did not write, and which I do not maintain.

The article makes other good cases to think twice before adding yet another gem to your project.

Recently I was facing the decision whether or not to add the Cloudinary gem to a project. Because I only needed to create signed URLs and to compute upload signatures I decided to write the necessary code myself.

Signed URLs

Signed URLs prevent tampering with URL parameters. For instance, suppose you display small photos on a thumbnail gallery, with a thumbnail URL looking like this:

https://res.cloudinary.com/example/image/upload/s--01Pkxgsb--/c_crop,f_jpg,w_240,h_240/v1/artworks/eQzy...c4vk

You don't want to enable downloading full-size images simply by manipulating parameters:

https://res.cloudinary.com/example/image/upload/s--01Pkxgsb--/c_crop,f_jpg,w_2000,h_2000/v1/artworks/eQzy...c4vk

Signed URLs prevent this kind of tampering. The code to create a signed URL looks like this:

def signed_url(public_id:, transformations:)
  to_sign = ([transformations, "v1", public_id]).join("/")

  secret = ENV.fetch('CLOUDINARY_API_SECRET')

  signature = 's--' + Base64.urlsafe_encode64(Digest::SHA1.digest(to_sign + secret))[0,8] + '--'

  "https://res.cloudinary.com/#{ENV.fetch('CLOUDINARY_CLOUD_NAME')}/image/upload/" + [signature, to_sign].join("/")
end

I obtained the signature magic from this article.

Compute upload signatures

The upload signature must be passed along when uploading a file to the Cloudinary API. It's a hash of the file name (called public ID), timestamp, folder name, and API secret.

The following comes straight from one of my Rails controllers:

def signature
  folder = ENV.fetch('CLOUDINARY_FOLDER')
  public_id = SecureRandom.urlsafe_base64(32)
  timestamp = Time.now.utc.to_i # Cloudinary expects UTC epoch
  payload_to_sign = "folder=#{ENV.fetch('CLOUDINARY_FOLDER')}"
  payload_to_sign << "&public_id=#{public_id}"
  payload_to_sign << "&timestamp=#{timestamp}"
  signature = Digest::SHA1.hexdigest(payload_to_sign + ENV.fetch('CLOUDINARY_API_SECRET'))
  render(json:
    {
      api_key: ENV.fetch('CLOUDINARY_API_KEY'),
      signature: signature,
      folder: folder,
      public_id: public_id,
      timestamp: timestamp
    })
end

By writing two small pieces of code (plus two tests) I've eliminated the need for an extra gem which would have added even more gems as dependencies (aws_cf_signer, domain_name, http-cookie, mime-types, mime-types-data, netrc, rest-client, unf, and unf_ext)!

It just feels cleaner to carry less bagage around.

Permalink — Comments or kudos? Find me on Twitter.

Are you a project or product manager? Ship better software with Releasewise!