Laravel9 + S3 (MinIO) で署名付き URL を発行してファイルをアップロードする

## 概要
元々 Heroku 上で Laravel 経由で S3 にファイルをアップロードしていたのですが、ファイルのサイズが大きいとタイムアウトエラーになっていました。そのため Laravel でアップロード用の署名付き URL を発行し、クライアントから直接 S3 にアップロードすることになりました。
ローカル環境では S3 の代わりに MinIO を利用できるように構築したので、備忘録としてまとめます。
また、アップロードされたファイルは CloudFront を経由して取得するようにしていますが、 MinIO では直アクセスするようにしています。

## 環境
- Docker v20.10.17
- Laravel v9.25.1
- MinIO
- S3

## 結論

Laravel 標準の Storage クラスではアップロード用の署名付き URL を発行する関数が用意されていないので、 S3 クライアントからコマンドを指定して実行する必要があります。
S3 クライアントは Storage クラスから `getClient()` で取得することができます。
ローカル環境で MinIO を使用する場合、そのままでは発行された署名付き URL にアップロードすることができないため、一部修正する必要があります。
以下の処理で S3 と MinIO どちらでもアップロード可能な署名付きURLを発行することができます。

```php:S3PresignedUrl.php
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;

class S3PresignedUrl
{
    public function __invoke(string $fileName, string $prefix)
    {
        $filePath = "${prefix}/${fileName}";

        $storage = Storage::cloud();
        $config = $storage->getConfig();

        if ($config['use_path_style_endpoint']) {
            $endpoint = Str::match('/https?:\/\/[^\/]*/', $config['url']);
            $storage = Storage::build(Arr::set($config, 'endpoint', $endpoint));
        }

        $client = $storage->getClient();
        $command = $client->getCommand('PutObject', [
            'Bucket' => $config['bucket'],
            'Key' => $filePath,
        ]);
        $expiration = now()->addMinutes(10);
        $presignedUrl = (string) $client->createPresignedRequest($command, $expiration)->getUri();

        $fileUrl = $prefix === 'public' ? $storage->url($fileName) : $storage->temporaryUrl($filePath, $expiration);

        return [
            'presignedUrl' => $presignedUrl,
            'filePath' => $filePath,
            'fileUrl' => $fileUrl,
        ]);
    }
}
```

取得した `presignedUrl` に対して cURL でアップロードします。

```
curl -X PUT --upload-file test.pdf 'https://バケット名.s3.ap-northeast-1.amazonaws.com/public/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx'
```

### S3 パターン
S3 を使用する際の環境変数は以下になります。
※ `https://xxx.cloudfront.net` は `バケット名/public` ディレクトリを参照するようにしています。

```env:.env
AWS_ACCESS_KEY_ID=アクセスキー
AWS_SECRET_ACCESS_KEY=秘密キー
AWS_DEFAULT_REGION=リージョン名
AWS_BUCKET=バケット名
AWS_URL=https://xxx.cloudfront.net
AWS_USE_PATH_STYLE_ENDPOINT=false
```

実行例

```php
$s3PresignedUrl = new S3PresignedUrl;
$s3PresignedUrl('test.pdf', 'public');

// [
//   'presignedUrl' => 'https://バケット名.s3.ap-northeast-1.amazonaws.com/public/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx',
//   'filePath' => 'public/test.pdf',
//   'fileUrl' => 'https://xxx.cloudfront.net/test.pdf',
// ]

$s3PresignedUrl = new S3PresignedUrl;
$s3PresignedUrl('test.pdf', 'private');

// [
//   'presignedUrl' => 'https://バケット名.s3.ap-northeast-1.amazonaws.com/private/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx',
//   'filePath' => 'private/test.pdf',
//   'fileUrl' => 'https://バケット名.s3.ap-northeast-1.amazonaws.com/private/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx',
// ]
```

### MinIO パターン
MinIO を使用する際の環境変数は以下になります。

```env:.env
AWS_ACCESS_KEY_ID=sail
AWS_SECRET_ACCESS_KEY=password
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=local
AWS_ENDPOINT=http://minio:9000
AWS_USE_PATH_STYLE_ENDPOINT=true
```

実行例
```php
$s3PresignedUrl = new S3PresignedUrl;
$s3PresignedUrl('test.pdf', 'public');

// [
//   'presignedUrl' => 'http://localhost:9000/local/public/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx',
//   'filePath' => 'public/test.pdf',
//   'fileUrl' => 'http://localhost:9000/local/public/test.pdf',
// ]

$s3PresignedUrl = new S3PresignedUrl;
$s3PresignedUrl('test.pdf', 'private');

// [
//   'presignedUrl' => 'http://localhost:9000/local/private/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx',
//   'filePath' => 'private/test.pdf',
//   'fileUrl' => 'http://localhost:9000/local/private/test.pdf?X-Amz-Content-Sha256=xxx&X-Amz-Algorithm=xxx&X-Amz-Credential=xxx&X-Amz-Date=xxx&X-Amz-SignedHeaders=xxx&X-Amz-Expires=xxx&X-Amz-Signature=xxx',
// ]
```

## 解説
MinIO を使用する際は環境変数に以下の設定をします。

```
AWS_URL=http://localhost:9000/local/public
AWS_ENDPOINT=http://minio:9000
AWS_USE_PATH_STYLE_ENDPOINT=true
```

```php
Storage::cloud()->get('private/test.pdf');
Storage::cloud()->temporaryUrl('private/test.pdf');
```

Docker コンテナを使用しているので Laravel から MinIO のファイルを取りに行くためには、コンテナ名を指定してアクセスする必要があります。
ただし、コンテナ外で動作するアプリケーションが、発行された署名付きURLに対してアップロードしようとした場合に問題が発生します。
Laravel はコンテナ内で動作しているため問題がないのですが、コンテナ外で動作するアプリケーションは `http://minio:9000` にアクセスすることができません。
そこで、環境変数を以下に変更するとアップロードできるようになるのですが、今度は Laravel から MinIO のファイルにアクセスすることができなくなります。

```
AWS_ENDPOINT=http://localhost:9000
```

この問題が発生するのはコンテナで MinIO を運用している時なので `AWS_USE_PATH_STYLE_ENDPOINT` が `true` の場合にS3クライアントの設定を書き換えるようにします。
Storage クラスから設定を取得し、その設定の内 `endpoint` のパラメータを、 `url` のパラメータからパスを取り除いた URL に置き換えます。

```php
$storage = Storage::cloud();
$config = $storage->getConfig();
if ($config['use_path_style_endpoint']) {
    $endpoint = Str::match('/https?:\/\/[^\/]*/', $config['url']);
    $storage = Storage::build(Arr::set($config, 'endpoint', $endpoint));
}

// [
//     url => http://localhost:9000/local/public,
// -   endpoint => http://minio:9000,
// +   endpoint => http://localhost:9000,
// ]
```

## 最後に
Laravel9 で S3 のアップロード用の署名付き URL を発行する方法を探していた方や MinIO との両立に困っていた方の助けになれば嬉しいです。
私がコードを読んで解釈した内容ですので、もし間違っている箇所がありましたらご指摘いただけますと幸いです。