Friday, 9 February 2024

Uploading Files in WordPress (in PHP, not using plugins)

It took me a quite while to work out a few things about file uploads so I am hoping to help others get there faster and easier.

The following is a simple form that we will use, the file input control has the name "upload", but it can be anything. I have seen CSS methods to make the "choose file" text pretty (actually replace it), but not without losing the attached file information in the form and a tooltip.

The maximum file size is specified to the PHP server (in "MAX_FILE_SIZE") and it is also check client size with Javascript.

Any form that uploads files should have an "enctype" of "multipart/form-data". The "accept" parameter below specifies the allowed file types/extension, the user can override this so mime types of uploaded files should be checked on the server for security reasons.


<h1>PDF + IMAGE Upload Form</h1>
<form id="upload_form" action="https://bungalooknursery.com.au/reimbursements" enctype="multipart/form-data" method="post" onsubmit='CheckFileSizes();'>
    <input type="hidden" id="MAX_FILE_SIZE" name="MAX_FILE_SIZE" value="3145728" />
    <p><input id="upload" name="upload[]" id="upload" type="file" accept="application/pdf, image/gif, image/png, image/webp, image/jpeg, image/pjpeg" multiple=true /></p>

    <p><input id="btnSubmit" type="submit" value="Upload Files" />
       <input id="reset_upload_form" type="reset" value="Reset form" /></p>
</form>

<script>
function CheckFileSizes()
{
    //--- Loops though files to be uploaded (check size) --------------
    //debugger;
    let MaxSizeObj = document.getElementById('MAX_FILE_SIZE');
    let UploadObj  = document.getElementById('upload');
    let MaxSize    = parseInt( MaxSizeObj.value );
    let FileList   = UploadObj.files;
    let Oops = '';
    for (var i in FileList)
    {
        let ThisFileObj = FileList[i];
        let FileSize    = ThisFileObj.size;
        let FileName    = ThisFileObj.name;
        if  (FileSize > MaxSize)
        {
            if (Oops != '')
               Oops = Oops + "\n";
            Oops = Oops + '* "' + FileName + '" (' + bytesToSize(FileSize) + ' bytes)';
        }
    }

    //--- Report and cancel submission --------------------------------
    if  (Oops != '')
    {
       //--- Report to user -------------------------------------------
       Oops =        'One of more files (listed below) are larger than ' + bytesToSize(MaxSize) + '.\n\n' + Oops + '\n\n';
       Oops = Oops + 'The easiest way to compress an image is using a photo editor to resize the picture while ensuring it remains legible!  If you halve both dimenstions you will generally lose 75% of the size.';
       alert('\n' + Oops);

       //--- Cancel submission ----------------------------------------
       event.preventDefault()      //Stop form being submitted!
    }
}

function bytesToSize(bytes) //https://gist.github.com/lanqy/5193417 // http://scratch99.com/web-development/javascript/convert-bytes-to-mb-kb/
{
    var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
    if (bytes == 0) return 'zero bytes';
    var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
    if (i == 0) return bytes + ' ' + sizes[i];
    return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i];
};
</script>

PHP has an INI value "upload_max_filesize" configured which is the maximum size of an upload it will accept. The "MAX_FILE_SIZE" control is used to further restrict the size below the maximum possible upload size for your particular form and is used above informs PHP to abort the upload of the file if it is larger than this value, this results in a quicker error.

You can validate the file size in Javascript (that is not shown here), but if it is critical, you should also check the filesize in your PHP code. You should also check the mime types uploaded, since while I specified that I only want certain mime types uploaded, the user can override that on the client and send any type of files.

The below code is PHP on the server.... When your PHP code starts, the files have already been uploaded (or failed doing so) to a temporary directory and filenames. It is best to use "move_uploaded_file" to move the file for the extra validations it performs.


HandleAnyUploadedFiles();

