Google Apps ScriptでCloud Storageに大容量のデータを送る

## はじめに
Google Apps Script（以降GAS）でCloud Storageにファイルを送りたいと思ったのですが、動画ファイルなど容量の大きなファイルは[GASの制限](https://developers.google.com/apps-script/guides/services/quotas?hl=ja)に引っ掛かります。そこでGAS Webアプリのフロントエンド側（JavaScript）でCloud Storageにファイルを送ることを試してみました。

## 事前準備・前提
Cloud Storageを操作できるサービスアカウントを作成してください。サービスアカウントの中で必要な情報はスクリプトプロパティとして以下の名前で保存しておきます。
+ SERVICE_ACCOUNT_EMAIL
+ PRIVATE_KEY

また、Cloud Storageにアップロード先のバケットが作成されている前提です。

## 実装内容
作成するファイルは以下の２ファイルです。
+ main.gs
+ index.html

### main.gs
```js:main.gs
function doGet() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const serviceAccountEmail = scriptProperties.getProperty('SERVICE_ACCOUNT_EMAIL');
  const privateKey = scriptProperties.getProperty('PRIVATE_KEY');

  const template = HtmlService.createTemplateFromFile('index');
  template.serviceAccountEmail = serviceAccountEmail;
  template.privateKey = privateKey;

  return template.evaluate();
}
```
サービスアカウントの情報をスクリプトプロパティから読み込み、html側に渡しています。

:::note warn
今回のやり方ではサービスアカウントを使用していますが、用途に応じて認証の方法や公開範囲は検討ください。
本実装ではhtml側にサービスアカウントの情報を渡しているため、開発者ツール等を使用するとサービスアカウントの情報は閲覧可能な状態となります。
:::

### index.html
少し長いので、全文は以下を展開してください。

<details>
<summary>
index.html
</summary>

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">

    <style>
      .loading-overlay {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(255, 255, 255, 0.8);
        z-index: 1000;
        text-align: center;
        padding-top: 200px;
      }
      .spinner-border {
        width: 3rem;
        height: 3rem;
      }
      #progressBar {
        width: 50%;
        margin: 0 auto;
      }
    </style>
  </head>
  <body>
    <h1 class="text-center my-4">Upload File to Cloud Storage</h1>
    <div class="container">
      <div class="mb-3">
        <label for="bucketName" class="form-label">Bucket Name:</label>
        <input type="text" class="form-control" id="bucketName">
      </div>
      <div class="mb-3">
        <label for="fileInput" class="form-label">Select File:</label>
        <input type="file" class="form-control" id="fileInput">
      </div>
      <div class="mb-3">
        <button class="btn btn-primary" onclick="uploadFile()">Upload</button>
      </div>
      <p id="output"></p>
    </div>
    <div id="loading" class="loading-overlay">
      <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">Loading...</span>
      </div>
      <p>Uploading file, please wait...</p>
      <div class="progress" id="progressBar">
        <div class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
      </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
    <script>
      const serviceAccountEmail = '<?= serviceAccountEmail ?>';
      const privateKey = '<?= privateKey ?>';

      async function uploadFile() {
        const fileInput = document.getElementById('fileInput');
        const file = fileInput.files[0];
        const bucketName = document.getElementById('bucketName').value;
        console.log(bucketName);

        if (!file) {
          alert('No file selected.');
          return;
        }

        document.getElementById('loading').style.display = 'block';

        const url = 'https://storage.googleapis.com/upload/storage/v1/b/' + bucketName + '/o?uploadType=media&name=' + encodeURIComponent(file.name);
        const token = await getAccessToken();

        const xhr = new XMLHttpRequest();
        xhr.open('POST', url, true);
        xhr.setRequestHeader('Authorization', 'Bearer ' + token);
        xhr.setRequestHeader('Content-Type', file.type);

        xhr.upload.onprogress = function(event) {
          if (event.lengthComputable) {
            const percentComplete = (event.loaded / event.total) * 100;
            const progressBar = document.querySelector('.progress-bar');
            progressBar.style.width = percentComplete + '%';
            progressBar.setAttribute('aria-valuenow', percentComplete);
            progressBar.textContent = Math.round(percentComplete) + '%';
          }
        };

        xhr.onload = function() {
          document.getElementById('loading').style.display = 'none';
          resetProgressBar();
        };

        xhr.onerror = function() {
          document.getElementById('loading').style.display = 'none';
          resetProgressBar();
          alert('Error uploading file.');
        };

        xhr.send(file);
      }

      function resetProgressBar() {
        const progressBar = document.querySelector('.progress-bar');
        progressBar.style.width = '0%';
        progressBar.setAttribute('aria-valuenow', 0);
        progressBar.textContent = '0%';
      }

      async function getAccessToken() {
        try {
          const now = Math.floor(Date.now() / 1000);
          const payload = {
            iss: serviceAccountEmail,
            sub: serviceAccountEmail,
            aud: 'https://oauth2.googleapis.com/token',
            iat: now,
            exp: now + 3600,
            scope: 'https://www.googleapis.com/auth/cloud-platform'
          };

          const header = {
            alg: 'RS256',
            typ: 'JWT'
          };

          const base64UrlEncode = (obj) => {
            return btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
          };

          const signatureInput = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

          let key;
          try {
            key = await crypto.subtle.importKey(
              'pkcs8',
              str2ab(privateKey),
              {
                name: 'RSASSA-PKCS1-v1_5',
                hash: { name: 'SHA-256' }
              },
              false,
              ['sign']
            );
          } catch (importKeyError) {
            console.error('Error importing key:', importKeyError);
            throw importKeyError;
          }

          let signature;
          try {
            signature = await crypto.subtle.sign(
              {
                name: 'RSASSA-PKCS1-v1_5',
                hash: { name: 'SHA-256' }
              },
              key,
              new TextEncoder().encode(signatureInput)
            );
          } catch (signError) {
            console.error('Error signing JWT:', signError);
            throw signError;
          }

          const jwt = signatureInput + '.' + btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

          let response;
          try {
            response = await fetch('https://oauth2.googleapis.com/token', {
              method: 'POST',
              headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
              },
              body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + jwt
            });
          } catch (fetchError) {
            console.error('Error fetching access token:', fetchError);
            throw fetchError;
          }

          if (!response.ok) {
            throw new Error('Failed to fetch access token: ' + response.statusText);
          }

          const data = await response.json();
          return data.access_token;
        } catch (error) {
          console.error('Error getting access token:', error);
          throw error;
        }
      }

      function str2ab(pem) {
        const pemHeader = "-----BEGIN PRIVATE KEY-----";
        const pemFooter = "-----END PRIVATE KEY-----";
        const pemContents = pem.replace(pemHeader, '').replace(pemFooter, '').replace(/\\n/g, '');
        const base64String = pemContents;
        const binary_string = window.atob(base64String);
        const len = binary_string.length;
        const bytes = new Uint8Array(len);
        for (let i = 0; i < len; i++) {
          bytes[i] = binary_string.charCodeAt(i);
        }
        return bytes.buffer;
      }
    </script>
  </body>
</html>
```

</details>

簡単にいくつか解説します。
```html
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">

    <style>
      .loading-overlay {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(255, 255, 255, 0.8);
        z-index: 1000;
        text-align: center;
        padding-top: 200px;
      }
      .spinner-border {
        width: 3rem;
        height: 3rem;
      }
      #progressBar {
        width: 50%;
        margin: 0 auto;
      }
    </style>
  </head>
  <body>
    <h1 class="text-center my-4">Upload File to Cloud Storage</h1>
    <div class="container">
      <div class="mb-3">
        <label for="bucketName" class="form-label">Bucket Name:</label>
        <input type="text" class="form-control" id="bucketName">
      </div>
      <div class="mb-3">
        <label for="fileInput" class="form-label">Select File:</label>
        <input type="file" class="form-control" id="fileInput">
      </div>
      <div class="mb-3">
        <button class="btn btn-primary" onclick="uploadFile()">Upload</button>
      </div>
      <p id="output"></p>
    </div>
    <div id="loading" class="loading-overlay">
      <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">Loading...</span>
      </div>
      <p>Uploading file, please wait...</p>
      <div class="progress" id="progressBar">
        <div class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
      </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
```

この部分は見た目を作っているところになります。CSSフレームワークとしてBootstrapを使用しました。
要素としてはバケット名の入力とファイル選択があるだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/603b7aae-2cef-47d0-69a6-32054c44ecce.png)

大きなファイルのアップロードには当然時間がかかるので、以下のようなローディング画面をつけています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3637890/3995f054-c95a-9ed1-34aa-27d8c181c45e.png)

以下の部分はサービスアカウントから認証用トークンを取得する処理です。
```js
      async function getAccessToken() {
        try {
          const now = Math.floor(Date.now() / 1000);
          const payload = {
            iss: serviceAccountEmail,
            sub: serviceAccountEmail,
            aud: 'https://oauth2.googleapis.com/token',
            iat: now,
            exp: now + 3600,
            scope: 'https://www.googleapis.com/auth/cloud-platform'
          };

          const header = {
            alg: 'RS256',
            typ: 'JWT'
          };

          const base64UrlEncode = (obj) => {
            return btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
          };

          const signatureInput = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

          let key;
          try {
            key = await crypto.subtle.importKey(
              'pkcs8',
              str2ab(privateKey),
              {
                name: 'RSASSA-PKCS1-v1_5',
                hash: { name: 'SHA-256' }
              },
              false,
              ['sign']
            );
          } catch (importKeyError) {
            console.error('Error importing key:', importKeyError);
            throw importKeyError;
          }

          let signature;
          try {
            signature = await crypto.subtle.sign(
              {
                name: 'RSASSA-PKCS1-v1_5',
                hash: { name: 'SHA-256' }
              },
              key,
              new TextEncoder().encode(signatureInput)
            );
          } catch (signError) {
            console.error('Error signing JWT:', signError);
            throw signError;
          }

          const jwt = signatureInput + '.' + btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

          let response;
          try {
            response = await fetch('https://oauth2.googleapis.com/token', {
              method: 'POST',
              headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
              },
              body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + jwt
            });
          } catch (fetchError) {
            console.error('Error fetching access token:', fetchError);
            throw fetchError;
          }

          if (!response.ok) {
            throw new Error('Failed to fetch access token: ' + response.statusText);
          }

          const data = await response.json();
          return data.access_token;
        } catch (error) {
          console.error('Error getting access token:', error);
          throw error;
        }
      }

      function str2ab(pem) {
        const pemHeader = "-----BEGIN PRIVATE KEY-----";
        const pemFooter = "-----END PRIVATE KEY-----";
        const pemContents = pem.replace(pemHeader, '').replace(pemFooter, '').replace(/\\n/g, '');
        const base64String = pemContents;
        const binary_string = window.atob(base64String);
        const len = binary_string.length;
        const bytes = new Uint8Array(len);
        for (let i = 0; i < len; i++) {
          bytes[i] = binary_string.charCodeAt(i);
        }
        return bytes.buffer;
      }
