FirebaseHostingにchatアプリをデプロイしてみた

# はじめに
* 普段は、AWSを主に使っているのですが、そろそろ真面目にGCPやらないといけない雰囲気がすごいので、ちゃんと勉強することにしました。
* 今回は、その第一弾として、firebaseへchatアプリをデプロイするまでを記事にしていきます。

# 使用ソース
まず、今回はデプロイするchatアプリについては、下記のものを使用していきます。
https://github.com/firebase/codelab-friendlychat-web

# Firebaseへログイン

① : 下記のリンクより、Firebaseのコンソールに移動します。
https://console.firebase.google.com/

② : [プロジェクトの追加]をクリックして、適当な名前を入力します。 

③ : [プロジェクトの作成]をクリックします。

プロジェクトの作成が完了すると、下記の画面に遷移すると思います。
![スクリーンショット 2021-06-14 21.58.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/ba4b9c9c-e7f8-905e-14a5-53a8f1009b9d.png)

# FirebaseWebアプリをプロジェクトに追加

まず、Firebaseの主な機能なのですが、上記画面の左側のナビゲータ「構築」を見ていただくと分かると思います

**Authentication**: ユーザーのメールアドレスとパスワードや、フェデレーション ID、プロバイダのOAuthトークンなどで、アプリへのユーザーのログイン認証をする時に使います

**Firestore Database**: モバイルアプリ開発用の最新のNoSQL系データベースです

**Realtime Database**: 従来からある、Firebaseのデータベースです

**Storage**: 写真や動画など、ユーザーが作成したコンテンツを保管、提供するためのストレージです

**Hosting**: ウェブアプリ、静的コンテンツと動的コンテンツなどのホスティングができます

**Functions**: サーバーレスフレームワークで、Firebase の機能と HTTPS リクエストによってトリガーされたイベントに応じて、バックエンド コードを自動的に実行できます

**Machine Learning**: 機械学習の初心者でも経験者でも、数行のコードで必要な機能を実装できるようにするモバイルSDKです。

---

さてと、そしたら早速Webアプリをfirebaseに追加していこうと思います。

* 下記の「web」を選択し、アプリを作成していきます。

![スクリーンショット 2021-06-14 22.19.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/9dee4243-29b8-b81e-5085-f60f7e124a39.png)

* まず、アプリのニックネームを入力し、「このアプリのFirebase Hostingも設定します」にチェックを入れます。
* その後、アプリを登録を選択します。
![スクリーンショット 2021-06-14 22.21.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/c3d8aeda-76e9-b151-b0dc-aac63f26a9e3.png)

* その後、いろいろ画面上に出ますが、とりあえず次に進んで問題ありません。
* 作成が完了すると、下記のように作成されたアプリが表示されます。

![スクリーンショット 2021-06-14 22.25.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/ce57dfad-d89c-7c7a-c860-217590326b4e.png)

## Firebase認証でGoogleログインを有効にします
* 左側のナビゲーション画面にある「構築」から「Authentication」を選択します。
* 「始める」をクリックする、もしくは下記の画面の「ログイン方法を設定」を選択します。

![スクリーンショット 2021-06-14 22.31.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/b5f16124-30ac-e459-4853-b6516c9ab35b.png)

* プロバイダが複数あるのですが、今回はGoogleログインでの認証を使用したいため、Googleの認証を有効にします。

![スクリーンショット 2021-06-14 22.31.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/efa766d4-dadf-b363-f4df-9928d4814527.png)


## Firestoreを有効にします
* 「データベースの作成」を選択します。
![スクリーンショット 2021-06-14 22.34.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/3de7f37d-a3e9-b5b8-aebe-f10ee32a9718.png)

* 「データベースの作成」を選択すると、「本番環境モード」か「テストモード」を要求されるので、「テストモードで開始する」を選択します。
* テストモードでは、開発中にデータベースに自由に書き込むことができます。

![スクリーンショット 2021-06-14 22.35.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/2576d0b0-6dd6-0e93-217e-7e274da962fd.png)

* 次へを押すと、Firestoreのデータが保存される場所を設定できます。
* これをデフォルトのままにするか、自分に近い地域を選択できます。
* 今回は、東京「asia-northeast1」を選択します。
* その後、「有効」をクリックするとプロビジョニングが始まります。
![スクリーンショット 2021-06-14 22.37.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/8fffe2e0-5642-5c37-ccb7-aebd07326e9d.png)

