クライアントからS3に署名付きURLでアップロードする(PHP編)

プログラミング

S3に署名付きURLでファイルをアップロードする方法についてまとめます。

つまずきポイントもあったのでそれらも含めて書いています。

環境

PHP 8.1
Laravel 8.*
league/flysystem-aws-s3-v3 1.0
TypeScript
axios

署名付きURLとは

S3はデフォルトですべてのバケット及びオブジェクトはプライベートになります。
そのため、プライベートなS3にアクセスするにはアクセスキーやシークレットキーなどの認証情報が必要だったりします。

権限がないユーザーやリソースからオブジェクトにアクセスできないため、プライベートバケットにあるオブジェクトをダウンロードできないなどの問題が起きます。
それらを解決してくれるのが署名付きURLです。

一言で言うと、有効期限内に一時的にS3にアクセスできるURLを発行する機能です。

この機能によって一時的に公開したい場合やアクセス権限を持っていないユーザーに対しても一時的にダウンロードやアップロードを実行させることが可能です。

署名付きURLを発行するには、該当のバケットやオブジェクトに対してアクセス権限を持っている必要があります。

署名付きURLの仕組みについてはこちらの記事で詳しく解説されていました。

【AWS S3】S3 Presigned URLの仕組みを調べてみた - Qiita
はじめにS3上にあるファイルを一時的に不特定多数に公開したい場合や、IAM Userアカウントを持っていない人に対して一時的にファイルのダウンロード/アップロードさせたい場合があります。このような…

この署名付きURLを使ってクライアント側から直接S3にファイルをアップロードする方法を解説します。

署名付きURLでアップロードする

本記事ではLaravelを前提で話を進めますが、基本的にファイルをアップロードするときは下記のようにクライアントからリクエストがあって、サーバー側でファイルをS3にアップロードすることが多いと思います。

今回はサーバー側でファイルをアップロードするのではなく、クライアントから「直接」アップロードする方法です。
直接と言ってもサーバー側でアップロードするための下準備が必要になります。

下記のようなイメージ↓↓↓

サーバー側

Storageファサードを利用してアップロード用の署名付きURLを発行するメソッドを自作します。

Storageファサードに元々一時的なURLを発行するtemporaryUrlメソッドが用意されていますが、ダウンロード用のメソッドになっているためアップロード時には使用できません。

