Monday, March 09, 2009

Safari 4 Multiple Upload With Progress Bar

Update - I slightly modified the demo page adding another bar, the one for the current file. I'll update the zip asap but all you need is a copy and paste of the JS in the index. Apparently multiple should be set as multiple, rather than true ... in any ... it works as long as it is an attribute.


This one from Ajaxian has been one of the best news I could read about HTML5 specs and browsers evolution.
I could not wait to download Safari 4 and test instantly this feature.
I have always been interested in this possibility, both progress bar, plus multiple uploads and that's why I would like to add this post to this list:


First Step: The Input Type File

We can create a multiple input files in different ways: directly in the layout

<input type="file" multiple="multiple" />

or via JavaScript:

var input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "multiple"); // note: input.multiple = "multiple" does not work in Safari 4 beta

I know we all like graceful degradation, but in this post I will only talk about a client side progress bar, something that is cool and possible, so far, only via JavaScript ;-)


Second Step: XMLHttpRequest Version 2

The last implementation of this constructor brings some cool stuff with him, the XMLHttpRequestUpload interface, used by an automatic created property called upload.
The reason this property is as cool as welcome, is that it can trace sent binary data via dispatched events like onload, onprogress, and others.

var xhr = new XMLHttpRequest,
upload = xhr.upload;
upload.onload = function(){
console.log("Data fully sent");
};
xhr.open("post", page, true);
xhr.send(binaryData);

Above snippet is the basis of the new feature introduced by Safari 4, feature better explained in the dedicated example I prepared for this post.


Third Step: The PHP Manager

If we use multiple attribute without XMLHttpRequest, we simply need to set a name, and manage data in the server via the superglobal $_FILES. But if we want to send directly the binary stream, the only thing we need to do in the server, security checks a part, is to save the content sent via input:

<?php
// e.g. url:"page.php?upload=true" as handler property
if(isset($_GET['upload']) && $_GET['upload'] === 'true'){
$headers = getallheaders();
if(
// basic checks
isset(
$headers['Content-Type'],
$headers['Content-Length'],
$headers['X-File-Size'],
$headers['X-File-Name']
) &&
$headers['Content-Type'] === 'multipart/form-data' &&
$headers['Content-Length'] === $headers['X-File-Size']
){
// create the object and assign property
$file = new stdClass;
$file->name = basename($headers['X-File-Name']);
$file->size = $headers['X-File-Size'];
$file->content = file_get_contents("php://input");

// if everything is ok, save the file somewhere
if(file_put_contents('files/'.$file->name, $file->content))
exit('OK');
}

// if there is an error this will be the output instead of "OK"
exit('Error');
}
?>



Last Step: The Client Manager With The Progress Bar

This is the last thing we should care about, and only after we are sure we implemented best security checks to avoid problems for users and the server itself. In this example I did not implement too many checks, so please take it as a hint, rather than a final solution for public production environments.
The client side is really simple and entirely managed via JavaScript.

/** basic Safari 4 multiple upload example
* @author Andrea Giammarchi
* @blog WebReflection [webreflection.blogspot.com]
*/
onload = function(){

function size(bytes){ // simple function to show a friendly size
var i = 0;
while(1023 < bytes){
bytes /= 1024;
++i;
};
return i ? bytes.toFixed(2) + ["", " Kb", " Mb", " Gb", " Tb"][i] : bytes + " bytes";
};

// create elements
var input = document.body.appendChild(document.createElement("input")),
bar = document.body.appendChild(document.createElement("div")).appendChild(document.createElement("span")),
div = document.body.appendChild(document.createElement("div"));

// set input type as file
input.setAttribute("type", "file");

// enable multiple selection (note: it does not work with direct input.multiple = true assignment)
input.setAttribute("multiple", "multiple");

// auto upload on files change
input.addEventListener("change", function(){

// disable the input
input.setAttribute("disabled", "true");

sendMultipleFiles({

// list of files to upload
files:input.files,

// clear the container
onloadstart:function(){
div.innerHTML = "Init upload ... ";
bar.style.width = "0px";
},

// do something during upload ...
onprogress:function(rpe){
div.innerHTML = [
"Uploading: " + this.file.fileName,
"Sent: " + size(rpe.loaded) + " of " + size(rpe.total),
"Total Sent: " + size(this.sent + rpe.loaded) + " of " + size(this.total)
].join("<br />");
bar.style.width = (((this.sent + rpe.loaded) * 200 / this.total) >> 0) + "px";
},

// fired when last file has been uploaded
onload:function(rpe, xhr){
div.innerHTML += ["",
"Server Response: " + xhr.responseText
].join("<br />");
bar.style.width = "200px";

// enable the input again
input.removeAttribute("disabled");
},

// if something is wrong ... (from native instance or because of size)
onerror:function(){
div.innerHTML = "The file " + this.file.fileName + " is too big [" + size(this.file.fileSize) + "]";

// enable the input again
input.removeAttribute("disabled");
}
});
}, false);

bar.parentNode.id = "progress";

};

The external file with sendFile and sendMultipleFile function is in my repository and in the attached zip, while a workable example page with a limit of 1Mb for each file is here in my host.

