Skip to content

Instantly share code, notes, and snippets.

@cjoudrey
Last active December 27, 2015 13:59
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cjoudrey/7337051 to your computer and use it in GitHub Desktop.
Save cjoudrey/7337051 to your computer and use it in GitHub Desktop.
File uploads in Batman

File uploads in Batman

Problem

When you use input type="file" in Batman, the change event is observed and node.files is used to obtain the file when it changes.

In browsers that do not support the FileReader API node.files will be undefined.

Strategy used in Shopify

Browsers that do not have window.FileReader are polyfilled by Shopify.IEFormUpload.

The polyfill works by intercepting Model#save for instances that are binded through a FormBinding. This means in order to use this polyfill, all forms that have input type="file" must have a data-formfor binding.

Whenever Model#save is called instead of letting Batman do a Batman.Request, the form is submitted to a hidden iframe. Whenever the iframe loads, the response is parsed and fed back into the normal response flow.

This also means the input type="file" must have a valid name attribute. Hidden inputs will be created for all other bindings within the form.

Problem 1 - JSON requests via an iframe

Because the form is submitted to the same end-point the Batman.Request calls JSON is used for the response. This is problematic because IE (amongst other browsers) will show a Download file modal when the Content-Type is application/json.

For this reason, an input _wihtIframe is injected when the form is submitted via the iframe and the Content-Type: application/json is set to Content-Type: text/html instead.

Problem 2 - Response status codes

Since the form is submitted to an iframe the response's status code is not known. All the browser has access to is the content of the page.

Rather than only respond back with the JSON representation of the response, the status code is also included in the payload as so:

response = ["#{status}, #{body}"]

Where status is an integer and body is JSON.

i.e.

[422, {"errors": {"name": "is required"}}]

or

