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