lib/plug_mime_type_check.ex

defmodule PlugMimeTypeCheck do
  @moduledoc """
  A plug that checks the mime-type of a uploaded file through a request

  It requires an option:

    * `:allowed_mime_types` - a list of allowed file mime types for the route. It must be a list.

  To use you can just plug in your controller and it will work to all your actions.
  You must pass the `:allowed_mime_types` list, for example:

      plug PlugMimeTypeCheck, allowed_mime_types: ["text/csv", "image/*"]

  You can apply just to defined actions, for example:

      plug PlugMimeTypeCheck, [allowed_mime_types: ["application/pdf"]] when action in [:create, :update]

  Or you can plug in your `router.ex` file:

      pipeline :uploads do
        plug PlugMimeTypeCheck, allowed_mime_types: ["image/png"]
      end

      scope "/api", MyModuleWeb do
        pipe_through :uploads

        post "/upload", UploadController, :upload
      end
  """
  import Plug.Conn

  def init(opts) do
    {allowed_mime_types, _} = Keyword.pop(opts, :allowed_mime_types)

    unless allowed_mime_types do
      raise ArgumentError,
            "PlugMimeTypeCheck expects a set of mime-types to be given in :allowed_mime_types"
    end

    %{allowed_mime_types: allowed_mime_types}
  end

  def call(conn, opts) do
    case get_req_header(conn, "content-type") do
      ["multipart/form-data" <> _] -> check_mime_type(conn, opts)
      _ -> conn
    end
  end

  defp check_mime_type(%{params: params} = conn, opts) do
    case check_invalids(params, opts[:allowed_mime_types]) do
      [] -> conn
      invalid_fields -> send_bad_request_response(conn, invalid_fields)
    end
  end

  defp check_invalids(params, allowed_mime_types) do
    params
    |> Enum.map(fn {k, v} -> {k, check_value(v, allowed_mime_types)} end)
    |> Enum.filter(fn v -> v |> elem(1) |> filter_invalids() end)
    |> Enum.map(fn v -> elem(v, 0) end)
  end

  defp check_value(%Plug.Upload{} = v, allowed_mime_types) do
    file_mime_type = get_file_mime_type(v.path)

    case Enum.any?(allowed_mime_types, fn t -> String.contains?(t, "/*") end) do
      true ->
        Enum.any?(allowed_mime_types, fn allowed_mime_type ->
          String.starts_with?(file_mime_type, String.trim(allowed_mime_type, "*"))
        end)

      false ->
        Enum.member?(allowed_mime_types, file_mime_type)
    end
  end

  defp check_value(v, allowed_mime_types) when is_map(v),
    do: check_invalids(v, allowed_mime_types)

  defp check_value(_, _), do: true

  defp filter_invalids(v) when is_list(v), do: v != []
  defp filter_invalids(v), do: v == false

  defp send_bad_request_response(conn, fields) do
    sufix = if length(fields) > 1, do: "s", else: ""
    msg = "Invalid file#{sufix} mime type#{sufix} in field#{sufix}: " <> Enum.join(fields, ", ")

    response = Jason.encode_to_iodata!(%{error_message: msg})

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(:unprocessable_entity, response)
    |> halt()
  end

  defp get_file_mime_type(path) do
    {type, 0} = System.cmd("file", ["--mime-type", "-b", path])

    String.replace(type, "\n", "")
  end
end