# frozen_string_literal: true require "securerandom" require "tempfile" module Octobat # Encodes parameters into a `multipart/form-data` payload as described by RFC # 2388: # # https://tools.ietf.org/html/rfc2388 # # This is most useful for transferring file-like objects. # # Parameters should be added with `#encode`. When ready, use `#body` to get # the encoded result and `#content_type` to get the value that should be # placed in the `Content-Type` header of a subsequent request (which includes # a boundary value). class MultipartEncoder MULTIPART_FORM_DATA = "multipart/form-data" # A shortcut for encoding a single set of parameters and finalizing a # result. # # Returns an encoded body and the value that should be set in the content # type header of a subsequent request. def self.encode(params) encoder = MultipartEncoder.new encoder.encode(params) encoder.close [encoder.body, encoder.content_type] end # Gets the object's randomly generated boundary string. attr_reader :boundary # Initializes a new multipart encoder. def initialize # Kind of weird, but required by Rubocop because the unary plus operator # is considered faster than `Octobat.new`. @body = +"" # Chose the same number of random bytes that Go uses in its standard # library implementation. Easily enough entropy to ensure that it won't # be present in a file we're sending. @boundary = SecureRandom.hex(30) @closed = false @first_field = true end # Gets the encoded body. `#close` must be called first. def body raise "object must be closed before getting body" unless @closed @body end # Finalizes the object by writing the final boundary. def close raise "object already closed" if @closed @body << "\r\n" @body << "--#{@boundary}--" @closed = true nil end # Gets the value including boundary that should be put into a multipart # request's `Content-Type`. def content_type "#{MULTIPART_FORM_DATA}; boundary=#{@boundary}" end # Encodes a set of parameters to the body. # # Note that parameters are expected to be a hash, but a "flat" hash such # that complex substructures like hashes and arrays have already been # appropriately Octobat-encoded. Pass a complex structure through # `Util.flatten_params` first before handing it off to this method. def encode(params) raise "no more parameters can be written to closed object" if @closed params.each do |name, val| if val.is_a?(::File) || val.is_a?(::Tempfile) write_field(name, val.read, filename: ::File.basename(val.path)) elsif val.respond_to?(:read) write_field(name, val.read, filename: "blob") else write_field(name, val, filename: nil) end end nil end # # private # # Escapes double quotes so that the given value can be used in a # double-quoted string and replaces any linebreak characters with spaces. private def escape(str) str.gsub('"', "%22").tr("\n", " ").tr("\r", " ") end private def write_field(name, data, filename:) if !@first_field @body << "\r\n" else @first_field = false end @body << "--#{@boundary}\r\n" if filename @body << %(Content-Disposition: form-data) + %(; name="#{escape(name.to_s)}") + %(; filename="#{escape(filename)}"\r\n) @body << %(Content-Type: application/octet-stream\r\n) else @body << %(Content-Disposition: form-data) + %(; name="#{escape(name.to_s)}"\r\n) end @body << "\r\n" @body << data.to_s end end end