6

I need to be able to send an image and some form fields from a client side canvas element to a PHP script, ending up in $_POST and $_FILES. When I send it like this:

<script type="text/javascript">
var img = canvas.toDataURL("image/png");
...
ajax.setRequestHeader('Content-Type', "multipart/form-data; boundary=" + boundary_str);
var request_body = boundary + '\n' 
+ 'Content-Disposition: form-data; name="formfield"' + '\n' 
+ '\n' 
+ formfield + '\n' 
+ '\n' 
+ boundary + '\n'
+ 'Content-Disposition: form-data; name="async-upload"; filename="' 
+ "ajax_test64_2.png" + '"' + '\n' 
+ 'Content-Type: image/png' + '\n' 
+ '\n' 
+ img
+ '\n' 
+ boundary;
ajax.send(request_body);
</script>

$_POST and $_FILES both come back populated, but the image data in $_FILES still needs decoding like this:

$loc = $_FILES['async-upload']['tmp_name'];
$file = fopen($loc, 'rb');
$contents = fread($file, filesize($loc));
fclose($file);
$filteredData=substr($contents, strpos($contents, ",")+1);
$unencodedData=base64_decode($filteredData);

...in order to save it as a readable PNG. This isn't an option as I'm trying to pass the image to Wordpress's media_handle_upload() function, which requires an index to $_FILES pointing to a readable image. I also can't decode, save and alter 'tmp_name' accordingly, as it falls foul of security checks.

So, I found this: http://www.webtoolkit.info/javascript-base64.html and tried to do the decode on the client side:

img_split = img.split(",",2)[1];
img_decoded = Base64.decode( img_split );

but for some reason I still don't end up with a readable file when it gets to the PHP. So the question is: "Why?" or "What am I doing wrong?" or "Is this even possible?" :-)

Any help very much appreciated!

Thanks, Kane

