[HTML5] Fetch API でファイルをアップロードする

正直、ファイルのアップロードは考慮すべきことが多すぎてあまり関わりたくないのですが、そうも行かないのが世の定めw 今回はFetchAPIを利用してサクッとファイルをアップする手法についてまとめます。

サーバ側はPHPで実装しますが、考え方は他の言語でも流用できるのではないかと思います。

基本的な原理

FetchAPI

そもそものFetchAPIの説明や利用方法については以下のページを参照ください。 blog.katsubemakito.net

ブラウザ側

ここから本題です。HTML側は非常にシンプルです。<input type="file">を適当な場所に書いておくだけ。id属性には適当な値を設定しておきます。

<form id="upload">
  <input type="file" id="avatar">
  <button>アップロード</button>
</form>

対してJavaScript側はぼちぼち仕事がありますが、以前紹介したFetchAPIの使い方と大して変わりません。

const avatar = document.querySelector("#avatar");  // <input type="file">

/*
 * ページの読み込みが完了したら実行
 */
window.onload = ()=>{
  /*
   * 送信イベントが発生したら実行
   */
  document.querySelector("#upload").addEventListener("submit", (e)=>{
    // 規定の送信処理をキャンセル(画面遷移などしない)
    e.preventDefault();

    // 送信データの準備
    const formData = new FormData();
    formData.append("avatar", avatar.files[0]);  // ファイル内容を詰める

    const param = {
      method: "POST",
      body: formData
    }

    // アップロードする
    fetch("https://example.com/receive.php", param)
      .then((res)=>{
        return( res.json() );
      })
      .then((json)=>{
        // 通信が成功した際の処理
      })
      .catch((error)=>{
        // エラー処理
      });
  });
});

ポイントはparamに詰める際にFormDataクラスを利用している部分ですね。このオブジェクトにサーバへ送りたい値をガンガン詰めてFetchAPIに渡せばいい感じに処理してくれる便利なクラスです。もちろんファイル以外のテキストや数値、バイナリなど自由に詰め込むことができます。

サーバ側

PHPの場合、自動的に裏側でデータを受け取って一時ディレクトリへ保存して置いてくれます。プログラムで処理するのはファイルを実際のディレクトリへ移動する部分だけです。

// 一時ディレクトリから公開先へファイルを移動
$result = move_uploaded_file($_FILES['avatar']['tmp_name'], 'image/file.png');

// 最終的な結果を返却
header('Content-type: application/json');
if( $result !== false ){
  echo json_encode(["status"=>true]);
}
else{
  echo json_encode(["status"=>false]);
}

実際にはここにエラー処理や正常なデータが送信されたかチェック(Validaiton)を追加することになります。

入力内容をチェックする

ファイルのアップロードはデータチェックが本題と言っても良いくらい考慮することが多岐に渡り非常に重要です。サーバ上にファイルとして保存されますので、悪意のある第三者によってアタック対象となり、もし突破されればウイルスやマルウェアを配布する踏み台として利用されることも考慮しなければなりません。

そこまで行かなくとも、ユーザーがこちらの意図通りの入力をしてくれるとは限りませんので十分なチェックを行うことが求められます。

ブラウザ側

ブラウザ側のチェックはユーザービリティ(使い勝手)の向上が目的です。例えば巨大なファイルを送信し終わったあとに「大きすぎます」と言われても後の祭り。送信前にチェックすることで二度手間を防ぐというわけです。そもそもJavaScriptでの処理は簡単に改ざんできますし、悪者に狙われる場合はPHPを直接アタックされるためブラウザ側ではサーバの事前チェックの意味合いが大きいと言えます。

ファイルが選択されているか

filesプロパティはファイルが選択されているかに関わらず配列が返ってきます。ファイルが1つ選択されていれば要素数は1、2つ選択されていれば要素数は2となりますので、files.lengthが必要なファイルの個数かどうかをチェックするだけです。

const avatar = document.querySelector("#avatar");  // <input type="file">

if( avatar.files.length !== 1 ){
  alert("ファイルが選択されていないか、複数選択されています");
}

ファイル容量

