React、RailsのActive StorageでAWS S3を使おう！

## Actvie Storageとは？

Rails5.2から追加されたRailsのストレージ機能です。AWS S3、Google Cloud Storageなどのクラウドストレージサービスにアップロードするのに便利らしいです。

Railsで画像などをアップロードする機能としてCarrier Waveも候補になるかと思います。

:::note info
**viewをReactやVueで書く場合はCarrier Waveを使った方がいいかも？**
[ActiveStorage vs CarrierWave - Qiita](https://qiita.com/w5966qzh/items/510d4c2a3829524b2e64)
:::

しかし、普段Active Storageを使っていたり、SPAでも使用したい人は多いと思いますので私の勉強も含めて、AWS S3のセットアップから行っていきたいと思います。

そこまでrailsやAWSに詳しくないので動いたコードを共有します。セキュリティやコードに改善点があればご指摘お願いします。

## 1.AWSのセットアップ

1. AWSのアカウントを作成します。

AWS公式のアカウント作成ガイドがあるので読みましょう

[AWS アカウント作成の流れ【AWS 公式】](https://aws.amazon.com/jp/register-flow/)

1. 上のサービスや検索からS3を検索します。

![スクリーンショット 2023-07-15 11.22.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/fbff5dc5-4d89-8afe-df58-2825bbca103f.png)

1. バケットを作成します

![スクリーンショット 2023-07-15 11.24.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/195fd0f7-d923-e16b-5895-01b52dda4ae6.png)

1. バケット名は自由で、リージョンはアジアパシフィック（東京）にします。ブロックパブリックアクセスをOFFにします。他の設定は何もせず、デフォルトの設定で良いかと思います

![スクリーンショット 2023-07-15 11.28.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/d3717c72-7409-6fa6-a5a3-1c5308f7d2d9.png)

![スクリーンショット 2023-07-15 11.50.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/795c9c6e-fa0b-65ab-af9c-6eb0114e32a8.png)

1. バケットポリシーを作成します。権限や公開設定のルールです。今回の方法は公開された、PUBLICなURLから画像を取得するので公開するようなポリシーにします。

![スクリーンショット 2023-07-15 11.39.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/24af89f1-bc97-9db1-6d5b-a4d9d6268cb4.png)

![スクリーンショット 2023-07-15 11.42.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/7a7fcabc-b974-ac8c-f607-b346d7724344.png)

```json
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "AllowPublicRead",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::your-bucket-name/*"
        }
    ]
}
```

`your-bucket-name`にはあなたのバケットの名前を入力してください

1. IAMユーザーを作成
![スクリーンショット 2023-07-15 12.01.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/19b0a473-145b-f72f-d62c-d6879095c3d1.png)


1. ユーザーのアクセスキーを作成します。ユーザーからセキュリティ認証情報をクリックし、アクセスキーを作成します。作成するとアクセスキーとシークレットキーが作成されるので、メモなどにコピーして取っておいてください。
![スクリーンショット 2023-07-15 13.16.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1922066/04684d74-4334-1acb-e997-57a80847364d.png)


## 2.Railsでの設定

### 認証情報の追加

AWSのAPIを操作するために、先ほど作成したアクセスキーとシークレットキーを記載します。

```bash
$ EDITOR=vim rails credentials:edit
```

vimなどのエディタでrailsのcredentialに

```bash
aws:
  access_key_id: 先ほど控えたもの
  secret_access_key: 先ほど控えたもの
```

として、`:wq`で保存します

## **config/storage.ymlの編集**

storage.ymlでストレージを定義します。



```yaml:config/storage.yml
test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: ap-northeast-1
  bucket: バケットの名前
```

これは元々storage.yml内のコメントアウトされている内容をそのまま使っているので、コメントアウトして、アクセスキーとシークレットキーを入力しましょう

### config/environments/development.rbの編集

localになっているかと思います。

```yaml:config/environments/development.rb
config.active_storage.service = :local
```

production環境でAmazon S3を利用するため、amazonにします。

```yaml
config.active_storage.service = :amazon
```

### Gemの追加

```ruby
gem 'aws-sdk-s3'
```

### Active Storageを有効にする

```ruby
$ rails active_storage:install
$ rails db:migrate
```

# 実装

### Active Storageの仕組み

active StorageのREADMEによると、

> *Active Storageでは、`Attachment` joinモデルを経由するポリモーフィック関連付けを使い、実際の`Blob`に接続します[1](https://techracho.bpsinc.jp/hachi8833/2018_02_06/52179#fn-52179-footnote1)。`Blob`モデルは添付ファイル（attachment）のメタデータ（ファイル名やコンテンツの種類など）と識別子のキーをストレージサービスに保存します。*

とあり、active_storage_blobsはファイルのメタデータ（データ本体）を管理します。active_storage_attachmentsは中間テーブルとしてアタッチ（activestorageと関連付ける）するモデルとの中間テーブルです

### Rails側-モデルの編集

私の場合、授業の過去問を共有するサイトを開発しており、画像をLectureモデルに対して複数添付したかったので、has_many_attachedを追加しました。

```ruby:models/lectue.rb
class Lecture < ApplicationRecord
  has_many :reviews
  has_many_attached :images
end
```

### Rails側-コントローラの編集



```ruby:lectures_controller.rb
before_action :set_lecture, only: [:show, :create_image, :show_image]

# 講義に画像をアップロードするアクション
def create_image  
  if params[:lecture][:image]
    # ActiveStorageを利用して画像を講義(@lecture)に添付
    @lecture.images.attach(params[:lecture][:image])
    render json: @lecture, status: :created
  else
    render json: { error: 'No image provided' }, status: :unprocessable_entity
  end
end

# 講義に関連付けられている画像を取得するアクション
def show_image
  # 講義に画像が添付されているか確認
  if @lecture.images.attached?
    images = @lecture.images.map do |image|
      {
        url: rails_blob_url(image),  # 画像のURLを取得
        type: image.blob.content_type # 画像のMIMEタイプを取得
      }
    end
    render json: { images: images }
  else
    render json: { error: 'No image attached' }, status: 404
  end
end

private

# パラメータから指定された講義を検索して@lectureに設定する
def set_lecture
  @lecture = Lecture.find(params[:id])
  puts @lecture.inspect
end

```

画像をアップロードするメソッド。のちにReactからFormDataでPOSTする`lecture[image]`がパラメータにあった場合、

```ruby
@モデル名.attach(params[:])
```

とすることで、画像を追加しています。

### React-画像アップロードフォーム

ReactにはuseDropZoneというライブラリを使用しています。


```jsx:imageUpload.jsx
//　コンポーネントと状態管理
const ImageUpload = ({ onImageUpload }) => {
// React RouterからURLパラメータ（id）を取得。
  const { id } = useParams(); 
  const [uploadStatus] = useState(''); 
  const [files, setFiles] = useState([]); 

  const maxSize = 1048576; // 1MB bytes

// ファイル選択処理
// ファイルサイズが1MB以下、ファイル形式が画像またはPDFの条件を満たしたファイルに対し、URL.createObjectURLを使用してプレビューURLを作成。

  const onDrop = useCallback((acceptedFiles) => {
    acceptedFiles.forEach((file) => {
      if (file.size > maxSize) {
        throw new Error("1MB以下のファイルを選択してください");
      } else if (!file.type.startsWith('image/') && !file.type === 'application/pdf') {
        throw new Error("画像ファイルを選択してください");
      } else {
        const newFile = {
          ...file,
          preview: URL.createObjectURL(file),
        };
        setFiles((prevFiles) => [...prevFiles, newFile]);      }
    });
  }, []);
  
// アップロード処理
// 各ファイルについて、再度検証。条件を満たすファイルをFormDataに追加。
  const handleUpload = () => {
    files.forEach((file) => {
      if (file.size <= maxSize && (file.type.startsWith('image/') || file.type === 'application/pdf')) {  
        const formData = new FormData();
        formData.append('lecture[image]', file);        
        fetch(`/api/lectures/${id}/images`, {
          method: 'POST',
          body: formData,
        })
          .then((response) => response.json())
          .then((data) => {
            success('投稿しました');
            onImageUpload(data);
          })
          .catch((error) => {
            console.error(error);
            throw new Error('投稿に失敗しました');
          });
      } else {
        throw new Error('1MB以下の画像ファイルを選択してください');
      }
    });
  };
  useEffect(() => () => {
    files.forEach(file => URL.revokeObjectURL(file.preview));
  }, [files]);


// ファイル選択UIの設定
// react-dropzoneを利用してドラッグ&ドロップ領域とファイル入力の属性を生成。
  const { getRootProps, getInputProps } = useDropzone({ onDrop, accept: 'image/*, application/pdf' });
```

これはファイルサイズが1MBであり、PDFの時のみ送信できるようにしています。FormDataメソッドはデータを送信するためのキーと値のセットをFormDataオブジェクトとして作成します。appendでlecture[image]というキーとfileという値を追加しています。それらをfetchでPOSTしています。

### React-画像アップロードフォーム-UIについて
```jsx:imageUpload.jsx
  return (
    <div className='imageUploadCon'>

{/* ファイル選択エリア */}
{/* getRootProps() と getInputProps() は react-dropzone から提供される関数。必要なプロパティを追加する。 */}
      <div {...getRootProps()}>
        <input {...getInputProps()} />
        <p>ここにファイルをドラッグ&ドロップ、またはクリックしてファイルを選択してください。</p>
      </div>
      {uploadStatus && <p>{uploadStatus}</p>} {/* アップロードのステータスを表示 */}
      <div className='imageUploadButton'>
        <button type='button' onClick={handleUpload}>アップロード</button> {/* アップロードボタン */}
      </div>
      {/* 選択された画像のプレビューを表示 */}
      <div className='imagePreviewCon'>
        {files.map(file => {
      if (file.type.startsWith('image/')) {
        return <img src={file.preview} alt={file.name} key={file.name} />;
      } if (file.type === 'application/pdf') {
        return <iframe src={file.preview} title={file.name} key={file.name} />;
      } 
        return null;  
        })}
      </div>
    </div>
  );
};
ImageUpload.propTypes = {
  onImageUpload: PropTypes.func.isRequired,
};

export default ImageUpload;
```


### React-アップロード画像の表示画面

```jsx:Lecture.jsx
// 取得した画像やPDFファイルのデータとファイル数を保持。
const [images, setImages] = useState([]);
const [imageCount, setImageCount] = useState(0);

// サーバーからのデータ取得
useEffect(() => {
    const fetchImages = async () => {
      const response = await fetch(`/api/lectures/${id}/images`);
      if (!response.ok) throw Error(response.statusText);
      const data = await response.json();
      const imageUrls = data.images ? data.images.map(image => ({ url: image.url, type: image.type })) : [];
      setImages(imageUrls);
      setImageCount(imageUrls.length);
    };

    fetchImages(); 
  }, [id]); 

// モーダル管理
  const openModal = () => {
    if (imageCount === 0) {
      throw new Error("過去問はありません");
      return;
    }
    setIsOpen(true);
  };

  const closeModal = () => { 
    setIsOpen(false);
  };

// ファイルの表示
<div className='imageContainer'>
  {images.map(image => {
	  console.log(image);
    if (image.type && image.type.startsWith('image/')) {
	    return <a key={image.url} href={image.url} target='_blank' rel="noopener noreferrer">
	      <img src={image.url} alt="過去問" />
      </a>;
	  } if (image.type && image.type === 'application/pdf') {
	    return <a key={image.url} href={image.url} target='_blank' rel="noopener noreferrer">
	      <div className="pdfContainer">
	        <Document file={image.url} key={image.url}>
	          <Page pageNumber={1} scale={0.3} renderTextLayer={false} />
          </Document>
        </div>
      </a>;
	   }
     return null;
   })}
	</div>
```

アップロードした画像を取得するLectureコンポーネントの処理はあまり変わったところはありません。fecthでPOSTしたデータを取得し、imageプロパティがあれば、urlとタイプを含むオブジェクトの配列を作り、それらのタイプから返すJSXを変えています。