temporaryUrlメソッドの中を見ると下記のようになっています。

    public function temporaryUrl($path, $expiration, array $options = [])
    {
        $adapter = $this->driver->getAdapter();

        if ($adapter instanceof CachedAdapter) {
            $adapter = $adapter->getAdapter();
        }

        if (method_exists($adapter, 'getTemporaryUrl')) {
            return $adapter->getTemporaryUrl($path, $expiration, $options);
        }

        if ($this->temporaryUrlCallback) {
            return $this->temporaryUrlCallback->bindTo($this, static::class)(
                $path, $expiration, $options
            );
        }

        if ($adapter instanceof AwsS3Adapter) {
            // S3の場合はこのメソッドが呼び出される
            return $this->getAwsTemporaryUrl($adapter, $path, $expiration, $options);
        }

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

    public function getAwsTemporaryUrl($adapter, $path, $expiration, $options)
    {
        $client = $adapter->getClient();
        // ここ↓
        $command = $client->getCommand('GetObject', 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.
        if (! is_null($url = $this->driver->getConfig()->get('temporary_url'))) {
            $uri = $this->replaceBaseUrl($uri, $url);
        }

        return (string) $uri;
    }

ドライバーをS3に設定しているのでtemporaryUrlメソッドの中のgetAwsTemporaryUrlメソッドが呼び出されます。

実際にURLを発行しているのはこの中の処理で、getCommandメソッド(31行目)を見てわかるようにGetObjectというコマンド指定しているため、temporaryUrlで発行されたURLはダウンロードしかできないことになります。

このgetAwsTemporaryUrlメソッドを少しハックしてアップロード用のメソッドを自作します。

    public function generatePresignedUploadUrl(string $path, UploadedFile $file): string
    {
        /** @var AwsS3Adapter $adapter */
        $adapter = Storage::disk('s3')
            ->getDriver()
            ->getAdapter();
        $client = $adapter->getClient();
        $command = $client->getCommand('PutObject', [ // ①
            'Bucket' => $adapter->getBucket(),        // ②
            'Key' => $path . '/' .$file->hashName(),  // ③
        ]);
        // ④
        $expire = now()->addSeconds(60);
        // ⑤
        $uri = $client->createPresignedRequest($command, $expire)
            ->getUri();
        // ⑥
        return (string) $uri;
    }

 S3の新しいオブジェクトを既存のオブジェクトの中に入れるために、getCommandメソッドの第一引数に PutObject を指定
 バケット名
 オブジェクトのパス名
 URLの有効期限
 ①で作成したコマンドと有効期限を元に署名付きURLを発行
 getUriメソッドの返り値の型が UriInterface のため、文字列にキャスト

下記のようなURLが発行されます。

https://<bucket名>.s3.amazonaws.com/<S3のpath>
	?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD
	&X-Amz-Algorithm=AWS4-HMAC-SHA256
	&X-Amz-Credential=access_key%2F20220415%2Fus-east-1%2Fs3%2Faws4_request
	&X-Amz-Date=20220415T082955Z
	&X-Amz-SignedHeaders=host
	&X-Amz-Expires=2
	&X-Amz-Signature=d3c7ba3fcf2d7bddd4986b3ff90ce616d30dcb87c9ffb242256ace4601afc2f3

小難しいことはLaravelやライブラリでやってくれるので必要な値を渡してあげるだけで基本的に完結します。

クライアント側

サーバー側でURLを発行しているので特にAWS-SDK等を使わずにサーバーから返ってきた署名付きURLをPUTでリクエストすればファイルのアップロードが可能です。

type PreSignedUrl = Readonly<{
    pre_signed_url: string,
}>

function generatePreSingedUrl(
    endpoint: string,
    file: File
): Promise<PreSignedUrl> {
    const formData = new FormData();
    formData.append('file', file, file.name)
    return axios.post<ResponseData<PreSignedUrl>>(endpoint, formData)
        .then((response) => response.data.data)
}

function uploadFile(
    url: string,
    file: File,
): Promise<any> {
    const options = {
        headers: {
            'Content-Type': file.type
        }
    }
    return axios.put<ResponseData<any>>(url, file, options)
        .then((response) => response)
}

// ①
const preSignedUrl = await generatePreSingedUrl('<署名付きURLを発行するエンドポイント>', fileData);
// ②
await uploadFile(preSignedUrl, fileData);

 署名付きURLをリクエスト
 返ってきた署名付きURLをPUT

アップロード完了後のレスポンスにアップロード先のパスは返ってきません。

注意点としてはuploadFileメソッドで Content-Type を指定していますが、これがないとすべて application/x-www-form-urlencoded としてS3に保存されます。

S3のCORS設定

クライアントから直接S3にリクエストを送るためS3側でCORSの設定が必要になります。

CORSについてはこちらをご覧ください↓

コンソール画面からS3>バケット>アクセス許可のタブを選択すると一番下にCORSの設定箇所があります。

そこに下記のようなJSON形式のもの貼り付けることで設定できます。

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT"
        ],
        "AllowedOrigins": [
            "許可するオリジン",
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 3000
    }
]

PUTリクエストを投げるので AllowedMethods にPUTの指定が必要です。

詰まったポイント

アップロードできたがダウンロードするとファイルが開けない

フロント側の知識が不足したいたため、何も考えず署名付きURLでPUTをするときに下記のようにFormDataオブジェクトを使ってリクエストを投げていました。

function uploadFile(
    url: string,
    file: File,
): Promise<any> {
    const formData = new FormData()
    formData.append('file', file, file.name)
    const options = {
        headers: {
            'Content-Type': file.type
        }
    }
    return axios.put<ResponseData<any>>(url, formData, options)
        .then((response) => response)
}

アップロードは成功するがダウンロードするとファイルが開けないという問題が発生し、アップロードしたファイルを見るとContent-Typemultipart/form-data; boundary=----WebKitFormBoundaryxHivTQjvcl2nv8aKになっていることを発見しました。

どうやらFormDataオブジェクトを使用するとHTTP Bodyを勝手に書き換えてしまうことが原因のようでした。↓↓↓

署名付きURLでAWS S3にファイルをアップロードする際に気をつけること

FormDataオブジェクトを使わずにそのままファイルをアップロードすることで解決しました。

(余談)DBへの保存処理との順番

はじめはアップロードされたファイル情報をDBに保存する処理のレスポンスとして署名付きURLを返し、その後PUTするという流れにしていました。

この場合だとPUTのタイミングで何らかの理由でアップロードが失敗すると、DBにはファイル情報が保存され一覧画面などに表示されるが、ダウンロードできないということが発生します。
アップロードが完了していないので指定のパスでリクエストしてもそもそもそんなオブジェクトはないとなります。

PUTしてからDBに情報を保存するなど処理の順番や失敗したときのエラーハンドリングは考える必要があります。

最後に

署名付きURLでダウンロードする方法は実装したことがありましたが、アップロードする方法は知らなかったので勉強になりました。

一時的にS3のオブジェクトにアクセスできる方法を知っておくとファイル周りの操作で選択肢が広がりそうです。

参考記事

コメント

タイトルとURLをコピーしました