From a7ed9a7b6364273b1b964fd2cf9691dec2cbec6b Mon Sep 17 00:00:00 2001 From: =?utf8?q?Micha=C5=82=20Go=C5=82=C4=99biowski-Owczarek?= Date: Wed, 1 Feb 2023 13:48:35 +0100 Subject: [PATCH] Ajax: Support binary data (including FormData) Two changes have been applied: * prefilters are now applied before data is converted to a string; this allows prefilters to disable such a conversion * a prefilter for binary data is added; it disables data conversion for non-string non-plain-object `data`; for `FormData` bodies, it removes manually-set `Content-Type` header - this is required as browsers need to append their own boundary to the header Ref gh-4150 Closes gh-5197 --- package.json | 1 + src/ajax.js | 6 ++--- src/ajax/binary.js | 17 ++++++++++++++ src/jquery.js | 1 + test/data/mock.php | 11 +++++++++ test/data/testinit.js | 7 ++++-- test/middleware-mockserver.js | 28 +++++++++++++++++++++++ test/unit/ajax.js | 43 +++++++++++++++++++++++++++++++++++ 8 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/ajax/binary.js diff --git a/package.json b/package.json index 3afa4e5f1..153eb8d6a 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "karma-qunit": "4.1.2", "karma-webkit-launcher": "2.1.0", "load-grunt-tasks": "5.1.0", + "multiparty": "4.2.3", "native-promise-only": "0.8.1", "playwright-webkit": "1.29.2", "promises-aplus-tests": "2.1.2", diff --git a/src/ajax.js b/src/ajax.js index 36a9c9b57..db4e30195 100644 --- a/src/ajax.js +++ b/src/ajax.js @@ -562,14 +562,14 @@ jQuery.extend( { } } + // Apply prefilters + inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); + // Convert data if not already a string if ( s.data && s.processData && typeof s.data !== "string" ) { s.data = jQuery.param( s.data, s.traditional ); } - // Apply prefilters - inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); - // If request was aborted inside a prefilter, stop there if ( completed ) { return jqXHR; diff --git a/src/ajax/binary.js b/src/ajax/binary.js new file mode 100644 index 000000000..e96661da7 --- /dev/null +++ b/src/ajax/binary.js @@ -0,0 +1,17 @@ +import jQuery from "../core.js"; + +import "../ajax.js"; + +jQuery.ajaxPrefilter( function( s ) { + + // Binary data needs to be passed to XHR as-is without stringification. + if ( typeof s.data !== "string" && !jQuery.isPlainObject( s.data ) ) { + s.processData = false; + } + + // `Content-Type` for requests with `FormData` bodies needs to be set + // by the browser as it needs to append the `boundary` it generated. + if ( s.data instanceof window.FormData ) { + s.contentType = false; + } +} ); diff --git a/src/jquery.js b/src/jquery.js index a0d5d3647..d833516d4 100644 --- a/src/jquery.js +++ b/src/jquery.js @@ -23,6 +23,7 @@ import "./ajax.js"; import "./ajax/xhr.js"; import "./ajax/script.js"; import "./ajax/jsonp.js"; +import "./ajax/binary.js"; import "./ajax/load.js"; import "./core/parseXML.js"; import "./core/parseHTML.js"; diff --git a/test/data/mock.php b/test/data/mock.php index 0cb88cf47..1955f56fc 100644 --- a/test/data/mock.php +++ b/test/data/mock.php @@ -124,6 +124,17 @@ QUnit.assert.ok( true, "mock executed");'; echo "$cleanCallback($text)\n"; } + protected function formData( $req ) { + $prefix = 'multipart/form-data; boundary=--'; + $contentTypeValue = $req->headers[ 'CONTENT-TYPE' ]; + if ( substr( $contentTypeValue, 0, strlen( $prefix ) ) === $prefix ) { + echo 'key1 -> ' . $_POST[ 'key1' ] . ', key2 -> ' . $_POST[ 'key2' ]; + } else { + echo 'Incorrect Content-Type: ' . $contentTypeValue . + "\nExpected prefix: " . $prefix; + } + } + protected function error( $req ) { header( 'HTTP/1.0 400 Bad Request' ); if ( isset( $req->query['json'] ) ) { diff --git a/test/data/testinit.js b/test/data/testinit.js index 6503b70a5..906686d86 100644 --- a/test/data/testinit.js +++ b/test/data/testinit.js @@ -174,8 +174,11 @@ function url( value ) { } // Ajax testing helper -this.ajaxTest = function( title, expect, options ) { - QUnit.test( title, function( assert ) { +this.ajaxTest = function( title, expect, options, wrapper ) { + if ( !wrapper ) { + wrapper = QUnit.test; + } + wrapper.call( QUnit, title, function( assert ) { assert.expect( expect ); var requestOptions; diff --git a/test/middleware-mockserver.js b/test/middleware-mockserver.js index 2b6970226..35e4c1778 100644 --- a/test/middleware-mockserver.js +++ b/test/middleware-mockserver.js @@ -3,6 +3,7 @@ const url = require( "url" ); const fs = require( "fs" ); const getRawBody = require( "raw-body" ); +const multiparty = require( "multiparty" ); let cspLog = ""; @@ -141,6 +142,19 @@ const mocks = { resp.writeHead( 200 ); resp.end( `${ cleanCallback( callback ) }(${ JSON.stringify( body ) })\n` ); }, + formData: function( req, resp, next ) { + const prefix = "multipart/form-data; boundary=--"; + const contentTypeValue = req.headers[ "content-type" ]; + resp.writeHead( 200 ); + if ( ( prefix || "" ).startsWith( prefix ) ) { + getMultiPartContent( req ).then( function( { fields = {} } ) { + resp.end( `key1 -> ${ fields.key1 }, key2 -> ${ fields.key2 }` ); + }, next ); + } else { + resp.end( `Incorrect Content-Type: ${ contentTypeValue + }\nExpected prefix: ${ prefix }` ); + } + }, error: function( req, resp ) { if ( req.query.json ) { resp.writeHead( 400, { "content-type": "application/json" } ); @@ -363,4 +377,18 @@ function getBody( req ) { } ); } +function getMultiPartContent( req ) { + return new Promise( function( resolve ) { + if ( req.method !== "POST" ) { + resolve( "" ); + return; + } + + const form = new multiparty.Form(); + form.parse( req, function( _err, fields, files ) { + resolve( { fields, files } ); + } ); + } ); +} + module.exports = MockserverMiddlewareFactory; diff --git a/test/unit/ajax.js b/test/unit/ajax.js index fec1d9565..7ecedc212 100644 --- a/test/unit/ajax.js +++ b/test/unit/ajax.js @@ -3105,4 +3105,47 @@ if ( typeof window.ArrayBuffer === "undefined" || typeof new XMLHttpRequest().re assert.ok( jQuery.active === 0, "ajax active counter should be zero: " + jQuery.active ); } ); + ajaxTest( "jQuery.ajax() - FormData", 1, function( assert ) { + var formData = new FormData(); + formData.append( "key1", "value1" ); + formData.append( "key2", "value2" ); + + return { + url: url( "mock.php?action=formData" ), + method: "post", + data: formData, + success: function( data ) { + assert.strictEqual( data, "key1 -> value1, key2 -> value2", + "FormData sent correctly" ); + } + }; + } ); + + ajaxTest( "jQuery.ajax() - URLSearchParams", 1, function( assert ) { + var urlSearchParams = new URLSearchParams(); + urlSearchParams.append( "name", "peter" ); + + return { + url: url( "mock.php?action=name" ), + method: "post", + data: urlSearchParams, + success: function( data ) { + assert.strictEqual( data, "pan", "URLSearchParams sent correctly" ); + } + }; + }, QUnit.testUnlessIE ); + + ajaxTest( "jQuery.ajax() - Blob", 1, function( assert ) { + var blob = new Blob( [ "name=peter" ], { type: "text/plain" } ); + + return { + url: url( "mock.php?action=name" ), + method: "post", + data: blob, + success: function( data ) { + assert.strictEqual( data, "pan", "Blob sent correctly" ); + } + }; + } ); + } )(); -- 2.39.5