Next.js に Firebase Storage を導入して画像をアップロードしたメモ

## 概要
[認証](https://qiita.com/hibohiboo/items/6edf851b3a5dc6601b37)、[Firestore](https://qiita.com/hibohiboo/items/27dfb1c2760ba82cd9bf)と来たので、次はStorageの操作をできるようにする。

[この時点のソース](https://github.com/hibohiboo/create-now/tree/b1fb8a3f0c5f2c884d2ff1fee90b2d9641e4007d/)

## Storageの準備
[firebase storage](https://firebase.google.com/docs/storage/web/start?hl=ja)を参考にして、Firebase consoleからストレージの利用を開始する。

## Storageのルールの更新

* ルールを追加する
* firebase sdk から変更するためのバッシュを作成

```diff:firebase.json
{
  "firestore": {
    "rules": "firestore.rules"
  },
+  "storage": {
+    "rules": "storage.rules"
+  }
}
```

```js:storage.rules
service firebase.storage {
  match /b/{bucket}/o {
    match /lostrpg/images {
      // Anyone can view any image (no auth, publicly readable)
      match /{allImages=**} {
        allow read;
      }
      // Only an individual user can write to "their" images
      match /{userId}/{imageId} {
        allow write: if request.auth.uid == userId;
      }
    }
  }
}
```

```sh:storage_rules_update.sh
#!/bin/bash

bin_dir=$(cd $(dirname $0) && pwd)
parent_dir=$(cd $bin_dir/.. && pwd)
docker_dir=$(cd $parent_dir/docker && pwd)
container_name=firebase
project_name="create-now-6fa17"

cd $docker_dir && docker-compose run $container_name firebase deploy --token "$FIREBASE_TOKEN" --only storage --project $project_name
```

## ページでの利用

### ファイル読込コンポーネント

```tsx:pages/lostrpg/edit.tsx

            <Box my={2}>
              <InputLabel>キャンプ画像</InputLabel>
              <Box>
                {prevUrl ? (
                  <img
                    alt="キャンプ画像"
                    src={prevUrl}
                  />
                ) : (<></>)}
              </Box>
            </Box>

            <Box my={2}>
              <Button component="label" color="primary">
                画像ファイル送信
                <input
                  type="file"
                  style={{ display: 'none' }}
                  onChange={handleImageChange}
                />
              </Button>
            </Box>
```

### ファイル読込イベントハンドラ
* 読み込む前にvalidationを行っている
* readAsDataURLで読み込み、プレビューができるようにしている

```tsx:pages/lostrpg/edit.tsx
  const [prevUrl, setPrevUrl] = useState('')
  const [file, setFile] = useState<File>(null)
  const [fileName, setFileName] = useState('')

  const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault()
    const reader = new FileReader()
    const file = e.target.files[0]
    if (!(await validate.validImageFile(file))) {
      return
    }
    setFileName(file.name)
    reader.onloadend = async () => {
      setFile(file)
      setPrevUrl(reader.result.toString())
    }

    reader.readAsDataURL(file)
  }
```

### イメージファイルのValidation
以下の確認を行っている。

* ファイルサイズ
* 拡張子
   * file.typeは拡張子で判断しているため、これだけでは確実なチェックにならない
* マジックナンバー

```ts:validate.ts
const limitFileSizeMbyte = 1 // Mバイト
const limitFileSize = limitFileSizeMbyte * 1024 * 1024
const allowImageFileType = ['image/png', 'image/gif', 'image/jpeg']

const getMimetype = (signature) => {
  switch (signature) {
    case '89504E47':
      return 'image/png'
    case '47494638':
      return 'image/gif'
    case '25504446':
      return 'application/pdf'
    case 'FFD8FFDB':
    case 'FFD8FFE0':
    case 'FFD8FFE1':
      return 'image/jpeg'
    case '504B0304':
      return 'application/zip'
    default:
      return 'Unknown filetype'
  }
}

const parseFileHead = (file: File): Promise<ArrayBuffer> =>
  new Promise((resolve, reject) => {
    const chunkSize = 4 // bytes
    const r = new FileReader()
    const blob = file.slice(0, chunkSize)
    r.onload = (evt) => {
      if (evt.target.error == null) {
        return resolve(evt.target.result as ArrayBuffer)
      }
      return reject('Read error: ' + evt.target.error)
    }
    r.readAsArrayBuffer(blob)
  })

export const validImageFile = async (file: File) => {
  if (file.size > limitFileSize) {
    alert(
      `ファイルサイズが大きすぎます。${limitFileSizeMbyte}Mバイト以下にしてください`,
    )
    return false
  }

  if (allowImageFileType.indexOf(file.type) === -1) {
    alert(`画像ファイル以外はアップロードできません。`)
    return false
  }

  const result = await parseFileHead(file)
  const uint = new Uint8Array(result)
  const bytes = []
  uint.forEach((byte) => {
    bytes.push(byte.toString(16))
  })
  const hex = bytes.join('').toUpperCase()
  if (allowImageFileType.indexOf(getMimetype(hex)) === -1) {
    console.log(hex)
    alert(`画像ファイル以外はアップロードできません!!`)
    return false
  }
  return true
}
```

### firebase storage利用準備

```diff:.env
FIREBASE_API_KEY=xXxXxX
FIREBASE_AUTH_DOMAIN=XXX.firebaseapp.com
FIREBASE_PROJECT_ID=XXX
+ FIREBASE_STORAGE_BUCKET=■■■■■■■■.appspot.com
# == Firebase admin keys (from serviceAccount-staging.json) ==
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-■■■■@■■■■■■■■.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n■■■■■■■■\n-----END PRIVATE KEY-----\n"
# Cookie
SESSION_SECRET=secretHOGEHOGE
SESSION_SECRET_PREVIOUS=secretFUGAFUGA

```


```diff:next.config.js
  // .envファイルから読み込んだ環境変数をnextで使用できるように定義
  env: {
    FIREBASE_API_KEY: process.env.FIREBASE_API_KEY,
    FIREBASE_AUTH_DOMAIN: process.env.FIREBASE_AUTH_DOMAIN,
    FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID,
    FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET,
    FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL,
    FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY,
    SESSION_SECRET: process.env.SESSION_SECRET,
+    SESSION_SECRET_PREVIOUS: process.env.SESSION_SECRET_PREVIOUS,
  },
```


```diff:initFirebase.ts
import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'
+ import 'firebase/storage'

const config = {
  apiKey: process.env.FIREBASE_API_KEY,
  authDomain: process.env.FIREBASE_AUTH_DOMAIN,
  projectId: process.env.FIREBASE_PROJECT_ID,
+   storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
}

if (!firebase.apps.length) {
  firebase.initializeApp(config)
 }

export const db = firebase.firestore()
export const auth = firebase.auth()
+ export const storage = firebase.storage()
```

```diff:now.json
{
  "version": 2,
  "routes": [
    {
      "src": "^/service-worker.js$",
      "dest": "/_next/static/service-worker.js",
      "headers": {
        "cache-control": "public, max-age=43200, immutable",
        "Service-Worker-Allowed": "/"
      }
    }
  ],
  "functions": {
    "api/pdf.ts": {
      "includeFiles": "fonts/**"
    }
  },
  "env": {
    "FIREBASE_API_KEY": "@firebase-api-key",
    "FIREBASE_AUTH_DOMAIN": "@firebase-auth-domain",
    "FIREBASE_PROJECT_ID": "@firebase-project-id",
+    "FIREBASE_STORAGE_BUCKET": "@firebase-storage-bucket",
    "FIREBASE_CLIENT_EMAIL": "@firebase-client-email",
    "FIREBASE_PRIVATE_KEY": "@firebase-private-key",
    "SESSION_SECRET": "@session-secret",
    "SESSION_SECRET_PREVIOUS": "@session-secret-previous"
  },
  "build": {
    "env": {
      "FIREBASE_API_KEY": "@firebase-api-key",
      "FIREBASE_AUTH_DOMAIN": "@firebase-auth-domain",
      "FIREBASE_PROJECT_ID": "@firebase-project-id",
+      "FIREBASE_STORAGE_BUCKET": "@firebase-storage-bucket",
      "FIREBASE_CLIENT_EMAIL": "@firebase-client-email",
      "FIREBASE_PRIVATE_KEY": "@firebase-private-key",
      "SESSION_SECRET": "@session-secret",
      "SESSION_SECRET_PREVIOUS": "@session-secret-previous"
    }
  }
}

```


```
 now secrets add firebase-storage-bucket ■■■■■■■■.appspot.com
```

### 追加・削除処理の実装
* ルールを設定したディレクトリに保存するようにしている

```ts:image.ts
import { storage } from '~/lib/firebase/initFirebase'

export const updateImage = async (
  path: string,
  data: File,
): Promise<string> => {
  if (!path) throw new Error('required path')

  const ref = storage.ref()
  const imageRef = ref.child(`lostrpg/images/${path}`)
  await imageRef.put(data)
  return await imageRef.getDownloadURL()
}
export const deleteImage = async (path: string) => {
  const ref = storage.ref()
  const imageRef = ref.child(`lostrpg/images/${path}`)
  return await imageRef.delete()
}
```
### dropに対応

[この時点のソース](https://github.com/hibohiboo/create-now/tree/59ee7bbea22865ce42f74556b56a6c7146bc1d72/)


## 参考
[firebase storage](https://firebase.google.com/docs/storage/web/start?hl=ja)
[マジックナンバーまとめ](https://qiita.com/forestsource/items/15933888466ba9c3f048)
[【JavaScript】長大なテキストファイルを分割して読み込む。](https://freefielder.jp/blog/2015/08/js-large-text-separation.html)
[Detect file mime type using magic numbers and JavaScript](https://medium.com/the-everyday-developer/detect-file-mime-type-using-magic-numbers-and-javascript-16bc513d4e1e)
[Firebaseの小ネタ集](https://qiita.com/shora_kujira16/items/95216245ecf06c4cd16d)
[FileReaderを使ってローカルにある画像をブラウザに読み込ませる方法](http://tech.innovation.co.jp/2017/02/25/image-File-Reader.html)
[マジックナンバー Fileexplorer](https://github.com/krishnakhandagale/Fileexplorer/blob/master/app/src/main/java/com/example/fileexplorer/FileSignatures.java)
[▲🔥 Getting started with Next.js, ZEIT Now, and Firebase](https://dev.to/benzguo/getting-started-with-next-js-now-firebase-4ejg)
[drop](https://github.com/react-dropzone/react-dropzone)