Skip to main content
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Kitt Hodsden
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Kitt Hodsden

Adding "Multipart/Form-Data" Support To My Parameter Grouping Polyfill In Adobe ColdFusion

By
Published in

Over the past few years, I've built-up an Adobe ColdFusion (ACF) polyfill for the field[] semantics that Lucee CFML exposes for grouping like-named parameters as an array. I started with form fields; and then later added URL search parameter support. Over the weekend, however, I discovered that form POSTs come in two different flavors (regarding Java servlet mechanics); and that a multipart/form-data submission breaks under my current polyfill implementation. Google showed me that I can work-around this issue by using an undocumented feature of ACF: form.getPartsArray(). Once I got my proof-of-concept working yesterday, I updated my polyfill this morning to support multipart/form-data requests.

To recap yesterday's exploration, the data that powers the form and url scopes in Adobe ColdFusion is fed from several different places within the underlying Java servlet (note that I'm omitting getPageContext().getRequest() from most of these in order to make it more readable):

  • GET requests.

    • url ← .getParameterMap()
  • POST requests with URL-encoded values.

    • url ← .getRequest().getParameterMap()
    • form ← .getParameterMap()
  • POST requests with multipart/form-data values.

    • url ← .getParameterMap()
    • form ← form.getPartsArray()

Note: I don't care about other HTTP verbs since this work is intended for user-facing form submissions - not hand-crafted API calls.

Since the form.getPartsArray() is undocumented, I put it behind my RequestMetadata.cfc abstraction; and exposed it as .getMultiparts(). This doesn't necessarily make it any safer to consume. But at least if it breaks in future ACF releases, I might be able to fix it without too much fuss.

My RequestMetadata.cfc has a lot of methods in it; so this is a truncated version with the methods relevant to this write-up:

component {

	/**
	* I get the HTTP method.
	*/
	public string function getMethod() {

		return cgi.request_method.ucase();

	}


	/**
	* I return a collection of multipart form post parts.
	* 
	* Caution: this uses an UNDOCUMENTED feature of ColdFusion. But it appears to be the
	* only way around this because the request parts aren't available on the page context.
	*/
	public array function getMultiparts() {

		var formParts = ( form.getPartsArray() ?? [] );
		var parts = [].append( formParts, true );

		return parts.map(
			( part ) => {

				if ( part.isParam() ) {

					return {
						isParam: true,
						isFile: false,
						name: part.getName(),
						value: part.getStringValue( "utf-8" ),
					};

				}

				// Note: if no file is selected, the underlying filename is null but the
				// content-type is defaulted to octet-stream.
				return {
					isParam: false,
					isFile: true,
					name: part.getName(),
					filename: ( part.getFilename() ?? "" ),
					contentType: part.getContentType(),
				};

			}
		);

	}


	/**
	* I determine if the current HTTP request is a get.
	*/
	public boolean function isGet() {

		return ( getMethod() == "get" );

	}


	/**
	* I determine if the current request is a multipart post.
	*/
	public boolean function isMultipartPost() {

		return ( isPost() && !! cgi.content_type.lcase().reFind( "^multipart/form-data" ) );

	}


	/**
	* I determine if the current HTTP request is a post.
	*/
	public boolean function isPost() {

		return ( getMethod() == "post" );

	}

}

My ParameterGroupPolyfill.cfc then looks at the type of request and translates keys that end with the bracket-notation, []. Each type of request is handled by a different fix* method:

  • fixMultipartPost()
  • fixUrlEncodedPost()
  • fixGet()

I went back-and-forth playing around with a lot of different shapes and abstractions. In the end, I err'd on the side that a little duplication is sometimes easier to read than an abstraction. What you'll see is that each of these "fix methods" just loops over "keys to fix", renames the CFML scope fields, and exposes the underlying request data as an array.

Really, the only interesting method is getMultipartsGroupedByKey(), which transforms the form.getPartsArray() data structure into a shape that adheres to the getParameterMap() method(s). This way, my fix methods can all implement the same shape of control flow.

The public .apply() method is called once in the onRequestStart() life-cycle event method in my Application.cfc. This is what applies the polyfill.