# ローカル環境の設定
* Firebase CLIを使用し、Firebase HostingにてWebアプリをFirebaseプロジェクトにデプロイすることができます。
* npmにてグローバルにcliを入れていきます。

```
npm -g install firebase-tools
```

* firebaseが正しくインストールされていることを確認します。  
**Firebase CLIのバージョンがv4.1.0以降であることを確認してください。**

```
❯ firebase --version
9.12.1
```

* 次のコマンドを実行して、FirebaseCLIを承認します。

```
❯ firebase login
i  Firebase optionally collects CLI usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.

? Allow Firebase to collect CLI usage and error reporting information? Yes
```

* ターミナルで、作成したWebアプリのrootディレクトリにアクセスしていることを確認してください。
(今回は、codelab-friendlychat-web/web-start)にいればOKです。

* プロンプトが表示されたら、プロジェクトIDを選択し、Firebaseプロジェクトにエイリアスを指定します。
* この時のエイリアスには、defaultを入力します。

# Webアプリをローカル実行する
* これで準備ができたので、ディレクトリ「codelab-friendlychat-web/web-start」下記のコマンドを実行します。
* localhostの5000番ポートで無事アプリが立ち上がりました。

```
firebase serve --only hosting
```

![スクリーンショット 2021-06-14 22.54.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/0944fadc-0e1d-db2d-b099-0c7b1c90bfb7.png)

### FirebaseSDKをインポートする
* 今回は、サンプルのプログラムを利用しているため、SDKをnpmでインストールすることもできるのですが、今回は既に組み込まれているものを使用していきます。
* サンプルコードのindex.htmlでは、既にinitalize処理までされています。(便利っすねw)

### コードの修正
* codelab-friendlychat-web/web-start/public/scripts/main.jsのコードを下記にものでコピペしてください。

