前言

普通的文件上传流程是客户端将文件发送到后端服务,然后后端服务器再上传到 S3,从中不能看出,一个文件被流转了两次。如果是小文件还可以,如果是大文件,则导致后端服务器流量被占用!

另一个问题就是如果后端服务在国内,经常会出现上传到 S3 超时。

为了避免以上两个问题,最好的方案是由客户端直接进行上传,即避免了多次流转,也便于解决国内服务器访问 S3 超时问题(客户端自行使用代理访问)。

认证问题

上传到 S3 需要 Key 和 Secret,而这些信息又不能直接给到客户端,包括浏览器和 Native App,因为一旦泄露,对于 S3 来说将是非常危险的!

为了避免认证问题,S3 提供与签名请求来上传文件,后端根据上传的文件生成预签名 URL,客户端根据这个 URL 再上传文件到 S3。

这样几不用暴露 Key 和 Secret 给前端,同时可以限制客户端只能将文件上传到指定 Bucket 下的指定路径!

即使,恶意用户通过获取这个 URL 来访问 S3 也只能上传文件,对整个桶造成不了什么影响!

生 PreSigned URL

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

Laravel 9.* 使用 league/flysystem-aws-s3-v3 “^3.0”。

直接使用 AWS SDK

$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();

扩展 league/flysystem

Laravel 8.* 需要再 AppServiceProvider.php 中的 boot 方法中定义如下代码:


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.');
});

Laravel 9.* 需要再 AppServiceProvider.php 中的 boot 方法中定义如下代码:

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;
});

使用

$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']);

前端拿到这个 URL 就可以上传文件了,另外 MinIO 也支持,具体如何实施可以参考官方文档

I hope this is helpful, Happy hacking…