3

The WooCommerce checkout page doesn't natively support adding a file upload field as a possible field due to security reasons. I'm looking to add a file upload field on the checkout page so that the customer can give us a document that can then be attached to their order and be referenced again by admins through the orders dashboard in the future if we need to.

I've tried 2 different methods but both have led to dead ends. The 2 different solutions I've tried were:

  1. Gravity Forms Upload Form - tried displaying a gravity forms form on the checkout page via a hook and the file field data would never show up in $_FILES or $_POST.
  2. Upload field with ajax - tried making an upload field and then using ajax to send the upload field's data to a wordpress ajax function but the issue with this approach is that you cannot validate the file size / if the file's been uploaded already. So a user could potentially upload very large files or they could mess with where the file is stored due to the file upload path being added in the HTML of the file upload element like so:
add_action( 'wp_ajax_mishaupload', 'misha_file_upload' );
add_action( 'wp_ajax_nopriv_mishaupload', 'misha_file_upload' );

function misha_file_upload(){

    $upload_dir = wp_upload_dir();

    if ( isset( $_FILES[ 'misha_file' ] ) ) {
        $path = $upload_dir[ 'path' ] . '/' . basename( $_FILES[ 'misha_file' ][ 'name' ] );

        if( move_uploaded_file( $_FILES[ 'misha_file' ][ 'tmp_name' ], $path ) ) {
            echo $upload_dir[ 'url' ] . '/' . basename( $_FILES[ 'misha_file' ][ 'name' ] );
        }
    }
    die;
}

where the line echo $upload_dir[ 'url' ] . '/' . basename( $_FILES[ 'misha_file' ][ 'name' ] ); adds the file directory to the value= part of the input element which isn't ideal in terms of security.

I then got to this point where I can now add 'file' type fields with the following code:

/**
 * Function for `woocommerce_form_field` filter-hook.
 *
 * @param  $field
 * @param  $key
 * @param  $args
 * @param  $value
 *
 * @return
 */
function wp_kama_woocommerce_form_field_filter( $field, $key, $args, $value ){

    // check if field is a file field
    if( $args['type'] == 'file' ){
            // add custom HTML to the field
            $field = '<div class="woocommerce-additional-fields__field-wrapper">';
            $field .= '<p class="form-row notes woocommerce-validated" id="certificate_file_upload_field" data-priority="">';
            $field .= '<label for="certificate_file_upload" class="">Upload Certificate</label>';
            $field .= '<span class="woocommerce-input-wrapper">';

            $field .= sprintf(
                '<input type="file" class="%s" name="%s" id="%s"/>',
                esc_attr( implode( ' ', $args['class'] ) ),
                esc_attr( $key ),
                esc_attr( $args['id'] ),
            );

            $field .= '</span>';
            $field .= '</p>';
            $field .= '</div>';
    }

    return $field;
}
add_filter( 'woocommerce_form_field', 'wp_kama_woocommerce_form_field_filter', 10, 4 );

The code above lets me then do the following in another hook:

function add_custom_checkout_field($checkout) {
    woocommerce_form_field('certificate_file_upload', array(
        'type' => 'file',
        'class' => array('form-row-wide'),
        'label' => __('Upload Certificate'),
        'required' => false,
    ), $checkout->get_value('certificate_file_upload'));
}
add_action( 'woocommerce_after_order_notes', 'add_custom_checkout_field' );

which this code then adds a file field to the checkout page. The issue at this point is that neither $_FILES nor $_POST has any file data related to the key "certificate_file_upload" which is an issue when trying to do anything with the actual file data itself.

I've tried searching around for how WooCommerce deals with the default checkout fields to see how I could possibly add my file data to $_FILES/$_POST but all I've come up with is that they manage the data possibly through the WooCommerce plugin: woocommerce->assets->js->frontend->checkout.js but I don't know how I could go about adding file support to the checkout page past this point without modifying their files (which will be overriden whenever they update the plugin) if this is even the correct file to be doing this in.

Is checkout.js the file that I should be looking at in the first place to add my file data to $_FILES or should I be looking somewhere else? If checkout.js is the correct file I should be looking at, is there a way around modifying their file to allow my file data to be added to $_FILES?

I would like to avoid having to download a plugin just to make file uploading on the checkout page possible as I'm trying to avoid bloat but if that's the only solution I guess I'll go with that if nothing is posssible to fix this.

2
  • I would recommend to force users to login before uploading (will save you alot of problems) also users can benefit of using same files they upload instead of reuploading it. Also what would happen if someone start uploading wihtout doing anything else on your checkout page? That and other cases are serious problems that you could face if guest can upload. Forcing users to be logged in will allow you to use Wordpress functions for upload. There are also alot of plugins offering that too. Gravity Forms prob is expecting user to upload not guest so that could be limitation from theyr end. Commented Jul 14, 2023 at 11:42
  • Also can be limitations by permissions on user role. So you should check that too. Commented Jul 14, 2023 at 11:47

1 Answer 1

7

Update 2024-04: Solved an issue

The following ultra lightweight plugin uses Ajax in a very secure way, to allow uploads in WooCommerce checkout page.

Note: This works only in classic checkout, but not in new Checkout Block.

When using Ajax, you can:

  • Restrict/check the file size,
  • Restrict to accepted file types only,
  • check if the file has already been uploaded (but this is not really helpful as someone can upload a file, and re-upload an update file with the same name, without checking out yet).
  • Hide completely all the sensitive data like the upload path, using WC Session to store it securely.

All uploaded files will go to a folder named "wc_checkout_uploads" located in WordPress main "uploads" directory. They will be included in a subfolder with the user ID as name (in 6 digits length).
For guests users, if checkout is enabled, the uploads have the same upload directory 000000 and a subdirectory based on the billing email.

See the "Complete Usage Examples" section (below after the plugin code) to:

  • Display an upload field,
  • Validate the field when it is required,
  • Save the file URL and name as custom order meta data and displayed in the admin,
  • Use that custom order meta data everywhere you like.

Here is the code of this lightweight plugin:

Main PHP file (add it to a folder named as you like): checkout_uploads.php

<?php
/*
Plugin Name: WooCommerce Checkout upload
Plugin URI: https://stackoverflow.com/a/76691778/3730754
Description: Add a input field type "file" for checkout (Ajax securely powered), and save the downloaded file URL and name as custom order metadata.
Version: 1.0
Author: LoicTheAztec
Author URI: https://stackoverflow.com/users/3730754/loictheaztec
*/

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

register_activation_hook(__FILE__, 'wcu_plugin_activation');
function wcu_plugin_activation() {
    // Make sure that WooCommerce plugin is active
    if ( ! in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) ) ) {
        $message = 'Requires WooCommerce plugin activated.';
        echo $message;
        trigger_error($message, E_USER_NOTICE);
    }
}

// Enqueue JavaScript file and localize it
add_action( 'wp_enqueue_scripts', 'checkout_uploads_enqueue_scripts' );
function checkout_uploads_enqueue_scripts() {
   if ( is_checkout() && ! is_wc_endpoint_url() ) {
        wp_enqueue_script( 
            'checkout-uploads',  
            plugins_url( 'js/checkout_upload.js', __FILE__ ), 
            array('jquery'), false, true 
        );

        wp_localize_script(
            'checkout-uploads',
            'checkout_uploads_params',
            array(
                'ajax_url' => admin_url( 'admin-ajax.php?action=checkout_upload&security='.wp_create_nonce('checkout_upload') ),
            )
        );
    }
}