```codelab-friendlychat-web/web-start/public/scripts/main.js
// Signs-in Friendly Chat.
function signIn() {
  var provider = new firebase.auth.GoogleAuthProvider();
  firebase.auth().signInWithPopup(provider);
}

// Signs-out of Friendly Chat.
function signOut() {
  firebase.auth().signOut();
}

// Initiate firebase auth.
function initFirebaseAuth() {
  firebase.auth().onAuthStateChanged(authStateObserver);
}

// Returns the signed-in user's profile Pic URL.
function getProfilePicUrl() {
  return firebase.auth().currentUser.photoURL || '/images/profile_placeholder.png';
}

// Returns the signed-in user's display name.
function getUserName() {
  return firebase.auth().currentUser.displayName;
}

// Returns true if a user is signed-in.
function isUserSignedIn() {
  return !!firebase.auth().currentUser;
}

// Saves a new message on the Firebase DB.
function saveMessage(messageText) {
  // Add a new message entry to the database.
  return firebase.firestore().collection('messages').add({
    name: getUserName(),
    text: messageText,
    profilePicUrl: getProfilePicUrl(),
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
  }).catch(function(error) {
    console.error('Error writing new message to database', error);
  });
}

// Loads chat messages history and listens for upcoming ones.
function loadMessages() {
  // Create the query to load the last 12 messages and listen for new ones.
  var query = firebase.firestore()
                  .collection('messages')
                  .orderBy('timestamp', 'desc')
                  .limit(12);
  
  // Start listening to the query.
  query.onSnapshot(function(snapshot) {
    snapshot.docChanges().forEach(function(change) {
      if (change.type === 'removed') {
        deleteMessage(change.doc.id);
      } else {
        var message = change.doc.data();
        displayMessage(change.doc.id, message.timestamp, message.name,
                       message.text, message.profilePicUrl, message.imageUrl);
      }
    });
  });
}

// Saves a new message containing an image in Firebase.
// This first saves the image in Firebase storage.
function saveImageMessage(file) {
  // 1 - We add a message with a loading icon that will get updated with the shared image.
  firebase.firestore().collection('messages').add({
    name: getUserName(),
    imageUrl: LOADING_IMAGE_URL,
    profilePicUrl: getProfilePicUrl(),
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
  }).then(function(messageRef) {
    // 2 - Upload the image to Cloud Storage.
    var filePath = firebase.auth().currentUser.uid + '/' + messageRef.id + '/' + file.name;
    return firebase.storage().ref(filePath).put(file).then(function(fileSnapshot) {
      // 3 - Generate a public URL for the file.
      return fileSnapshot.ref.getDownloadURL().then((url) => {
        // 4 - Update the chat message placeholder with the image's URL.
        return messageRef.update({
          imageUrl: url,
          storageUri: fileSnapshot.metadata.fullPath
        });
      });
    });
  }).catch(function(error) {
    console.error('There was an error uploading a file to Cloud Storage:', error);
  });
}

// Saves the messaging device token to the datastore.
function saveMessagingDeviceToken() {
  firebase.messaging().getToken().then(function(currentToken) {
    if (currentToken) {
      console.log('Got FCM device token:', currentToken);
      // Saving the Device Token to the datastore.
      firebase.firestore().collection('fcmTokens').doc(currentToken)
          .set({uid: firebase.auth().currentUser.uid});
    } else {
      // Need to request permissions to show notifications.
      requestNotificationsPermissions();
    }
  }).catch(function(error){
    console.error('Unable to get messaging token.', error);
  });
}

// Requests permissions to show notifications.
function requestNotificationsPermissions() {
  console.log('Requesting notifications permission...');
  firebase.messaging().requestPermission().then(function() {
    // Notification permission granted.
    saveMessagingDeviceToken();
  }).catch(function(error) {
    console.error('Unable to get permission to notify.', error);
  });
}

// Triggered when a file is selected via the media picker.
function onMediaFileSelected(event) {
  event.preventDefault();
  var file = event.target.files[0];

  // Clear the selection in the file picker input.
  imageFormElement.reset();

  // Check if the file is an image.
  if (!file.type.match('image.*')) {
    var data = {
      message: 'You can only share images',
      timeout: 2000
    };
    signInSnackbarElement.MaterialSnackbar.showSnackbar(data);
    return;
  }
  // Check if the user is signed-in
  if (checkSignedInWithMessage()) {
    saveImageMessage(file);
  }
}

// Triggered when the send new message form is submitted.
function onMessageFormSubmit(e) {
  e.preventDefault();
  // Check that the user entered a message and is signed in.
  if (messageInputElement.value && checkSignedInWithMessage()) {
    saveMessage(messageInputElement.value).then(function() {
      // Clear message text field and re-enable the SEND button.
      resetMaterialTextfield(messageInputElement);
      toggleButton();
    });
  }
}

// Triggers when the auth state change for instance when the user signs-in or signs-out.
function authStateObserver(user) {
  if (user) { // User is signed in!
    // Get the signed-in user's profile pic and name.
    var profilePicUrl = getProfilePicUrl();
    var userName = getUserName();

    // Set the user's profile pic and name.
    userPicElement.style.backgroundImage = 'url(' + addSizeToGoogleProfilePic(profilePicUrl) + ')';
    userNameElement.textContent = userName;

    // Show user's profile and sign-out button.
    userNameElement.removeAttribute('hidden');
    userPicElement.removeAttribute('hidden');
    signOutButtonElement.removeAttribute('hidden');

    // Hide sign-in button.
    signInButtonElement.setAttribute('hidden', 'true');

    // We save the Firebase Messaging Device token and enable notifications.
    saveMessagingDeviceToken();
  } else { // User is signed out!
    // Hide user's profile and sign-out button.
    userNameElement.setAttribute('hidden', 'true');
    userPicElement.setAttribute('hidden', 'true');
    signOutButtonElement.setAttribute('hidden', 'true');

    // Show sign-in button.
    signInButtonElement.removeAttribute('hidden');
  }
}

// Returns true if user is signed-in. Otherwise false and displays a message.
function checkSignedInWithMessage() {
  // Return true if the user is signed in Firebase
  if (isUserSignedIn()) {
    return true;
  }

  // Display a message to the user using a Toast.
  var data = {
    message: 'You must sign-in first',
    timeout: 2000
  };
  signInSnackbarElement.MaterialSnackbar.showSnackbar(data);
  return false;
}

// Resets the given MaterialTextField.
function resetMaterialTextfield(element) {
  element.value = '';
  element.parentNode.MaterialTextfield.boundUpdateClassesHandler();
}

// Template for messages.
var MESSAGE_TEMPLATE =
    '<div class="message-container">' +
      '<div class="spacing"><div class="pic"></div></div>' +
      '<div class="message"></div>' +
      '<div class="name"></div>' +
    '</div>';

// Adds a size to Google Profile pics URLs.
function addSizeToGoogleProfilePic(url) {
  if (url.indexOf('googleusercontent.com') !== -1 && url.indexOf('?') === -1) {
    return url + '?sz=150';
  }
  return url;
}

// A loading image URL.
var LOADING_IMAGE_URL = 'https://www.google.com/images/spin-32.gif?a';

// Delete a Message from the UI.
function deleteMessage(id) {
  var div = document.getElementById(id);
  // If an element for that message exists we delete it.
  if (div) {
    div.parentNode.removeChild(div);
  }
}

function createAndInsertMessage(id, timestamp) {
  const container = document.createElement('div');
  container.innerHTML = MESSAGE_TEMPLATE;
  const div = container.firstChild;
  div.setAttribute('id', id);

  // If timestamp is null, assume we've gotten a brand new message.
  // https://stackoverflow.com/a/47781432/4816918
  timestamp = timestamp ? timestamp.toMillis() : Date.now();
  div.setAttribute('timestamp', timestamp);

  // figure out where to insert new message
  const existingMessages = messageListElement.children;
  if (existingMessages.length === 0) {
    messageListElement.appendChild(div);
  } else {
    let messageListNode = existingMessages[0];

    while (messageListNode) {
      const messageListNodeTime = messageListNode.getAttribute('timestamp');

      if (!messageListNodeTime) {
        throw new Error(
          `Child ${messageListNode.id} has no 'timestamp' attribute`
        );
      }

      if (messageListNodeTime > timestamp) {
        break;
      }

      messageListNode = messageListNode.nextSibling;
    }

    messageListElement.insertBefore(div, messageListNode);
  }

  return div;
}

// Displays a Message in the UI.
function displayMessage(id, timestamp, name, text, picUrl, imageUrl) {
  var div = document.getElementById(id) || createAndInsertMessage(id, timestamp);

  // profile picture
  if (picUrl) {
    div.querySelector('.pic').style.backgroundImage = 'url(' + addSizeToGoogleProfilePic(picUrl) + ')';
  }

  div.querySelector('.name').textContent = name;
  var messageElement = div.querySelector('.message');

  if (text) { // If the message is text.
    messageElement.textContent = text;
    // Replace all line breaks by <br>.
    messageElement.innerHTML = messageElement.innerHTML.replace(/\n/g, '<br>');
  } else if (imageUrl) { // If the message is an image.
    var image = document.createElement('img');
    image.addEventListener('load', function() {
      messageListElement.scrollTop = messageListElement.scrollHeight;
    });
    image.src = imageUrl + '&' + new Date().getTime();
    messageElement.innerHTML = '';
    messageElement.appendChild(image);
  }
  // Show the card fading-in and scroll to view the new message.
  setTimeout(function() {div.classList.add('visible')}, 1);
  messageListElement.scrollTop = messageListElement.scrollHeight;
  messageInputElement.focus();
}

// Enables or disables the submit button depending on the values of the input
// fields.
function toggleButton() {
  if (messageInputElement.value) {
    submitButtonElement.removeAttribute('disabled');
  } else {
    submitButtonElement.setAttribute('disabled', 'true');
  }
}

// Checks that the Firebase SDK has been correctly setup and configured.
function checkSetup() {
  if (!window.firebase || !(firebase.app instanceof Function) || !firebase.app().options) {
    window.alert('You have not configured and imported the Firebase SDK. ' +
        'Make sure you go through the codelab setup instructions and make ' +
        'sure you are running the codelab using `firebase serve`');
  }
}

// Checks that Firebase has been imported.
checkSetup();

// Shortcuts to DOM Elements.
var messageListElement = document.getElementById('messages');
var messageFormElement = document.getElementById('message-form');
var messageInputElement = document.getElementById('message');
var submitButtonElement = document.getElementById('submit');
var imageButtonElement = document.getElementById('submitImage');
var imageFormElement = document.getElementById('image-form');
var mediaCaptureElement = document.getElementById('mediaCapture');
var userPicElement = document.getElementById('user-pic');
var userNameElement = document.getElementById('user-name');
var signInButtonElement = document.getElementById('sign-in');
var signOutButtonElement = document.getElementById('sign-out');
var signInSnackbarElement = document.getElementById('must-signin-snackbar');

// Saves message on form submit.
messageFormElement.addEventListener('submit', onMessageFormSubmit);
signOutButtonElement.addEventListener('click', signOut);
signInButtonElement.addEventListener('click', signIn);

// Toggle for the button.
messageInputElement.addEventListener('keyup', toggleButton);
messageInputElement.addEventListener('change', toggleButton);

// Events for image upload.
imageButtonElement.addEventListener('click', function(e) {
  e.preventDefault();
  mediaCaptureElement.click();
});
mediaCaptureElement.addEventListener('change', onMediaFileSelected);

// initialize Firebase
initFirebaseAuth();

firebase.performance();

// We load currently existing chat messages and listen to new ones.
loadMessages();
```

