【AWS】S3署名付きURLによる画像保存

1. 使用するサービス

(AWS)

  • S3
  • Lambda


2.概要

S3に画像をアップロードする際にいくつか手法があると思いますが、API GatewayとLambdaのサーバレスの構成の際、Lambdaに対してbase64エンコードして送るなど考えがちかと思います。少し、時間はかかると思いますが、悪くない選択だと思います。しかし、API Gatewayの制約として、入力ペイロードの最大が10MBまでということもあり、複数画像の一括アップロードや動画の保存は現実的に不可能です。
そこで、今回、クライアント側から直接S3へセキュアな方法でアップロードする方法を説明します。

全体的な流れは下記のようになります。

s3_presigned_url
画像アップロード流れ

API Gatewayの設定等は前回の記事参考
buffalokusojima.hatenablog.com


3. 実装

【バックエンド実装】

3-1. S3署名付きURL発行Lambda

S3に署名付きURLの発行をS3に依頼し、署名付きURLと各種認証情報等を受け取り、レスポンスとして返すLambdaを作成します。

import json
import os
import boto3
import uuid

s3Client = boto3.client('s3')

FOLDER = '/tmp'
BUCKET_NAME = os.environ["PhotoBucketName"]

def handler(event, context):
    
    print(event)
    
    body = event["queryStringParameters"]
    
    file_name = body["file_name"]
    file_type = body["file_type"]
    file_num = body["file_num"]
    userName = event['requestContext']['authorizer']['claims']['cognito:username']
    file_extention = file_name.split(".")[-1]
    folder = os.path.join(userName, file_num)
    key = os.path.join(folder, str(uuid.uuid4())+"."+file_extention)
    
    presigned_post = s3Client.generate_presigned_post(
    Bucket = BUCKET_NAME,
    Key = key,
    Fields = {"acl": "public-read", "Content-Type": file_type},
    Conditions = [
      {"acl": "public-read"},
      {"Content-Type": file_type}
    ],
    ExpiresIn = 3600
    )
    
    return {
            "statusCode": 200,
            "body": json.dumps({"success": True, "url": presigned_post["url"], "data": presigned_post}),
            "headers": {
                "Access-Control-Allow-Headers": "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "OPTIONS"
            }
    }

3-2. S3のPUTトリガーが設定されたLambda

これはおまけですが、S3のPUTに対して反応するLambdaを作成します。イメージとしては、S3にPUTされた画像のURLをRDS等に保存するといった感じです。(今回はSQSに投げるまでにします)

import json
import os
import boto3
import uuid


queueClient = boto3.client('sqs')

QUEUE_URL = os.environ["ImageDataWriteQueue"]

def handler(event, context):
    
    print(event)
    
    records = event["Records"]
    
    for record in records:
        
        s3 = record["s3"]
        bucket_name = s3["bucket"]["name"] + ".s3.amazonaws.com"
        key = s3["object"]["key"]
        folder = "/".join(key.split("/")[:-1])
        url = "https://"+os.path.join(bucket_name, key)
        msg = {'messageId': str(uuid.uuid4()), "image_url": url, "folder": folder}
        queueClient.send_message(QueueUrl=QUEUE_URL, MessageBody=json.dumps(msg), MessageGroupId="group")

【フロントエンド】

3-3. 画像のアップロードとダウンロード

フロント側の実装になります。画像の表示とアップロードを行っています。
注意点としては、アップロード中に更新等で処理を中断すると、写真が古いままになる可能性があります。フロント側で処理が中断されないようにする必要があります。
getImageファンクションで叩いているAPIは別途作成する必要あります。RDS等に画像のURLを保存するなりして、そこからURLを引っ張り、レスポンスとして返すLambdaが理想です。機会があれば別途、そこら辺も紹介出来たらと思います。

<html>
    
    <head>
        <meta http-equiv="Cache-Control" content="no-store">

        <title>test</title>
    </head>
    
    <body onload="begining()">

    <input type="file" id="postfile1"/>
    <input type="file" id="postfile2"/>
    <input type="file" id="postfile3"/>
    <input type="file" id="postfile4"/>
    <input type="file" id="postfile5"/>
    <input type="file" id="postfile6"/>
    <input type="file" id="postfile7"/>
    <input type="file" id="postfile8"/>
    <input type="file" id="postfile9"/>
    <input type="file" id="postfile10"/>
    <input type="button" value="submit" onclick="postImage()" />
    
    <div id="image_field"></div>
    <script>
        
        const URL = "https://URL";

        function sendRequest(data, callback, callbackValue){
            
            var request = new XMLHttpRequest();

            let storage = localStorage;
            let id_token = storage.getItem("idToken");

            request.open(data.method, data.url);
            request.setRequestHeader("Content-Type", "application/json");
            
            request.onload = function(err){
                console.log(err);
                if(request.readyState == 4){
                    if(request.status == 200){
                        console.log("success");
                        var res = JSON.parse(request.responseText);
                        console.log(res);
                        callback(res, callbackValue);
                    }else if(request.status == 401){
                        var res = JSON.parse(request.responseText);
                        console.log(res);
                        singnIn();
                    }else{
                        console.log(request.status, request.response);
                        console.log(request.responseText);
                    }
                }
            }
            
            request.onerror = function(){
                console.log("err:" + request.status);
                console.log(request.responseText)
            }
            
            console.log("sending");
            
            var body;
            
            if(data.body){
                /*
                body = {
                    "body": JSON.stringify(data.body)
                };*/
                body = JSON.stringify(data.body);
                //body = data.body
            }
            
            console.log(body);
            
            request.send(body);
            
        }
        
        function begining(){
            getImage();
        }
        
        function getImage(){
            
            var data = {
                "method": "GET",
                "url": URL + "getImageURL",
                "body": null
            }
            
            sendRequest(data, showImage, null);
        }

        function showImage(data){
            var url_list = data.url_list;
            var image_filed = document.getElementById("image_field");
            for(var i=0; i<url_list.length; i++){
                var imgSrc = document.createElement("img");
                imgSrc.id = i+1;
                imgSrc.src = url_list[i];
                image_filed.appendChild(imgSrc);
            }
        }


        function postImage(){

            for(var i=1; i<11; i++){
                var file = document.getElementById("postfile"+i);
                console.log(file);

                file = file.files[0];
                if(!file){
                    continue;
                }
                
                var data = {
                    "method": "GET",
                    "url": URL + "getImagePresignedURL?file_name=" + file.name + "&file_type=" +file.type + "&file_num=" + i,
                    "body": null
                }

                sendRequest(data, uploadFile, file); 
            }
        }

        function uploadFile(s3Data, file){
            var xhr = new XMLHttpRequest();
            xhr.open("POST", s3Data.url);

            var postData = new FormData();
            for(var key in s3Data.data.fields){
                //console.log(key, s3Data.data.fields[key])
                postData.append(key, s3Data.data.fields[key]);
            }
            postData.append('file', file);

            xhr.onreadystatechange = function() {
                if(xhr.readyState === 4){
                if(xhr.status === 200 || xhr.status === 204){
                    //document.getElementById("preview").src = url;
                    //document.getElementById("avatar-url").value = url;
                }
                else{
                    alert("Could not upload file.");
                }
            }
            };
            xhr.send(postData);
        }

        
    </script>    
    </body>
</html>

4. おわりに

以上で簡単なS3の署名付きURLによる画像アップロードになります。
次回以降、PUTトリガー、SQSによるRDSとの連携とかも出来たらと思います。