lib/gcs_signed_url.ex

defmodule GcsSignedUrl do
  @moduledoc """
  Create Signed URLs for Google Cloud Storage in Elixir
  """

  alias GcsSignedUrl.{Client, Crypto, SignBlob, StringToSign}

  @type sign_v2_opts :: [
          verb: String.t(),
          md5_digest: String.t(),
          content_type: String.t(),
          expires: integer()
        ]

  @type sign_v4_opts :: [
          verb: String.t(),
          headers: Keyword.t(),
          query_params: Keyword.t(),
          valid_from: DateTime.t(),
          expires: integer,
          host: String.t()
        ]

  @deprecated "Use GcsSignedUrl.generate_v4/4 instead. [V2 signing process is considered legacy](https://cloud.google.com/storage/docs/access-control/signed-urls-v2)"
  @doc """
  If the first argument is a `GcsSignedUrl.Client{}`: Generate V2 signed url using its private key.

  If the first argument is a `%GcsSignedUrl.SignBlob.OAuthConfig{}`: Generate V2 signed url using the
  Google IAM REST API with a OAuth2 token of a service account.


  ## Examples

      iex> client = %GcsSignedUrl.Client{private_key: "...", client_email: "..."}
      iex> GcsSignedUrl.generate(client, "my-bucket", "my-object.mp4", expires: 1503599316)
      "https://storage.googleapis.com/my-bucket/my-object.mp4?Expires=15..."

      iex> oauth_config = %GcsSignedUrl.SignBlob.OAuthConfig{service_account: "...", access_token: "..."}
      iex> GcsSignedUrl.generate(oauth_config, "my-bucket", "my-object.mp4", expires: 1503599316)
      {:ok, "https://storage.googleapis.com/my-bucket/my-object.mp4?X-Goog-Expires=1800..."}

  """
  @spec generate(Client.t(), String.t(), String.t()) :: String.t()
  @spec generate(Client.t(), String.t(), String.t(), sign_v2_opts) :: String.t()
  @spec generate(SignBlob.OAuthConfig.t(), String.t(), String.t()) ::
          {:ok, String.t()} | {:error, String.t()}
  @spec generate(SignBlob.OAuthConfig.t(), String.t(), String.t(), sign_v2_opts) ::
          {:ok, String.t()} | {:error, String.t()}
  def generate(client, bucket, filename, opts \\ [])

  def generate(%Client{client_email: client_email} = client, bucket, filename, opts) do
    %StringToSign{string_to_sign: string_to_sign, url_template: url_template} =
      StringToSign.generate_v2(client_email, bucket, filename, opts)

    signature = Crypto.sign(string_to_sign, client) |> Base.encode64() |> encode_rfc3986()

    String.replace(url_template, "#SIGNATURE#", signature)
  end

  def generate(
        %SignBlob.OAuthConfig{service_account: service_account} = oauth_config,
        bucket,
        filename,
        opts
      ) do
    %StringToSign{string_to_sign: string_to_sign, url_template: url_template} =
      StringToSign.generate_v2(service_account, bucket, filename, opts)

    case Crypto.sign(string_to_sign, oauth_config) do
      {:ok, signature} ->
        encoded = encode_rfc3986(signature)
        url = String.replace(url_template, "#SIGNATURE#", encoded)
        {:ok, url}

      error ->
        error
    end
  end

  @doc """
  If the first argument is a `GcsSignedUrl.Client{}`: Generate V4 signed url using its private key.

  If the first argument is a `%GcsSignedUrl.SignBlob.OAuthConfig{}`: Generate V4 signed url using the
  Google IAM REST API with a OAuth2 token of a service account.

  ## Examples

      iex> client = %GcsSignedUrl.Client%{private_key: "...", client_email: "..."}
      iex> GcsSignedUrl.generate_v4(client, "my-bucket", "my-object.mp4", verb: "PUT", expires: 1800, headers: ["Content-Type": "application/json"])
      "https://storage.googleapis.com/my-bucket/my-object.mp4?X-Goog-Expires=1800..."

      iex> oauth_config = %GcsSignedUrl.SignBlob.OAuthConfig{service_account: "...", access_token: "..."}
      iex> GcsSignedUrl.generate_v4(oauth_config, "my-bucket", "my-object.mp4", verb: "PUT", expires: 1800, headers: ["Content-Type": "application/json"])
      {:ok, "https://storage.googleapis.com/my-bucket/my-object.mp4?X-Goog-Expires=1800..."}

  """
  @spec generate_v4(Client.t(), String.t(), String.t()) :: String.t()
  @spec generate_v4(Client.t(), String.t(), String.t(), sign_v4_opts) :: String.t()
  @spec generate_v4(SignBlob.OAuthConfig.t(), String.t(), String.t()) ::
          {:ok, String.t()} | {:error, String.t()}
  @spec generate_v4(SignBlob.OAuthConfig.t(), String.t(), String.t(), sign_v4_opts) ::
          {:ok, String.t()} | {:error, String.t()}
  def generate_v4(client, bucket, filename, opts \\ [])

  def generate_v4(%Client{client_email: client_email} = client, bucket, filename, opts) do
    %StringToSign{string_to_sign: string_to_sign, url_template: url_template} =
      StringToSign.generate_v4(client_email, bucket, filename, opts)

    signature = Crypto.sign(string_to_sign, client) |> Base.encode16() |> String.downcase()
    String.replace(url_template, "#SIGNATURE#", signature)
  end

  def generate_v4(
        %SignBlob.OAuthConfig{service_account: service_account} = oauth_config,
        bucket,
        filename,
        opts
      ) do
    %StringToSign{string_to_sign: string_to_sign, url_template: url_template} =
      StringToSign.generate_v4(service_account, bucket, filename, opts)

    case Crypto.sign(string_to_sign, oauth_config) do
      {:ok, signature} ->
        url =
          signature
          |> Base.decode64!()
          |> Base.encode16()
          |> String.downcase()
          |> (&String.replace(url_template, "#SIGNATURE#", &1)).()

        {:ok, url}

      error ->
        error
    end
  end

  @doc """
  Calculate future timestamp from given hour offset.

  ## Examples

      iex> 10 |> GcsUrlSigner.hours_after
      1503599316

  """

  # coveralls-ignore-start, reason: not a pure function. Will always return something different.
  def hours_after(hour) do
    DateTime.utc_now()
    |> DateTime.to_unix()
    |> Kernel.+(hour * 3600)
  end

  # coveralls-ignore-stop

  defp encode_rfc3986(input) when is_binary(input) do
    URI.encode(input, &URI.char_unreserved?/1)
  end
end