files[i].sizeで入力されたファイルのファイルサイズを知ることができます。単位はbyteです。

const avatar = document.querySelector("#avatar");  // <input type="file">

if( avatar.files[0].size > (30 * 1024) ){  // 30k byteを超えればエラー
  alert("ファイルサイズが大きすぎます");
}

ファイル形式

files[i].typeで入力されたファイルのファイル形式を知ることができます。

const avatar = document.querySelector("#avatar");  // <input type="file">

if( avatar.files[0].type !== "image/jpeg" ){
  alert("ファイル形式はJPEGのみです");
}

files[i].typeが返すのは以下のようなMIMEタイプになります。

MIMEタイプ 説明
text/plain テキストファイル
image/jpeg JPEG画像
image/gif GIF画像
image/png PNG画像
video/mp4 MP4動画(ビデオ)
video/ogg Ogg動画(ビデオ)
audio/mpeg MP3形式の音声(オーディオ)
audio/ogg Ogg形式の音声(オーディオ)
application/pdf PDF
application/zip ZIPファイル

MIMEタイプの詳細は以下のページを参照ください。 developer.mozilla.org

WordやEXCEL、PowerPointなどはこちらを。 developer.mozilla.org

サーバ側(PHP)

ファイルが正常に受信できているか

ファイルがブラウザから正常に送信されており、なおかつサーバへ正常に保存されているかを3段階に渡りチェックします。

ファイルが送信されていない

ファイルを受信するとPHPは特殊なグローバル変数$_FILESに内容を入れてくれます。これが空(isset()がfalse)であればそもそもブラウザから送信されていません。

if( ! isset($_FILES['avatar']) ){
  error_log('ファイルが未送信');
}

ファイル受信時にエラーが発生

ファイルを受信している最中及び、一時ファイルに保存している最中に何らかのエラーが発生すると$_FILES['name']['error']にその内容がセットされます。

if( (isset($_FILES['avatar']['error'])) && ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) ){
  error_log('何らかのエラーが発生');
}

正常な場合は$_FILES['name']['error'] === UPLOAD_ERR_OKがtrue、何らかのエラーが起こっている場合にはエラーコードが入っています。

具体的なエラーコードの一覧は以下の通りです。

定数 実際の値 説明
UPLOAD_ERR_OK 0 成功
UPLOAD_ERR_INI_SIZE 1 エラー。php.iniupload_max_filesizeをオーバー
UPLOAD_ERR_FORM_SIZE 2 エラー。HTMLで指定されたMAX_FILE_SIZEをオーバー
UPLOAD_ERR_PARTIAL 3 エラー。一部しかアップロードされていない
UPLOAD_ERR_NO_FILE 4 エラー。アップロードされていない
UPLOAD_ERR_NO_TMP_DIR 6 エラー。一時ディレクトリが存在しない
UPLOAD_ERR_CANT_WRITE 7 エラー。ディスクへの書き込みに失敗
UPLOAD_ERR_EXTENSION 8 エラー。PHPのモジュールが何らかの理由によりアップロードを停止。

何らかのアタックを受けている

悪意のある第三者に例えば/etc/passwdなどの外部に公開したくないファイルを盗み出そうとされていないかチェックしてくれるのがis_uploaded_file()関数です。

if( ! is_uploaded_file( $_FILES['avatar']['tmp_name'] ) ){
  error_log('何らかの攻撃を受けている');
}

$_FILES['name']['tmp_name']は一時ファイルが保存されているパスです。

ファイルサイズ

ファイルサイズは$_FILES['name']['size']でチェックできます。単位はbyteです。

if( $_FILES['avatar']['size'] > (30 * 1024) ){  // 30k byte以上ならエラー
  error_log('ファイルサイズが大きすぎます');
}

ファイル形式(画像ファイルか)

ファイル形式を特定する処理はPHP側で用意されていませんので、こちらでがんばって実装する必要があります。

今回は画像ファイルかどうか、また画像形式が特定のものかをチェックするためにgetimagesize()関数を利用した処理を一例として書いておきます。

$mime = getMimeType(`$_FILES['name']['tmp_name']`);
if( $mime === null ){
  error_log('画像ではないか、JPEG/GIF/PNGのいずれかでない');
}

