Skip to content

Instantly share code, notes, and snippets.

@jberger
Last active May 4, 2020 09:09
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jberger/4744482 to your computer and use it in GitHub Desktop.
Save jberger/4744482 to your computer and use it in GitHub Desktop.
mojolicious file uploads via websocket
#!/usr/bin/env perl
use Mojolicious::Lite;
use Mojo::JSON 'j';
use Mojo::Asset::Memory;
use File::Spec;
helper send_ready_signal => sub {
my $self = shift;
my $payload = { ready => \1 };
$payload->{chunksize} = shift if @_;
$self->send({ text => j($payload) });
};
helper send_error_signal => sub {
my $self = shift;
my $message = shift;
my $payload = {
error => $message,
fatal => $_[0] ? \1 : \0,
};
$self->send({ text => j($payload) });
};
helper send_close_signal => sub {
my $self = shift;
$self->send({ text => j({ close => \1 }) });
};
helper receive_file => sub {
my $self = shift;
# setup text/binary handlers
# create file_start/file_chunk/file_finish events
{
my $unsafe_keys = eval { ref $_[-1] eq 'ARRAY' } ? pop : [qw/directory/];
my $meta = shift || {};
my $file = Mojo::Asset::Memory->new;
$self->on( text => sub {
my ($ws, $text) = @_;
# receive file metadata
my %got = %{ j($text) };
# prevent client-side abuse
my %unsafe;
@unsafe{@$unsafe_keys} = delete @got{@$unsafe_keys};
%$meta = (%got, %$meta);
# finished
if ( $got{finished} ) {
$ws->tx->emit( file_finish => $file, $meta );
return;
}
# inform the sender to send the file
$ws->tx->emit( file_start => $file, $meta, \%unsafe );
});
$self->on( binary => sub {
my ($ws, $bytes) = @_;
$file->add_chunk( $bytes );
$ws->tx->emit( file_chunk => $file, $meta );
});
}
# connect default handlers for new file_* events
# begin file receipt
$self->on( file_start => sub { $_[0]->send_ready_signal } );
# log progress
$self->on( file_chunk => sub {
my ($ws, $file, $meta) = @_;
state $old_size = 0;
my $new_size = $file->size;
my $message = sprintf q{Upload: '%s' - %d | %d | %d}, $meta->{name}, ($new_size - $old_size), $new_size, $meta->{size};
$ws->app->log->debug( $message );
$old_size = $new_size;
});
# inform the sender to send the next chunk
$self->on( file_chunk => sub { $_[0]->send_ready_signal } );
# save file
$self->on( file_finish => sub {
my ($ws, $file, $meta) = @_;
my $target = $meta->{name} || 'unknown';
if ( -d $meta->{directory} ) {
$target = File::Spec->catfile( $meta->{directory}, $target );
}
$file->move_to($target);
my $message = sprintf q{Upload: '%s' - Saved to '%s'}, $meta->{name}, $target;
$ws->app->log->debug( $message );
$ws->send_close_signal;
});
};
any '/' => 'index';
websocket '/upload' => sub {
my $self = shift;
my $dir = File::Spec->rel2abs('upload');
mkdir $dir unless -d $dir;
$self->receive_file({directory => $dir});
};
app->start;
__DATA__
@@ index.html.ep
<!DOCTYPE html>
<html>
<head>
<title>Testing</title>
<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.0/css/bootstrap-combined.min.css" rel="stylesheet">
<script src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.0/js/bootstrap.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
%= javascript 'upload.js'
%= javascript begin
function sendfile () {
//var file = document.getElementById('file').files[0];
var update = function(ratio) {
var percent = Math.ceil( 100 * ratio );
$('#progress .bar').css('width', percent + '%');
};
var success = function() {
$('#progress').removeClass('progress-striped active');
$('#progress .bar').addClass('bar-success');
};
var failure = function (messages) {
$('#progress').removeClass('progress-striped active');
$('#progress .bar').addClass('bar-danger');
console.log(messages);
};
sendFileViaWS({
url: '<%= url_for('upload')->to_abs %>',
file: $('#file').get(0).files[0],
onchunk: update,
onsuccess: success,
onfailure: failure
});
}
% end
</head>
<body>
<div class="container">
<input id="file" type="file">
<button onclick="sendfile()">Send</button>
<div id="progress" class="progress progress-striped active">
<div class="bar" style="width: 0%;"></div>
</div>
</div>
</body>
</html>
@@ upload.js
function sendFileViaWS (param) {
var ws = new WebSocket(param.url);
var file = param.file;
var filedata = { name : file.name, size : file.size };
var chunksize = param.chunksize || 250000;
var slice_start = 0;
var end = filedata.size;
var finished = false;
var success = false; // set to true on completion
var error_messages = [];
ws.onopen = function(){ ws.send(JSON.stringify(filedata)) };
ws.onmessage = function(e){
var status = JSON.parse(e.data);
// got close signal
if ( status.close ) {
if ( finished ) {
success = true;
}
ws.close();
return;
}
// server reports error
if ( status.error ) {
if ( param.onerror ) {
param.onerror( status );
}
error_messages.push( status );
if ( status.fatal ) {
ws.close();
}
return;
}
// anything else but ready signal is ignored
if ( ! status.ready ) {
return;
}
// upload already successful, inform server
if ( finished ) {
ws.send(JSON.stringify({ finished : true }));
return;
}
// server is ready for next chunk
var slice_end = slice_start + ( status.chunksize || chunksize );
if ( slice_end >= end ) {
slice_end = end;
finished = true;
}
ws.send( file.slice(slice_start,slice_end) );
if ( param.onchunk ) {
param.onchunk( slice_end / end ); // send ratio completed
}
slice_start = slice_end;
return;
};
ws.onclose = function () {
if ( success ) {
if ( param.onsuccess ) {
param.onsuccess();
}
return;
}
if (error_messages.length == 0) {
error_messages[0] = { error : 'Unknown upload error' };
}
if ( param.onfailure ) {
param.onfailure( error_messages );
} else {
console.log( error_messages );
}
}
}
__END__
# Protocol Documentation
* All signals/metadata are simply JSON formatted strings sent with via
websocket with TEXT opcode.
* All file data is sent via websocket with BINARY opcode
## Client side (javascript)
* Client starts by connecting and sending file meta-data.
{ name : filename, size : size_in_bytes }
Clien then waits for ready signal.
* On receipt of ready signal reply with chunk of file (on BINARY channel),
fire the `onchunck` handler (called with the ratio of sent data to total data)
then wait for ready signal. Repeat until file is finished, or another signal
causes other action to be taken.
* On receipt of ready signal when the file has finished transmitting, reply
with finished signal.
{ finished : true }
An optional `hash` key is planned, which if present would convey the hash type
and the file's hash result for comparison to the received file.
{ finished : true, hash : { type : sha1, value : hash_result } }
Servers should not necessarily interpret the lack of a hash parameter as a
reason for failure, as the browser may not support it.
* On receipt of error signal, store the error signal and fire the `onerror`
handler (with argument being the error signal contents). If fatal, close the
connection, which will then fire the `onfailure` handler.
* On receipt of close signal close connection. If all filedata has been sent,
mark as successful (`onsuccess` will fire rather than `onfailure`).
* In any case, the `onclose` handler will fire either the `onsuccess` (no
arguments) handler or the `onfailure` handler (called with an array of received
error signals).
*TODO: Transport error (ws.onerror handler)*
## Server side (generic)
* On reciept of file metadata and when ready for file chunks send ready signal.
{ ready : true [, chunksize : size_in_bytes ] }
Optionally a `chunksize` key may be sent telling the client the maximum number
of bytes the next chunk may be; if this number is zero, the default will be
used.
* On any error send error signal with optional `fatal` boolean flag. All other
keys are assumed to be for the handler.
{ error : truthy_value [, fatal : boolean ] }
* On receipt of finish signal, reply with either a standard error signal or the
close signal.
{ close : true }
Note that a lack of a final close signal will indicate a failure (will fire
`onfailure` handler) when the websocket finally closes due to timeout. Note
also that closing early will fire the `onfailure` handler immediately, sending
an error message with the close will not do what you mean; in this case use the
error signal with the `fatal` flag.
## Server side (Perl/Mojolicious)
coming soon ...
@jberger
Copy link
Author

jberger commented Feb 11, 2013

This code has now been modularized and is available at https://github.com/jberger/GalileoSend

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment