FirebaseのHostingとCloud Functionsを利用してStorageからファイルをダウンロードするデモアプリケーション

# 概要

以前に投稿した「[FirebaseのHostingとCloud Functionsを利用してStorageへファイルをアップロードするデモアプリケーション] (https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365)」という記事で開発したデモアプリケーションにファイルダウンロード機能を追加しました。

ファイルダウンロードの方法は、まずダウンロードするファイル名をgetリクエストのパラメータに指定し、Storageにファイルが存在すればダウンロード用の署名付きURLをレスポンスします。
次に署名付きURLにアクセスしてStorageから直接ファイルをダウンロードします。

**今回実装した機能**

1. Cloud FunctionsのHTTP関数でgetリクエストされたファイルの署名付きURLをレスポンスする処理を実装します。
1. Vue.jsアプリケーションでファイルをダウンロードするフォームを実装します。
1. ダウンロードURLをHostingのrewrite機能でHTTP関数のエンドポイントへディスパッチします。
1. 署名付きURLを使ってStorageから直接ファイルをダウンロードします。

**前回実装した機能**

* Cloud FunctionsのHTTP関数でpostリクエストされたファイルをStorageに保存する処理を実装します。
* Vue.jsアプリケーションでファイルをアップロードするフォームを実装します。
* アップロードURLをHostingのrewrite機能でHTTP関数のエンドポイントへディスパッチします。

**構成図**

