You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

132 lines
4.7 KiB
CoffeeScript

# 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