```

以下の箇所が実際にCloud Storageにアップロードしている処理になります。
main.gsのdoGet関数から受け取ったサービスアカウントの情報を最初に受け取り、uploadFile関数内で画面にて設定した情報を受け取って[REST API](https://cloud.google.com/storage/docs/apis)でファイルのアップロードを実施しています。
```js
      const serviceAccountEmail = '<?= serviceAccountEmail ?>';
      const privateKey = '<?= privateKey ?>';

      async function uploadFile() {
        const fileInput = document.getElementById('fileInput');
        const file = fileInput.files[0];
        const bucketName = document.getElementById('bucketName').value;
        console.log(bucketName);

        if (!file) {
          alert('No file selected.');
          return;
        }

        document.getElementById('loading').style.display = 'block';

        const url = 'https://storage.googleapis.com/upload/storage/v1/b/' + bucketName + '/o?uploadType=media&name=' + encodeURIComponent(file.name);
        const token = await getAccessToken();

        const xhr = new XMLHttpRequest();
        xhr.open('POST', url, true);
        xhr.setRequestHeader('Authorization', 'Bearer ' + token);
        xhr.setRequestHeader('Content-Type', file.type);

        xhr.upload.onprogress = function(event) {
          if (event.lengthComputable) {
            const percentComplete = (event.loaded / event.total) * 100;
            const progressBar = document.querySelector('.progress-bar');
            progressBar.style.width = percentComplete + '%';
            progressBar.setAttribute('aria-valuenow', percentComplete);
            progressBar.textContent = Math.round(percentComplete) + '%';
          }
        };

        xhr.onload = function() {
          document.getElementById('loading').style.display = 'none';
          resetProgressBar();
        };

        xhr.onerror = function() {
          document.getElementById('loading').style.display = 'none';
          resetProgressBar();
          alert('Error uploading file.');
        };

        xhr.send(file);
      }

      function resetProgressBar() {
        const progressBar = document.querySelector('.progress-bar');
        progressBar.style.width = '0%';
        progressBar.setAttribute('aria-valuenow', 0);
        progressBar.textContent = '0%';
      }
```

なお、XMLHttpRequestを使用しているのは画面に進捗を表示したかったからなので、ただファイルを送信するだけであればもっとシンプルに書けるかと思います。

ファイル作成後は[Webアプリとしてデプロイ](https://developers.google.com/apps-script/guides/web?hl=ja#deploy_a_script_as_a_web_app)を実施して試してみてください。

## まとめ
このやり方であれば2GBくらいのデータでもCloud Storageにアップロードすることができました。何か皆さんの参考になることがあれば嬉しく思います。