Feels good to be back here. What, almost after 6 months I guess!! We came out from one lockdown, fumbled the freedom around to go into another and now coming out of the second all during this time.
I hope all of you and your loved ones are keeping safe. To the topic of this blog post now.

While working with Mart, we had a situation recently where we needed to add file (<input type="file" />) form fields dynamically in javascript client-side. I know almost all of Drupal’ers would point towards using Drupal’s ajax-based Form API (FAPI) to inject the new file fields on the form. And I would agree to the suggestion for most use-cases. But we had a legacy form, a lot of which was generated client-side in javascript and submitted as json to the server.

And my focus was on getting the job done quickly (of adding the new file field support) to the form rather than re-writing the entire client-side functionality to use Drupal’s Ajax’ed forms. Plus I thought the functionality might come-in handy in future also when for example, providing drop-areas for files (for drag-drop file upload) which doesn’t use the traditional form fields.

Almost all file-upload functionality we create today for our Drupal-based products uses the managed_file form fields. Which completely disassociates the responsibility of handling file uploads from your Drupal forms. ManagedFile form field would automatically upload the file to Drupal in an Ajax’ed form submission as soon as a file is selected. And you would get a File class instance in your submitForm handler when you call $form_state->getValue('file_field_name') in the handler (well actually an array of File instances).

The next logical destination was to look at the file form field from the FAPI. I tried to look at the FileTestForm class which uses the file field. FileTestForm uses the file_save_upload function to save file server-side. I tried using the same function to save files from our dynamically added file form fields in javascript passing the name attribute of the form field to the file_save_upload function. And it did NOT work. The function did not recognise the name and returned NULL.

On a closer look at the function’s code and other areas of Drupal, it appeared Drupal uses own internal logic to assign file names in the files collection of Symfony’s Request object. And the file_save_upload function can only handle those files which are uploaded via either file or managed_file form elements from Drupal’s FAPI, as those form elements appropriately assign and update the Request class’ files property as expected by file_save_upload function.

C’mon this shouldn’t be that difficult was what I told myself at this point. Ultimately all this is HTTP and good old multipart form submissions and there should be a way to get our hands on the file upload from the client-side. I found the file upload in PHP‘s $_FILES superglobal which was good. I had atleast some way to get the work done. I preferred a more civilised (read Drupal’ised 😉 ) way though, and ultimately thought why not check the existence of the file upload in Symfony’s files property on the Request object.

And voila, I found it there with the name as passed by the client. Yay!!! Now all I needed was apply the same checks that file_save_upload does to prevent most hacks. So I decided to write a custom version of file_save_upload (copying most of what was present in the Drupal’s function itself), but getting files from Symfony’s Request object’s files property using the name passed by the client. Here’s the function (wrapped inside a class as a static one):

class FileUtil {

	public static function saveUpload($form_field_name, $validators = [], $destination = FALSE, $replace = FILE_EXISTS_RENAME) {
		$all_files = \Drupal::request()->files;

		// Make sure there's an upload to process.
		$file_upload = $all_files->get($form_field_name);
		if (empty($file_upload)) {
			return NULL;
		}

		$user = \Drupal::currentUser();

		// Check for file upload errors and return FALSE for this file if a lower
		// level system error occurred. For a complete list of errors:
		// See http://php.net/manual/features.file-upload.errors.php.
		switch ($file_upload
			->getError()) {
			case UPLOAD_ERR_INI_SIZE:
			case UPLOAD_ERR_FORM_SIZE:
				\Drupal::messenger()
					->addError(t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [
						'%file' => $file_upload->getFilename(),
						'%maxsize' => format_size(file_upload_max_size()),
					]));
			return FALSE;

			case UPLOAD_ERR_PARTIAL:
			case UPLOAD_ERR_NO_FILE:
				\Drupal::messenger()
					->addError(t('The file %file could not be saved because the upload did not complete.', [
						'%file' => $file_upload->getFilename(),
					]));
			return FALSE;

			case UPLOAD_ERR_OK:
				// Final check that this is a valid upload, if it isn't, use the
				// default error handler.
				if (is_uploaded_file($file_upload->getRealPath())) {
					break;
				}

			default:
				// Unknown error
				\Drupal::messenger()
					->addError(t('The file %file could not be saved. An unknown error has occurred.', [
						'%file' => $file_upload->getFilename(),
					]));
			return FALSE;
		}

