kfirba's avatar
Level 50

[Package] Generate AWS Signature V4 - Direct upload to S3

Hey guys.

I'm currently building a project which involves lot of file uploads and ofcourse, I did everything traditionally. The user uploads a file, I then upload it to my server and from there I send it Amazon S3. But it bothered me. Why do I need to make this useless, expensive trip to my server just to put it on Amazon S3 directly? There has to be a better way.

I did some research and saw that some file uploaders such as FineUploader offers the functionality to upload files directly to S3 - Great! However, it turns out that it's not that straight-forward. In order to upload directly to S3 you need to generate an AWS Signature V4 which requires to generate a policy for that and some form inputs. The thing is that everything has to be exactly as S3 wants it. For example, your policy contains an integer and not a string? god-forbid. You are going to face a 403 Access Denied with no explanation what's wrong or for some errors they are actually nice enough to return a 400 Bad Request with a line of description that may not be super useful.

So I started looking around to see if there are any existing libraries but couldn't find one until I came across a great post by Edd Turtle. So it seems he had figured this out and wrote a blog post about it.

I carefully examined his code and wrote an implementation that includes a little bit more functionality and most importantly, a bridge to Laravel. I call it Directo.

Thought it might be useful for others. Let me know what you think about this! :)

0 likes
3 replies
kfirba's avatar
Level 50

@kiwo123 I can share an example, sure.

I have some simple HTML that looks like:

<form action="{{ Directo::formUrl() }}" method="post" enctype="multipart/form-data" class="direct-upload">
    {!! Directo::inputsAsHtml() !!}

    <div class="progress-bar-area mb-1"></div>
    <input type="file" name="file" id="upload-input">
</form>

When you submit the form above, the file will be directly uploaded to S3 without touching your server. In my case, I boosted the process a little bit. I have some Javascript in place that uploads the file in the background and shows me the progression so I get some visual feedback. In order to achieve that, I use jQuery fileupload plugin:

<script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-file-upload/9.13.0/js/jquery.fileupload.min.js
"></script>
<script>
    var form = $('.direct-upload');

    form.fileupload({
        url: form.attr('action'),
        type: form.attr('method'),
        datatype: 'xml',
        replaceFileInput:false,
        add: function (e, data) {
            window.onbeforeunload = function () {
                return 'Upload in progress.';
            }

            var uploadButton = $('#upload-btn');
            uploadButton = uploadButton.length > 0 ? uploadButton : $('<button id="upload-btn" class="btn btn-primary mt-1"/>');
            uploadButton.off('click');

            data.context = uploadButton.text('Upload').appendTo('#gratitude-upload').click(function () {
                // we hide the input to prevent the user from selecting another file.
                $('#upload-input').hide();

                var nameInput = form.find('input[name="key"]');
                var extension = data.files[0].name.split('.').pop();
                var filename = (Math.random()*1e17).toString(36) + '.' + (Math.random()*1e17).toString(36) + '.' + extension;

                nameInput.val('gratitude/' + filename);
                // form.find('input[name="Content-Type"]').val(file.type);
                // we set the content-type to application/octet-stream to force the user to download the file
                form.find('input[name="Content-Type"]').val('application/octet-stream');

                // Replace the button with Uploading text and begin the upload.
                data.context = $('<p/>').text('Uploading...').replaceAll($(this));
                data.submit();

                // Set the progress bar.
                var bar = $('<div class="progress" data-mod="'+data.files[0].size+'"><div class="bar"></div></div>');
                $('.progress-bar-area').empty().append(bar);
                bar.slideDown('fast');
            });
        },

        progress: function (e, data) {
            var percent = Math.round((data.loaded / data.total) * 100);
            $('.progress[data-mod="'+data.files[0].size+'"] .bar').css('width', percent + '%').html(percent+'%');
        },

        fail: function (e, data) {
            window.onbeforeunload = null;
            $('.progress[data-mod="'+data.files[0].size+'"] .bar').css('width', '100%').addClass('red').html('');
        },

        done: function (event, data) {
            window.onbeforeunload = null;

            var gratitude = data.result.documentElement.children[2].innerHTML;

            $.ajax({
                url: '/api/donations/' + donationId + '/gratitude',
                type: 'PATCH',
                data: {gratitude: gratitude}
            }).done(function () {
                window.location.reload();
            });
        }
    });
</script>

Well, this is a very specific example, but you can probably understand how to take that and use it in your own project.

Please or to participate in this conversation.