defmodule Multipart do @moduledoc """ `Multipart` constructs multipart messages. It aims to produce multipart messages that are compatible with [RFC 2046](https://tools.ietf.org/html/rfc2046#section-5.1) for general use, and [RFC 7578](https://tools.ietf.org/html/rfc7578) for constructing `multipart/form-data` requests. """ defstruct boundary: nil, parts: [] @type t :: %__MODULE__{ boundary: String.t(), parts: list(Multipart.Part.t()) } @crlf "\r\n" @separator "--" @boundary_regex ~r/\A[0-9A-Za-z'()+_,\-.\/:=?]{1,70}\z/ alias Multipart.Part @doc """ Create a new `Multipart` request. Pass in the boundary as the first argument to set it explicitly, otherwise it will default to a random 16 character alphanumeric string padded by `==` on either side. The boundary must conform to the RFC 2046 boundary grammar (1-70 of the allowed `bcharsnospace` characters); anything else raises `ArgumentError`. The default boundary is a 128-bit random token, so it is safe against a part body that happens to contain the boundary. If you supply a custom boundary you are responsible for ensuring it cannot appear in any part body. """ @spec new(String.t()) :: t() def new(boundary \\ generate_boundary()) do validate_boundary!(boundary) %__MODULE__{boundary: boundary} end # RFC 2046 boundary grammar: 1*70 bcharsnospace. Rejecting anything outside # this charset prevents CRLF/quote injection into the body delimiters and the # `content_type/3` header. defp validate_boundary!(boundary) do unless is_binary(boundary) and Regex.match?(@boundary_regex, boundary) do raise ArgumentError, "invalid multipart boundary: #{inspect(boundary)}" end end @doc """ Adds a part to the `Multipart` message. """ @spec add_part(t(), Multipart.Part.t()) :: t() def add_part(%__MODULE__{parts: parts} = multipart, %Part{} = part) do %__MODULE__{multipart | parts: parts ++ [part]} end @doc """ Returns a `Stream` of the `Multipart` message body. """ @spec body_stream(Multipart.t()) :: Enum.t() def body_stream(%__MODULE__{boundary: boundary, parts: parts}) do parts |> Enum.map(&part_stream(&1, boundary)) |> Stream.concat() |> Stream.concat([final_delimiter(boundary)]) end @doc """ Returns a binary of the `Multipart` message body. This uses `body_stream/1` under the hood. """ @spec body_binary(Multipart.t()) :: binary() def body_binary(%__MODULE__{} = multipart) do multipart |> body_stream() |> Enum.join("") end @doc """ Returns the Content-Type header for the `Multipart` message. Pass in `Keyword` option `:quote_boundary` set `false` to disable quoting the boundary. iex> multipart = Multipart.new("abc123") iex> Multipart.content_type(multipart, "multipart/mixed") "multipart/mixed; boundary=\\"abc123\\"" iex> Multipart.content_type(multipart, "multipart/mixed", quote_boundary: false) "multipart/mixed; boundary=abc123" """ @spec content_type(Multipart.t(), String.t(), Keyword.t()) :: String.t() def content_type(%__MODULE__{boundary: boundary}, mime_type, opts \\ []) do quote_boundary = Keyword.get(opts, :quote_boundary, true) boundary = case quote_boundary do true -> "\"#{boundary}\"" false -> boundary end [mime_type, "boundary=#{boundary}"] |> Enum.join("; ") end @doc """ Returns the length of the `Multipart` message in bytes. It uses the `content_length` property in each of the message parts to calculate the length of the multipart message without reading the entire body into memory. `content_length` is set on the `Multipart.Part` by the constructor functions when possible, such as when the in-memory binary or the file on disk can be inspected. This will throw an error if any of the parts does not have `content_length` defined. """ @spec content_length(Multipart.t()) :: pos_integer() def content_length(%__MODULE__{parts: parts, boundary: boundary}) do final_delimiter_length = final_delimiter(boundary) |> Enum.join("") |> byte_size() parts |> Enum.with_index() |> Enum.reduce(0, fn {%Part{} = part, index}, total -> case part_content_length(part, boundary) do cl when is_integer(cl) -> cl + total nil -> throw("Part at index #{index} has nil content_length") end end) |> Kernel.+(final_delimiter_length) end defp part_stream(%Part{} = part, boundary) do Stream.concat([part_delimiter(boundary), part_headers(part), part_body_stream(part)]) end defp part_content_length(%Part{content_length: content_length} = part, boundary) do if is_integer(content_length) do Enum.concat(part_delimiter(boundary), part_headers(part)) |> Enum.reduce(0, fn str, acc -> byte_size(str) + acc end) |> Kernel.+(content_length) else nil end end defp part_delimiter(boundary) do [@crlf, @separator, boundary, @crlf] end defp final_delimiter(boundary) do [@crlf, @separator, boundary, @separator, @crlf] end defp part_headers(%Part{headers: headers}) do headers |> Enum.flat_map(fn {k, v} -> validate_header!(k, v) ["#{k}: #{v}", @crlf] end) |> List.insert_at(-1, @crlf) end defp validate_header!(k, v) do if String.contains?("#{k}", ["\r", "\n"]) or String.contains?("#{v}", ["\r", "\n"]) do raise ArgumentError, "header name/value contains CR/LF: #{inspect({k, v})}" end end defp part_body_stream(%Part{body: body}) when is_binary(body) do [body] end defp part_body_stream(%Part{body: body}) when is_list(body) do body end defp part_body_stream(%Part{body: %type{} = body}) when type in [Stream, File.Stream] do body end defp generate_boundary() do token = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower) "==#{token}==" end end