[200, {"product": {"id": 123, "name": "My product"}}]
Shopify.IEFormUpload = ->
originalRequest = Batman.RestStorage::request
Batman.RestStorage::request = (env, next) ->
if env.action in ['create', 'update'] && env.subject._batman.saveWithForm
return @iframeUpload(env, next)
originalRequest.call(this, env, next)
Batman.RestStorage::iframeUpload = (env, next) ->
form = env.subject._batman.saveWithForm.get('node')
iframe = document.getElementById('admin2_iframe_bridge')
_bridgeIframeCallback = ->
Batman.DOM.removeEventListener(iframe, 'load', _bridgeIframeCallback)
data = Batman.DOM.textContent(iframe.contentWindow.document.body)
[status, parsedData] = JSON.parse(data)
if status >= 400
responseText = JSON.stringify(parsedData)
env.error =
request:
status: status
responseText: responseText
env.response = responseText
else
env.data = parsedData
next()
Batman.DOM.addEventListener(iframe, 'load', _bridgeIframeCallback)
@_setupFormAttributes(form, env.options.url)
@_setupFormFields(form, env)
form.submit()
Batman.RestStorage::_setupFormAttributes = (form, action) ->
form.setAttribute('method', 'POST')
form.setAttribute('enctype', 'multipart/form-data')
form.setAttribute('action', action)
form.setAttribute('target', 'admin2_iframe_bridge')
Batman.RestStorage::_setupFormFields = (form, env) ->
# Cleanup any previous field containers
for node in Batman.DOM.querySelectorAll(form, '.iframe_upload_fields')
Batman.DOM.destroyNode(node)
# Create a container for the fields
fieldsContainer = document.createElement('div')
fieldsContainer.setAttribute('class', 'iframe_upload_fields')
fieldsContainer.style.display = 'none'
form.appendChild(fieldsContainer)
# Add hidden fields for inputs
@createInputs(fieldsContainer, env.options.data)
# Add authenticity token
csrfParam = Batman.DOM.querySelector(null, 'meta[name="csrf-param"]')
csrfToken = Batman.DOM.querySelector(null, 'meta[name="csrf-token"]')
@createInput(fieldsContainer, csrfParam.getAttribute('content'), csrfToken.getAttribute('content'))
# Add input so back-end does not send back Content-Type: text/json
@createInput(fieldsContainer, '_withIframe', '1')
# Create a hidden inputs for API headers since we can't pass them
@createInput(fieldsContainer, "_apiFeatures", apiFeatures) if apiFeatures = env.options.headers['X-Shopify-Api-Features']
# If this isn't a POST action we will need to create a hidden input
# with _method = METHOD
if env.options.method != 'POST'
@createInput(fieldsContainer, '_method', env.options.method)
Batman.RestStorage::createInputs = (form, data, namespace = '') ->
for key of data
value = data[key]
namespaceKey = if Batman.typeOf(data) == 'Array'
''
else
key
nextNamespace = if namespace == ''
namespaceKey
else
namespace + "[#{namespaceKey}]"
typeOfValue = Batman.typeOf(value)
if typeOfValue == 'Array'
@createInputs(form, value, nextNamespace)
else if typeOfValue == 'Object'
# IE 9
# We polyfill File so the binding will have a value,
# but we don't want to create hidden inputs for it
# as it will overwrite our <input type="file"> that is
# in the HTML.
continue if value instanceof File
@createInputs(form, value, nextNamespace)
else if typeOfValue == 'File'
# Safari 5.1 has partial file reader support.
# You can obtain the filename, but you cannot
# read the content of the files. This is why we have
# to skip it here.
else
@createInput(form, nextNamespace, value)
null
Batman.RestStorage::createInput = (form, name, value) ->
return if window.File && value instanceof File
input = document.createElement('input')
value = '' if value is null
input.setAttribute('type', 'text')
input.setAttribute('name', name)
input.setAttribute('value', value)
form.appendChild(input)
originalInitialized = Batman.DOM.FormBinding::initialized
Batman.DOM.FormBinding::initialized = ->
originalInitialized?.call(this)
if $(this.node).find('input:file').length > 0
@setupUploadPolyfill()
Batman.DOM.FormBinding::setupUploadPolyfill = ->
keyPath = @get('keyPath')
model = @view.lookupKeypath(keyPath)
return unless model
return if model._batman.saveWithForm
model._batman.saveWithForm = this
Batman.DOM.FormBinding::destroyUploadPolyfill = (model) ->
model._batman.saveWithForm = null
originalDie = Batman.DOM.FormBinding::die
Batman.DOM.FormBinding::die = ->
keyPath = @get('keyPath')
model = @view.lookupKeypath(keyPath)
@destroyUploadPolyfill(model) if model?._batman.saveWithForm
originalDie?.call(this)
class File
constructor: (node) ->
this.name = node.value.split('\\').pop()
originalNodeChange = Batman.DOM.FileBinding::nodeChange
Batman.DOM.FileBinding::nodeChange = (node, subContext) ->
node.files = [new File(node)]
originalNodeChange.call(this, node, subContext)
module IframeFileMiddleware
class StripJsonHeadersForIframeMiddleware
include Rack::Utils
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
status, headers, response = @app.call(env)
if is_json?(headers) && has_param?(request)
headers['Content-Type'] = 'text/html'
response = add_status(status, response)
headers['Content-Length'] = new_content_length(response).to_s if headers['Content-Length']
end
[status, headers, response]
end
private
def is_json?(headers)
headers.key?('Content-Type') && headers['Content-Type'].include?('application/json')
end
def has_param?(request)
request.params.include?('_withIframe')
end
# From Rack::JSONP
# <https://github.com/rack/rack-contrib/blob/551ae792bc8c3c0610e7f334bdc1c55de2fc2707/lib/rack/contrib/jsonp.rb>
def add_status(status, response, body = '')
response.each do |s|
body << s.to_s
end
response.close if response.respond_to?(:close)
["[#{status}, #{body}]"]
end
def new_content_length(response)
response.to_ary.inject(0) { |len, part| len + bytesize(part) }
end
end
end
<script type="text/javascript">
if (!Shopify.get('hasFileReader')) {
Shopify.IEFormUpload();
}
</script>
<iframe src="javascript:;" id="admin2_iframe_bridge" name="admin2_iframe_bridge" style="display: none;"></iframe>
@jeremyricketts
Copy link

Thanks for posting this!

@zhubert
Copy link

zhubert commented Nov 12, 2013

Sadly, this isn't working for us reliably. There must be some piece undocumented or different in our setup.

What I'm seeing is frequent iframe.dll errors (L_CONNECTION, initConnectionStatus, etc) which seem innocuous but bothersome. I'm also seeing 1 in 10 pictures uploaded successfully...making me think there is some sort of race condition.

The times it fails, it gets access denied on the iframe.contentWindow or just fails silently.

@zhubert
Copy link

zhubert commented Nov 12, 2013

Really appreciate the writeup and really wish we could offer file upload to IE10. Big market share to lose.

@zhubert
Copy link

zhubert commented Nov 14, 2013

Ok, an update. After spending a few days, I found the spots that weren't working for us and fixed them.

@zhubert
Copy link

zhubert commented Nov 14, 2013

Well that was weird, my comments were eaten on clicking preview. Short version of changes:

  1. form detection didn't work, commented out saveWithForm logic
  2. addEventListener didn't work, changed it to jQuery.load on the iframe
  3. createInput would overwrite the file upload input on IE10, added a short circuit based on naming convention (return if input is *_file)
  4. iframe src needed to be javascript:false to prevent IE warnings iframe.dll, etc
  5. form.submit conflicted with our technique for decorating the file input, removed decoration (dirty field, ie10)
  6. doco: middleware has to be added to application.rb
  7. doco: rails controller needs an html return type that is the JSON content of the model
  8. had to put setTimeout around IEFormUpload call

I have no idea how it's working for Shopify as is. Perhaps seeing the form HTML might help make sense?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment