Search code examples
phpjqueryajaxwoocommercefile-upload

How to add file upload to WooCommerce checkout?


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.


Solution

  • 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 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'] 
    );