function getMimeType($path){
  list($width, $height, $mime, $attr) = getimagesize($path);
  switch($mime){
    case IMAGETYPE_JPEG:
      return('jpeg');
    case IMAGETYPE_PNG:
      return('png');
    case IMAGETYPE_GIF:
      return('gif');
    default:
      return(null);
  }
}

拡張子などで簡易的なチェックを行うやり方だと簡単に突破されますので、これだけに頼るのはオススメできません。また画像などファイルの内部に悪意のあるコードを埋め込むマルウェアが存在します。このような攻撃を防ぐためにお仕事でやる場合はウイルスチェックなどをサーバ上でかけることになりますが今回は割愛します。 blog.kaspersky.co.jp

サンプル

実行例

以下から実際のサンプルをお試しいただけます。 miku3.net

  • 保存された画像は定期的に削除されます
  • 保存された画像はURLが分かれば誰でも閲覧可能です。裁判所などを通して開示請求があった場合はIPアドレスの開示などを行いますので悪いことには利用しないでくださいね。

コード

HTML

実際の処理は外部のJavaScriptのファイルで行っています。またCSSも少し長くなったので別ファイルに分けました。CSSはこちらから閲覧できます。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Fetch API Sample2</title>
  <link rel="stylesheet" href="style.css" type="text/css" media="all">
</head>
<body>

<header>
  <h1>Fetch API Sample2</h1>
</header>

<section>
  <h2>ファイル選択</h2>
  <p>画像ファイルをアップできます。ファイルを選択後「アップロード」ボタンをクリックしてください。</p>
  <form id="upload">
    <input type="hidden" name="MAX_FILE_SIZE" value="30720">
    <input type="file" id="avatar" accept="image/jpeg,image/gif,image/png">
    <button id="btn-upload">アップロード</button>
  </form>
  <ul>
    <li><small>JPEG/GIF/PNGのみ, 30kbyte未満のデータがアップ可能です</small></li>
  </ul>
</section>

<section>
  <h2 id="title-list">アップロードされた画像</h2>
  <ul id="imagelist">
  </ul>
  <ul>
    <li><small>サーバへアップロードした画像がここに表示されます</small></li>
  </ul>
</section>

<!-- 実際の処理は外部ファイルで -->
<script src="app.js"></script>

</body>
</html>

JavaScript

入力内容をValidation(チェック)していますが、あくまで使い勝手を向上する程度でサーバ側のセキュリティには寄与しないと思った方が良いでしょう。そのためこのあと紹介するPHPでも同様の処理を行っています。

/**
 * [event] ページ読み込み完了時に実行
 */
window.onload = ()=>{
  // アップロード先のURL
  const URL_UPLOAD = 'https://example.com/receive.php';

  // 画像ファイルの公開URL
  const URL_IMAGE  = 'https://example.com/image/';

  // 最大ファイル容量
  const MAX_FILE_SIZE = document.querySelector("input[name=MAX_FILE_SIZE]").value;  // <input type="hidden">

  // ファイル
  const avatar = document.querySelector("#avatar");  // <input type="file">

  /**
   * [event] フォーム送信イベント発生時
   */
  document.querySelector("#upload").addEventListener("submit", (e)=>{
    // 規定の送信イベントをキャンセル
    e.preventDefault();

    //-------------------------------------------------
    // Validation
    //-------------------------------------------------
    // ファイルが選択されているか
    if( avatar.files.length !== 1 ){
      alert("ファイルが選択されていないか、複数選択されています");
      return(1);
    }
    // ファイルサイズが指定サイズ未満か
    if( avatar.files[0].size > MAX_FILE_SIZE ){
      alert("ファイルサイズが大きすぎます");
      return(1);
    }
    // ファイル形式が指定のものか
    if( ! avatar.files[0].type.match(/^image\/(jpeg|gif|png)$/) ){
      alert("ファイル形式はJPEG,GIF,PNGのいずれかを選択してください");
      return(1);
    }

    //-------------------------------------------------
    // 送信するデータの準備
    //-------------------------------------------------
    const formData = new FormData();
    formData.append("MAX_FILE_SIZE", MAX_FILE_SIZE);  // ファイルより先に追加する
    formData.append("avatar", avatar.files[0]);       // ファイル内容

    const param = {
      method: "POST",  // or "PUT"
      body: formData
    }

    //-------------------------------------------------
    // サーバへ送信する
    //-------------------------------------------------
    fetch(URL_UPLOAD, param)
      .then((res)=>{
        if( ! res.ok ) {
          throw new Error(`Fetch: ${res.status} ${res.statusText}`);
        }
        return( res.json() );
      })
      .then((json)=>{
        if( json.status ){
          alert("アップロードに「成功」しました");
          setImage(`${URL_IMAGE}/${json.result}`);  // アップロードした画像を表示
        }
        else{
          alert("アップロードに「失敗」しました");
          console.error(`[Fetch] upload faild, ${json.result}`);
        }
      })
      .catch((error)=>{
        alert("エラーが発生しました");
        console.error(`[Fetch] ${error}, ${URL_UPLOAD}`);
      });
  });
}

