AWS SDK for PHP を使って Laravel で Amazon S3 の署名付き URL を取得する

6 min read読了の目安(約5900字

はじめに

PHP の SDK を使って Laravel で S3 の署名付き URL を取得する処理を実装しました。今回は、その過程で学んだことやその実装方法についてまとめます。

署名付き URL とは

Amazon S3 では、バケットあるいはそのバケットのオブジェクトに対して、アクセスポリシーを定義したり IAM 単位で制御したりしてアクセスを制御できます。

署名付き URL とは、一言で言うと 有効期限内に一時的にアクセスできる URL を発行する機能です。署名付き URL を発行するには、該当の S3 バケットあるいはオブジェクトへの権限を持つユーザあるいは IAM ロールをアタッチされているリソースが任意の有効期限を指定して作成する必要があります。

方法としては、REST API, AWS CLI, AWS SDK の 3 パターンで実現できます。余談ですが、AWS のほとんどのリソースはこのように複数のインターフェースより抵抗なく操作できる点が魅力だと感じています。

今回は、3 つ目の AWS SDK for PHP を使って Laravel で構築したアプリケーション上に REST API として実装することにしました。

https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/s3-presigned-url.html

実装

ルーティング

まず、API のエンドポイントをルーティングに定義します。RESTful API の思想で構築しているため、このエンドポイントの命名にすごく悩みました。何かいいネーミングがあれば、是非教えていただけると嬉しいです。

routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

+ Route::get('s3/presignedurl', 'S3ClientController@getPresignedUrl')->name('s3.getPresignedUrl');

コントローラ

次に、ルーティングに定義したコントローラにアクションを作成していきます。最初に全体を。

app/Http/Controllers/S3ClientController.php
use App\Http\Requests\GetPresignedUrlRequest;
use Aws\S3\S3Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;

/**
* @param  \App\Http\Requests\GetPresignedUrlRequest  $request
* @return \Illuminate\Http\JsonResponse
*/
public function getPresignedUrl(GetPresignedUrlRequest $request): JsonResponse
{
    $s3Client = new S3Client([
        'region' => config('filesystems.disks.s3.region'),
        'version' => config('filesystems.disks.s3.aws_sdk_version'),
        'endpoint' => config('filesystems.disks.s3.client_url')
    ]);

    $filename = $this->makeUniqueFilename(
        pathinfo($request->filename, PATHINFO_EXTENSION)
    );

    $cmd = $s3Client->getCommand($request->method), [
        'Bucket' => config('filesystems.disks.s3.bucket'),
        'Key' => $filename
    ]);

    $presignedRequest = $s3Client->createPresignedRequest(
        $cmd,
        config('filesystems.disks.s3.aws_sdk.pre_signed_url.expired_time')
    );

    return response()->json([
        'filename' => $filename,
        'pre_signed_url' => (string) $presignedRequest->getUri()
    ]);
}

/**
* @param  string $extension
* @return string
*/
public function makeUniqueFilename(string $extenstion): string
{
    return (string) Str::uuid() . '.' . $extension;
}

順序立てて説明します。

getPresignedUrl と言うメソッドでは GetPresignedUrlRequest というフォームリクエストを引数として受け取ります。そうすることでコントローラ内にバリデーションを実装することなく処理を簡潔に書くことができます。詳細については次で触れます。

フォームリクエスト

フォームリクエストとは、HTTP リクエストをバリデーションする便利な機能です。下記の記事にわかりやすく解説してあったので是非参考にしてみてください。

https://qiita.com/sakuraya/items/abca057a424fa9b5a187

下記では、 filename , method というパラメータに対してのルールを指定しました。

app/Http/Requests/GetPresignedUrlRequest.php
public function rules()
{
    return [
        'filename' => 'bail|string|required',     // 文字列、必須ルール
        'method' => 'string|required|in:get,put'  // 'get' or 'put' の文字列、必須ルール
    ];
}

config

次に、 config() と何箇所か書いているところについて軽く触れておきます。

Laravel では、コントローラなどの処理に直接秘密情報である .env の情報を書くことは御法度とされています。だったらどうするかと言うと config/*.php 内で .env の値を取得し、コントローラでは config から読み取るという手法を取っています。

理由については、下記らしいです。癖でいつもこのように書いていて深くは気にすることはなかったので大変勉強になりました。

.env ファイルの読み込みは, php artisan config:cache していない場合にしか行われません! キャッシュを有効にしてある場合, .env に書いてあるだけでシェルから起動する時点で定義されていない環境変数はすべて未定義になってしまうので注意しましょう。

https://qiita.com/mpyw/items/34f37742d9a18b80a08c

続いて S3 の環境変数を取得できるよう config と .env に定義します。

config/filesystems.php
return [
  'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION', 'us-west-2'),
        'bucket' => env('AWS_BUCKET', 'test-bucket'),
        'aws_sdk' => [
            'version' => 'latest',
            'pre_signed_url' => [
                'expired_time' => '+5 minutes'  // 署名付き URL の有効期限のデフォルト値
            ]
        ]
    ]
  ]
];
.env
AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXX
AWS_SECRET_ACCESS_KEY=YYYYYYYYYY
AWS_DEFAULT_REGION=us-west-2
AWS_BUCKET=test-bucket

最後に、makeUniqueFilename メソッドについてです。

app/Http/Controllers/S3ClientController.php
use Illuminate\Support\Str;

/**
* @param  string $extension
* @return string
*/
public function makeUniqueFilename(string $extenstion): string
{
    return (string) Str::uuid() . '.' . $extension;
}

これはおまけみたいなものですが、uuid にファイル名を整形する処理を挟んでいます。理由としては S3 バケット内のオブジェクトを一意に識別できるため、このようにしました。

S3 はオブジェクトを一意に識別するために 16 桁のプレフィックスを付けることを以前は推奨されていましたが昨年くらいのアップデートによりその制限はなくなっています。

確認

実装したエンドポイントに対してリクエストを送って確認してみます。次のようなレスポンスが返って来たら OK です。

アップロードしたい場合の例

$ curl "http://localhost:3000/s3/presignedurl?filename=sample.jpeg&method=put" | jq .
{
    "filename": "cb12afe2-4e70-1234-5678-c248f4d1213b.jpeg",
    "pre_signed_url": "https://test-bucket.s3.us-west-2.amazonaws.com/cb12afe2-4e70-1234-5678-c248f4d1213b.jpeg?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA1AB234ABCDE5FGHIJ%2F20200719%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20200722T103113Z&X-Amz-SignedHeaders=host&X-Amz-Expires=300&X-Amz-Signature=123456789abc10ef203a46c75ff0e89c5678fd5f69a12345ee6d608123456cbd"
}

ここまで確認できれば、filename のファイル名で pre_signed_url のエンドポイントに対してリクエストを送るとアップロードできます。これで非公開に指定あるバケットに対して、認証情報を持たないフロントエンドからでも直接アップロードができるようになります。

まとめ

Laravel で S3 の署名付き URL を取得する API を実装してみました。

これまでは、サーバサイド側でアップロードの処理を実装していましが、署名付き URL を取得することでフロントエンドからでもアップロードが可能になりました。より実装の自由度が高くなったことで、アーキテクチャを疎結合化したり、 UX を向上を目指していきたいです。

参考にさせていただいた記事

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/PresignedUrlUploadObject.html
https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/s3-presigned-url.html
https://dev.classmethod.jp/articles/create-pre-signed-url-with-lambda/
https://qiita.com/tmiki/items/87697d3d3d5330c6fc08#aws-signature-v2v4とは