DataTaransferオブジェクトを使えば<input type="file">のvalue属性に値をセットするのと近しいことができる

## 背景

JavaScriptで`<input type="file">`のvalue属性に値を直接設定することはできません。

これは、セキュリティ上の理由で、悪意のあるスクリプトがユーザーのローカルファイルを勝手にアップロードすることを防ぐためです。
ブラウザはファイル選択ダイアログを通してユーザーが手動でファイルを選択することを要求します。

しかし、`<input type="file">`のvalue属性に値をセットするようなことと近しいことをしたくなる場合もあると思います。(かなりマイナーなケースかもしれませんが)

そのような場合に`DataTransfer`オブジェクトが使えます。

私が遭遇したケースでは、RailsのViewテンプレートからReactのファイル選択コンポーネントを呼び出しているパターンで、そのようなことをしたくなりました。

RailsのViewでは[nested_form](https://github.com/ryanb/nested_form)というgemの`nested_form_for`メソッドでフォームが作られており、そのフォームの中でReactのファイル選択コンポーネントが`react_component`メソッドで呼ばれていました。


:::note warn
[nested_form](https://github.com/ryanb/nested_form)はすでにアーカイブされているgemなので、新規で開発する場合は採用されないと思います。
しかし、一部のレガシーな環境などではまだ使われていることもあるかと思います。
:::

:::note warn
一般的にRailsのViewテンプレートでReactコンポーネントを呼ぶ場合[react-rails](https://github.com/reactjs/react-rails)というgemが使われることが多いと思いますが、遭遇したケースでは`react_component`メソッドを自前実装していました。
しかし、挙動としてはreact-railsの`react_component`メソッドと大差ないと思います。
:::

### コード例 (修正前)

私が修正する前のコードはこのようになっていました。
(雰囲気だけ掴んでもらえるように、かなり簡易的に記述しています:pray:)

```slim:app/views/items/edit.html.slim
= nested_form_for @item, url: '/item', html: { role: 'form' } do |f|
  .row
    section.form-section-group
      = react_component 'ItemAttachment', { attachments: @item.attachments.map{|a| { id: a.id, name: a.file.identifier }} }, { camelize_props: true }
  = f.button '保存する', class: 'btn btn-primary'
```

```tsx:app/javascript/components/ItemAttachment.tsx
import { useEffect, useState } from 'react';

type SelectorFileType = {
  id?: string;
  name: string;
  uploadedAt?: string;
  url?: string;
};

type Props = {
  attachments: SelectorFileType[];
};

export const ItemAttachment = ({
  attachments: defaultAttachments,
}: Props) => {
  const [attachments, setAttachments] = useState<File[]>([]);
  const [existingAttachments, setExistingAttachments] = useState<SelectorFileType[]>([]);

  const addAttachments = (files: File[]) => {
    setAttachments([...attachments, ...files]);
  };

  const removeAttachment = (file: SelectorFileType) => {
    setAttachments(attachments.filter((a) => a.name !== file.name));
  };

  const removeExistingAttachment = (file: SelectorFileType) => {
    setExistingAttachments(existingAttachments.filter((a) => a.name !== file.name));
  };

  const attachmentNames = () => {
    attachments.map((a) => a.name).concat(existingAttachments.map((ea) => ea.name));
  };

  useEffect(() => {
    setExistingAttachments(defaultAttachments);
  }, [setExistingAttachments, defaultAttachments]);

  return (
    <div id="item_attachment">
      {existingAttachments.map((a) => (
        <FileItem
          key={a.name}
          file={{ name: a.name }}
          onClick={removeExistingAttachment}
        />
      ))}
      {attachments.map((a) => (
        <FileItem
          key={a.name}
          file={{ name: a.name }}
          onClick={removeAttachment}
        />
      ))}
      <div>
        <label htmlFor="add_attachment">
          ファイルを選択
        </label>
        <input
          id="add_attachment"
          type="file"
          className="d-none"
          multiple
          accept=".pdf"
          name="item[attachments][]"
          onChange={(e) => {
            addAttachments(Array.from(e.target.files ?? []));
          }}
        />
      </div>
      {defaultAttachments
        .filter((da) => !attachmentNames.includes(da.name))
        .map((da) => (
          <input
            key={a.name}
            type="hidden"
            name="item[remov_attachment_ids][]"
            value={a.id}
           />
         ))}
    </div>
  );
};
```

```rb:app/controllers/items_controller.rb
class ItemsController < ApplicationController
  before_action :set_item

  permits :attachments: []

  def edit
  end

  def update(item)
    build_attachments(item)
    if @item.update(item.except(:attachments))
      redirect_to '/items', notice: 'アイテムを変更しました'
    else
      render 'edit'
    end
  end

  private

  def set_item
    @item ||= Item.find(params[:id])
  end

  def build_attachments(item)
    attachments_to_destroy = (item[:remove_attachment_ids] || []).map.with_index do |id, i|
      { i.to_s => { id: id.to_i, _destroy: '1' } }
    end

    new_attachments = (item[:attachments] || []).map.with_index do |file, i|
      { (@item.attachments.size + i).to_s => { file: } }
    end

    item[:attachments_attributes] = (attachments_to_destroy + new_attachments).reduce({}, :merge)
  end
end
```

```rb:app/models/item.rb
class Item < ApplicationRecord
  has_many :attachments, dependent: :destroy, class_name: 'ItemAttachment', inverse_of: :item

  accepts_nested_attributes_for :attachments, allow_destroy: true
end
```

```rb:app/models/item_attachment.rb
class ItemAttachment < ApplicationRecord
  belongs_to :item, inverse_of: :attachments

  mount_uploader :file, FileUploader
end
```

ざっとこのような感じの構成になっていました。

UIについて軽く説明します。

1. 「ファイルを選択」ボタンを押すとファイル選択のダイアログが開き、ファイルを追加できます
2. 追加されたファイルは、FileItemとして画面に表示されます
3. FileItemは複数表示できます
4. FileItemはクリックすることで削除できます
5. 「保存する」ボタンを押すと追加したファイルが保存されます

## 解決したかった課題

コード例(修正前)では、1回のファイル選択で複数ファイルを同時に選択すれば、複数ファイルを保存することが可能です。
しかし、1回の選択で1つのファイルのみ選択して、次にもう一度ファイル選択ダイアログを開き1つのファイルを選択した場合、保存されるのは最後に選択したファイルのみです。

UI上は複数のファイルが選択されているように見えるので、バグのような挙動になってしまっています。

原因は、ReactのStateで管理しているファイルと`<input type="file">`に実際にセットされているファイルに乖離があるためと考えられます。

このような課題を解決するのに`DataTransfer`オブジェクトが使えました。

## DataTransferオブジェクトを使って解決する方法

コード例(修正前)で示した`app/javascript/components/ItemAttachment.tsx`に修正を加えるだけで上記の課題を解決できます。

具体的には、Stateで保持したファイルを`DataTransfer`オブジェクト経由でinputの`files`プロパティに渡します。

### コード例 (修正後)

```diff_tsx:app/javascript/components/ItemAttachment.tsx
- import { useEffect, useState } from 'react';
+ import { useEffect, useRef, useState } from 'react';

type SelectorFileType = {
  id?: string;
  name: string;
  uploadedAt?: string;
  url?: string;
};

type Props = {
  attachments: SelectorFileType[];
};

export const ItemAttachment = ({
  attachments: defaultAttachments,
}: Props) => {
  const [attachments, setAttachments] = useState<File[]>([]);
  const [existingAttachments, setExistingAttachments] = useState<SelectorFileType[]>([]);
+ const inputRef = useRef<HTMLInputElement | null>(null);

  const addAttachments = (files: File[]) => {
    setAttachments([...attachments, ...files]);
  };

  const removeAttachment = (file: SelectorFileType) => {
    setAttachments(attachments.filter((a) => a.name !== file.name));
  };

  const removeExistingAttachment = (file: SelectorFileType) => {
    setExistingAttachments(existingAttachments.filter((a) => a.name !== file.name));
  };

  const attachmentNames = () => {
    attachments.map((a) => a.name).concat(existingAttachments.map((ea) => ea.name));
  };

  useEffect(() => {
    setExistingAttachments(defaultAttachments);
  }, [setExistingAttachments, defaultAttachments]);
+
+ useEffect(() => {
+   if (!inputRef.current) return;
+
+   const dataTransfer = new DataTransfer();
+   attachments.forEach((file) => dataTransfer.items.add(file));
+   inputRef.current.files = dataTransfer.files;
+ }, [attachments, inputRef.current]);

  return (
    <div id="item_attachment">
      {existingAttachments.map((a) => (
        <FileItem
          key={a.name}
          file={{ name: a.name }}
          onClick={removeExistingAttachment}
        />
      ))}
      {attachments.map((a) => (
        <FileItem
          key={a.name}
          file={{ name: a.name }}
          onClick={removeAttachment}
        />
      ))}
      <div>
        <label htmlFor="add_attachment">
          ファイルを選択
        </label>
        <input
          id="add_attachment"
          type="file"
          className="d-none"
          multiple
          accept=".pdf"
-         name="item[attachments][]"
          onChange={(e) => {
            addAttachments(Array.from(e.target.files ?? []));
          }}
        />
      </div>
+     <input
+       ref={inputRef}
+       type="file"
+       name="item[attachments][]"
+       className="d-none"
+       multiple
+     />
      {defaultAttachments
        .filter((da) => !attachmentNames.includes(da.name))
        .map((da) => (
          <input
            key={a.name}
            type="hidden"
            name="item[remov_attachment_ids][]"
            value={a.id}
           />
         ))}
    </div>
  );
};
```

## 解説

### 1. `<input type="file">`で選択されたファイルの情報の在処

そもそも`<input type="file">`で選択されたファイルの情報はどこに格納されるのでしょうか？

これは、このinput要素 (`HTMLInputElement`) の`files`プロパティに`FileList`オブジェクトという形で格納されます。

### 2. `FileList`オブジェクトについて

`FileList`オブジェクトは`File`オブジェクトのリストを含むオブジェクトです。配列のように振る舞います。

この`FileList`オブジェクトは`new`メソッドで作成することができません。

実際に検証モードのコンソールなどで試してみると分かります。

![スクリーンショット 2024-09-05 3.04.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174479/fbd16e22-f31a-9d31-6df0-7f2efea81ad2.png)

そのため、input要素の`files`プロパティには、JavaScript経由で値をセットすることはできないように見えます。

しかし、`DataTransfer`オブジェクトを使えばこれができてしまうのです。

### 3. `DataTransfer`オブジェクトについて

`DataTransfer`オブジェクトは、主にドラッグ＆ドロップ操作でデータを保持するために使われるオブジェクトです。

ドラッグ＆ドロップされたデータは`DataTransfer.items`に`DataTransferItemList`オブジェクトとして格納されます。

`DataTransferItemList`オブジェクトは`add`メソッドを持っており、この`add`メソッドを使って`DataTransferItemList`に`File`オブジェクトを追加することができます。

`DataTransfer`オブジェクトの`files`プロパティには、`DataTransfer`オブジェクトが保持している全てのローカルファイルのリストが`FileList`オブジェクトという形で格納されています。

そして（ここが重要です)、`DataTransfer`オブジェクトは`new`メソッドで作成することができます。

![スクリーンショット 2024-09-05 3.07.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174479/298e9e9e-43d9-6332-39bb-d24066e78343.png)

つまり、`DataTransfer`オブジェクト経由でinput要素の`files`プロパティに値を渡せるのです！

## 結論

ドラッグ＆ドロップを行わなくても`DataTransfer`オブジェクトは`new`メソッドで作成でき、この`DataTransfer`オブジェクト経由で`<input type="file">`の`files`プロパティに値をセットすることが可能であると分かりました。

これは`<input type="file">`のvalue属性に値をセットするのと近しい行為だと思います。

## 感想

初めて`DataTransfer`のことを知ったとき、ブラウザはこのような`<input type="file">`をハックできるような手段を提供していて良いのかと思いました。

しかし、`DataTransfer`がなければ自分が直面した課題を解決するのが難しかったのも事実です。

この裏口的なハック方法が存在する事実はセキュリティ的には大丈夫なのでしょうか？

私はまだセキュリティ周りの見識は浅いので、この辺の情報に関しては引き続き情報収集をしていきたいと思います。

もしこのあたりに詳しい方がいましたらご教授いただけると幸いです:bow:

## 参考記事

https://usefully.co.jp/tips/javascript%E3%81%A7-input-typefile-%E3%81%AB%E5%80%A4%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AF%EF%BC%9F%E2%91%A0/

https://qiita.com/jkr_2255/items/1c30f7afefe6959506d2

https://developer.mozilla.org/ja/docs/Web/API/DataTransfer

https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/file#%E9%81%B8%E6%8A%9E%E3%81%95%E3%82%8C%E3%81%9F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E6%83%85%E5%A0%B1%E3%81%AE%E5%8F%96%E5%BE%97

https://developer.mozilla.org/ja/docs/Web/API/HTMLInputElement/files

https://developer.mozilla.org/ja/docs/Web/API/FileList