1
2

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

Last updated at Posted at 2024-09-04

背景

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

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

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

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

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

RailsのViewではnested_formというgemのnested_form_forメソッドでフォームが作られており、そのフォームの中でReactのファイル選択コンポーネントがreact_componentメソッドで呼ばれていました。

nested_formはすでにアーカイブされているgemなので、新規で開発する場合は採用されないと思います。
しかし、一部のレガシーな環境などではまだ使われていることもあるかと思います。

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

コード例 (修正前)

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

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'
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>
  );
};
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
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
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プロパティに渡します。

コード例 (修正後)

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

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

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

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

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

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

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

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

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

スクリーンショット 2024-09-05 3.07.36.png

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

結論

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

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

感想

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

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

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

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

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

参考記事

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2