wp_handle_upload returns a critical error response or invalid form submission

Note: Currently untested, but posting here as I write it

The PHP can go in a plugin or your themes functions.php and will give you an endpoint at yoursite.com/wp-json/demonipo/v1/profilephoto that you can make POST requests to via AJAX. Note it assumes you want only logged in users to use this. If that’s not the case, change is_user_logged_in to __return_true. Don’t forget to resave permalinks before trying to use it:

PHP:

<?php

add_action( 'rest_api_init', 'demonipo_rest_api_init' );

function demonipo_rest_api_init() : void {
    register_rest_route( 'demonipo/v1' , '/profilephoto/', [
        'methods' => 'POST',
        'callback' => 'demonipo_rest_profilephoto', // function to run when this is called
        'permission_callback' => 'is_user_logged_in', // only logged in users
    ] );
}
/**
 * Handle the uploading of a profile photo
 *
 * @param \WP_REST_Request $request the AJAX request with all the fields WP recieved
 * @return mixed returns a WP_Error if something went wrong, otherwise it returns an
 *               array with some info about the uploaded file
 */
function demonipo_rest_profilephoto( $request ) {
    // Grab all our parameters, the file and the post to add it to

    // Grab Post ID and check it's legit
    if ( empty( $request['postid'] ) ) {
        return new WP_Error( 'invalid', 'You need to tell us which post this is going on' );
    }

    $post_id = $request['postid'];

    // is it really a post though?
    $post = get_post( $post_id );
    if ( ! $post instanceof WP_Post ) {
        return new WP_Error( 'invalid', 'No post was found using the post ID you gave' );
    }

    $files = $request->get_file_params();
    $headers = $request->get_headers();

    if ( empty( $files['profile_picture'] ) ) {
        return new WP_Error( 'invalid', 'No profile photo was found!' );
    }

    $file = $files['profile_picture'];

    // Check the upload worked and is valid:

    // confirm file uploaded via POST
    if ( ! is_uploaded_file( $file['tmp_name'] ) ) {
        return new WP_Error( 'error', 'Is uploaded file check failed!' );
    }

    // confirm no file errors
    if (! $file['error'] === UPLOAD_ERR_OK ) {
        return new WP_Error( 'error', 'Upload error!' . $file['error'] );
    }

    $att_id = media_handle_upload( 'profile_picture', $post_id );

    // if sideloading failed, return the error so we know what happened:
    if ( is_wp_error( $att_id ) ) {
        return $att_id;
    }

    $new_data = [
        'file' => basename( wp_get_attachment_image_url( $att_id, 'full' ) ),
        'url' => wp_get_attachment_image_url( $att_id, 'full' ),
        'type' => 'image/jpeg',
    ];

    // All is good! Update the post meta
    $ufiles = get_post_meta( $post_id, 'my_files', true );
    if( empty( $ufiles ) ) {
        $ufiles = [];
    }
    $ufiles[] = $new_data;
    update_post_meta( $post_id, 'my_files', $ufiles );

    // return any necessary data in the response here
    return rest_ensure_response( $new_data );

}

Important notes:

  • I noticed it used $post_id in the PHP but it was never defined anywhere so it had no way to know which post to update. Make sure to include a post_id field in your formdata.
  • wp_handle_upload will move the file into the right place, but it won’t create attachments, so I swapped it for media_handle_upload. This is the missing piece you needed for it to show in the media library. I’ve also set it to attach it to the post you pass it
  • This article was very informative in writing this: https://firxworx.com/blog/wordpress/adding-an-endpoint-to-wordpress-rest-api-for-file-uploads/ you may recognise some parts of the error checking
  • lots more error checking, it should never be triggered but if it does then the REST API will pass it back to your javascript and jQuery will kick up an error you can read
  • I wasn’t sure off the top of my head how to grab the mimetype so I just hardcoded it to image/jpeg. I also told it to return the fullsize image, but WP will also have created thumbnail sizes etc
  • If you can, try to store the attachment ID rather than the filename/URL, it’ll make migrations much easier
  • This article/gist was also informative, though I would recommend using a PHP based block rather than a shortcode these days: https://gist.github.com/ahmadawais/0ccb8a32ea795ffac4adfae84797c19a

That last gist has JS code that’s close to what you want:

// Check, if a file is selected.
if ( 'undefined' === typeof( jQuery( '#profile_picture' )[0].files[0] ) ) {
    alert( 'Select a file!' );
    return;
}

// Grab the file from the input.
var file = jQuery( '#profile_picture' )[0].files[0];
var formData = new FormData();
formData.append( 'file', file );

// TODO: Add Put the post ID in your HTML somewhere
var post_id = jQuery( '#post_id' )
formData.append( 'post_id', post_id );

// Fire the request.
jQuery.ajax( {
    url: '/wp-json/demonipo/v1/profilephoto',
    method: 'POST',
    processData: false,
    contentType: false,
    data: formData
} ).success( function ( response ) {
    console.log( 'success!' );
    console.log( response );
} ).error( function( response ) {
    console.log( 'error' );
    console.log( response );
});

You will need to add a hidden input with the post ID named post_id with the same ID and name alongside that file input for this to work.

e.g.

<input type="hidden" id="post_id" name="post_id" value="<?php ECHO POST ID HERE ?>" />
<input type="file" id="profile_picture" name="profile_picture"><br>

There is one thing I left out that I don’t think is necessary, the REST API nonce. Normally for authenticated requests you have to pass a nonce, which isn’t hard but I don’t think you need to. If you do, the API will just tell you by saying the request is forbidden, if so let me know. How to do that is covered in some of the links I added so it isn’t too much work.

I’ve also used jQuery instead of $ as the jQuery that comes with WordPress uses no conflict mode by default, but feel free to change that.


As a general guide, you can take a random PHP file like this:

<?php
//load both to make sure wordpress core functions are loaded
require( dirname(__FILE__) . '/../../../../../wp-config.php' );
require( dirname(__FILE__) . '/../../../../../wp-load.php' );

echo "Hi there " . $_GET['name'];

And convert it into a pretty URL REST API endpoint like this:

<?php

add_action( 'rest_api_init', 'demonipo_rest_api_init' );

function demonipo_rest_api_init() : void {
    register_rest_route( 'demonipo/v1' , '/randomphp/', [
        'methods' => 'GET',
        'callback' => 'demonipo_randomphp', // function to run when this is called
        'permission_callback' => '__return_true',
    ] );
}

function demonipo_randomphp( $request ) {
    return 'Hi there' . $request['name'];
}