// ADd Input File type to WooCommerce form fields
add_filter( 'woocommerce_form_field', 'woocommerce_form_input_field_type_file', 10, 4 );
function woocommerce_form_input_field_type_file( $field, $key, $args, $value ){
    if( $args['type'] === 'file' && is_checkout() && ! is_wc_endpoint_url() ){
        if ( $args['required'] ) {
            $args['class'][] = 'validate-required';
            $required        = '&nbsp;<abbr class="required" title="' . esc_attr__( 'required', 'woocommerce' ) . '">*</abbr>';
        } else {
            $required = '&nbsp;<span class="optional">(' . esc_html__( 'optional', 'woocommerce' ) . ')</span>';
        }
        $field           = '';
        $label_id        = $args['id'];
        $sort            = $args['priority'] ? $args['priority'] : '';
        $field_container = '<p class="form-row %1$s" id="%2$s" data-priority="' . esc_attr( $sort ) . '">%3$s</p>';
        $max_size        = isset($args['max_size']) ? 'data-max_size="' . intval( $args['max_size'] ) . '" ' : '';
        $accept          = isset($args['accept']) ? 'accept="' . esc_attr( $args['accept'] ) . '" ' : '';
        
        $field .= sprintf( '<input type="%s" class="input-file %s" name="%s" id="%s" %s/>', esc_attr( $args['type'] ), 
            esc_attr( implode( ' ', $args['input_class'] ) ), esc_attr( $key ), esc_attr( $args['id'] ), $max_size . $accept );

        if ( ! empty( $field ) ) {
            $field_html = '<label for="' . esc_attr( $label_id ) . '" class="' . esc_attr( implode( ' ', $args['label_class'] ) ) . '">' . wp_kses_post( $args['label'] ) . $required . '</label>';
            $field_html .= '<span class="woocommerce-input-wrapper">' . $field;

            if ( $args['description'] ) {
                $field_html .= '<span class="description" id="' . esc_attr( $args['id'] ) . '-description" aria-hidden="true">' . wp_kses_post( $args['description'] ) . '</span>';
            }

            $field_html .= '<span class="upload-response" style="display:none"></span></span>';

            $container_class = esc_attr( implode( ' ', $args['class'] ) );
            $container_id    = esc_attr( $args['id'] ) . '_field';
            $field           = sprintf( $field_container, $container_class, $container_id, $field_html );
        }
    }
    return $field;
}

// PHP Ajax responder
add_action( 'wp_ajax_checkout_upload', 'checkout_ajax_file_upload' );
add_action( 'wp_ajax_nopriv_checkout_upload', 'checkout_ajax_file_upload' );
function checkout_ajax_file_upload(){
    check_ajax_referer('checkout_upload', 'security'); 

    global $current_user;

    if ( isset($_FILES['uploads']) ) {
        if ( ! $current_user->ID && isset($_POST['email']) && ! empty($_POST['email']) ) {
            // Generating a sub / subfolder (path) from billing email in '000000' guest directory
            $user_path = '000000/'.substr(sanitize_title($_POST['email']), 0, 10); // For Guests
        } else {
            $user_path = str_pad($current_user->ID, 6, '0', STR_PAD_LEFT); // For logged in users
        }
        $upload_dir  = wp_upload_dir();
        $user_path   = '/wc_checkout_uploads/' . $user_path;
        $user_folder = $upload_dir['basedir']  . $user_path;
        $user_url    = $upload_dir['baseurl']  . $user_path;

        if ( ! is_dir( $user_folder ) ) {
            wp_mkdir_p( $user_folder );
            chmod( $user_folder, 0777 );
        }
        $file_path = $user_folder . '/' . basename($_FILES['uploads']['name']);
        $file_url  = $user_url . '/' . basename( $_FILES['uploads']['name']);

        if( move_uploaded_file($_FILES['uploads']['tmp_name'], $file_path)) {
            // Save the file URL and the file name to WC Session
            WC()->session->set('checkout_upload', array(
                'file_url'  => $file_url, 
                'file_name' => $_FILES['uploads']['name']
            ));
            
            echo '<span style="color:green">' . __('Upload completed', 'woocommerce') . '</span><br>';
        } else {
            echo '<span style="color:red">' . __('Upload failed.') . '</span>';
        }
    }
    wp_die();
}

