lib/req_s3.ex

defmodule ReqS3 do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  @doc """
  Attaches the plugin.
  """
  def attach(request, options \\ []) do
    request
    |> Req.Request.register_options([:aws_endpoint_url_s3])
    |> add_request_steps_before([s3_handle_url: &__MODULE__.handle_s3_url/1], :put_aws_sigv4)
    |> Req.merge(options)
  end

  @doc """
  Handles the `s3://` URL scheme.

  This request step is automatically added on `ReqS3.attach(request)`.

  See module documentation for usage examples.

  ## Request Options

    * `:aws_endpoint_url_s3` - if set, the endpoint URL for S3-compatible services.
      If `AWS_ENDPOINT_URL_S3` system environment variable is set, it is considered first.
  """
  def handle_s3_url(request) do
    if request.url.scheme == "s3" do
      url = normalize_url(request.url, request.options[:aws_endpoint_url_s3])

      request
      |> Map.replace!(:url, url)
      |> Map.update!(:options, fn options ->
        access_key_id =
          options[:aws_sigv4][:access_key_id] || System.get_env("AWS_ACCESS_KEY_ID")

        secret_access_key =
          options[:aws_sigv4][:secret_access_key] || System.get_env("AWS_SECRET_ACCESS_KEY")

        if access_key_id do
          options = Map.put_new(options, :aws_sigv4, [])
          options = put_in(options[:aws_sigv4][:service], :s3)
          options = put_in(options[:aws_sigv4][:access_key_id], access_key_id)
          options = put_in(options[:aws_sigv4][:secret_access_key], secret_access_key)
          options
        else
          options
        end
      end)
      |> Req.Request.append_response_steps(req_s3_decode_body: &decode_body(&1, request.url.path))
    else
      request
    end
  end

  defp decode_body({request, response}, path) do
    if request.method in [:get, :head] and
         path in [nil, "", "/"] and
         request.options[:decode_body] != false and
         request.options[:raw] != true and
         match?(["application/xml" <> _], response.headers["content-type"]) do
      response = update_in(response.body, &ReqS3.XML.parse_s3/1)
      {request, response}
    else
      {request, response}
    end
  end

  @doc ~S"""
  Returns a presigned URL for fetching bucket object contents.

  ## Options

    * `:access_key_id` - the AWS access key id. Defaults to the value of `AWS_ACCESS_KEY_ID`
      system environment variable.

    * `:secret_access_key` - the AWS secret access key. Defaults to the value of
      `AWS_SECRET_ACCESS_KEY` system environment variable.

    * `:region` - the AWS region. Defaults to the value of `AWS_REGION` system environment
      variable, then `"us-east-1"`.

    * `:url` - the URL to presign, for example: `"https://{bucket}.s3.amazonaws.com/{key}"`,
      `s3://{bucket}/{key}`, etc. `s3://` URL uses `:endpoint_url` option described below.

      Instead of passing the `:url` option, you can instead pass `:bucket` and `:key` options
      which will generate `https://{bucket}.s3.amazonaws.com/{key}` (or
      `{endpoint_url}/{bucket}/{key}`).

    * `:endpoint_url` - if set, the endpoint URL for S3-compatible services. If
      `AWS_ENDPOINT_URL_S3` system environment variable is set, it is considered first.

  ## Examples

  Note: This example assumes `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables
  are set.

      iex> req = Req.new() |> ReqS3.attach()
      iex> bucket = System.fetch_env!("BUCKET_NAME")
      iex> key = "key1"
      iex> %{status: 200} = Req.put!(req, url: "s3://#{bucket}/#{key}", body: "Hello, World!")
      iex> url = ReqS3.presign_url(bucket: bucket, key: key)
      iex> url =~ "https://#{bucket}.s3.amazonaws.com/#{key}?X-Amz-Algorithm=AWS4-HMAC-SHA256&"
      true
      iex> %{status: 200, body: body} = Req.get!(url)
      iex> body
      "Hello, World!"
  """
  def presign_url(options) do
    options
    |> Keyword.put_new_lazy(:access_key_id, fn ->
      System.get_env("AWS_ACCESS_KEY_ID") ||
        raise ArgumentError,
              ":access_key_id option or AWS_ACCESS_KEY_ID environment variable must be set"
    end)
    |> Keyword.put_new_lazy(:secret_access_key, fn ->
      System.get_env("AWS_SECRET_ACCESS_KEY") ||
        raise ArgumentError,
              ":secret_access_key option or AWS_SECRET_ACCESS_KEY environment variable must be set"
    end)
    |> Keyword.put_new(:region, System.get_env("AWS_REGION", "us-east-1"))
    |> Keyword.put_new(:method, :get)
    # TODO: deprecate :url in v0.3
    |> Keyword.put_new_lazy(:url, fn ->
      bucket = Keyword.fetch!(options, :bucket)
      key = Keyword.fetch!(options, :key)

      endpoint_url = options[:endpoint_url] || System.get_env("AWS_ENDPOINT_URL_S3")

      if endpoint_url do
        "#{endpoint_url}/#{bucket}/#{key}"
      else
        "https://#{bucket}.s3.amazonaws.com/#{key}"
      end
    end)
    |> Keyword.update!(:url, &normalize_url(&1, options[:endpoint_url]))
    |> Keyword.put(:service, "s3")
    |> Keyword.put(:datetime, DateTime.utc_now())
    |> Keyword.drop([:bucket, :key, :endpoint_url])
    |> Req.Utils.aws_sigv4_url()
    |> URI.to_string()
  end

  @doc """
  Returns presigned form for upload.

  ## Options

    * `:access_key_id` - the AWS access key id. Defaults to the value of `AWS_ACCESS_KEY_ID`
      system environment variable.

    * `:secret_access_key` - the AWS secret access key. Defaults to the value of
      `AWS_SECRET_ACCESS_KEY` system environment variable.

    * `:region` - if set, AWS region. Defaults to the value of `AWS_REGION` system environment
      variable, then `"us-east-1"`.

    * `:bucket` - the S3 bucket.

    * `:key` - the S3 bucket key.

    * `:endpoint_url` - if set, the endpoint URL for S3-compatible services. If
      `AWS_ENDPOINT_URL_S3` system environment variable is set, it is considered first.

    * `:content_type` - if set, the content-type of the uploaded object.

    * `:max_size` - if set, the maximum size of the uploaded object.

    * `:expires_in` - the time in milliseconds before the signed upload expires. Defaults to 1h
      (`60 * 60 * 1000` milliseconds).

    * `:datetime` - the request datetime, defaults to `DateTime.utc_now(:second)`.

  ## Examples

      iex> options = [
      ...>   access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
      ...>   secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY"),
      ...>   bucket: "bucket1",
      ...>   key: "key1",
      ...>   expires_in: :timer.hours(1)
      ...> ]
      iex> form = ReqS3.presign_form(options)
      iex> form.url
      "https://bucket1.s3.amazonaws.com"
      iex> form.fields
      [
        {"key", "key1"},
        {"policy", "eyJjb25kaXRpb25z...ifQ=="},
        {"x-amz-algorithm", "AWS4-HMAC-SHA256"},
        {"x-amz-credential", "AKIA.../20240528/us-east-1/s3/aws4_request"},
        {"x-amz-date", "20240528T105226Z"},
        {"x-amz-server-side-encryption", "AES256"},
        {"x-amz-signature", "465315d202fbb2ce081f79fca755a958a18ff68d253e6d2a611ca4b2292d8925"}
      ]
  """
  def presign_form(options) when is_list(options) do
    # aws_credentials returns this key so let's ignore it
    options = Keyword.drop(options, [:credential_provider])

    Keyword.validate!(
      options,
      [
        :region,
        :access_key_id,
        :secret_access_key,
        :content_type,
        :max_size,
        :datetime,
        :expires_in,
        :bucket,
        :key,
        :endpoint_url
      ]
    )

    service = "s3"
    region = Keyword.get(options, :region, System.get_env("AWS_REGION", "us-east-1"))

    access_key_id =
      options[:access_key_id] || System.get_env("AWS_ACCESS_KEY_ID") ||
        raise ArgumentError,
              ":access_key_id option or AWS_ACCESS_KEY_ID system environment variable must be set"

    secret_access_key =
      options[:secret_access_key] || System.get_env("AWS_SECRET_ACCESS_KEY") ||
        raise ArgumentError,
              ":secret_access_key option or AWS_SECRET_ACCESS_KEY system environment variable must be set"

    bucket = Keyword.fetch!(options, :bucket)
    key = Keyword.fetch!(options, :key)
    content_type = Keyword.get(options, :content_type)
    max_size = Keyword.get(options, :max_size)
    datetime = Keyword.get(options, :datetime, DateTime.utc_now())
    expires_in = Keyword.get(options, :expires_in, 60 * 60 * 1000)

    datetime = DateTime.truncate(datetime, :second)
    datetime = DateTime.add(datetime, expires_in, :millisecond)
    datetime_string = datetime |> DateTime.truncate(:second) |> DateTime.to_iso8601(:basic)
    date_string = binary_part(datetime_string, 0, 8)

    credential = "#{access_key_id}/#{date_string}/#{region}/#{service}/aws4_request"

    amz_headers = [
      {"x-amz-server-side-encryption", "AES256"},
      {"x-amz-credential", credential},
      {"x-amz-algorithm", "AWS4-HMAC-SHA256"},
      {"x-amz-date", datetime_string}
    ]

    content_type_conditions =
      if content_type do
        [["eq", "$Content-Type", "#{content_type}"]]
      else
        []
      end

    content_length_range_conditions =
      if max_size do
        [["content-length-range", 0, max_size]]
      else
        []
      end

    conditions =
      [
        %{"bucket" => "#{bucket}"},
        ["eq", "$key", "#{key}"]
      ] ++
        content_type_conditions ++
        content_length_range_conditions ++
        Enum.map(amz_headers, fn {key, value} -> %{key => value} end)

    policy = %{
      "expiration" => DateTime.to_iso8601(datetime),
      "conditions" => conditions
    }

    encoded_policy = policy |> Jason.encode!() |> Base.encode64()

    signature =
      Req.Utils.aws_sigv4(
        encoded_policy,
        date_string,
        region,
        service,
        secret_access_key
      )

    fields =
      Map.merge(
        Map.new(amz_headers),
        %{
          "key" => key,
          "policy" => encoded_policy,
          "x-amz-signature" => signature
        }
      )

    fields =
      if content_type do
        Map.merge(fields, %{"content-type" => content_type})
      else
        fields
      end

    endpoint_url = options[:endpoint_url] || System.get_env("AWS_ENDPOINT_URL_S3")

    url =
      if endpoint_url do
        "#{endpoint_url}/#{bucket}"
      else
        "https://#{options[:bucket]}.s3.amazonaws.com"
      end

    %{
      url: url,
      fields: Enum.to_list(fields)
    }
  end

  def presign_form(options) do
    presign_form(Enum.into(options, []))
  end

  defp normalize_url(string, endpoint_url) when is_binary(string) do
    normalize_url(URI.new!(string), endpoint_url)
  end

  defp normalize_url(%URI{scheme: "s3"} = url, endpoint_url) do
    url = %{url | scheme: "https", port: 443}
    endpoint_url = endpoint_url || System.get_env("AWS_ENDPOINT_URL_S3")

    case String.split(url.host, ".") do
      _ when url.host == "" ->
        url =
          if endpoint_url do
            endpoint_url = URI.new!(endpoint_url)

            %{
              url
              | scheme: endpoint_url.scheme,
                host: endpoint_url.host,
                authority: nil,
                port: endpoint_url.port
            }
          else
            host = "s3.amazonaws.com"
            %{url | host: host, authority: nil, path: "/"}
          end

        url

      [bucket] ->
        url =
          if endpoint_url do
            endpoint_url = URI.new!(endpoint_url)

            %{
              url
              | scheme: endpoint_url.scheme,
                host: endpoint_url.host,
                authority: nil,
                port: endpoint_url.port,
                path: "/#{bucket}#{url.path}"
            }
          else
            host = "#{bucket}.s3.amazonaws.com"
            %{url | host: host, authority: nil}
          end

        url

      # leave e.g. s3.amazonaws.com as is
      _ ->
        url
    end
  end

  defp normalize_url(%URI{} = url, _endpoint_url) do
    url
  end

  # TODO: Req.add_request_steps(req, steps, before: step)
  defp add_request_steps_before(request, steps, before_step_name) do
    request
    |> Map.update!(:request_steps, &prepend_steps(&1, steps, before_step_name))
    |> Map.update!(:current_request_steps, &prepend_current_steps(&1, steps, before_step_name))
  end

  defp prepend_steps([{before_step_name, _} | _] = rest, steps, before_step_name) do
    steps ++ rest
  end

  defp prepend_steps([step | rest], steps, before_step_name) do
    [step | prepend_steps(rest, steps, before_step_name)]
  end

  defp prepend_steps([], _steps, _before_step_name) do
    []
  end

  defp prepend_current_steps([before_step_name | _] = rest, steps, before_step_name) do
    Keyword.keys(steps) ++ rest
  end

  defp prepend_current_steps([step | rest], steps, before_step_name) do
    [step | prepend_current_steps(rest, steps, before_step_name)]
  end

  defp prepend_current_steps([], _steps, _before_step_name) do
    []
  end
end