## Firestoreのセキュリティ設定の変更

* 今回は、Firebaseプロジェクトを設定する際、データストアへのアクセスを制限しないように、「テストモード」のデフォルトのセキュリティルールを使用することを選択しました。 
* ( Firebaseコンソールの[データベース]セクションの[ルール]タブで、これらのルールを表示および変更できます。 )

**その為、現在はデータストアへのアクセスを制限しないデフォルトのルールが表示されているので、すべてのユーザーがデータストア内の任意のコレクションに対して読み取りと書き込みを行えてしまいます。**

* なので、Firebaseのコンソール画面からFirestoreに行き、下記のルールを変更します。

![スクリーンショット 2021-06-14 23.13.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/b4709360-505b-ebac-e311-41088947bd39.png)

```
service cloud.firestore {
  match /databases/{database}/documents {
    // Messages:
    //   - Anyone can read.
    //   - Authenticated users can add and edit messages.
    //   - Validation: Check name is same as auth token and text length below 300 char or that imageUrl is a URL.
    //   - Deletes are not allowed.
    match /messages/{messageId} {
      allow read;
      allow create, update: if request.auth != null
                    && request.resource.data.name == request.auth.token.name
                    && (request.resource.data.text is string
                      && request.resource.data.text.size() <= 300
                      || request.resource.data.imageUrl is string
                      && request.resource.data.imageUrl.matches('https?://.*'));
      allow delete: if false;
    }
    // FCM Tokens:
    //   - Anyone can write their token.
    //   - Reading list of tokens is not allowed.
    match /fcmTokens/{token} {
      allow read: if false;
      allow write;
    }
  }
}
```