The Javascript file located in a "js" subfolder: checkout_upload.js

jQuery( function($) {
    if (typeof checkout_uploads_params === 'undefined') {
        return false;
    }

    $('form.checkout').on( 'change', 'input[type=file]', function() {
        const files = $(this).prop('files');
        const email = $('input#billing_email').val();

        if ( files.length ) {
            const file = files[0];
            const maxSize = $(this).data('max_size');
            const formData = new FormData();
            formData.append( 'uploads', file );
            formData.append( 'email', email );

            if ( maxSize > 0 && file.size > ( maxSize * 1024 ) ) {
                const maxSizeText = 'This file is to heavy (' + parseInt(file.size / 1024) + ' ko)';
                $( '.upload-response' ).html( maxSizeText ).css('color','red').fadeIn().delay(2000).fadeOut();
                return;
            }
            $('form.checkout').block({message: null, overlayCSS:{background:"#fff",opacity: .6}});

            $.ajax({
                url: checkout_uploads_params.ajax_url,
                type: 'POST',
                data: formData,
                contentType: false,
                enctype: 'multipart/form-data',
                processData: false,
                success: function ( response ) {
                    $('form.checkout').unblock();
                    $( '.upload-response' ).html( response ).fadeIn().delay(2000).fadeOut();
                },
                error: function ( error ) {
                    $('form.checkout').unblock();
                    $( '.upload-response' ).html( error ).css('color','red').fadeIn().delay(2000).fadeOut();
                }
            });
        }
    });
});

End of the plugin code.

A new input field type "file" is now available for woocommerce form fields and has 2 additional optional arguments:


Complete Usage Examples:

1) Adding an upload field:

A) After the order notes (accepting only text / pdf files and limitting the download size).

add_action( 'woocommerce_after_order_notes', 'add_custom_checkout_field' );
function add_custom_checkout_field($checkout) {

    echo '<div class="woocommerce-additional-fields__field-wrapper">';

    woocommerce_form_field('certificate', array(
        'type'      => 'file',
        'class'     => array('form-row-wide'),
        'label'     => __('Upload Certificate', 'woocommerce'),
        'required'  => false,
        'max_size'  => '3072', // in ko (here 3 Mo size limit)
        'accept'    => '.img,.doc,.docx,.rtf,.txt', // text documents and pdf
    ), '');

    echo '</div>';
}

This code goes in functions.php file of your active child theme (or active theme) or using Code Snippets plugin (recommended by WooCommerce).

B) In the billing (or shipping) address section for a specific user role (accepting only text / pdf files and limitting the download size).

Note: Here the field has "required" option enabled.

add_filter( 'woocommerce_checkout_fields', 'add_custom_billing_field' );
function add_custom_billing_field( $fields ) {
    // Only for 'wholesale_customer' user role
    if( ! current_user_can( 'wholesale_customer' ) ) return $fields;

    $fields['billing']['billing_image'] = array(
        'type' => 'file',
        'label' => __('Upload your image', 'woocommerce'),
        'class' => array('form-row-wide'),
        'required' => true,
        'max_size'  => '5120', // in ko (here 5 Mo size limit)
        'accept'    => 'image/*', // Image files only
        'priority' => 200,
    );    
    
    return $fields;
}

For logged users only, you can use:

    // Only for logged in users
    if( ! is_user_logged_in() ) return $fields;

Important: When the field is required, and located in billing or shipping fields sections, add the following code, to avoid woocommerce stopping checkout (when the file has been uploaded):

// On billing or shipping section, when "upload" field is required
add_action( 'woocommerce_after_checkout_validation', 'custom_after_checkout_validation', 20, 2 );
function custom_after_checkout_validation($data, $errors) {
    $field_key = 'billing_image'; // Here define the field key (or field ID)

    $errors->remove($field_key.'_required'); // Remove unwanted error for input file
}

