lib/bow/storage/s3.ex

defmodule Bow.Storage.S3 do
  @behaviour Bow.Storage

  @moduledoc """
  Amazon S3 storage adapter

  ## Configuration

      config :recruitee, :bow,
        storage:  Bow.Storage.S3

      config :ex_aws,
        access_key_id:      "aws-key",
        secret_access_key:  "aws-secret",
        region:             "eu-central-1"

      config :ex_aws, :s3,
        bucket: "my-bucket"

  """

  defp s3config, do: ExAws.Config.new(:s3)

  defp bucket do
    case s3config() do
      %{bucket: bucket} -> bucket
      _ -> raise ArgumentError, message: "Missing :ex_aws, :s3, bucket: \"...\" configuration"
    end
  end

  defp assets_host do
    case Application.get_env(:bow, :assets_host) do
      nil ->
        %{bucket: bucket, host: host} = conf = s3config()
        scheme = Map.get(conf, :scheme, "https://")
        "#{scheme}#{bucket}.#{host}"

      host ->
        host
    end
  end

  defp expires_in, do: Application.get_env(:bow, :expires_in, 24 * 60 * 60)

  @impl true
  def store(path, dir, name, opts) do
    if File.stat!(path).size == 0 do
      # Currently stream_file() doesn't work with empty files
      # ( https://github.com/ex-aws/ex_aws_s3/issues/3 ),
      # so let's do it in the more simple way in that case.
      ExAws.S3.put_object(bucket(), Path.join(dir, name), "")
      |> ExAws.request()
      |> case do
        {:ok, %{status_code: 200}} -> :ok
        error -> error
      end
    else
      path
      |> ExAws.S3.Upload.stream_file()
      |> ExAws.S3.upload(bucket(), Path.join(dir, name), opts)
      |> ExAws.request()
      |> case do
        {:ok, %{status_code: 200}} -> :ok
        error -> error
      end
    end
  rescue
    ex in ExAws.Error -> {:error, ex}
  end

  @impl true
  def load(dir, name, opts) do
    path = Plug.Upload.random_file!("bow-s3")

    bucket()
    |> ExAws.S3.download_file(Path.join(dir, name), path, opts)
    |> ExAws.request()
    |> case do
      {:ok, :done} -> {:ok, path}
      {:error, _} = error -> error
      error -> {:error, error}
    end
  rescue
    ex in ExAws.Error -> {:error, ex}
  end

  @impl true
  def delete(dir, name, opts) do
    bucket()
    |> ExAws.S3.delete_object(Path.join(dir, name), opts)
    |> ExAws.request()
    |> case do
      {:ok, %{status_code: 204}} -> :ok
      error -> error
    end
  rescue
    ex in ExAws.Error -> {:error, ex}
  end

  @impl true
  def copy(src_dir, src_name, dst_dir, dst_name, opts) do
    src_path = Path.join(src_dir, src_name)
    dst_path = Path.join(dst_dir, dst_name)

    bucket()
    |> ExAws.S3.put_object_copy(dst_path, bucket(), src_path, opts)
    |> ExAws.request()
    |> case do
      {:ok, %{status_code: 200}} -> :ok
      error -> error
    end
  rescue
    ex in ExAws.Error -> {:error, ex}
  end

  @impl true
  def url(dir, name, opts) do
    key = Path.join(dir, name)

    case Keyword.pop(opts, :signed) do
      {true, opts} -> signed_url(key, opts)
      _ -> unsigned_url(key, opts)
    end
  end

  defp signed_url(key, opts) do
    opts =
      opts
      |> Keyword.put_new(:expires_in, expires_in())
      |> Keyword.put_new(:virtual_host, true)

    {:ok, url} = ExAws.S3.presigned_url(ExAws.Config.new(:s3), :get, bucket(), key, opts)
    url
  end

  defp unsigned_url(key, opts) do
    Keyword.get(opts, :assets_host)
    |> case do
      nil -> assets_host()
      host -> host
    end
    |> Path.join(key)
  end
end