## CloudStorageのセキュリティルールの変更

* 今のままだと、CloudStorageではサインインしたユーザーがストレージバケット内のファイルを読み書きできるようにするデフォルトのルールになっています。
* その為、そのルールを下記のように変更します。

1. 各ユーザーが自分の特定のフォルダーにのみ書き込むことを許可
2. 誰でもクラウドストレージから読み取れる
3. アップロードされたファイルが画像であることを確認するバリデーション
4. アップロードできる画像のサイズを最大5MBに制限

* 変更は、Firebaseのコンソール画面からCloudStorageに行き、下記のルールを変更します。

![スクリーンショット 2021-06-14 23.19.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/ab62cd73-187f-8c8b-eb12-1d673826c32c.png)


```
function isImageBelowMaxSize(maxSizeMB) {
  return request.resource.size < maxSizeMB * 1024 * 1024
      && request.resource.contentType.matches('image/.*');
}

service firebase.storage {
  match /b/{bucket}/o {
    match /{userId}/{messageId}/{fileName} {
      allow write: if request.auth != null && request.auth.uid == userId && isImageBelowMaxSize(5);
      allow read;
    }
  }
}
```

## FirebaseHostingを使用してアプリをデプロイ
* Firebase CLIを使用して、ファイルをFirebaseHostingにデプロイします。
* この時、firebase.jsonにデプロイするローカルファイルを指定する必要があります。

```firebase.json
{
  "firestore": {
    "rules": "firestore.rules"
  },
  "storage": {
    "rules": "storage.rules"
  },
  "hosting": {
    "public": "./public"
  }
}
```

* ディレクトリ「codelab-friendlychat-web/web-start」下記のコマンドを実行します。

```
firebase deploy --except functions
```

* 無事にDeployができると、下記のドメイン等の情報が表示されるので、それを確認する。

```
https://<firebase-projectId>.firebaseapp.com
https://<firebase-projectId>.web.app .
```

**無事にできました！**
![スクリーンショット 2021-06-14 23.26.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/0758ed82-993c-2a7a-6a4b-5cdfdbe935e6.png)

# 最後に作成したWebアプリを削除します
* 最後にDeployしたWebアプリのホスティングを無効化して、終了です！

```
firebase hosting:disable
```

# 参考
https://firebase.google.com/codelabs/firebase-web#1