component {

	// Define properties for dependency-injection.
	property name="requestMetadata" ioc:type="core.lib.web.RequestMetadata";

	// ColdFusion language extensions (global functions).
	include "/core/cfmlx.cfm";

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I apply the parameter grouping polyfill to the CFML scopes.
	*/
	public void function apply() {

		var servletRequest = getPageContext().getRequest();
		// Note: each "fix*" method below is going to check to see if any keys have been
		// provided. As such, we're not going to add any early-exit logic here.
		var urlKeysToFix = findKeysToFix( url );
		var formKeysToFix = findKeysToFix( form ?? {} );

		if ( requestMetadata.isMultipartPost() ) {

			fixMultipartPost( servletRequest, urlKeysToFix, formKeysToFix );
			return;

		}

		if ( requestMetadata.isPost() ) {

			fixUrlEncodedPost( servletRequest, urlKeysToFix, formKeysToFix );
			return;

		}

		if ( requestMetadata.isGet() ) {

			fixGet( servletRequest, urlKeysToFix );
			return;

		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I normalize the given Java array into a native ColdFusion array.
	*/
	private array function asArray( required any javaArray ) {

		return [].append( javaArray, true );

	}


	/**
	* I normalize the given Java struct into a native ColdFusion struct.
	*/
	private struct function asStruct( required any javaStruct ) {

		return {}.append( javaStruct );

	}


	/**
	* I identify which keys in the given scope need to be fixed. These are the keys that
	* still have the "[]" suffix, indicating that the CFML engine didn't handle the
	* grouping properly.
	*/
	private array function findKeysToFix( required struct cfmlScope ) {

		return cfmlScope
			.keyArray()
			.filter( ( key ) => ( key.right( 2 ) == "[]" ) )
		;

	}


	/**
	* I remove any "[]" notation from the fieldnames property of the form.
	*/
	private void function fixFieldNames() {

		// In standard posts, the duplicate field names just overwrite each other.
		// However, when using a "multipart/form-data" post, the duplicate field names
		// show up as separate names in the field list. As such, we have to use a set in
		// order to make sure that the final list of cleaned-up names is made unique.
		var names = setNew( "ordered-nocase" );

		for ( var name in form.fieldNames.listToArray() ) {

			var nameNormalized = ( name.right( 2 ) == "[]" )
				? name.left( -2 )
				: name
			;
			names.add( nameNormalized );

		}

		form.fieldNames = names.toList( "," );

	}


	/**
	* I fix the URL scope for GET requests.
	*/
	private void function fixGet(
		required any servletRequest,
		required array urlKeysToFix
		) {

		if ( urlKeysToFix.len() ) {

			var rawUrlScope = asStruct( servletRequest.getParameterMap() );

			for ( var key in urlKeysToFix ) {

				url[ key.left( -2 ) ] = asArray( rawUrlScope[ key ] );
				url.delete( key );

			}

		}

	}


	/**
	* I fix the URL and FORM scopes for multipart POST requests.
	*/
	private void function fixMultipartPost(
		required any servletRequest,
		required array urlKeysToFix,
		required array formKeysToFix
		) {

		if ( urlKeysToFix.len() ) {

			var rawUrlScope = asStruct( servletRequest.getParameterMap() );

			for ( var key in urlKeysToFix ) {

				url[ key.left( -2 ) ] = asArray( rawUrlScope[ key ] );
				url.delete( key );

			}

		}

		if ( formKeysToFix.len() ) {

			// Note: unlike other servlet-based collections, the following method has
			// already grouped the values into native ColdFusion structs and arrays. As
			// such, we don't have to use any asStruct/asArray logic in this control flow.
			var rawFormScope = getMultipartsGroupedByKey();

			for ( var key in formKeysToFix ) {

				form[ key.left( -2 ) ] = rawFormScope[ key ];
				form.delete( key );

			}

			fixFieldNames();

		}

	}


	/**
	* I fix the URL and FORM scopes for standard POST requests.
	*/
	private void function fixUrlEncodedPost(
		required any servletRequest,
		required array urlKeysToFix,
		required array formKeysToFix
		) {

		if ( urlKeysToFix.len() ) {

			var rawUrlScope = asStruct( servletRequest.getRequest().getParameterMap() );

			for ( var key in urlKeysToFix ) {

				url[ key.left( -2 ) ] = asArray( rawUrlScope[ key ] );
				url.delete( key );

			}

		}

		if ( formKeysToFix.len() ) {

			var rawFormScope = asStruct( servletRequest.getParameterMap() );

			for ( var key in formKeysToFix ) {

				form[ key.left( -2 ) ] = asArray( rawFormScope[ key ] );
				form.delete( key );

			}

			fixFieldNames();

		}

	}


	/**
	* I translate the multipart collection into something that more closely resembles the
	* raw URL/FORM scopes in other types of requests. Meaning, I map each key onto an
	* array of values. Simple values for params, complex values for files. Each value
	* array is intended to be consumed as-is in the translation logic.
	*
	* This only collects fields that are using the "name[]" notation. All standard fields
	* are left as-is, since we don't have to translate them for the polyfill.
	*
	* Caution: this mutates the form scope (slightly) as it translates the list of
	* temporary file paths (strings) into arrays of part structs.
	*/
	private struct function getMultipartsGroupedByKey() {

		var grouped = {};

		for ( var part in requestMetadata.getMultiparts() ) {

			if ( part.name.right( 2 ) != "[]" ) {

				continue;

			}

			var values
				= grouped[ part.name ]
					= ( grouped[ part.name ] ?? [] )
			;

			// Group params as an array of simple string values.
			if ( part.isParam ) {

				values.append( part.value );

			// Group files as an array of structs.
			} else {

				// When files are uploaded in like-named fields, ColdFusion will upload
				// the files in turn and then provide the server-side temporary file paths
				// as a comma-delimited list. But it will only do this for file fields
				// that are populated. Which means that we can have more "parts" than we
				// have "files". As such, we're going to consume the file path values as a
				// list; but only shift list values into translated array when we have a
				// populated filename.
				// --
				// Caution: this is using an undocumented feature of ColdFusion; so we are
				// taking it as a leap of faith that order to the multiparts is presented
				// in the same order as the temporary file paths.
				// --
				// Caution: if the temporary upload folder path ever contains a comma,
				// this entire strategy blows up. It relies on the fact that the list of
				// file paths is just that: a list. If a random comma ever shows up in one
				// of the paths, the list is no longer valid.
				if ( part.filename.len() ) {

					var filepath = form[ part.name ].listGetAt( 1 );
					// Shift the temporary file path off the form field so that we don't
					// consume it on the next part processing.
					form[ part.name ] = form[ part.name ].listDeleteAt( 1 );

				} else {

					var filepath = "";

				}

				values.append({
					filename: part.filename,
					filepath: filepath,
					contentType: part.contentType,
				});

			}

		}

		return grouped;

	}

}

Once my ParameterGroupingPolyfill.cfc was updated, I went back to my demo from yesterday and stripped-out all of the special handling. Since this demo is running in my ColdFusion application's context, the polyfill is being applied implicitly; and all I have to do is consume the cleaned-up scope values.

The only thing of interest here is the file upload (?v=3) since it exercises the polyfill update. Which, again, means that I can't use ColdFusion's native fileUpload() BIF (Built-In Function) — at least not with []-style file fields. As such, the demo is using explicit logic to move the uploaded files from the temporary location to the permanent location.

<cfscript>

	param name="url.v" type="numeric" default=0;

	// GET request.
	if ( url.v == 1 ) {
		// URL scope translation handled by polyfill.
	}

	// POST request (with URL parameters as well).
	if ( url.v == 2 ) {
		// URL scope translation handled by polyfill.
		// FORM scope translation handled by polyfill.
	}

	// POST request with `multipart/form-data` (with URL parameters as well).
	if ( url.v == 3 ) {

		// URL scope translation handled by polyfill.
		// FORM scope translation handled by polyfill.

		// CAUTION: Once we start using `[]` notation to group FILE UPLOAD FIELDS, we're
		// basically opting out of being able to use ColdFusion's fileUpload() BIF (as far
		// as I can tell). As such, we have to consume the `.tmp` filepath directly,
		// moving and renaming files with our own logic.
		for ( entry in form.filename ) {

			if ( ! entry.filepath.len() ) {

				continue;

			}

			// Don't trust the content-type provided by the user - we need to verify that
			// we have the expected types on the server.
			switch ( fileGetMimeType( entry.filepath ) ) {
				case "image/jpeg":
				case "image/png":

					// Note: I'm hard-coding `.png` of the sake of debugging.
					fileMove(
						source = entry.filepath,
						destination = expandPath( "./uploads/#createUuid()#.png" )
					);

				break;
			}

		}

	}

</cfscript>

<!doctype html>
<html>
<head>
	<meta charset="utf-8">
</head>
<body>

	<!--- V(1) - URL only. --->
	<!--- Caution: FORM[GET] will drop search parameters in the ACTION attribute. --->
	<form method="get" action="./index.cfm">
		<input type="hidden" name="v" value="1" />
		<input type="hidden" name="ids[]" value="1" />
		<input type="hidden" name="ids[]" value="2" />
		<input type="hidden" name="ids[]" value="3" />
		<button type="submit">
			URL ONLY
		</button>
	</form>

	<!--- V(2) - FORM + URL, url-encoded parameters. --->
	<form method="post" action="./index.cfm?v=2&ids[]=1&ids[]=2&ids[]=3">
		<input type="hidden" name="sort[]" value="4" />
		<input type="hidden" name="sort[]" value="5" />
		<input type="hidden" name="sort[]" value="6" />
		<button type="submit">
			URL + FORM
		</button>
	</form>

	<!--- V(3) - FORM + URL, multipart-encoded parameters. --->
	<form
		method="post"
		action="./index.cfm?v=3&ids[]=1&ids[]=2&ids[]=3"
		enctype="multipart/form-data">

		<input type="hidden" name="sort[]" value="4" />
		<input type="hidden" name="sort[]" value="5" />
		<input type="hidden" name="sort[]" value="6" />

		<input type="file" name="filename[]" />
		<input type="file" name="filename[]" />
		<input type="file" name="filename[]" />

		<button type="submit">
			URL + FORM / Multipart
		</button>
	</form>

	<cfdump label="URL Scope" var="#url#" />
	<cfdump label="FORM Scope" var="#form#" />

</body>
</html>

If I submit the ?v=3 form, with 2 of the 3 file fields populated, we get the following page output:

As you can see, the fixMultipartPost() in my polyfill has successfully translated all of the multipart/form-data into grouped fields (at least for fields that ended in [] — unadorned fields will come through as simple values).

Well, this is my 3rd attempt at this polyfill. So hopefully 3rd time's the charm!

Want to use code from this post? Check out the license.

Reader Comments

Post A Comment — I'd Love To Hear From You!

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
— Ben Nadel
Managed ColdFusion hosting services provided by:
xByte Cloud Logo