form-data2.coffee 4.71 KB
# Standard library
path = require "path"
stream = require "stream"

# Third-party dependencies
uuid = require "uuid"
mime = require "mime"
combinedStream2 = require "combined-stream2"
Promise = require "bluebird"
_ = require "lodash"
debug = require("debug")("form-data2")

CRLF = "\r\n"

# Utility functions
ofTypes = (obj, types) ->
	match = false
	for type in types
		match = match or obj instanceof type
	return match


module.exports = class FormData
	constructor: ->
		@_firstHeader = false
		@_closingHeaderAppended = false
		@_boundary = "----" + uuid.v4()
		@_headers = { "content-type": "multipart/form-data; boundary=#{@_boundary}" }
		@_stream = combinedStream2.create()

	_getStreamMetadata: (source, options) -> # FIXME: Make work with deferred sources (ie. callback-provided)
		debug "obtaining metadata for source: %s", source.toString().replace(/\n/g, "\\n").replace(/\r/g, "\\r")
		fullPath = options.filename ? source.client?._httpMessage?.path ? source.path

		if fullPath? # This is a file...
			filename = path.basename(fullPath)
			contentType = options.contentType ? source.headers?["content-type"] ? mime.lookup(filename)
			contentLength = options.knownLength ? options.contentLength ? source.headers?["content-length"] # FIXME: Is this even used anywhere?
		else # Probably just a plaintext form value, or an unidentified stream
			contentType = options.contentType ? source.headers?["content-type"]
			contentLength = options.knownLength ? options.contentLength ? source.headers?["content-length"]

		return {filename: filename, contentType: contentType, contentLength: contentLength}

	_generateHeaderFields: (name, metadata) ->
		debug "generating headers for: %s", metadata
		headerFields = []

		if metadata.filename?
			escapedFilename = metadata.filename.replace '"', '\\"'
			headerFields.push "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{escapedFilename}\""
		else
			headerFields.push "Content-Disposition: form-data; name=\"#{name}\""

		if metadata.contentType?
			headerFields.push "Content-Type: #{metadata.contentType}"

		debug "generated headers: %s", headerFields
		return headerFields.join CRLF

	_appendHeader: (name, metadata) ->
		if @_firstHeader == false
			debug "appending header"
			leadingCRLF = ""
			@_firstHeader = true
		else
			debug "appending first header"
			leadingCRLF = CRLF

		headerFields = @_generateHeaderFields name, metadata

		@_stream.append new Buffer(leadingCRLF + "--#{@_boundary}" + CRLF + headerFields + CRLF + CRLF)

	_appendClosingHeader: ->
		debug "appending closing header"
		@_stream.append new Buffer(CRLF + "--#{@_boundary}--")

	append: (name, source, options = {}) ->
		debug "appending source"
		if @_closingHeaderAppended
			throw new Error "The stream has already been prepared for usage; you either piped it or generated the HTTP headers. No new sources can be appended anymore."

		if not ofTypes(source, [stream.Readable, stream.Duplex, stream.Transform, Buffer, Function]) and typeof source != "string"
			throw new Error "The provided value must be either a readable stream, a Buffer, a callback providing either of those, or a string."

		if typeof source == "string"
			source = new Buffer(source) # If the string isn't UTF-8, this won't end well!
			options.contentType ?= "text/plain"

		metadata = @_getStreamMetadata source, options
		@_appendHeader name, metadata

		@_stream.append source, options

	done: ->
		# This method should be called when the user is finished adding streams. It adds the termination header at the end of the combined stream. When piping, this method is automatically called!
		debug "called 'done'"

		if not @_closingHeaderAppended
			@_closingHeaderAppended = true
			@_appendClosingHeader()

	getBoundary: ->
		return @_boundary

	getHeaders: (callback) ->
		# Returns the headers needed to correctly transmit the generated multipart/form-data blob. We will first need to call @done() to make sure that the multipart footer is there - from this point on, no new sources can be appended anymore.
		@done()

		Promise.try =>
			@_stream.getCombinedStreamLength()
		.then (length) ->
			debug "total combined stream length: %s", length
			Promise.resolve { "content-length": length }
		.catch (err) ->
			# We couldn't get the stream length, most likely there was a stream involved that `stream-length` does not support.
			debug "WARN: could not get total combined stream length"
			Promise.resolve { "transfer-encoding": "chunked" }
		.then (sizeHeaders) =>
			Promise.resolve _.extend(sizeHeaders, @_headers)
		.nodeify(callback)

	getLength: (callback) ->
		@_stream.getCombinedStreamLength(callback)

	pipe: (target) ->
		@done()

		# Pass through to the underlying `combined-stream`.
		debug "piping underlying combined-stream2 to target writable"
		@_stream.pipe target