26 comments:

Lachlan Hunt said...

The value of the multiple attribute needs to be multiple="multiple", or simply multiple="". (In HTML, but not XHTML, you can also just write multiple and omit the =""). Using multiple="true" is invalid.

Andrea Giammarchi said...

the point is the attribute, so since it is valid assign it via (x)HTML without any value, I guess every value is fine via setAttribute.
Anyway, I'll correct the typo but the code is working without problems :)

Andrea Giammarchi said...

P.S. Lachlan, I created a complete demo without specs (HTML5 draft has nothing about it) so thanks for the hint but I guess it is the last important thing ever for this post, isn't it? Regards

Lachlan Hunt said...

It would just be nice if the blog post was corrected so that anyone copying and pasting from this in the future doesn't end up copying invalid markup.

Andrea Giammarchi said...

done ...

programcsharp said...

Love the script! Is there any way to remove a single file when multiple files have been selected? I'm showing a list of all the selected files on my page, and I want to be able to allow the user to remove them one by one.

Andrea Giammarchi said...

Have try a double click over a file in the list :)

Ryan said...

Nice work it's great to see improvements like this starting to show up in the better browsers. Firefox 3.6 has added support for drag'n drop uploading using xhr2 similar to your article but the user can drag their files from their desktop http://www.thecssninja.com/javascript/drag-and-drop-upload

Andrea Giammarchi said...

Ryan, I am waiting for Chrome and Opera, then I'll update the complete project: noSWFUpload

Spencer Thiel said...
This comment has been removed by the author.
Mark said...

When it errors it says the filesize is too big - where's this defined from?

I've changed PHP's:

post_max_size
apc.max_size
upload_max_filesize

any ideas?

Mark said...

Disregard my last comment. It's been a long day. I "found" (opened my eyes and saw) the sendFile variable :).

Great script - thankyou!!

Anonymous said...

I can't seem to get it to work with the the getallheaders() command. My server doesn't recognize it. I believe it is because of how it's running php. I tried with using an emulated function off several websites, but the code keeps rejecting the upload and giving an error.

Any ideas?

kn33ch41 said...

I should point out that assigning the following to a variable:

file_get_contents("php://input")

is a bad idea. Particularly if all that's being done is for saving the input stream as a file using file_put_contents().

Doing this directly is much better:

file_put_contents($name,"php://input");

The reason is memory. file_get_contents() stores the entire stream in memory before then saving it to a file. For example, sending a 2MB file to a server with a memory_limit of just 8MB will throw this error: PHP Fatal error: Allowed memory size of 8388608. That could be alleviated by bumping up the allotted memory, which works fine. Though there's no point to it if you simply push the stream directly to a file, which is the superior method and uses very little memory.

I would only recommend your method of assigning the stream to a variable if you need to edit it--otherwise save it directly to a file and save yourself from the excessive and unnecessary memory usage.

Andrea Giammarchi said...

good points, thanks

jonny li said...

how to retrieve the file data from server using java??

Thanks!

jonny li said...

i got it working with java struts using the source code you have for your noswupload project. i don't think the demo with progress bar works with java yet,i spent a whole day on it but couldn't get it working. but sure enough u will get it working if you change it. thanks!

Anonymous said...

optionally you may use the 'min' and 'max' file count attributes that are used by other browsers. I have added them to the script sample and things seem to work fine in FF for example.

Philip said...

Please tell me that demo upload folder is flushed automatically. I wasn't aware it would upload the files without any initiation from myself once I selected the files. Else could you be so upstanding as to delete the files.

Thank you.

Andrea Giammarchi said...

the demo unlink the file from the temp folder as soon as the upload is finished so don't worry, I have no idea what you have uploaded ;-)

Hannibal said...

On Windows hosting (I have local XAMPP packet) there is an error: Missing boundary in multipart/form-data POST data

But on Linux server everything with ajax upload is fine.

Andrew said...

I know this post is a bit old now, but I think technically the file size units should be KB, MB, etc. instead of Kb, Mb. Kilobytes versus Kilobits...

Anonymous said...

Hello Andrea -
thanks for this example, we're using it productively.. but it's limited to the Gecko and Webkit Browser since it obviously uses the HTML5 File API which is not supported by Trident and Presto Engines.. I just don't know how to access the files (you do it via the property "files" of the input element) in those engines.. have you come across this problem already?
ilu-tg

Andrea Giammarchi said...

the code should fallback into iframe solution and it was working even in IE. No progress bar in that case, but the possibility to chose multiple files one after another one and the progress is about uploaded VS total

Acato // Colin said...

Changing the content type to "application/x-www-form-urlencoded" seems to get rid of my boundaries error. Make sure you adjust the js and php code.

Anonymous said...

I am finding that the file uploader works very nicely, but the javascript somehow manages to layer over / cover / overwrite all CSS within the same page. E.g. I am including a header and footer file around the JS. Without the JS it works fine - but with it, it becomes a blank white screen. I have changed the code to use pre-existing input and div tags rather than using append.child on the body, but it hasnt made a difference!