Introduction

In a conventional file upload process, the client sends files to the backend service, which then uploads them to S3. As you can see, a file is transferred twice. This might be acceptable for small files, but for large files, it consumes significant backend server bandwidth!

Another issue is that if the backend service is located in mainland China, uploads to S3 often time out.

To avoid these two problems, the best solution is to have the client upload directly, which both prevents multiple transfers and helps solve the timeout issue when accessing S3 from servers in China (clients can use their own proxies).

Authentication Issues

Uploading to S3 requires a Key and Secret, but this information cannot be directly provided to clients, including browsers and Native Apps, because if leaked, it would be extremely dangerous for S3!

To avoid authentication issues, S3 provides signed requests for file uploads. The backend generates a pre-signed URL based on the file to be uploaded, and the client uses this URL to upload the file to S3.

This way, you don’t need to expose the Key and Secret to the frontend, and you can limit clients to uploading files only to specific paths in designated buckets!

Even if malicious users obtain this URL to access S3, they can only upload files, which won’t significantly impact the entire bucket!

Generating PreSigned URLs

composer require league/flysystem-aws-s3-v3 "~1.0"

Laravel 9.* uses league/flysystem-aws-s3-v3 “^3.0”.

Using AWS SDK Directly

$s3 = Storage::disk('s3');
$client = $s3->getDriver()->getAdapter()->getClient();
$expiry = now()->addSeconds(60);

$command = $client->getCommand('PutObject', [
    'Bucket' => config('filesystems.disks.s3.bucket'),
    'Key' => 'path/to/file/' . Str::random(64),
    'ACL' => 'public-read',
]);

$request = $client->createPresignedRequest($command, $expiry);

$presignedUrl = (string)$request->getUri();

Extending league/flysystem

For Laravel 8.*, add the following code to the boot method in AppServiceProvider.php:


use Illuminate\Filesystem\FilesystemAdapter;

FilesystemAdapter::macro('temporaryUploadUrl', function ($path, $expiration, array $options = [])
{
    $adapter = $this->driver->getAdapter();

    if ($adapter instanceof AwsS3Adapter) {
        $client = $adapter->getClient();

        $command = $client->getCommand('PutObject', array_merge([
            'Bucket' => $adapter->getBucket(),
            'Key' => $adapter->getPathPrefix().$path
        ], $options));

        $uri = $client->createPresignedRequest(
            $command, $expiration
        )->getUri();

        // If an explicit base URL has been set on the disk configuration then we will use
        // it as the base URL instead of the default path. This allows the developer to
        // have full control over the base path for this filesystem's generated URLs.
        /** @phpstan-ignore-next-line */
        if (! is_null($url = $this->driver->getConfig()->get('temporary_url'))) {
            /** @phpstan-ignore-next-line */
            $uri = $this->replaceBaseUrl($uri, $url);
        }

        return (string) $uri;
    }

    throw new RuntimeException('This driver does not support creating temporary upload URLs.');
});

For Laravel 9.*, add the following code to the boot method in AppServiceProvider.php:

use Illuminate\Filesystem\AwsS3V3Adapter;

AwsS3V3Adapter::macro('temporaryUploadUrl', function ($path, $expiration, array $options = []) {
    /** @phpstan-ignore-next-line */
    $command = $this->getClient()->getCommand('PutObject', array_merge([
        'Bucket' => Arr::get($this->getConfig(), 'bucket'),
        'Key' => $this->path($path),
    ], $options));

    /** @phpstan-ignore-next-line */
    $uri = $this->getClient()->createPresignedRequest(
        $command, $expiration, $options
    )->getUri();

    // If an explicit base URL has been set on the disk configuration then we will use
    // it as the base URL instead of the default path. This allows the developer to
    // have full control over the base path for this filesystem's generated URLs.
    $temporaryUrl = Arr::get($this->getConfig(), 'temporary_url');
    if ($temporaryUrl) {
        /** @phpstan-ignore-next-line */
        $uri = $this->replaceBaseUrl($uri, $temporaryUrl);
    }

    return (string) $uri;
});

Usage

$prefix = 'path/to/file';
$name = Str::random(64);
$path = sprintf('%s/%s', $prefix, $name);
Storage::disk('s3')->temporaryUploadUrl($path, now()->addMinutes(10), ['ACL' => 'public-read']);

With this URL, the frontend can upload files directly. MinIO also supports this feature. For specific implementation details, please refer to the official documentation.

I hope this is helpful, Happy hacking…