/**
 * サーバ上の画像を表示する
 *
 * @param url {string} 画像のURL
 * @return void
 */
function setImage(url){
  const result = document.querySelector("#imagelist");
  const li = document.createElement("li");
  li.innerHTML = `<a href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}"></a>`;
  result.insertBefore(li, result.firstChild);
}

PHP

サーバ側はPHPで記述しています。保存するだけであればPHPの機能で簡単に実現できますが、悪意のある行為がされていないかチェックするための部分が大半を締めていますねw JSからMAX_FILE_SIZEが渡されていますがこれも簡単に改ざんできますので、サーバ側でも最大ファイル容量は持って置いた方が良いでしょう。

<?php
/**
 * ファイルを受信する
 *
 * @author M.Katsube <katsubemakito@gmail.com>
 */

//-------------------------------------------------
// 定数
//-------------------------------------------------
// $_FILESのキー
define('KEY', 'avatar');

// 最大ファイルサイズ
define('MAX_SIZE', (30 * 1024) );

// 画像を保存するディレクトリ
define('SAVE_DIR', 'image');

// エラーメッセージ
// https://www.php.net/manual/ja/features.file-upload.errors.php
define('ERROR_MSG', [
  UPLOAD_ERR_INI_SIZE   => 'Over file size (php.ini -> upload_max_filesize)',
  UPLOAD_ERR_FORM_SIZE  => 'Over file size (HTML -> MAX_FILE_SIZE)',
  UPLOAD_ERR_PARTIAL    => 'Not Complete',    // 一部しかアップロードされてない
  UPLOAD_ERR_NO_FILE    => 'Not Ipload',      // アップロードされていない
  UPLOAD_ERR_NO_TMP_DIR => 'InternalServerError(1)',  // 一時ディレクトリが存在しない
  UPLOAD_ERR_CANT_WRITE => 'InternalServerError(2)',  // 書き込みエラー
  UPLOAD_ERR_EXTENSION  => 'InternalServerError(3)'   // モジュールによる停止
]);

//-------------------------------------------------
// Validation
//-------------------------------------------------
// ファイルが渡されているか
if( ! isset($_FILES[KEY]) ){
  sendResult(false, 'Empty file data');
  exit(1);
}
// 何らかのエラーが発生しているか
if( (isset($_FILES[KEY]['error'])) && ($_FILES[KEY]['error'] !== UPLOAD_ERR_OK) ){
  sendResult(false, 'Exception ' . ERROR_MSG[$_FILES[KEY]['error']]);
  exit(1);
}
// アップロードされたファイルを指しているか (not /etc/passwd...)
if( ! is_uploaded_file( $_FILES[KEY]['tmp_name'] ) ){
  sendResult(false, 'Internal Server Error');
  exit(1);
}
// ファイルサイズが30k未満か
if( $_FILES[KEY]['size'] > MAX_SIZE ){
  sendResult(false, 'Too large file');
  exit(1);
}
// MIME TYPEが画像形式か(JPEG,PNG,GIF)
$mime = getMimeType( $_FILES[KEY]['tmp_name']);  // 画像形式を取得
if( getMimeType( $_FILES[KEY]['tmp_name']) === null ){
  sendResult(false, 'Not Allow file type');
  exit(1);
}

