lib/waffle/storage/google/url_v2.ex

defmodule Waffle.Storage.Google.UrlV2 do
  @moduledoc """
  This is an implementation of the v2 URL signing for Google Cloud Storage. See
  [the Google documentation](https://cloud.google.com/storage/docs/access-control/signed-urls-v2)
  for more details.

  The bulk of the major logic is taken from Martide's `arc_gcs` work:
  https://github.com/martide/arc_gcs.
  """

  use Waffle.Storage.Google.Url

  alias Waffle.Types
  alias Waffle.Storage.Google.{CloudStorage, Util}

  # Default expiration time is 3600 seconds, or 1 hour
  @default_expiry 3600

  # It's unlikely, but in the event that someone accidentally tries to give a
  # zero or negative expiration time, this will be used to correct that mistake
  @min_expiry 1

  # Maximum expiration time is 7 days from the creation of the signed URL
  @max_expiry 604800

  # The official Google Cloud Storage host
  @endpoint "storage.googleapis.com"

  @doc """
  Returns the amount of time, in seconds, before a signed URL becomes invalid.
  Assumes the key for the option is `:expires_in`.
  """
  @spec expiry(Keyword.t) :: pos_integer
  def expiry(opts \\ []) do
    case Util.option(opts, :expires_in, @default_expiry) do
      val when val < @min_expiry -> @min_expiry
      val when val > @max_expiry -> @max_expiry
      val -> val
    end
  end

  @doc """
  Determines whether or not the URL should be signed. Assumes the key for the
  option is `:signed`.
  """
  @spec signed?(Keyword.t) :: boolean
  def signed?(opts \\ []), do: Util.option(opts, :signed, false)

  @doc """
  Returns the remote asset host. The config key is assumed to be `:asset_host`.
  """
  @spec endpoint(Keyword.t) :: String.t
  def endpoint(opts \\ []) do
    opts
    |> Util.option(:asset_host, @endpoint)
    |> Util.var()
  end

  @impl Waffle.Storage.Google.Url
  def build(definition, version, meta, options) do
    path = CloudStorage.path_for(definition, version, meta)

    if signed?(options) do
      build_signed_url(definition, path, options)
    else
      build_url(definition, path)
    end
  end

  @spec build_url(Types.definition, String.t) :: String.t
  defp build_url(definition, path) do
    %URI{
      host: endpoint(),
      path: build_path(definition, path),
      scheme: "https"
    }
    |> URI.to_string()
  end

  @spec build_signed_url(Types.definition, String.t, Keyword.t) :: String.t
  defp build_signed_url(definition, path, options) do
    {:ok, client_id} = Goth.Config.get(:client_email)

    expiration = System.os_time(:second) + expiry(options)

    signature = definition
    |> build_path(path)
    |> canonical_request(expiration)
    |> sign_request()

    base_url = build_url(definition, path)

    "#{base_url}?GoogleAccessId=#{client_id}&Expires=#{expiration}&Signature=#{signature}"
  end

  @spec build_path(Types.definition, String.t) :: String.t
  defp build_path(definition, path) do
    path = if endpoint() != @endpoint do
      path
    else
      bucket_and_path(definition, path)
    end

    path
    |> Util.prepend_slash()
    |> URI.encode()
  end

  @spec bucket_and_path(Types.definition, String.t) :: String.t
  defp bucket_and_path(definition, path) do
    definition
    |> CloudStorage.bucket()
    |> Path.join(path)
  end

  @spec canonical_request(String.t, pos_integer) :: String.t
  defp canonical_request(resource, expiration) do
    "GET\n\n\n#{expiration}\n#{resource}"
  end

  @spec sign_request(String.t) :: String.t
  defp sign_request(request) do
    {:ok, pem_bin} = Goth.Config.get("private_key")
    [pem_key_data] = :public_key.pem_decode(pem_bin)
    otp_release = System.otp_release() |> String.to_integer()

    rsa_key =
      case otp_release do
        n when n >= 21 ->
          :public_key.pem_entry_decode(pem_key_data)

        _ ->
          pem_key = :public_key.pem_entry_decode(pem_key_data)
          :public_key.der_decode(:RSAPrivateKey, elem(pem_key, 3))
      end

    request
    |> :public_key.sign(:sha256, rsa_key)
    |> Base.encode64()
    |> URI.encode_www_form()
  end
end