This code goes in functions.php file of your active child theme (or active theme) or using Code Snippets plugin (recommended by WooCommerce).

2) Validation to be used when the file is required:

// Required upload field validation
add_action( 'woocommerce_checkout_process', 'checkout_required_upload_validation' );
function checkout_required_upload_validation() {
    $checkout_upload = WC()->session->get('checkout_upload');
    if( empty( $checkout_upload ) ) {
        wc_add_notice( __('Uploading your file is required in order to checkout.', 'woocommerce'), 'error' ); // Displays an error notice
    }
}

This code goes in functions.php file of your active child theme (or active theme) or using Code Snippets plugin (recommended by WooCommerce).

3) Save the uploaded file URL and name:

// Save the uploaded file URL and name (array
add_action( 'woocommerce_checkout_create_order', 'save_checkout_uploaded_file', 10, 2 );
function save_checkout_uploaded_file( $order, $data ){
    if( $checkout_upload = WC()->session->get('checkout_upload') ) {
        $order->update_meta_data( '_checkout_upload', $checkout_upload ); // Save 
    }
    WC()->session->__unset('checkout_upload'); // Remove session variable
}

4) Display the uploaded file URL and name:

A) In the admin, on order edit pages, after billing address:

// Display the uploaded file in admin orders
add_action('woocommerce_admin_order_data_after_billing_address', 'display_uploaded_file_in_admin_orders');
function display_uploaded_file_in_admin_orders( $order ){
    if( $checkout_upload = $order->get_meta( '_checkout_upload' ) ) {
        printf( '<p>%s <br><a href="%s">%s</a></p>', 
            __("File Upload:", 'woocommerce'), 
            $checkout_upload['file_url'], 
            $checkout_upload['file_name'] 
        );
    }
}

This code goes in functions.php file of your active child theme (or active theme) or using Code Snippets plugin (recommended by WooCommerce).

B) Everywhere needed with $order variable (the WC_Order object):

First, if needed, you can get the WC_Order object from the order ID like:

$order = wc_get_order( $order_id );

Then you will get the data using:

$upload = $order->get_meta('_checkout_upload');

Then, for a file, you can display it as a link like:

$upload = $order->get_meta('_checkout_upload');

printf( '<p>%s <br><a href="%s">%s</a></p>', 
    __("File Upload:", 'woocommerce'), 
    $upload['file_url'], 
    $upload['file_name'] 
);

Or for an image, you can display the image like:

$upload = $order->get_meta('_checkout_upload');

printf( '<p>%s <br><img src="%s" alt="%s" /><br><a href="%s">%s</a></p>', 
    __("Image Uploaded:", 'woocommerce'), 
    $upload['file_url'], 
    $upload['file_name'], 
    $upload['file_url'], 
    $upload['file_name'] 
);
Sign up to request clarification or add additional context in comments.

6 Comments

This might not be much of an issue, but couldn't any user technically change the value of the variable maxSize in the JS file from their browser from: const maxSize = $(this).data('max_size'); to const maxSize = 100000000; for example, which effectively ignores any file size validation? Also, couldn't any user upload any file type since the file type validation is on their browser and not on the server?
@Camryn2223 Within the plugin, in the PHP, you can rename the data-max_size to something not understandable like data-raw_id, then change also in the JavaScript $(this).data('max_size') to $(this).data('raw_id') and compress/minify the JavaScript file content. This way it will not be understandable. Or even, you can input directly a value in the javascript, replacing const maxSize = $(this).data('max_size'); by const maxSize = 3000; and compress/minify the JavaScript file content.
What if you want to add file upload to each cart item in checkout? Will the new woocommerce let you do it?
Only on legacy checkout (shortcode), not on default checkout Blocks, using some custom code like in my answer that allows to do it via Ajax.
I was trying a few things, but it just wouldn't let the files go through, so I made it with file upload on the product page.
@LoicTheAztec, Can you show the plugin code for uploading multiple files at the same time?

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.