GASでURLから画像をS3にをアップロードする

## 経緯
画像URLからGASで画像をS3にをアップロードしたら画像ファイルが壊れていて開けなかったので、
その時の対応をまとめました。

## S3ライブラリ
下記のライブラリIDを使用
`1Qx-smYQLJ2B6ae7Pncbf_8QdFaNm0f-br4pbDg0DXsJ9mZJPdFcIEkw_`

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67962/15e5f6d8-c299-b504-cc99-687ad923a116.png" width=500>

## 実装
```
const s3 = S3.getInstance("your_accessKeyId", "your_secretAccessKey");

var blob = UrlFetchApp.fetch("https://xxxxx.com/img/2001/2001.jpg").getBlob();

s3.putObject("bucket_name", "/img/2001/2001.jpg", blob, {logRequests:true});
```

この実装だとS3にアップロードされたファイルをダウンロードして開こうとするとエラーになる
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67962/346fe94b-a85f-8f8c-b9e9-d77937fe9229.png" width=300>

## 原因
s3.putObjectが画像に対応していなく文字列になってしまうため


## 解決
- S3ライブラリをコピーして修正を行う
- [S3.gs line 24](https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3Request.gs#L24) 
  - `this.region = 'us-east-1';` -> `this.region = 'ap-northeast-1';`　に変更

- 下記リンク先をみながらs3ライブラリを修正する
  - https://github.com/eschultink/S3-for-Google-Apps-Script/issues/8#issuecomment-796907327

（転写）
> * [S3.gs line 142](https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3.gs#L142): change `request.setContent(object.getDataAsString());` to `request.setContent(object);`
> * [S3Request.gs line 60](https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3Request.gs#L60): remove the line `if (typeof content != 'string') throw 'content must be passed as a string'`
> * [S3Request.gs line 393](https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3Request.gs#L393): replace the function with the following:
> 
> ```js
> S3Request.prototype.hexEncodedHash = function(string) {
>   if(typeof string === "string")
>     return this.hex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, string, Utilities.Charset.UTF_8));
>   else
>     return this.hex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, string.getBytes()));
> }
> ```


## S3ライブラリの修正方法
下記のファイルの内容をコピーしてGASに貼り付けて上記の解決方法を適用する
https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3.gs
https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3Request.gs

## 実装（修正後）
- S3ライブラリを同一ファイルにコピーし上記解決方法を適用
- `S3.getInstance` -> `getInstance` に修正

```
function myFunction() {
  
    // const s3 = S3.getInstance("your_accessKeyId", "your_secretAccessKey");
    const s3 = getInstance("your_accessKeyId", "your_secretAccessKey");

    var blob = UrlFetchApp.fetch("https://xxxxx.com/img/2001/2001.jpg").getBlob();

    s3.putObject("bucket_name", "/img/2001/2001.jpg", blob, {logRequests:true});

}

/* 
 * very basic AWS S3 Client library for Google Apps Script
 * @author Erik Schultink <erik@engetc.com>
 * includes create/delete buckets, create/read/delete objects. very limited support for any optional params.
 * 
 * @see http://engetc.com/projects/amazon-s3-api-binding-for-google-apps-script/
 */

/**
 * @license Copyright 2014-15 Eng Etc LLC - All Rights Reserved
 *
 * LICENSE (Modified BSD) - Redistribution and use in source and binary forms, with or without modification, 
 * are permitted provided that the following conditions are met:
 *   1) Redistributions of source code must retain the above copyright notice, this list of conditions and 
 *      the following disclaimer.
 *   2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions 
 *      and the following disclaimer in the documentation and/or other materials provided with the 
 *      distribution.
 *   3) Neither the name of the Eng Etc LLC, S3-for-Google-Apps-Script, nor the names of its contributors may be used to endorse or 
 *      promote products derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED 
 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 
 * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ENG ETC LLC BE LIABLE FOR ANY DIRECT, INDIRECT, 
 * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED 
 * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 
 * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 
 * SUCH DAMAGE.
 */

//Body of the library


/* constructs an S3 service
 *
 * @constructor
 * @param {string} accessKeyId your AWS AccessKeyId
 * @param {string} secretAccessKey your AWS SecretAccessKey
 * @param {Object} options key-value object of options, unused
 *
 * @return {S3}
 */
function getInstance(accessKeyId, secretAccessKey, options) {
  return new S3(accessKeyId, secretAccessKey, options);
}

/* constructs an S3 service
 *
 * @constructor
 * @param {string} accessKeyId your AWS AccessKeyId
 * @param {string} secretAccessKey your AWS SecretAccessKey
 * @param {Object} options key-value object of options, unused
 */
function S3(accessKeyId, secretAccessKey, options) {
  if (typeof accessKeyId !== 'string') throw "Must pass accessKeyId to S3 constructor";
  if (typeof secretAccessKey !== 'string') throw "Must pass secretAcessKey to S3 constructor";
  
  this.accessKeyId = accessKeyId;
  this.secretAccessKey = secretAccessKey;
  this.options = options | {};
}



/* creates bucket in S3
 *
 * @param {string} bucket name of bucket
 * @param {Object} options optional parameters to create request; supports x-amz-acl
 * @throws {Object} AwsError on failure
 * @return void
 */
S3.prototype.createBucket = function (bucket, options) {
  options = options || {}; 
  
  
  var request = new S3Request(this);
  request.setHttpMethod('PUT');
  
  //this is dumb and is optional from AWS perspective
  //but UrlFetchApp will default a Content-Type header to application/xml-www-form-url-encoded or whatever, which 
  //screws up the signature of the request
  request.setContentType('text/plain');
  
  //support setting of ACL
  if (typeof options["x-amz-acl"] == 'undefined') {
    options["x-amz-acl"] = "private";
  }
  request.addHeader("x-amz-acl", options["x-amz-acl"]);
  
  request.setBucket(bucket);
  
  request.execute(options);
  
};

/* deletes bucket from S3 
 *
 * @param {string} bucket name of bucket
 * @param {Object} options optional parameters to delete request
 * @throws {Object} AwsError on failure
 * @return void
 */
S3.prototype.deleteBucket = function (bucket, options) {
  options = options || {};

  var request = new S3Request(this);
  request.setHttpMethod('DELETE');
  
  request.setBucket(bucket);
  request.execute(options);
};

/* puts an object into S3 bucket
 * 
 * @param {string} bucket 
 * @param {string} objectName name to uniquely identify object within bucket
 * @param {string} object byte sequence that is object's content
 * @param {Object} options optional parameters
 * @throws {Object} AwsError on failure
 * @return void
 */
S3.prototype.putObject = function (bucket, objectName, object, options) {
  options = options || {};

  var request = new S3Request(this);
  request.setHttpMethod('PUT');
  request.setBucket(bucket);
  request.setObjectName(objectName);
  
  var failedBlobDuckTest = !(typeof object.copyBlob == 'function' &&
                      typeof object.getDataAsString == 'function' &&
                      typeof object.getContentType == 'function'
                      );
  
  //wrap object in a Blob if it doesn't appear to be one
  if (failedBlobDuckTest) {
    object = Utilities.newBlob(JSON.stringify(object), "application/json");
    object.setName(objectName);
  }
  
  // request.setContent(object.getDataAsString());
  request.setContent(object);
  request.setContentType(object.getContentType());
  
  request.execute(options);  
};

/* gets object from S3 bucket
 *
 * @param {string} bucket name of bucket
 * @param {string} objectName name that uniquely identifies object within bucket
 * @param {Object} options optional parameters for get request (unused)
 * @throws {Object} AwsError on failure
 * @return {Blob|Object} data value, converted from JSON or as a Blob if it was something else; null if it doesn't exist
 */
S3.prototype.getObject = function (bucket, objectName, options) {
  options = options || {};
  
  var request = new S3Request(this);
  request.setHttpMethod('GET');
  
  request.setBucket(bucket);
  request.setObjectName(objectName);
  try {
    var responseBlob = request.execute(options).getBlob();
  } catch (e) {
    if (e.name == "AwsError" && e.code == 'NoSuchKey') {
      return null;
    } else {
      //some other type of error, rethrow
      throw e; 
    }
  }
  
  //not sure this is better to put here, rather than in S3Request class
  if (responseBlob.getContentType() == "application/json") {
     return JSON.parse(responseBlob.getDataAsString());
  }
  return responseBlob;
};

/* deletes object from S3 bucket
 *
 * @param {string} bucket bucket name
 * @param {string} objectName name that uniquely identifies object within bucket
 * @param {Object} options optional parameters to delete request, unused
 * @throws {Object} AwsError on failure
 * @return void
 */
S3.prototype.deleteObject = function (bucket, objectName, options) {
  options = options || {};  
  
  var request = new S3Request(this);
  request.setHttpMethod('DELETE');
  
  request.setBucket(bucket);
  request.setObjectName(objectName);
  
  request.execute(options);  
};


//for debugging
S3.prototype.getLastExchangeLog = function() {
  return this.lastExchangeLog; 
}

/*
 * helper to format log entry about HTTP request/response
 * 
 * @param {Object} request object, from UrlFetchApp.getRequest()
 * @param {goog.HTTPResponse} response object, from UrlFetchApp
 */
S3.prototype.logExchange_ = function(request, response) {
  var logContent = "";
  logContent += "\n-- REQUEST --\n";
  for (i in request) {
    if (typeof request[i] == 'string' && request[i].length > 1000) {
      //truncate to avoid making log unreadable
      request[i] = request[i].slice(0, 1000) + " ... [TRUNCATED]"; 
    }
    logContent += Utilities.formatString("\t%s: %s\n", i, request[i]);
  }
    
  logContent += "-- RESPONSE --\n";
  logContent += "HTTP Status Code: " + response.getResponseCode() + "\n";
  logContent += "Headers:\n";
  
  var headers = response.getHeaders();
  for (i in headers) {
    logContent += Utilities.formatString("\t%s: %s\n", i, headers[i]);
  }
  logContent += "Body:\n" + response.getContentText();
  this.lastExchangeLog = logContent;
}

/*
 * Most code of AWS Signature Version 4 is ported from the aws-sdk-js
 * https://github.com/aws/aws-sdk-js/blob/7cc9ae5b0d7b2935fa69dee945d5f3e6e638c660/lib/signers/v4.js
 * 
 */

/* constructs an S3Request to an S3 service
 *
 * @constructor
 * @param {S3} service S3 service to which this request will be sent
 */
function S3Request(service) {
  this.service = service;

  this.httpMethod = "GET";
  this.contentType = "";
  this.content = ""; //content of the HTTP request
  this.bucket = ""; //gets turned into host (bucketName.s3.amazonaws.com)
  this.objectName = "";
  this.headers = {};

  this.date = new Date();
  this.serviceName = 's3';
  // this.region = 'us-east-1';
  this.region = 'ap-northeast-1';
  this.expiresHeader = 'presigned-expires';
  this.extQueryString = '';
}

/* sets contenetType of the request
 * @param {string} contentType mime-type, based on RFC, indicated how content is encoded
 * @throws {string} message if invalid input
 * @return {S3Request} this request, for chaining
 */
S3Request.prototype.setContentType = function (contentType) {
  if (typeof contentType != 'string') throw 'contentType must be passed as a string';
  this.contentType = contentType;
  return this;
};

S3Request.prototype.getContentType = function () {
  if (this.contentType) {
    return this.contentType;
  } else {
    //if no contentType has been explicitly set, default based on HTTP methods
    if (this.httpMethod == "PUT" || this.httpMethod == "POST") {
      //UrlFetchApp defaults to this for these HTTP methods
      return "application/x-www-form-urlencoded";
    }
  }
  return "";
}


/* sets content of request
 * @param {string} content request content encoded as a string
 * @throws {string} message if invalid input
 * @return {S3Request} this request, for chaining
 */
S3Request.prototype.setContent = function(content) {
  // if (typeof content != 'string') throw 'content must be passed as a string'
  this.content = content;
  return this;
};

/* sets Http method for request
 * @param {string} method http method for request
 * @throws {string} message if invalid input
 * @return {S3Request} this request, for chaining
 */
S3Request.prototype.setHttpMethod = function(method) {
  if (typeof method != 'string') throw "http method must be string";
  this.httpMethod = method;
  return this;
};

/* sets bucket name for the request
 * @param {string} bucket name of bucket on which request operates
 * @throws {string} message if invalid input
 * @return {S3Request} this request, for chaining
 */
S3Request.prototype.setBucket = function(bucket) {
  if (typeof bucket != 'string') throw "bucket name must be string";
  this.bucket = bucket;
  return this;
};
/* sets objectName (key) for request
 * @param {string} objectName name that uniquely identifies object within bucket
 * @throws {string} message if invalid input
 * @return {S3Request} this request, for chaining
 */
S3Request.prototype.setObjectName = function(objectName) {
  if (typeof objectName != 'string') throw "objectName must be string";
  this.objectName = objectName;
  return this;
};


/* adds HTTP header to S3 request (see AWS S3 REST api documentation for possible values)
 *
 * @param {string} name Header name
 * @param {string} value Header value
 * @throws {string} message if invalid input
 * @return {S3Request} this object, for chaining
 */
S3Request.prototype.addHeader = function(name, value) {
  if (typeof name != 'string') throw "header name must be string";
  if (typeof value != 'string') throw "header value must be string";
  this.headers[name] = encodeURIComponent(value);
  return this;
};

S3Request.prototype._getUrl = function() {
  return "https://" + this.bucket.toLowerCase() + ".s3." + this.region + ".amazonaws.com/" + this.objectName;
};
/* gets Url for S3 request
 * @return {string} url to which request will be sent
 */
S3Request.prototype.getUrl = function() {
  return this._getUrl() + this.extQueryString;
};
/* executes the S3 request and returns HttpResponse
 *
 * Supported options:
 *   logRequests - log requests (and responses) will be logged to Apps Script's Logger. default false.
 *   echoRequestToUrl - also send the request to this URL (useful for debugging Apps Script weirdness)
 *
 * @param {Object} options object with properties corresponding to option values; see documentation
 * @throws {Object} AwsError on failure
 * @returns {goog.UrlFetchApp.HttpResponse}
 */
S3Request.prototype.execute = function(options) {
  options = options || {};
  for (var key in options) {
    var lowerKey = key.toLowerCase()
    if (lowerKey.indexOf('x-amz-') === 0) {
      this.addHeader(key, options[key])
    }
  }

  delete this.headers['Authorization'];
  delete this.headers['Date'];
  delete this.headers['X-Amz-Date'];
  this.headers['X-Amz-Content-Sha256'] = this.hexEncodedBodyHash();
  this.headers['Host'] = this._getUrl().replace(/https?:\/\/(.+amazonaws\.com).*/, '$1');

  var credentials = {
    accessKeyId: this.service.accessKeyId,
    secretAccessKey: this.service.secretAccessKey,
    sessionToken: options.sessionToken
  }

  this.addAuthorization(credentials, this.date)
  // To avoid conflict with UrlFetchApp#fetch. UrlFetchApp#fetch adds a Host header.
  delete this.headers['Host']

  var params = {
    method: this.httpMethod,
    payload: this.content,
    headers: this.headers,
    muteHttpExceptions: true //get error content in the response
  }

  //only add a ContentType header if non-empty (although should be OK either way)
  if (this.getContentType()) {
    params.contentType = this.getContentType();
  }

  var response = UrlFetchApp.fetch(this.getUrl(), params);

  //debugging stuff
  var request = UrlFetchApp.getRequest(this.getUrl(), params);

  //Log request and response
  this.lastExchangeLog = this.service.logExchange_(request, response);
  if (options.logRequests) {
    Logger.log(this.service.getLastExchangeLog());
  }

  //used in case you want to peak at the actual raw HTTP request coming out of Google's UrlFetchApp infrastructure
  if (options.echoRequestToUrl) {
    UrlFetchApp.fetch(options.echoRequestToUrl, params);
  }

  //check for error codes (AWS uses variants of 200s for flavors of success)
  if (response.getResponseCode() > 299) {
    //convert XML error response from AWS into JS object, and give it a name
    var error = {};
    error.name = "AwsError";
    try {
      var errorXmlElements = XmlService.parse(response.getContentText()).getRootElement().getChildren();
      for (i in errorXmlElements) {
        var name = errorXmlElements[i].getName();
        name = name.charAt(0).toLowerCase() + name.slice(1);
        error[name] = errorXmlElements[i].getText();
      }
      error.toString = function() { return "AWS Error - "+this.code+": "+this.message; };
      error.httpRequestLog = this.service.getLastExchangeLog();
    } catch (e) {
      //error parsing XML error response from AWS (will obscure actual error)
      error.message = "AWS returned HTTP code " + response.getResponseCode() + ", but error content could not be parsed."
      error.toString = function () { return this.message; };
      error.httpRequestLog = this.service.getLastExchangeLog();
    }
    throw error;
  }

  return response;
};

S3Request.prototype.addAuthorization = function(credentials, date) {
  var datetime = date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
  if (this.isPresigned()) {
    this.updateForPresigned(credentials, datetime);
  } else {
    this.addHeaders(credentials, datetime);
  }
  this.headers['Authorization'] = this.authorization(credentials, datetime)
}

S3Request.prototype.addHeaders = function (credentials, datetime) {
  this.headers['X-Amz-Date'] = datetime;
  if (credentials.sessionToken) {
    this.headers['x-amz-security-token'] = credentials.sessionToken;
  }
}

S3Request.prototype.updateForPresigned = function(credentials, datetime) {
  var credString = this.credentialString(datetime);
  var qs = {
    'X-Amz-Date': datetime,
    'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
    'X-Amz-Credential': credentials.accessKeyId + '/' + credString,
    'X-Amz-Expires': this.headers[this.expiresHeader],
    'X-Amz-SignedHeaders': this.signedHeaders()
  };

  if (credentials.sessionToken) {
    qs['X-Amz-Security-Token'] = credentials.sessionToken;
  }

  if (this.headers['Content-Type']) {
    qs['Content-Type'] = this.headers['Content-Type'];
  }
  if (this.headers['Content-MD5']) {
    qs['Content-MD5'] = this.headers['Content-MD5'];
  }
  if (this.headers['Cache-Control']) {
    qs['Cache-Control'] = this.headers['Cache-Control'];
  }

  for (var key in this.headers) {
    if (key === this.expiresHeader) continue;
    if (this.isSignableHeader(key)) {
      var lowerKey = key.toLowerCase();
      // Metadata should be normalized
      if (lowerKey.indexOf('x-amz-meta-') === 0) {
        qs[lowerKey] = this.headers[key];
      } else if (lowerKey.indexOf('x-amz-') === 0) {
        qs[key] = this.headers[key];
      }
    }
  }

  var sep = this._getUrl().indexOf('?') >= 0 ? '&' : '?';
  var queryParamsToString = function(params) {
    var items = [];
    for (var key in params) {
      var value = params[key];
      var ename = encodeURIComponent(key);
      if (Array.isArray(value)) {
        var vals = [];
        for(var i in value) { vals.push(encodeURIComponent(value[i])) }
        items.push(ename + '=' + vals.sort().join('&' + ename + '='))
      } else {
        items.push(ename + '=' + encodeURIComponent(value))
      }
    }
    return items.sort().join('&')
  }
  this.extQueryString += sep + queryParamsToString(qs);
}

S3Request.prototype.authorization = function(credentials, datetime) {
  var parts = [];
  var credString = this.credentialString(datetime);
  parts.push('AWS4-HMAC-SHA256 Credential=' + credentials.accessKeyId + '/' + credString);
  parts.push('SignedHeaders=' + this.signedHeaders());
  parts.push('Signature=' + this.signature(credentials, datetime));
  return parts.join(', ');
}

S3Request.prototype.signature = function(credentials, datetime) {
  var sigingKey = this.getSignatureKey(
    credentials.secretAccessKey,
    datetime.substr(0, 8),
    this.region,
    this.serviceName
  )
  var signature = Utilities.computeHmacSha256Signature(Utilities.newBlob(this.stringToSign(datetime)).getBytes(), sigingKey)
  return this.hex(signature)
}

S3Request.prototype.hex = function(values) {
  return values.reduce(function(str, chr){
    chr = (chr < 0 ? chr + 256 : chr).toString(16);
    return str + (chr.length == 1 ? '0' : '') + chr;
  }, '');
}

S3Request.prototype.getSignatureKey = function(key, dateStamp, regionName, serviceName) {
  var kDate = Utilities.computeHmacSha256Signature(dateStamp, "AWS4" + key);
  var kRegion = Utilities.computeHmacSha256Signature(Utilities.newBlob(regionName).getBytes(), kDate);
  var kService = Utilities.computeHmacSha256Signature(Utilities.newBlob(serviceName).getBytes(), kRegion);
  var kSigning = Utilities.computeHmacSha256Signature(Utilities.newBlob("aws4_request").getBytes(), kService);
  return kSigning;
}

S3Request.prototype.stringToSign = function(datetime) {
  var parts = [];
  parts.push('AWS4-HMAC-SHA256');
  parts.push(datetime);
  parts.push(this.credentialString(datetime));
  parts.push(this.hexEncodedHash(this.canonicalString()));
  return parts.join('\n');
}

S3Request.prototype.canonicalString = function() {
  var parts = [];
  var [base, search] = this.getUrl().split("?", 2)
  parts.push(this.httpMethod);
  parts.push(this.canonicalUri(base));
  parts.push(this.canonicalQueryString(search));
  parts.push(this.canonicalHeaders() + '\n');
  parts.push(this.signedHeaders());
  parts.push(this.hexEncodedBodyHash());
  return parts.join('\n');
}

S3Request.prototype.canonicalUri = function(uri) {
  var m = uri.match(/https?:\/\/(.+)\.s3.*\.amazonaws\.com\/(.+)$/);
  var object = m ? m[2] : ""
  return "/" + encodeURIComponent(object).replace(/%2F/ig, '/')
}

S3Request.prototype.canonicalQueryString = function(values) {
  if (!values) return ""
  var parts = [];
  var items = values.split("&");
  for (var i in items) {
    var [key, value] = items[i].split("=")
    parts.push(encodeURIComponent(key.toLowerCase()) + "=" + encodeURIComponent(value))
  }
  return parts.sort().join("&")
}

S3Request.prototype.canonicalHeaders = function() {
  var parts = [];
  for (var item in this.headers) {
    var key = item.toLowerCase();
    if (this.isSignableHeader(key)) {
      var header = key + ":" + this.canonicalHeaderValues(this.headers[item].toString())
      parts.push(header)
    }
  }
  return parts.sort().join("\n")
}

S3Request.prototype.canonicalHeaderValues = function(values) {
  return values.replace(/\s+/g, " ").trim();
}

S3Request.prototype.signedHeaders = function() {
  var keys = [];
  for (var key in this.headers) {
    key = key.toLowerCase();
    if (this.isSignableHeader(key)) {
      keys.push(key);
    }
  }
  return keys.sort().join(';');
}

S3Request.prototype.credentialString = function(datetime) {
  return [
    datetime.substr(0, 8),
    this.region,
    this.serviceName,
    'aws4_request'
  ].join('/');
}

// S3Request.prototype.hexEncodedHash = function(string) {
//   return this.hex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, string, Utilities.Charset.UTF_8));
// }
S3Request.prototype.hexEncodedHash = function(string) {
  if(typeof string === "string")
    return this.hex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, string, Utilities.Charset.UTF_8));
  else
    return this.hex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, string.getBytes()));
}

S3Request.prototype.hexEncodedBodyHash = function() {
  if (this.isPresigned() && !this.content.length) {
    return 'UNSIGNED-PAYLOAD'
  } else if (this.headers['X-Amz-Content-Sha256']) {
    return this.headers['X-Amz-Content-Sha256']
  } else {
    return this.hexEncodedHash(this.content || '')
  }
}

S3Request.prototype.isSignableHeader = function(key) {
  var lowerKey = key.toLowerCase()
  if (lowerKey.indexOf('x-amz-') === 0) return true;
  var unsignableHeaders = [
    'authorization',
    'content-type',
    'content-length',
    'user-agent',
    this.expiresHeader,
    'expect',
    'x-amzn-trace-id'
  ];
  return unsignableHeaders.indexOf(lowerKey) < 0
}

S3Request.prototype.isPresigned = function() {
  return this.headers[this.expiresHeader] ? true : false;
}
```

## 参考にしたリンク
- https://note.com/marina1017/n/n431f0bb4e342
- https://github.com/eschultink/S3-for-Google-Apps-Script/issues/8#issuecomment-796907327