//=============================================================================================
function HandleAnyUploadedFiles()
//=============================================================================================
{
    //--- Any files attached? -------------------------------------------
    $Rc = 0;
    if  (! empty( $_FILES ) )
    {
        //--- Get the destination directory -----------------------------
        //echo MyDump($_FILES, '$_FILES');
        //echo MyDump($_POST,  '$_POST');
        $UploadDir = wp_get_upload_dir();
        $ToDir     = $UploadDir['basedir'] . '/$Reimbursements/' . '{IdEventually}';

        //--- We accept any number of uploads at once -------------------
        $Uploaded  = $_FILES[ 'upload' ];        //The name of the HTML "input" control doing the uploading
        $Names     = $Uploaded['name'];          //The name of the file at the client end
        $TmpNames  = $Uploaded['tmp_name'];      //Names of already uploaded files
        $MimeTypes = $Uploaded['type'];          //What is the mime type of the file
        $UlErrors  = $Uploaded['error'];         //Upload RC
        for ($i = 0; $i < count($Names); $i++)
        {
            //--- Get filename info for the current upload --------------
            $FromFile = $TmpNames[$i];
            $ToFileSN = basename( $Names[$i] );
            $ToFile   = $ToDir . '/' . $ToFileSN;
            $UlError  = $UlErrors[$i];
            $MimeType = $MimeTypes[$i];

            //--- Check for upload error --------------------------------
            if ($UlError != UPLOAD_ERR_OK)
                $Oops = "The uploaded failed, RC: " . GetFileUploadErrorMessage($UlError);
            else
            {
                if  (! ValidateMimeType($MimeType))
                    $Oops = "The mime type of \"{$MimeType}\" is unsupported";
                else
                {

                    //--- Sanity check, Does the tmp file exist (it should) ---
                    if  (!is_file($FromFile))
                        $Oops = "The uploaded file \"{$FromFile}\" doesn't exist!";
                    else
                    {
                        //--- Make sure the destination dir exists ----------
                        if  (!is_dir($ToDir))
                            mkdir($ToDir, recursive: true);

                        //--- Move the file (it will overwrite existing if required which we probably want) ---
                        error_clear_last();
                        $MoveRc = move_uploaded_file($FromFile, $ToFile);
                        if  (! $MoveRc)
                            $Oops = "The upload file \"{$FromFile}\" couldn't be moved, REASON: " . GetLastErrorMessage();
                        else
                            $Oops = '';         //ALL GOOD
                    }
                }
            }

            //--- Report failures ---------------------------------------
            if  ($Oops != '')
            {
                echo "ERROR: Upload of \"{$ToFileSN}\" failed: {$Oops}";
                $Rc = $Rc + 1;
            }
        }
    }
    return $Rc;     //Count of failed uploads
}



//=============================================================================================
function GetFileUploadErrorMessage($Rc)
//       https://www.php.net/manual/en/function.error-get-last.php
//       SIMILAR TO: $php_errormsg
//=============================================================================================
{
    //--- List of error messages ----------------------------------------
    $UploadErrors = array(
            0 => 'There is no error, the file uploaded with success',
            1 => 'The uploaded file exceeds the "upload_max_filesize" directive in PHP (currently "' . ini_get('upload_max_filesize') . '", often set in php.ini, or CPANEL,PHP OPTIONS)',
            2 => 'The uploaded file exceeds the "MAX_FILE_SIZE" directive that was specified in the HTML form (currently ' . $_REQUEST['MAX_FILE_SIZE'] . ' bytes)',
            3 => 'The uploaded file was only partially uploaded',
            4 => 'No file was uploaded',
            6 => 'Missing a temporary folder',
            7 => 'Failed to write file to disk',
            8 => 'A PHP extension stopped the file upload',
    );

    //--- We were pass the index to the above array, return text if it exists ---
    $R = $Rc;
    if  ( isset($UploadErrors[$Rc]) )
        $R = $R . ' = ' . $UploadErrors[$Rc];
    return $R;
}

//=============================================================================================
function ValidateMimeType($MimeType)
//       https://wpengine.com/support/mime-types-wordpress/
//                * .jpg    image/jpeg, image/pjpeg
//                * .jpeg   image/jpeg, image/pjpeg
//                * .png    image/png
//                * .gif    image/gif
//                * .pdf    application/pdf
//=============================================================================================
{
    //Supported mime types
    //--- List of error messages ----------------------------------------
    $DoesNotMatter = '';
    $AllowedMimes = array(
            'application/pdf' => $DoesNotMatter,
            'image/gif'       => $DoesNotMatter,
            'image/jpeg'      => $DoesNotMatter, 'image/pjpeg' => $DoesNotMatter,
            'image/png'       => $DoesNotMatter,
            'image/webp'      => $DoesNotMatter,
    );

    //Is the mime type OK?
    if  (isset($AllowedMimes[$MimeType]))
        $R = true;
    else
        $R = false;    //Not in the allowed list
    return $R;
}



//=============================================================================================
function GetLastErrorMessage()
//       https://www.php.net/manual/en/function.error-get-last.php
//       SIMILAR TO: $php_errormsg
//=============================================================================================
{
    $Err = error_get_last();
    if  ($Err == null)
        $R = '';
    else
        $R = $Err['message'];
    return $R;
}

The following dump/code shows what "$_FILES" contains on the server (for the 2 files being uploaded):


array (
  'upload' =>
  array (
    'name' =>
    array (
      0 => '[PICTURE] TCL TV model P745.webp',
      1 => '[Owners Manual] TCL TV model P745 - P745_Series_Owners_Manual.pdf',
    ),
    'full_path' =>
    array (
      0 => '[PICTURE] TCL TV model P745.webp',
      1 => '[Owners Manual] TCL TV model P745 - P745_Series_Owners_Manual.pdf',
    ),
    'type' =>
    array (
      0 => 'image/webp',
      1 => 'application/pdf',
    ),
    'tmp_name' =>
    array (
      0 => '/tmp/phpDKxB48',
      1 => '/tmp/phplqdiXg',
    ),
    'error' =>
    array (
      0 => 0,
      1 => 0,
    ),
    'size' =>
    array (
      0 => 48276,
      1 => 2611756,
    ),
  ),
)

The code fails with a a 403 response code on one particular PDF (out of many) for no obvious reasons. I have also seen this in other Wordpress situations, so I suspect something about the file's contents breaks some sort of Wordpress or browser (Chrome on Windows in my case) encoding/decoding. Googling came up with many other possible reasons none of which applied to my situation.

No comments: