背景
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
メソッドと大差ないと思います。
コード例 (修正前)
私が修正する前のコードはこのようになっていました。
(雰囲気だけ掴んでもらえるように、かなり簡易的に記述しています)
= 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'
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>
);
};
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
class Item < ApplicationRecord
has_many :attachments, dependent: :destroy, class_name: 'ItemAttachment', inverse_of: :item
accepts_nested_attributes_for :attachments, allow_destroy: true
end
class ItemAttachment < ApplicationRecord
belongs_to :item, inverse_of: :attachments
mount_uploader :file, FileUploader
end
ざっとこのような感じの構成になっていました。
UIについて軽く説明します。
- 「ファイルを選択」ボタンを押すとファイル選択のダイアログが開き、ファイルを追加できます
- 追加されたファイルは、FileItemとして画面に表示されます
- FileItemは複数表示できます
- FileItemはクリックすることで削除できます
- 「保存する」ボタンを押すと追加したファイルが保存されます
解決したかった課題
コード例(修正前)では、1回のファイル選択で複数ファイルを同時に選択すれば、複数ファイルを保存することが可能です。
しかし、1回の選択で1つのファイルのみ選択して、次にもう一度ファイル選択ダイアログを開き1つのファイルを選択した場合、保存されるのは最後に選択したファイルのみです。
UI上は複数のファイルが選択されているように見えるので、バグのような挙動になってしまっています。
原因は、ReactのStateで管理しているファイルと<input type="file">
に実際にセットされているファイルに乖離があるためと考えられます。
このような課題を解決するのにDataTransfer
オブジェクトが使えました。
DataTransferオブジェクトを使って解決する方法
コード例(修正前)で示したapp/javascript/components/ItemAttachment.tsx
に修正を加えるだけで上記の課題を解決できます。
具体的には、Stateで保持したファイルをDataTransfer
オブジェクト経由でinputのfiles
プロパティに渡します。
コード例 (修正後)
- 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
メソッドで作成することができません。
実際に検証モードのコンソールなどで試してみると分かります。
そのため、input要素のfiles
プロパティには、JavaScript経由で値をセットすることはできないように見えます。
しかし、DataTransfer
オブジェクトを使えばこれができてしまうのです。
3. DataTransfer
オブジェクトについて
DataTransfer
オブジェクトは、主にドラッグ&ドロップ操作でデータを保持するために使われるオブジェクトです。
ドラッグ&ドロップされたデータはDataTransfer.items
にDataTransferItemList
オブジェクトとして格納されます。
DataTransferItemList
オブジェクトはadd
メソッドを持っており、このadd
メソッドを使ってDataTransferItemList
にFile
オブジェクトを追加することができます。
DataTransfer
オブジェクトのfiles
プロパティには、DataTransfer
オブジェクトが保持している全てのローカルファイルのリストがFileList
オブジェクトという形で格納されています。
そして(ここが重要です)、DataTransfer
オブジェクトはnew
メソッドで作成することができます。
つまり、DataTransfer
オブジェクト経由でinput要素のfiles
プロパティに値を渡せるのです!
結論
ドラッグ&ドロップを行わなくてもDataTransfer
オブジェクトはnew
メソッドで作成でき、このDataTransfer
オブジェクト経由で<input type="file">
のfiles
プロパティに値をセットすることが可能であると分かりました。
これは<input type="file">
のvalue属性に値をセットするのと近しい行為だと思います。
感想
初めてDataTransfer
のことを知ったとき、ブラウザはこのような<input type="file">
をハックできるような手段を提供していて良いのかと思いました。
しかし、DataTransfer
がなければ自分が直面した課題を解決するのが難しかったのも事実です。
この裏口的なハック方法が存在する事実はセキュリティ的には大丈夫なのでしょうか?
私はまだセキュリティ周りの見識は浅いので、この辺の情報に関しては引き続き情報収集をしていきたいと思います。
もしこのあたりに詳しい方がいましたらご教授いただけると幸いです
参考記事