![firebase.png](https://qiita-image-store.s3.amazonaws.com/0/22772/f4e15f89-72bb-5c72-9749-5d3a4dfb54a2.png)

# アプリケーション開発の事前準備

以前に投稿した「[FirebaseのHostingとCloud Functionsを利用してStorageへファイルをアップロードするデモアプリケーション] (https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365)」と重複する部分が多いので、ファイルダウンロード機能の追加で必要になった作業のみ記載します。

## 署名付きURLを発行するための設定

署名付きURLの発行に必要な権限は`iam.serviceAccounts.signBlob`です。この権限を含むロール(役割)を新しく作成するか、この権限を持っている既存のロール（サービスアカウントトークン作成者）を利用してサービスアカウントに割り当てます。
この記事では既存のロール（サービスアカウント トークン作成者）を利用する方法にしました。

![iam4.png](https://qiita-image-store.s3.amazonaws.com/0/22772/a8898628-9efc-6298-24eb-a6ebae0c5444.png)

また、"Identity and Access Management (IAM) API"を有効にする必要があります。
これらの作業は現時点(2018/05)でFirebase Consoleからは行えないので、GCP Consoleから行います。

### GCP Console

[GCP Console] (https://console.cloud.google.com/?hl=ja)にログインし、Firebaseで作成したプロジェクトを選択します。

### 役割（ロール）の設定

GCP Consoleの左側メニューより"IAMと管理" → "IAM"を選択するとメンバーが表示されます。
その中で名前が”App Engine default service account”というメンバーの鉛筆アイコンをクリックします。

![iam0.png](https://qiita-image-store.s3.amazonaws.com/0/22772/5d7bfd66-2cbb-1027-1251-65cef772f7f2.png)

「別の役割を追加」をクリックし”サービスアカウントトークン作成者”という役割を追加します。

![iam1.png](https://qiita-image-store.s3.amazonaws.com/0/22772/93d533f9-e943-47b9-2e26-d593ba1293fb.png)

### Identity and Access Management (IAM) APIの有効化

左側メニューの"APIとサービス" → "ライブラリ"を選択し、検索フィールドに”Identity and Access Management”と入力します。検索結果にAPIが表示されるのでクリックします。

![iam2.png](https://qiita-image-store.s3.amazonaws.com/0/22772/8f7ba5c5-7d08-747f-5ecd-35765c11c451.png)

「有効にする」ボタンをクリックしてAPIを有効にします。

![iam3.png](https://qiita-image-store.s3.amazonaws.com/0/22772/17f6297d-37d8-59d4-97bd-f9def4ab8633.png)

これで署名付きURLを発行する準備は整いました。

## StorageにCORSの設定を行う

CORS設定の方法はオフィシャルのドキュメント「[クロスオリジン リソース シェアリング（CORS）の設定] (https://cloud.google.com/storage/docs/configuring-cors?hl=ja)」に詳しく紹介されています。
この記事ではこのドキュメントに従って以下の作業を行いました。

### Cloud SDKのインストール

CORSの設定にCloud SDKの`gsutil`コンポーネントを利用します。Cloud SDKのインストール方法は、[Google Cloud SDK ドキュメント] (https://cloud.google.com/sdk/docs/?hl=ja)に詳しく説明されています。

### CORSの設定

corsの設定ファイルを作成します。
originに接続元のドメインを列挙します。この例では1番上がHostingにデプロイした静的サイトで、次の2つはローカルからの接続になります。

```json:cors-json-file.json
[
  {
    "origin": [
      "https://project********.firebaseapp.com",
      "http://localhost:8080",
      "http://localhost:5000"
    ],
    "responseHeader": ["Content-Type"],
    "method": ["GET"],
    "maxAgeSeconds": 3600
  }
]
```

CORSの設定にStorageのURLが必要になるのでFirebase ConsoleのStorageで確認します。

![sd.png](https://qiita-image-store.s3.amazonaws.com/0/22772/832f4796-d793-baf2-ac82-a9cfe737c8de.png)

`gsutil`コマンドでstorageにcorsの設定を行います。

```text
> gsutil cors set cors-json-file.json <storageのURL>
```

storageのcors設定を確認します。

```text
> gsutil cors get <storageのURL>
```

以上でStorageのCORS設定は完了です。

# Cloud Functionsで署名付きURLをレスポンスする処理を実装する

## HTTP関数の実装

関数名は「filedownload」としました。
署名付きURLを取得するメソッドは[File.getSignedUrl] (https://cloud.google.com/nodejs/docs/reference/storage/1.6.x/File#getSignedUrl)です。
リファレンスのドキュメントに書かれているように署名付きURLには有効期間を設定することが可能です。

> Get a signed URL to allow limited time access to the file. 

この例ではconfigで2018/05/30まで有効な署名付きURLを生成しています。

```js
const config = {
  action: 'read',
  expires: '2018-05-30'
};
```

ソースコード全体

```js:functions/index.js
'use strict';
const functions = require('firebase-functions');
const storage = require('@google-cloud/storage')();

exports.filedownload = functions.https.onRequest((req, res) => {
  if (req.method !== 'GET') {
    console.warn('Not Allowed: ' + req.method);
    res.status(405).send('Method Not Allowed');
    return;
  }
  if (!req.query.file) {
    res.status(400).send('Request Parameter');
    return;
  }

  const bucket = storage.bucket(functions.config().fileupload.bucket.name);
  const file = bucket.file(`upload_images/${req.query.file}`);
  file.exists()
    .then(data => {
      console.log(data);
      return new Promise((resolve, reject) => {
        if (!data[0]) {
          reject(new Error(`file not found: ${req.query.file}`));
        } else {
          const config = {
            action: 'read',
            expires: '2018-05-30'
          };
          resolve(file.getSignedUrl(config));
        }
      });
    })
    .then(results => {
      return res.status(200).json({ url: results[0] });
    })
    .catch(err => {
      console.error(err);
      return res.status(400).json({ message: "file not found" });
    });
});
```

## デプロイ

```text
> firebase deploy --only functions:filedownload
```

きちんとデプロイできているかFirebase ConsoleのFunctionsダッシュボードで確認できます。

## 動作確認

事前に動作検証用のファイルをStorageへアップロードしておきます。
curlでダウンロードURLにアクセスして署名付きURLを取得します。

```text
> curl "https://us-central1-project********.cloudfunctions.net/filedownload?file=logo.png"
"{ url: \"https://storage.googleapis.com/project*********.appspot.com/upload_images%2Flogo.png?GoogleAccessId=*********@appspot.gserviceaccount.com&Expires=1527638400&Signature=*********\" }"
```

取得した署名付きURLにアクセスしてみます。

```text
> curl -o logo.png "https://storage.googleapis.com/project*********.appspot.com/upload_images%2Flogo.png?GoogleAccessId=*********@appspot.gserviceaccount.com&Expires=1527638400&Signature=*********"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 79543  100 79543    0     0  99928      0 --:--:-- --:--:-- --:--:--   99k
```

# Vue.jsでファイルをダウンロードするフォームを実装する

## ファイルダウンロードページの追加

```vue:components/FileDownload.vue
<template>
  <div class="download">
    <h1>File Download</h1>
    <div>
      <form>
        <input type="text" @change="onFileSelected">
        <button @click.prevent="onDownload">Download</button>
      </form>
      <div class="images">
        <p>image list</p>
        <p v-for="(img, index) in imageList" v-bind:key="index">
          <img v-bind:src="img">
        </p>
      </div>
    </div>
  </div>
</template>

<script>
import axios from 'axios'

const instance = axios.create({
  baseURL: process.env.SITE_URL,
  timeout: 10000
})

export default {
  name: 'FileDownloadForm',
  data () {
    return {
      selectedFile: null,
      imageList: []
    }
  },
  methods: {
    onFileSelected (event) {
      this.selectedFile = event.target.value
    },
    onDownload () {
      instance.get(`/download?file=${this.selectedFile}`)
        .then(res => {
          return new Promise((resolve, reject) => {
            if (res.status === 200 && res.data.url) {
              resolve(res.data.url)
            } else {
              reject(new Error('SignedUrl not found'))
            }
          })
        })
        .then(res => {
          this.imageList.push(res)
        })
        .catch(err => {
          console.error(err)
        })
    }
  }
}
</script>
```

## ルーターにルートを追加

ファイルダウンロードページへ遷移できるようにルートを追加します。

```js:router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
import FileUploadForm from '@/components/FileUploadForm'
import FileDownloadForm from '@/components/FileDownloadForm'  // 追加

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld
    },
    {
      path: '/form',
      name: 'FileUploadForm',
      component: FileUploadForm
    },
    // ↓ 追加
    {
      path: '/download-form',
      name: 'FileDownloadForm',
      component: FileDownloadForm
    }
    // ↑ 追加
  ]
})
```

## HostingのリライトとCORSの設定

```js:firebase.json
{

  // ...省略...

  "hosting": {

    // ...省略...

    "headers": [
      {
        "source": "**/upload",
        "headers": [
          {
            "key": "Access-Control-Allow-Origin",
            "value": "*"
          }
        ]
      },
      // ↓ 追加
      {
        "source": "**/download",
        "headers": [
          {
            "key": "Access-Control-Allow-Origin",
            "value": "*"
          }
        ]
      }
      // ↑ 追加
    ],
    "rewrites": [
      {
        "source": "/upload",
        "function": "fileupload"
      },
      // ↓ 追加
      {
        "source": "/download",
        "function": "filedownload"
      },
      // ↑ 追加
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}
```

# HostingにVue.jsアプリケーションをデプロイ

## デプロイ

```text
> npm run build
```

```text
> firebase deploy --only hosting

or

> firebase deploy
```

## 動作確認

コンソールに出力されているHosting URLのアドレスにアクセスして動作確認します。

---

操作している様子をgifアニメーションにしました。

![xee.gif](https://qiita-image-store.s3.amazonaws.com/0/22772/4b25634b-f321-231a-9707-c4c7a9a89f5c.gif)

---