Cloud Storageを利用する

## 環境
- React v18.2.0
- TypeScript v4.9.5
- firebase v9.17.1
- [react-query-firebase][react-query-firebase]

[react-query-firebase]: https://github.com/invertase/react-query-firebase

# 目標：FirestoreとCloud Storageを連携してメディア付き投稿をする

## 目次
- [セットアップ](#セットアップ)
- [データ取得](#データ取得)
- [データ操作](#データ操作)
- [Security Rules](#security-rules)

## セットアップ
前回作成した設定ファイルにStorageの設定を追記する

https://qiita.com/ATTACK3500/items/8b9dc5193d87660ebd8f
```ts:firebase.ts
const storage = getStorage();
FIREBASE_EMULATE === 'true' && connectStorageEmulator(storage, 'localhost', 9199);
```

## データ取得
基本的にCloud Storageだけを使用すことは無さそうで、Firestoreのようなリアルタイムな更新にも対応していない。
一応理解の足掛けとしてStorageだけで完結する簡単なカスタムhookを作成してみた。
特定パスのファイル一覧を取得したい場合このように出来る。
```ts:useFireStorage.ts
import { useState, useEffect } from 'react';

import { getDownloadURL, getMetadata, listAll, ref } from 'firebase/storage';

import { storage } from '@/config/firebase';

import type { FullMetadata } from 'firebase/storage';

export const useFireStorage = (path: string) => {
  const [fireStorage, setFireStorage] = useState<{
    data: (FullMetadata & { downloadUrl: string })[];
    error: Error | null;
    isLoading: boolean;
  }>({
    data: [],
    error: null,
    isLoading: true,
  });

  useEffect(() => {
    console.log(fireStorage);
  }, [fireStorage]);

  useEffect(() => {
    setFireStorage({
      data: [],
      error: null,
      isLoading: true,
    });

    listAll(ref(storage, path))
      .then((res) => {
        res.items.forEach((itemRef) => {
          Promise.all([getMetadata(itemRef), getDownloadURL(itemRef)])
            .then(([metadata, downloadUrl]) => {
              const fileData = { ...metadata, downloadUrl: downloadUrl };
              console.log(fileData);

              setFireStorage((prevState) => ({
                ...prevState,
                data: [...prevState.data, fileData],
              }));
            })
            .catch((error) => {
              setFireStorage((prevState) => ({
                ...prevState,
                error: error,
                isLoading: false,
              }));
            });
        });
      })
      .catch((error) => {
        setFireStorage((prevState) => ({
          ...prevState,
          error: error,
          isLoading: false,
        }));
      });
  }, [path]);

  return fireStorage;
};
```
実際は、ファイルの情報を別の場所に置いておいて、downloadUrlを使用してファイルを呼び出す。
これはFirestoreにmediaPostドキュメントを作成し、files配列フィールドにimageかvideo形式のファイルのデータを入れたもの。
```tsx:mediaPost.tsx
export const MediaPost = ({ mediaPost }: MediaPostProps) => {
  //...
  return (
    <div>
      <h1>{mediaPost.body}</h1>
      {mediaPost.files?.map(
        (file, index) =>
          (file.contentType?.startsWith('image/') && (
            <img key={index} src={file.downloadUrl} alt="File" />
          )) ||
          (file.contentType?.startsWith('video/') && (
            // eslint-disable-next-line jsx-a11y/media-has-caption
            <video key={index} src={file.downloadUrl} controls />
          ))
      )}
    </div>
  );
};
```

## データ操作
#### 目次
- [データ追加](#データ追加)
- [データ削除](#データ削除)
- [Firestoreと組み合わせる](#firestoreと組み合わせる)
#### データ追加
カスタムhookを作成した。

__やっている事__
1. 1つのmultipleなinputタグor multipleではない複数のinputタグのファイルを管理
2. ファイル形式をチェック
3. 選択されているファイル全てのアップロード
4. アップロードしたファイルの情報を貰ってくる

ファイル1つ1つを配列に入れて管理しているため、選択されたファイルが変更された時にどの配列要素を更新するのかを知る必要があった。モヤモヤしたが、取り敢えず1意のkeyを受け取って対応するものを更新するようにした。

これでは全てのファイルを同じパスにしか保存できないが、別々にしたい場合setFileの引数にpathを受け取るよう改修する等すれば良い。

作ってはみたがそもそもこのカスタムhookがどうなんだこれはという所はある。

```ts:useFireStorageMutation.ts
import { useEffect, useState } from 'react';

import { uuidv4 } from '@firebase/util';
import { uploadBytesResumable, ref, getDownloadURL, getMetadata } from 'firebase/storage';

import { storage } from '@/config/firebase';

import type { StorageError, TaskState, FullMetadata } from 'firebase/storage';

type FileData = {
  file: File | undefined;
  error: Error | StorageError | null;
  status: TaskState | null;
  progress: number;
  data?: (FullMetadata & { downloadUrl: string }) | undefined;
};

type FireStorageMutation = {
  files: FileData[];
  isLoading: boolean;
  error: Error | StorageError | null;
};

const FILE_TYPE_ERROR_MESSAGE = 'Unsupported file type';
const FILE_NOT_SELECTED_ERROR_MESSAGE = 'File not selected';
const DEFAULT_ALLOW_TYPE = ['image', 'video'];
const KEY_IS_REQUIRED = 'if not multiple mode, key is required';

export const useFireStorageMutation = (
  allowType: string[] = DEFAULT_ALLOW_TYPE,
  multiple: boolean
) => {
  const [fireStorageMutation, setFireStorageMutation] = useState<FireStorageMutation>({
    files: [],
    isLoading: false,
    error: null,
  });
  const [options, setOptions] = useState<
    | {
        onSuccess?: (data: (FullMetadata & { downloadUrl: string })[]) => void;
        onError?: (error?: StorageError) => void;
      }
    | undefined
  >(undefined);

  // エラーが発生したらログに出力
  useEffect(() => {
    fireStorageMutation.error && console.log(fireStorageMutation.error);
    fireStorageMutation.files.forEach((file, index) => {
      file.error && console.log(index, file.error);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [fireStorageMutation.error, fireStorageMutation.files.map((file) => file.error)]);

  // ファイルをセットする処理
  const setFile = (files: FileList | null, key?: number) => {
    console.log(files);

    if (!multiple && key === undefined) {
      console.error(KEY_IS_REQUIRED);
      return;
    }

    if (!files || files.length === 0) {
      multiple
        ? setFireStorageMutation((prevState) => ({ ...prevState, files: [] }))
        : setFireStorageMutation((prevState) => ({
            ...prevState,
            files: prevState.files.map((file, index) =>
              index === key ? { ...file, file: undefined, error: null } : file
            ),
          }));
      return;
    }

    const newFiles: FileData[] = multiple ? [] : fireStorageMutation.files;

    Array.from(files).map((file, index) => {
      console.log(file);

      const allow = allowType.includes(file.type.split('/')[0]);

      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      newFiles[multiple ? index : key!] = {
        file: allow ? file : undefined,
        error: allow ? null : new Error(FILE_TYPE_ERROR_MESSAGE),
        status: null,
        progress: 0,
        data: undefined,
      };
    });

    setFireStorageMutation((prevState) => ({
      ...prevState,
      files: newFiles,
    }));
  };

  // アップロード処理
  const mutate = (
    path: string,
    options?: {
      ignoreInvalidFile?: boolean;
      customMetadata?: { [key: string]: string & { owner?: string } };
      onSuccess?: (data: (FullMetadata & { downloadUrl: string })[]) => void;
      onError?: (error?: StorageError) => void;
    }
  ) => {
    // 有効なファイルが選択されているかチェック
    if (!fireStorageMutation.files.some((file) => file.file)) {
      setFireStorageMutation((prevState) => ({
        ...prevState,
        error: new Error(FILE_NOT_SELECTED_ERROR_MESSAGE),
      }));
      options?.onError && options.onError();
      return;
    }

    // 無効なファイルがある場合はエラーを返す
    if (!options?.ignoreInvalidFile && fireStorageMutation.files.some((file) => file.error)) {
      setFireStorageMutation((prevState) => ({
        ...prevState,
        error: new Error(FILE_TYPE_ERROR_MESSAGE),
      }));
      options?.onError && options.onError();
      return;
    }

    // 有効なファイルを全てアップロード
    setFireStorageMutation((prevState) => ({
      ...prevState,
      isLoading: true,
      error: null,
    }));

    // optionsからonSuccessとonErrorを取り出す
    const { onSuccess, onError } = options || {};
    setOptions({ onSuccess, onError });

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const uploadTasks = fireStorageMutation.files.map((file, fileIndex) => {
      if (!file.file) {
        console.error('invalid file');
        return;
      }

      const fileName = `${uuidv4()}.${file.file.name.split('.').pop()}`;

      const uploadTask = uploadBytesResumable(
        ref(storage, `${path}/${fileName}`),
        file.file.slice(0, file.file.size, file.file.type),
        {
          customMetadata: options?.customMetadata,
        }
      );

      uploadTask.on(
        'state_changed',
        (snapshot) => {
          const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
          console.log('Upload is ' + progress + '% done');
          setFireStorageMutation((prevState) => ({
            ...prevState,
            files: prevState.files.map((prev, index) => {
              if (fileIndex === index) {
                return {
                  ...prev,
                  progress: progress,
                  status: snapshot.state,
                };
              }
              return prev;
            }),
          }));
        },
        (error) => {
          setFireStorageMutation((prevState) => ({
            ...prevState,
            files: prevState.files.map((prev, index) => {
              if (fileIndex === index) {
                return {
                  ...prev,
                  error: error,
                };
              }
              return prev;
            }),
          }));
          options?.onError && options.onError(error);
        },
        () => {
          Promise.all([
            getMetadata(uploadTask.snapshot.ref),
            getDownloadURL(uploadTask.snapshot.ref),
          ])
            .then(([metadata, downloadUrl]) => {
              setFireStorageMutation((prevState) => ({
                ...prevState,
                files: prevState.files.map((prev, index) => {
                  if (fileIndex === index) {
                    return {
                      ...prev,
                      status: 'success',
                      data: {
                        ...metadata,
                        downloadUrl,
                      },
                    };
                  }
                  return prev;
                }),
              }));
            })
            .catch((error) => {
              setFireStorageMutation((prevState) => ({
                ...prevState,
                files: prevState.files.map((prev, index) => {
                  if (fileIndex === index) {
                    return {
                      ...prev,
                      error: error,
                    };
                  }
                  return prev;
                }),
              }));
              options?.onError && options.onError(error);
            });
        }
      );

      return uploadTask;
    });
  };

  useEffect(() => {
    console.log(fireStorageMutation.files.filter((file) => file.file).map((file) => file.status));

    // すべてのファイルのアップロードが完了したかをチェック
    if (
      fireStorageMutation.files
        .filter((file) => file.file)
        .every((file) => file.status === 'success')
    ) {
      setFireStorageMutation((prevState) => ({
        ...prevState,
        isLoading: false,
      }));

      if (options?.onSuccess) {
        console.log(fireStorageMutation.files);
        const successFiles = fireStorageMutation.files.filter((file) => file.data);

        console.log(successFiles);
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        options.onSuccess(successFiles.map((file) => file.data!));
      }
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    // eslint-disable-next-line react-hooks/exhaustive-deps
    JSON.stringify(
      fireStorageMutation.files.filter((file) => file.file).map((file) => file.status)
    ),
  ]);

  return {
    ...fireStorageMutation,
    setFile,
    mutate,
  };
};
```
#### データ削除
こちらもカスタムhookを作成した。
これは単体ファイルにしか対応していないが・・・
一応mutateの引数に違うpathを渡してforEachなりで回せばそのような動きをできるため今回はこれで。

```ts:useFireStorageDeletion.ts
import { useState, useEffect } from 'react';

import { ref, deleteObject } from 'firebase/storage';

import { storage } from '@/config/firebase';

export const useFireStorageDeletion = () => {
  const [deletion, setDeletion] = useState<{
    isLoading: boolean;
    error: Error | null;
  }>({
    isLoading: false,
    error: null,
  });

  useEffect(() => {
    console.log(deletion.error);
  }, [deletion.error]);

  const mutate = (path: string, options?: { onSuccess?: () => void; onError?: () => void }) => {
    setDeletion({
      isLoading: true,
      error: null,
    });

    deleteObject(ref(storage, path))
      .then(() => {
        setDeletion({
          isLoading: false,
          error: null,
        });
        options?.onSuccess && options.onSuccess();
      })
      .catch((error) => {
        setDeletion({
          isLoading: false,
          error: error,
        });
        options?.onError && options.onError();
      });
  };

  return { deletion, mutate };
};
```

#### Firestoreと組み合わせる
本題である。
今回は任意の数のファイルを持つ投稿を作成することにした。

__やっていること__
1. データ追加の項のuseFireStorageMutation.tsでファイルを管理する
2. mutateTSX関数が実行されると、upLoadFile関数でファイルを保存する
3. uploadFileが成功したら、createPost(mutateDTO)関数で、保存したファイルの情報・その他のデータを持つ投稿をmediaPostsコレクションに保存する
4. createPostが失敗したら、storageに保存したファイル達は呼び出される投稿を失うため、deleteFile関数で削除してロールバックする。
```ts:crateMediaPost.ts
import { useFirestoreCollectionMutation } from '@react-query-firebase/firestore';
import { collection, doc, serverTimestamp } from 'firebase/firestore';
import _ from 'lodash';

import { db } from '@/config/firebase';
import { useFireStorageDeletion } from '@/hooks/useFireStorageDeletion';
import { useFireStorageMutation } from '@/hooks/useFireStorageMutaion';
import { useFireAuth } from '@/lib/fireAuth';

import type { MediaData, MediaPost } from '../types';
import type { DocumentData, DocumentReference, FirestoreError } from 'firebase/firestore';

type CreateMediaPostDTO = {
  data: Partial<MediaPost>;
  options?: {
    onSuccess?: (data: DocumentReference<DocumentData>) => void;
    onError?: (error: FirestoreError) => void;
  };
};

const FILE_UPLOAD_SUCCESS = 'file upload success';
const FILE_UPLOAD_FAILED = 'file upload failed';
const POST_CREATE_SUCCESS = 'post create success';
const POST_CREATE_FAILED = 'post create failed';
const FILE_DELETE_SUCCESS = 'file delete (rollback) success';
const FILE_DELETE_FAILED = 'file delete (rollback) failed';
const PICk_FILE_DATA = ['bucket', 'contentType', 'downloadUrl', 'fullPath'];

export const useCreateMediaPost = () => {
  const { user } = useFireAuth();
  const userRef = doc(db, 'users', user ? user?.uid : '_');
  const createMediaPostMutaion = useFirestoreCollectionMutation(collection(userRef, 'mediaPosts'));
  const fireStorageMutation = useFireStorageMutation(undefined, false);
  const fireStorageDeletion = useFireStorageDeletion();

  const mutateTSX = (config: CreateMediaPostDTO) => {
    uploadFile(config);
    // if uploadFile is success, createPost is called
    // if cratePost is failed, deleteFile is called
  };

  // 投稿作成関数
  const mutateDTO = (config: CreateMediaPostDTO) => {
    console.log(config.data);

    const newMediaPost = {
      body: config.data.body,
      files: config.data.files,
      author: userRef,
      createdAt: serverTimestamp(),
    };

    createMediaPostMutaion.mutate(newMediaPost, {
      onSuccess: (data) => config.options?.onSuccess && config.options.onSuccess(data),
      onError: (error) => config.options?.onError && config.options.onError(error),
    });
  };

  // ファイルアップロード
  const uploadFile = (config: CreateMediaPostDTO) => {
    fireStorageMutation.mutate('files', {
      customMetadata: {
        owner: user?.uid as string,
      },
      onSuccess: (data) => {
        console.log(FILE_UPLOAD_SUCCESS);
        console.log(data);
        createPost(config, data);
      },
      onError: (error) => console.log(FILE_UPLOAD_FAILED, error),
    });
  };

  // 投稿作成関数に入力データとアップロードしたファイルの情報を渡す
  const createPost = (config: CreateMediaPostDTO, files: MediaData[]) => {
    const pickedFileData = files.map((file) => _.pick(file, PICk_FILE_DATA));

    console.log('pickedFileData', pickedFileData);

    mutateDTO({
      data: {
        body: config.data.body,
        files: pickedFileData,
      },
      options: {
        onSuccess: (data) => console.log(POST_CREATE_SUCCESS, data),
        onError: (error) => {
          console.log(POST_CREATE_FAILED, error);
          deleteFile(files);
        },
      },
    });
  };

  // ファイル削除
  const deleteFile = (files: MediaData[]) => {
    files.map((file) => {
      fireStorageDeletion.mutate(file.fullPath, {
        onSuccess: () => console.log(FILE_DELETE_SUCCESS),
        onError: () => console.log(FILE_DELETE_FAILED),
      });
    });
  };

  return {
    ...createMediaPostMutaion,
    mutateTSX,
    fireStorageMutation,
  };
};
```
このように使用する。
今回はテストとして3つのinputタグを表示させている。
```tsx:createMediaPost.tsx
import { useState } from 'react';

import { Button } from '@/components/Elements';

import { useCreateMediaPost } from '../api/createMediaPost';

export const CreateMediaPost = () => {
  const createMediaPost = useCreateMediaPost();

  const [mediaPostBody, setMediaPostBody] = useState<string>('');

  const handleMediaPostBodyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setMediaPostBody(event.currentTarget.value);
  };

  const handleCreateMediaPostClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    createMediaPost.mutateTSX({ data: { body: mediaPostBody } });
  };

  const handleChangeFile = (event: React.ChangeEvent<HTMLInputElement>, index: number) => {
    createMediaPost.fireStorageMutation.setFile(event.target.files, index);
  };

  return (
    <div>
      <h1>ファイルアップロード</h1>
      {[...Array(3)].map((_, index) => (
        <div key={index}>
          <input type="file" onChange={(event) => handleChangeFile(event, index)} />
          <span>
            {createMediaPost.fireStorageMutation.files[index] &&
              createMediaPost.fireStorageMutation.files[index].error?.message}
          </span>
        </div>
      ))}
      <input type="text" value={mediaPostBody} onChange={handleMediaPostBodyChange} />
      <Button onClick={handleCreateMediaPostClick}>Create</Button>
    </div>
  );
};
```
投稿したものを表示するコンポーネントは以下。

今回、投稿の削除をする責務は投稿を表示しているコンポーネント自身が持っているため、Firestoreから投稿が消えた瞬間このコンポーネントはアンマウントされる。
そのため何が起こるかというと投稿に紐付けられたファイルを削除できない。
そこで、Cloud Functionsでファイルを削除している。
Cloud Functionsの関数も続けて記載する。
```tsx:mediaPost.tsx
import { Button } from '@/components/Elements';

import { useDeleteMediaPost } from '../api/deleteMediaPost';

import type { MediaPost as MediaPostType } from '../types';

type MediaPostProps = {
  mediaPost: MediaPostType;
};

export const MediaPost = ({ mediaPost }: MediaPostProps) => {
  const deleteMediaPost = useDeleteMediaPost(mediaPost);

  const onDeleteClick = () => {
    deleteMediaPost.mutate(undefined, {
      onSuccess: () => console.log('投稿削除成功, Cloud Functionsでファイルの削除を実行'),
    });
  };

  return (
    <div>
      <h1>{mediaPost.body}</h1>
      {mediaPost.files?.map(
        (file, index) =>
          (file.contentType?.startsWith('image/') && (
            <img key={index} src={file.downloadUrl} alt="File" />
          )) ||
          (file.contentType?.startsWith('video/') && (
            // eslint-disable-next-line jsx-a11y/media-has-caption
            <video key={index} src={file.downloadUrl} controls />
          ))
      )}
      <Button onClick={onDeleteClick} isLoading={deleteMediaPost.isLoading}>
        Delete
      </Button>
    </div>
  );
};
```
Functionsは小さく切り出すと良い。
```ts:mediaPosts.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

export const deleteWithStorage = functions.firestore
  .document("users/{userId}/mediaPosts/{mediaPostId}")
  .onDelete((snap) => {
    const deletedData = snap.data();
    const bucket = admin.storage().bucket();
    deletedData.files.map((file: { fullPath: string; }) => {
      const filePath = file.fullPath;
      return bucket.file(filePath).delete();
    });
  });
```
この様にindexで纏める。
```ts:index.ts
// import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

import * as mediaPosts from "./mediaPosts";

admin.initializeApp();

exports.mediaPosts = { ...mediaPosts };
```

## Security Rules
Cloud Storageにもルールを設定できる。今回は以下のようにした。
```js:storage.rules
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // authenticated
    function isAuthenticated() {
      return request.auth != null;
    }
    // request user is same as resource owner
    // must be set custom metadata {owner: uid}
    function isOwner() {
      return request.auth.uid == request.resource.metadata.owner || request.auth.uid == resource.metadata.owner;
    }

    // type check /////////////////////////////////////////////////////////////////////////
    // image
    function isImage() {
      return request.resource.contentType.matches('image/.*');
    }
    // video
    function isVideo() {
      return request.resource.contentType.matches('video/.*');
    }
    /////////////////////////////////////////////////////////////////////////

    match /files/{fileId} {
      allow read;
      allow create: if isOwner() && (isImage() || isVideo());
      allow update: if isOwner() && (isImage() || isVideo());
      allow delete: if isOwner();
    }
  }
}
```
#
以上。勉強しているうちはFirebaseもなかなか面白い