BaronVonKaneHoffen
  • 1,902
  • 1
  • 21
  • 29
  • I'd set a `Content-Transfer-Encoding: base64` & look at [this answer](http://stackoverflow.com/questions/934012/get-image-data-in-javascript) to lose the prefix, haven't tested it though. – Wrikken Mar 13 '11 at 22:01
  • @Wrikken Content-Transfer-Encoding, although a valid MIME header, is not a valid HTTP header. See [this appendix](http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html#sec19.4.5) of the spec. – Nathan Ostgard Mar 14 '11 at 18:57
  • @Nathan: Ack, my mistake. Does it show I've never needed to manually build a file upload :) – Wrikken Mar 14 '11 at 19:33
  • @Wrikken: you're not the only one. This is whole new and confusing world for me :) – BaronVonKaneHoffen Mar 15 '11 at 12:10

2 Answers2

15

Unfortunately, this isn't possible in JavaScript without some intermediate encoding. To understand why, let's assume you base64 decoded and posted the data, like you described in your example. The first few lines in hex of a valid PHP file might look like this:

0000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
0000010: 0000 0080 0000 0080 0806 0000 00c3 3e61  ..............>a

If you looked at the same range of hex of your uploaded PNG file, it would look like this:

0000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
0000010: 0000 00c2 8000 0000 c280 0806 0000 00c3  ................

The differences are subtle. Compare the second and third columns of the second line. In the valid file, the four bytes are 0x00 0x80 0x00 0x00. In your uploaded file, the same four bytes are 0x00 0xc2 0x80 0x00. Why?

JavaScript strings are UTF. This means that any ASCII binary values (0-127) are encoded with one byte. However, anything from 128-2047 gets two bytes. That extra 0xc2 in the uploaded file is an artifact of this multibyte encoding. If you want to know exactly why this happens, you can read more about UTF encoding on Wikipedia.

You can't prevent this from happening with JavaScript strings, so you can't upload this binary data via AJAX without using base64.

EDIT: After some further digging, this is possible with some modern browsers. If a browser supports XMLHttpRequest.prototype.sendAsBinary (Firefox 3 and 4), you can use this to send the image, like so:

function postCanvasToURL(url, name, fn, canvas, type) {
  var data = canvas.toDataURL(type);
  data = data.replace('data:' + type + ';base64,', '');

  var xhr = new XMLHttpRequest();
  xhr.open('POST', url, true);
  var boundary = 'ohaiimaboundary';
  xhr.setRequestHeader(
    'Content-Type', 'multipart/form-data; boundary=' + boundary);
  xhr.sendAsBinary([
    '--' + boundary,
    'Content-Disposition: form-data; name="' + name + '"; filename="' + fn + '"',
    'Content-Type: ' + type,
    '',
    atob(data),
    '--' + boundary + '--'
  ].join('\r\n'));
}

For browsers that don't have sendAsBinary, but do have Uint8Array (Chrome and WebKit), you can polyfill it like so:

if (XMLHttpRequest.prototype.sendAsBinary === undefined) {
  XMLHttpRequest.prototype.sendAsBinary = function(string) {
    var bytes = Array.prototype.map.call(string, function(c) {
      return c.charCodeAt(0) & 0xff;
    });
    this.send(new Uint8Array(bytes).buffer);
  };
}
Nathan Ostgard
  • 8,258
  • 2
  • 27
  • 19
  • Ah right! Thanks for the explanation! So the question is how do I build the post request so PHP knows I'm trying to send it base64 data and it should decode it? I tried setting `Content-Transfer-Encoding: base64` (thanks @Wrikken!) but like you said it's not a valid header so the browser refuses to do it ("Refused to set unsafe header"). Is there something I can put after `Content-Disposition` that would do it? Been experimenting for a while but haven't found anything that works. – BaronVonKaneHoffen Mar 15 '11 at 12:08
  • ...also, another thing I find confusing here is when I look at a working request generated by the browser itself (from a form with an image input), I don't see any image data. Just `Content-Disposition: form-data; name="formfield"; filename="picture.png" Content-type: image/png` then nothing. Where is this data? Does it mean I should be transferring the actual image data in a separate request or something? Sorry, I'm very new to this kind of stuff and have yet to find a decent tutorial... – BaronVonKaneHoffen Mar 15 '11 at 12:16
  • @BaronVonKaneHoffen I messed with it for quite a while to try to find a way to get PHP to automatically base64 decode the file. I couldn't find one. :( Are you able to upload any custom PHP to the server? If you can, you could proxy the request through a custom script to decode it. – Nathan Ostgard Mar 15 '11 at 14:05
  • @BaronVonKaneHoffen From your other post, it sounds like you got this figured out with base64. But I was poking at this some more, and managed to get it working in Firefox, Chrome, and WebKit -- answer updated. – Nathan Ostgard Mar 16 '11 at 05:02
  • Ah right! So it does. That's brilliant! Sorry - I didn't see this for ages, but it works a treat. Very much appreciated :) – BaronVonKaneHoffen Apr 26 '11 at 22:28
4

Building on Nathan's excellent answer, I was able to finnagle it so that it is still going through jQuery.ajax. Just add this to the ajax request:

            xhr: function () {
                var myXHR = new XMLHttpRequest();
                if (myXHR.sendAsBinary == undefined) {
                    myXHR.legacySend = myXHR.send;
                    myXHR.sendAsBinary = function (string) {
                        var bytes = Array.prototype.map.call(string, function (c) {
                            return c.charCodeAt(0) & 0xff;
                        });
                        this.legacySend(new Uint8Array(bytes).buffer);
                    };
                }
                myXHR.send = myXHR.sendAsBinary;
                return myXHR;
            },

Basically, you just return back an xhr object that is overriden so that "send" means "sendAsBinary". Then jQuery does the right thing.

user435779
  • 413
  • 4
  • 15
  • Thanks for this - just gave it a go and it works great. Ended up abandoning Wordpress for the original project this was for, but I've got something else WP coming up that this should be useful for, so it's very much appreciated sir! – BaronVonKaneHoffen May 18 '12 at 12:07
  • Check out http://stackoverflow.com/questions/9915199/download-a-html5-canvas-element-as-an-image-with-the-file-extension-with-javascr/11200935#11200935 . I have posted an answer there which might be really helpful – Aman Virk Jun 26 '12 at 04:54