4. Direct to S3 Uploads
In the last chapter, you learned that to upload files to Amazon S3 in a Laravel app, you simply configure your S3 credentials and use the S3 driver for the upload. Other than that, it works exactly as if you were storing files on the server's local filesystem. You also learned about how to retrieve files from S3, and particularly interesting was how to use public or signed temporary URLs to enable users to download files directly from S3, skipping your server altogether. This is a good practice - not only does it speed up your app, but it also reduces the load on your servers. Wouldn't it be great if we could apply the same approach to uploading files to S3? That's exactly what we'll do in this chapter.
4.1. Pre-signed POST requests
You met the concept of a pre-signed URL in the previous chapter. Uploading files directly to S3 works in a similar way - using a pre-signed POST request. The idea is that rather than submitting our upload form to the Web server, we make a POST request directly to Amazon S3. It performs some checks, stores the file and optionally redirects the user to a URL specified by your app. To make this POST request, we need to generate a POST URL and a series of form inputs that will be submitted to S3 to verify the authenticity of the request. Let's go ahead an implement this now.
4.2. Generating the Form Attributes and Inputs
Open routes/web.php
and above the first route, add the following code:
use Aws\S3\PostObjectV4;
This code imports the PostObjectV4
class from the AWS PHP SDK. Laravel itself doesn't include a mechanism by which we can use the Storage
facade or filesystem abstraction to genereate pre-signed request objects, so we will use the AWS SDK directly.
Next, we need to add some code to our main upload form route. This code should be added to the GET route closure for the /
path, which currently just returns a welcome
view. First, we use Laravel's Storage
facade to access the AWS client and bucket information.
$adapter = Storage::getAdapter();
$client = $adapter->getClient();
$bucket = $adapter->getBucket();
Next, define some variables for parameters that will tell AWS about how we want to store the file. This includes a prefix for the path of where to store the uploaded file in the S3 bucket, an access control property defining that the file is publicly viewable, an expiry timeframe for the POST request of 10 minutes, and the absolute URL that S3 should redirect back to once the upload has completed.
$prefix = 'uploads/';
$acl = 'public-read';
$expires = '+10 minutes';
$redirectUrl = url('/show');
The next step is to create an array of form inputs that will be added to the form, in addition to those that the Amazon SDK generates.
$formInputs = [
'acl' => $acl,
'key' => $prefix . '${filename}',
'success_action_redirect' => $redirectUrl,
];
The key
item in this array uses a ${filename}
variable that S3 will recognise and substitute with the original file name. The success_action_redirect
item tells S3 where to redirect to following the upload.
Next, we'll create an options array that defines some rules that S3 will validate the uploaded file against. These options are pretty self-explanatory.
$options = [
['acl' => $acl],
['bucket' => $bucket],
['starts-with', '$key', $prefix],
['eq', '$success_action_redirect', $redirectUrl],
];
Finally, we generate the post object using the PostObjectV4
class constructor, and get the form attributes and inputs, passing these into the welcome
view so they can be used when rendering the form HTML.
$postObject = new PostObjectV4($client, $bucket, $formInputs, $options, $expires);
$attributes = $postObject->getFormAttributes();
$inputs = $postObject->getFormInputs();
return view('welcome', compact(['attributes', 'inputs']));
Now that we've generated the relevant attributes and inputs, it's time to add them to our upload form.
4.3. Constructing the Direct to S3 Upload Form
Open resources/views/welcome.blade.php
and replace the existing <form>
block with the following:
<form action="{{ $attributes['action'] }}" method="{{ $attributes['method'] }}" enctype="{{ $attributes['enctype'] }}">
@foreach($inputs as $key => $input)
<input type="hidden" name="{{ $key }}" value="{{ $input }}" />
@endforeach
<input type="file" name="file" />
<p><button>Submit</button></p>
</form>
This takes the $attributes
associative array and uses it to output the action
, method
, and enctype
form attributes. A Blade @foreach
block iterates over the $inputs
array and generates a hidden form input for each item in it. The rest of the form is the same as before.
If you open the upload form page in your browser and view the source code, you should see that something like the following was generated:
<form action="https://yorubucket.s3-yourregion.amazonaws.com" method="POST" enctype="multipart/form-data">
<input type="hidden" name="acl" value="public-read" />
<input type="hidden" name="key" value="uploads/${filename}" />
<input type="hidden" name="success_action_redirect" value="http://yourdomain/show" />
<input type="hidden" name="X-Amz-Credential" value="YOURAWSKEY/20170803/yourregion/s3/aws4_request" />
<input type="hidden" name="X-Amz-Algorithm" value="AWS4-HMAC-SHA256" />
<input type="hidden" name="X-Amz-Date" value="20170803T152425Z" />
<input type="hidden" name="Policy" value="eyJleHBpcmF0aW9uIjoiMjAxNy0wOC0wM1QxNTozNDoyNVoiLCJjb25kaXRpb25zIjpbeyJhY2wiOiJwdWJsaWMtcmVhZCJ9LHsiYnVja2V0IjoicHJlc2lnbmVkLXBvc3QifSxbInN0YXJ0cy13aXRoIiwiJGtleSIsInVwbG9hZHNcLyJdLFsiZXEiLCIkc3VjY2Vzc19hY3Rpb25fcmVkaXJlY3QiLCJodHRwOlwvXC91cGxvYWRzLmRldlwvc2hvdyJdLHsiWC1BbXotRGF0ZSI6IjIwMTcwODAzVDE1MjQyNVoifSx7IlgtQW16LUNyZWRlbnRpYWwiOiJBS0lBSU9aVDdVNjJLMlU1WTJZUVwvMjAxNzA4MDNcL2V1LXdlc3QtMVwvczNcL2F3czRfcmVxdWVzdCJ9LHsiWC1BbXotQWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9XX0=" />
<input type="hidden" name="X-Amz-Signature" value="e7e49d7d4b703b14da8d22bfb1026fbff65b03526ab0881f0a1f5bd84f5ed451" />
<input type="file" name="file" />
<p><button>Submit</button></p>
</form>
With the form created, you have everything you need to upload the file. We do need to make one slight change to the show
route before we're done, though.
4.4. Handling the S3 Success Redirect
When the file has been successfully uploaded, S3 will look for the success_action_redirect
field in your request, and if found it will redirect the user back to that URL. By default, it will add a series of query string parameters to the URL, for example:
bucket=yourbucket&key=uploads%2Ffilename.jpg&etag="8a22e660fd0f781c645a2e954897d74b"
As you can see, the key of the S3 object is provided in the key
query string parameter. Right now, our show
route is looking for a path
parameter. Change the assignment of the $path
variable in the show
route closure in routes/web.php
to the following:
$path = request()->query('key');
Load the upload form again, select a file and submit. The file will be sent directly from the client to Amazon S3, which then redirects back to your application where you display the newly uploaded file.
4.5. Validating Direct to S3 Uploads
One thing you may have noticed is that we are no longer performing validation on the uploaded file. After all, the upload is bypassing our Laravel application altogether now, so there is nowhere for us to perform such validation. Let's fix this now using S3 policy rules and also some client-side validation to improve the user experience.
Our existing code includes a couple of policy rules already - it checks that the object key starts with the specified prefix, and it also validates the redirect URL. We can easily perform additional validation by adding items to the $options
array in our /
route closure. Change the $options
array assignment block to the following:
$options = [
['acl' => $acl],
['bucket' => $bucket],
['starts-with', '$key', $prefix],
['eq', '$success_action_redirect', $redirectUrl],
['starts-with', '$Content-Type', 'image/'],
['content-length-range', 0, (2 * 1024 * 1024)],
];
The two additions validate that the MIME type of the uploaded file is an image, and that the size of the uploaded file is between 0 and 2 megabytes, respectively. If the user tries to upload a file that is not an image, or a file that is too large, an S3 error will prevent the upload from completing successfully.Unfortunately, the S3 SDK does not provide a mechanism which we can use to redirect back to the application when an error occurs. In the next chapter, we will learn how to use JavaScript to upload files using Ajax, which will allow us to catch such errors and display a friendly message to the user. For now, however, let's add some client-side validation to our view that will help prevent the user from encountering some of these validation errors.
Open resources/views/welcome.blade.php
and find the line:
<input type="file" name="file" />
Replace it with the following:
<input type="file" name="file" accept="image/*" required />
Now, if the user tries to submit the form without selecting a file it will display an error. Also, when selecting files using the "Choose file" dialog, non-image files will not be selectable. Unfortunately, we can't restrict the file size of the selected file using HTML alone, so we'll handle this when we improve the upload form with JavaScript in the next chapter.
4.6. Summary
At this point, user files never touch the Laravel Web server and are uploaded to and retrieved from Amazon S3 directly. The user experience to date isn't exactly delightful mind you. In the next chapter, we'll use JavaScript to perform the upload using an XMLHttpRequest
and display real-time upload progress to the user.