		// Begin building file entity.
		$values = [
			'uid' => $user->id(),
			'status' => 0,
			'filename' => $file_upload->getClientOriginalName(),
			'uri' => $file_upload->getRealPath(),
			'filesize' => $file_upload->getSize(),
		];
		
		$values['filemime'] = \Drupal::service('file.mime_type.guesser')
			->guess($values['filename']);
		$file = File::create($values);
		
		$extensions = '';
		if (isset($validators['file_validate_extensions'])) {
			if (isset($validators['file_validate_extensions'][0])) {
				// Build the list of non-munged extensions if the caller provided them.
				$extensions = $validators['file_validate_extensions'][0];
			} else {
				// If 'file_validate_extensions' is set and the list is empty then the
				// caller wants to allow any extension. In this case we have to remove the
				// validator or else it will reject all extensions.
				unset($validators['file_validate_extensions']);
			}
		} else {
			// No validator was provided, so add one using the default list.
			// Build a default non-munged safe list for file_munge_filename().
			$extensions = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp';
			$validators['file_validate_extensions'] = [];
			$validators['file_validate_extensions'][0] = $extensions;
		}
		if (!empty($extensions)) {
			// Munge the filename to protect against possible malicious extension
			// hiding within an unknown file type (ie: filename.html.foo).
			$file
				->setFilename(file_munge_filename($file->getFilename(), $extensions));
		}

		// Rename potentially executable files, to help prevent exploits (i.e. will
		// rename filename.php.foo and filename.php to filename.php.foo.txt and
		// filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
		// evaluates to TRUE.
		if (!\Drupal::config('system.file')
				->get('allow_insecure_uploads') &amp;&amp;
			preg_match(FILE_INSECURE_EXTENSION_REGEX, $file->getFilename()) &amp;&amp;
			substr($file->getFilename(), -4) != '.txt') {

			$file
				->setMimeType('text/plain');

			// The destination filename will also later be used to create the URI.
			$file
			->setFilename($file->getFilename() . '.txt');

			// The .txt extension may not be in the allowed list of extensions. We have
			// to add it here or else the file upload will fail.
			if (!empty($extensions)) {
				$validators['file_validate_extensions'][0] .= ' txt';
				\Drupal::messenger()
					->addStatus(t('For security reasons, your upload has been renamed to %filename.', [
						'%filename' => $file
						->getFilename(),
					]));
			}
		}

		// If the destination is not provided, use the temporary directory.
		if (empty($destination)) {
			$destination = 'temporary://';
		}

		// Assert that the destination contains a valid stream.
		$destination_scheme = file_uri_scheme($destination);
		if (!file_stream_wrapper_valid_scheme($destination_scheme)) {
			\Drupal::messenger()
				->addError(t('The file could not be uploaded because the destination %destination is invalid.', [
					'%destination' => $destination,
				]));
			return FALSE;
		}
		
		$file->source = $form_field_name;

		// A file URI may already have a trailing slash or look like "public://".
		if (substr($destination, -1) != '/') {
			$destination .= '/';
		}
		
		try {
			$file->destination = file_destination($destination . $file->getFilename(), $replace);
		} catch (\RuntimeException $e) {
			\Drupal::messenger()
				->addError(t('The file %filename could not be uploaded because the name is invalid.', [
					'%filename' => $file->getFilename(),
				]));
			return FALSE;
		}

		// If file_destination() returns FALSE then $replace === FILE_EXISTS_ERROR and
		// there's an existing file so we need to bail.
		if ($file->destination === FALSE) {
			\Drupal::messenger()
				->addError(t('The file %source could not be uploaded because a file by that name already exists in the destination %directory.', [
					'%source' => $form_field_name,
					'%directory' => $destination,
				]));
			return FALSE;
		}

		// Add in our check of the file name length.
		$validators['file_validate_name_length'] = [];

		// Call the validation functions specified by this function's caller.
		$errors = file_validate($file, $validators);

		// Check for errors.
		if (!empty($errors)) {
			$message = [
				'error' => [
					'#markup' => t('The specified file %name could not be uploaded.', [
						'%name' => $file->getFilename(),
					]),
				],
				'item_list' => [
					'#theme' => 'item_list',
					'#items' => $errors,
				],
			];

			// @todo Add support for render arrays in
			// \Drupal\Core\Messenger\MessengerInterface::addMessage()?
			// @see https://www.drupal.org/node/2505497.
			\Drupal::messenger()
			->addError(\Drupal::service('renderer')->renderPlain($message));
			return FALSE;
		}
		
		$file
		->setFileUri($file->destination);
		
		if (!drupal_move_uploaded_file($file_upload->getRealPath(), $file->getFileUri())) {
			\Drupal::messenger()
				->addError(t('File upload error. Could not move uploaded file.'));
			\Drupal::logger('file')
				->notice('Upload error. Could not move uploaded file %file to destination %destination.', [
					'%file' => $file->getFilename(),
					'%destination' => $file->getFileUri(),
				]);
			return FALSE;
		}

		// Set the permissions on the new file.
		drupal_chmod($file->getFileUri());

		// If we are replacing an existing file re-use its database record.
		// @todo Do not create a new entity in order to update it. See
		//   https://www.drupal.org/node/2241865.
		if ($replace == FILE_EXISTS_REPLACE) {
			$existing_files = entity_load_multiple_by_properties('file', [
				'uri' => $file->getFileUri(),
			]);
			if (count($existing_files)) {
				$existing = reset($existing_files);
				$file->fid = $existing->id();
				$file
					->setOriginalId($existing->id());
			}
		}

		// Update the filename with any changes as a result of security or renaming
		// due to an existing file.
		$file
			->setFilename(\Drupal::service('file_system')
			->basename($file->destination));

		// If we made it this far it's safe to record this file in the database.
		$file
			->save();

		// Allow an anonymous user who creates a non-public file to see it. See
		// \Drupal\file\FileAccessControlHandler::checkAccess().
		if ($user->isAnonymous() &amp;&amp; $destination_scheme !== 'public') {
			$session = \Drupal::request()
				->getSession();
			$allowed_temp_files = $session
				->get('anonymous_allowed_file_ids', []);
			$allowed_temp_files[$file->id()] = $file->id();
			$session
				->set('anonymous_allowed_file_ids', $allowed_temp_files);
		}
		
		// Add files to the cache.
		return $file;
	}
}

The function is almost an exact replica of Drupal’s file_save_upload, just extracting the file from Request object’s files property in these lines at the beginning:

		$all_files = \Drupal::request()->files;

		// Make sure there's an upload to process.
		$file_upload = $all_files->get($form_field_name);
		if (empty($file_upload)) {
			return NULL;
		}

Rest the function is identical to file_save_upload. And you should try to keep it up-to-date with the same with subsequent Drupal releases if you do decide to go ahead and use this approach.

Here’s how you can call this function and save the file in your submitForm callback.

$fileValidators = array();
$fileValidators['file_validate_is_image'] = array();
//Change extensions based on your needs. We were handling image uploads.
$fileValidators['file_validate_extensions'] = array('jpg jpeg png gif');

//You can change the destination below as per your needs.
$uploadDir = 'public://somedir';
//mart-size-file-image-0 is the name of the form field submitted from client-side.
$file = \Imbibe\Util\FileUtil::saveUpload('mart-size-file-image-0', $fileValidators, $uploadDir, 0, FILE_EXISTS_RENAME);
if($file) {
	//Save $file->id() somewhere so you can reference this file later.
	//$size->image_fid = $file->id();
}
  1. We create a list of allowed file extensions for the file upload. We were handling image uploads. You can specify any extension(s) you need or eliminate it all together if you do not care about any specific type of file.
  2. You can specify the upload directory. Note this uses stream wrappers, so you can use any stream wrapper your installation supports, public://, private://, temporary:// etc being the most common ones.
  3. The function then saves the file, and returns the File class instance (if the save was successful), whose id you can then appropriately save somewhere for referencing it later.
  4. As you might be able to guess from the above code, we were actually handling multiple file uploads in a loop using the above code. You can easily put it in a loop if your use-case warrants so.

Many Drupal experts would point towards security issues in handling file-uploads where the file form field was generated client-side and insist on using Drupal FAPI fields. Well I would respect and agree with those concerns. In our case, like I said we had a large legacy form which I had no time to completely re-write. Plus this form was accessible to privileged users only (not even to regular logged-in users or site editors). And we also had additional checks server-side to validate those uploads.

All-in-all, this got the work done beautifully for us, the client was happy and so were we 🙂