//-------------------------------------------------
// サーバへ保存
//-------------------------------------------------
// ファイル名を作成
$filename = sprintf('%s.%s', makeFileName(), $mime);

// 一時ディレクトリから移動
$result = move_uploaded_file(
  $_FILES[KEY]['tmp_name'],
  sprintf('%s/%s', SAVE_DIR, $filename)
);

//-------------------------------------------------
// 結果を返却
//-------------------------------------------------
if( $result !== false ){
  sendResult(true, $filename);
}
else{
  // 書き込みエラー
  sendResult(false, 'InternalServerError(4)');
}

/**
 * 結果をJSON形式で返却
 *
 * @param  boolean $status 成功:true, 失敗:false
 * @param  mixed   $data   ブラウザに返却するデータ
 * @return void
 */
function sendResult($status, $data){
  // CORS (必要に応じて指定)
  header('Access-Control-Allow-Origin: *');
  header('Access-Control-Allow-Headers: *');

  header('Content-type: application/json');
  echo json_encode([
    "status" => $status,
    "result" => $data
  ]);
}

/**
 * 画像のファイル形式を返却する
 *
 * @param string $path 対象ファイルのパス
 * @return mixed
 */
function getMimeType($path){
  list($width, $height, $mime, $attr) = getimagesize($path);
  switch($mime){
    case IMAGETYPE_JPEG:
      return('jpeg');
    case IMAGETYPE_PNG:
      return('png');
    case IMAGETYPE_GIF:
      return('gif');
    default:
      return(null);
  }
}

/**
 * ファイル名を生成する
 *
 * @return string
 */
function makeFileName(){
  return( sha1(uniqid()) );  // 実際にはもう少し複雑にした方が安全
}

その他

HTMLでファイル形式を制限する

例えば画像だけしかアップしてほしくない場合、こちらが望むファイル形式以外が選択できなければユーザーの入力ミスも起こりにくいと言えます。そこでHTML5では<input type="file">accept属性が追加されています。

以下のようにaccept属性にMIMEタイプか拡張子をカンマで区切って指定するだけです。

<form>
  <input type="file" accept="image/jpeg,image/gif,image/png">
  <button>アップロード</button>
</form>

すると上記で指定したJPEG, GIF, PNG以外は選択できないようグレーになっているのがわかりますね。

以下のように拡張子で指定する場合は以下のようにします。もちろん.docx.xlsxなどの指定も可能です。

<input type="file" accept=".jpeg,.jpg,.gif,.png">

ただこれもあくまでユーザービリティ(使い勝手)を上げるだけのものですので、サーバ上でファイル形式のチェックは行う必要があります。

PHPでファイル容量を制限する

MAX_FILE_SIZEはPHPに実装されている便利機能なのですが正直気休めですね。知識として知っているだけで良いと思います。

以下のように<input type="hidden">で最大容量を指定します。ここでは30*1024=30720で30kbyte。注意点としては<input type="file">よりも前に書いておく必要があります。

<form action="receive.php" enctype="multipart/form-data">
  <!-- ファイルの最大容量をbyteで指定 -->
  <input type="hidden" name="MAX_FILE_SIZE" value="30720">
  <input type="file" name="avatar">
  <button>アップロード</button>
</form>

あとは自動的にPHP側でこの値を見て、もしファイル容量がオーバーしていれば$_FILES['name']['error']にエラーがセットされるというわけです。PHP側はすべて裏側で自動で行ってくれるのです。簡単ですね。

<?php
if( $_FILES['avatar']['error'] === UPLOAD_ERR_FORM_SIZE ){
  error_log('ファイルサイズが超過しています');
}

で、なぜ気休めかと言いますとWebブラウザのデベロッパーツールから簡単に書き換えることができるんですよね。巨大な数値に書き換えた状態で「アップロード」ボタンを押せば事実上の上限は無くなるというわけです。

参考ページ