k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

TECHSCORE BLOGに『PostgreSQLのUUID型とpgcryptoモジュールを使って会員パスワード認証を実装してみる』を掲載していただきました

TECHSCORE BLOG に記事を掲載していただきました。

普段の業務では主にLAMPおじさんとしてPHP + MySQLで動く小規模なWebアプリケーションばかり開発している私ですが、TECHSCORE BLOGではJava, Ruby(Rails), PostgreSQLの記事が多いということで、今回は久しぶりにPostgreSQLを触って記事を書きました。

最新版の9.5をWindowに入れてみたんですが、公式のインストーラーで問題なく完了できましたし(昔はもっと苦労してたような…)、付属のPgAdminがとても良く出来ていて、これがあればpsql要らないんじゃないかな…って思いつつ、記事としてはSQLを手で実行して試さないといけないので、psqlを使いました。

何か普段の業務で書くようなコードをPostgreSQLっぽい機能で実装してみようと、マニュアル PostgreSQL 9.4.5文書 を眺めていて思い付いたのが今回の記事で、会員制Webサイトでのパスワード認証にまつわる処理を、拡張モジュールの pgcrypto を使って書いてみるというものです。

UUID型 はそのついでに使ってみた程度ですが、この型とpgcryptoモジュールを使ったユーザー定義関数を併用すれば、なかなか実用的だと感じました。(エンタープライズ界隈ではごく普通に使われているのかもしれませんが)

もう一つ思い付いたのは、JSON型の応用例として、トリガーを使ったテーブルの汎用更新ログというネタでしたが、試しに検索してみたらQiitaにすごいのが上がってたので、早々に諦めました…。

これの「2.6. 監査ログテーブル」辺りです。

私も今回 JSON作成関数のrow_to_json()を知って思い付いたのですが、こちらのQiita記事では記録のみならず差分抽出までトリガーで実装されてます。いやはや。

MySQLでも5.7からネイティブJSONデータ型で性能強化されたとかで、「Generated Columns」という機能でjson_extract()関数の抽出結果をカラムとして定義して、インデックスを張ったりできるようになったみたいなので、同じネタで実用的なものができそうです。

TECHSCORE BLOGへの投稿をきっかけに、今後はPostgreSQLも業務の方で積極採用していきたいです。(まずは自作のDBライブラリをPostgreSQLに対応させないといけませんが…)

Windows7+VirtualBox+Vagrant再入門

Vagrantは2013年の記事 Windows7にVirtualBoxとVagrantをインストールしたメモ でインストールはしてみたものの、日々の開発作業に追われるまま長らく使っていませんでした。

実はこのブログでは一番人気の記事で、言及リンクしていただいた数も最も多いのですが、書いた私自身はほぼそれっきりでVagrant使ってなかったという…。(ノ∀`)

ローカル開発で常用するには重くて厳しいし、そもそも担当者は自分だけで管理してるサーバも片手で数えられる程、root権限貰って一から構築できる機会もない、といった理由で学習コストに見合う利点がまだ感じられなかったのです。

(自分自身がそうなんだから、上司にとってはもっとそうだろうなぁと…なかなかこういう開発環境構築、みたいな作業に何日も使うのも難しいんですよね。)

しかし、今回再び新規サーバ環境を構築機会ができ、そろそろ一人担当からも卒業しなければ、という流れにもなってきましたので、再び入門してみたメモです。

以下、手順は途中まで前の記事そのままですが、Windowsで開発中のApache + MySQL + PHPアプリケーションのテスト環境構築をAnsibleで自動化するというのが真の目標なので、とりあえずVagrantBoxのインストールと起動、Vagrantfileの編集までに留めます。

また、ゲストOSからホストOSの所属するLAN上のデータベースを利用したいので、プライベートネットワークを有効にします。

VirtualBoxを入れる

Downloads - Oracle VM VirtualBox から VirtualBox 4.3.26 for Windows hosts x86/amd64 をダウンロードして実行。

Setup Wizardが起動するので Next Next。なんかOracle製のドライバ類が色々インストールされるけど気にしない。

Vagrantを入れる

Download Vagrant - Vagrant から WINDOWS Universal (32 and 64-bit) をダウンロードして実行。

Setup Wizardが起動するので Next Next。インストールが終わると「再起動しろ」と言われるので、再起動します。

Vagrant Boxファイルを入れる

A list of base boxes for Vagrant - Vagrantbox.es から、使いたい仮想サーバのイメージファイルを選択して、Vagrantに追加します。

今回も使い慣れたCentOS6系で CentOS 6.4 i386 Minimal (VirtualBox Guest Additions 4.3.2, Chef 11.8.0, Puppet 3.3.1) を選択。

以下、コマンドはNYAOSでホームディレクトリにて実行。

ホームディレクトリにてVagrant boxを追加

$ vagrant box add centos64_i386 http://developer.nrel.gov/downloads/vagrant-boxes/CentOS-6.4-i386-v20131103.box
==> box: Adding box 'centos64_i386' (v0) for provider:
    box: Downloading: http://developer.nrel.gov/downloads/vagrant-boxes/CentOS-6.4-i386-v20131103.box
    box: Progress: 100% (Rate: 9639k/s, Estimated time remaining: --:--:--)
==> box: Successfully added box 'centos64_i386' (v0) for 'virtualbox'!

以前と比べて、進捗が分かりやすく表示されています。

'centos64_i386' (v0) と表示されているのは、今のVagrantではboxファイルのバージョン管理を行ってくれるためのようです。

Vagrantが管理しているboxは追加の際に付けた名前でディレクトリが作成され、例えば今回の場合は ~/.vagrant.d/boxes/centos64_i386/0/virtualbox 以下にファイルが配置されます。間の 0 がバージョン番号になるんでしょうか。

プロジェクトのVagrant設定

Vagrantの設定も含めてプロジェクト単位でgitのバージョン管理下に入れるため、以後はプロジェクトディレクトリ内で行います。

プロジェクト名は "delivery" としました。

$ cd ~/Documents/Projects/delivery
$ mkdir vagrant
$ cd vagrant
$ vagrant init centos64_i386
A `Vagrantfile` has been placed in this directory. You are now
ready to `vagrant up` your first virtual environment! Please read
the comments in the Vagrantfile as well as documentation on
`vagrantup.com` for more information on using Vagrant.

vagrant init コマンドを実行した場所にVagrantfileが作成されました。

ひとまず準備できたので起動してみます。

$ vagrant up
Bringing machine 'default' up with 'virtualbox' provider...
==> default: Importing base box 'centos64_i386'...
==> default: Matching MAC address for NAT networking...
==> default: Setting the name of the VM: vagrant_default_1430453933460_60304
==> default: Clearing any previously set network interfaces...
==> default: Preparing network interfaces based on configuration...
    default: Adapter 1: nat
==> default: Forwarding ports...
    default: 22 => 2222 (adapter 1)
==> default: Booting VM...
==> default: Waiting for machine to boot. This may take a few minutes...
    default: SSH address: 127.0.0.1:2222
    default: SSH username: vagrant
    default: SSH auth method: private key
    default: Warning: Connection timeout. Retrying...
    default: Warning: Connection timeout. Retrying...
    default: Warning: Remote connection disconnect. Retrying...
    default:
    default: Vagrant insecure key detected. Vagrant will automatically replace
    default: this with a newly generated keypair for better security.
    default:
    default: Inserting generated public key within guest...
    default: Removing insecure key from the guest if its present...
    default: Key inserted! Disconnecting and reconnecting using new SSH key...
==> default: Machine booted and ready!
==> default: Checking for guest additions in VM...
==> default: Mounting shared folders...
    default: /vagrant => C:/Users/k_horii/Documents/Projects/delivery/vagrant

以前はPageant(PuTTY authentication agent)を起動していると怒られたんですが、改善されたみたいですね。これは嬉しい。

$ vagrant ssh
Welcome to your Vagrant-built virtual machine.
[vagrant@localhost ~]$ pwd
/home/vagrant

無事に入れました。

[vagrant@localhost ~]$ sudo vi /etc/sysconfig/network
NETWORKING=yes
HOSTNAME=localhost.localdomain

以前はPuTTY経由でないとできなかったファイルの編集も、できるようになってます。

Vagrantのプライベートネットワークを利用する

プライベートネットワークを利用して、ゲストOSからホストOSが所属するLANにアクセスできるようにします。

Vagranfileの内容を確認してみます。

# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure(2) do |config|
  # The most common configuration options are documented and commented below.
  # For a complete reference, please see the online documentation at
  # https://docs.vagrantup.com.

  # Every Vagrant development environment requires a box. You can search for
  # boxes at https://atlas.hashicorp.com/search.
  config.vm.box = "centos64_i386"

  # Disable automatic box update checking. If you disable this, then
  # boxes will only be checked for updates when the user runs
  # `vagrant box outdated`. This is not recommended.
  # config.vm.box_check_update = false

  # Create a forwarded port mapping which allows access to a specific port
  # within the machine from a port on the host machine. In the example below,
  # accessing "localhost:8080" will access port 80 on the guest machine.
  # config.vm.network "forwarded_port", guest: 80, host: 8080

  # Create a private network, which allows host-only access to the machine
  # using a specific IP.
  # config.vm.network "private_network", ip: "192.168.33.10"

  # Create a public network, which generally matched to bridged network.
  # Bridged networks make the machine appear as another physical device on
  # your network.
  # config.vm.network "public_network"

  # Share an additional folder to the guest VM. The first argument is
  # the path on the host to the actual folder. The second argument is
  # the path on the guest to mount the folder. And the optional third
  # argument is a set of non-required options.
  # config.vm.synced_folder "../data", "/vagrant_data"

  # Provider-specific configuration so you can fine-tune various
  # backing providers for Vagrant. These expose provider-specific options.
  # Example for VirtualBox:
  #
  # config.vm.provider "virtualbox" do |vb|
  #   # Display the VirtualBox GUI when booting the machine
  #   vb.gui = true
  #
  #   # Customize the amount of memory on the VM:
  #   vb.memory = "1024"
  # end
  #
  # View the documentation for the provider you are using for more
  # information on available options.

  # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies
  # such as FTP and Heroku are also available. See the documentation at
  # https://docs.vagrantup.com/v2/push/atlas.html for more information.
  # config.push.define "atlas" do |push|
  #   push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME"
  # end

  # Enable provisioning with a shell script. Additional provisioners such as
  # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
  # documentation for more information about their specific syntax and use.
  # config.vm.provision "shell", inline: <<-SHELL
  #   sudo apt-get update
  #   sudo apt-get install -y apache2
  # SHELL
end

めっちゃ英語 (((;゚Д゚))) ですが、変えるのはここだけ。

# Create a private network, which allows host-only access to the machine
# using a specific IP.
config.vm.network :private_network, ip: "192.168.33.10"

プライベートネットワークを有効、ゲストOSのIPアドレスを "192.168.33.10" とし、vagrant reload で再起動します。

$ vagrant reload
==> default: Attempting graceful shutdown of VM...
==> default: Clearing any previously set forwarded ports...
==> default: Clearing any previously set network interfaces...
==> default: Preparing network interfaces based on configuration...
    default: Adapter 1: nat
    default: Adapter 2: hostonly
==> default: Forwarding ports...
    default: 22 => 2222 (adapter 1)
==> default: Booting VM...
==> default: Waiting for machine to boot. This may take a few minutes...
    default: SSH address: 127.0.0.1:2222
    default: SSH username: vagrant
    default: SSH auth method: private key
    default: Warning: Connection timeout. Retrying...
    default: Warning: Connection timeout. Retrying...
    default: Warning: Remote connection disconnect. Retrying...
==> default: Machine booted and ready!
==> default: Checking for guest additions in VM...
==> default: Configuring and enabling network interfaces...
==> default: Mounting shared folders...
    default: /vagrant => C:/Users/k_horii/Documents/Projects/delivery/vagrant
==> default: Machine already provisioned. Run `vagrant provision` or use the `--provision`
==> default: to force provisioning. Provisioners marked to run always will still run.

前回の起動時は Adapter 1: nat だけだったのが、Adapter 2: hostonly が追加されています。

ゲストOSに入ってネットワーク設定を確認してみます。

$ vagrant ssh
Welcome to your Vagrant-built virtual machine.
[vagrant@localhost ~]$ ifconfig
eth0      Link encap:Ethernet  HWaddr **:**:**:**:**:**
          inet addr:10.0.2.15  Bcast:10.0.2.255  Mask:255.255.255.0
          inet6 addr: fe80::a00:27ff:fe5a:fb02/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:774 errors:0 dropped:0 overruns:0 frame:0
          TX packets:579 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:81387 (79.4 KiB)  TX bytes:69757 (68.1 KiB)

eth1      Link encap:Ethernet  HWaddr **:**:**:**:**:**
          inet addr:192.168.33.10  Bcast:192.168.33.255  Mask:255.255.255.0
          inet6 addr: fe80::a00:27ff:fe87:e4a1/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:5 errors:0 dropped:0 overruns:0 frame:0
          TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:326 (326.0 b)  TX bytes:552 (552.0 b)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:16436  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 b)  TX bytes:0 (0.0 b)

eth1 として 192.168.33.10 が追加されています。

ホストOSからゲストOS (192.168.33.10)にpingしてみます。

$ ping -n 3 192.168.33.10

192.168.33.10 に ping を送信しています 32 バイトのデータ:
192.168.33.10 からの応答: バイト数 =32 時間 <1ms TTL=64
192.168.33.10 からの応答: バイト数 =32 時間 <1ms TTL=64
192.168.33.10 からの応答: バイト数 =32 時間 <1ms TTL=64

192.168.33.10 の ping 統計:
    パケット数: 送信 = 3、受信 = 3、損失 = 0 (0% の損失)、
ラウンド トリップの概算時間 (ミリ秒):
    最小 = 0ms、最大 = 0ms、平均 = 0ms

ちゃんと届いています。

ゲストOS (192.168.33.10)からホストOSが所属するLAN内にある開発サーバ (192.168.1.100)にpingしてみます。

[vagrant@localhost ~]$ ping -c 3 192.168.1.100
PING 192.168.1.100 (192.168.1.100) 56(84) bytes of data.
64 bytes from 192.168.1.100: icmp_seq=1 ttl=63 time=4.11 ms
64 bytes from 192.168.1.100: icmp_seq=2 ttl=63 time=1.03 ms
64 bytes from 192.168.1.100: icmp_seq=3 ttl=63 time=0.969 ms

--- 192.168.1.100 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 0.969/2.039/4.111/1.465 ms

こちらも届きました。

念のため、LAN内にある開発サーバ (192.168.1.100)からゲストOS (192.168.33.10)にpingしてみます。

[develop@centos6 ~]$ ping -c 3 192.168.33.10
PING 192.168.33.10 (192.168.33.10) 56(84) bytes of data.

--- 192.168.33.10 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 12000ms

プライベートネットワークなので届きません。

参考にした記事

上の記事では更に、ゲストOS同士でのホストOSでのポートフォワーディングによって、ゲストOSに接続させる方法についても書かれています。

他にもQiitaの vagarnt タグを見ると、現在1200件以上の投稿があります。

共有フォルダのマウントで引っ掛かったり、CentOSだとカーネルを更新したらVirtualBox GuestAdditionsが壊れたとか、そういった情報が散見されます。

いくつか気になる記事をメモしておきます。

後はAnsibleを覚えて、コマンドコピペ作業からちゃんと卒業したいです。

(Fabricは…今のところバックアップ作業や開発サーバへのgitリポジトリ作成などに使ってますが、再利用可能なコマンドとしてまとめる程の作業量が発生しないというか…)

画像アップロード(4)IEでもドラッグ&ドロップ対応、Ajax化しつつJavaScript無効の環境にも対応

前回の記事 画像アップロード(3)ドラッグ&ドロップ対応(IE除く) の続きです。

前回のファイル入力欄を使ったドラッグ&ドロップ対応ではInternet Explolerで期待通り動作しませんでした。

IEへの対応が必須となると、通常のフォーム送信によるファイルアップロードは断念せざるを得ないでしょう。

そういうわけで、File APIはこれまではCanvasによるプレビュー画像生成時の受け渡しにのみ利用していましたが、今回はドラッグ&ドロップからのファイル送信のために利用してみます。

また今回は、ブラウザの設定でJavaScriptをOFFにされている環境でも、通常のファイル選択フォームとして動作することを目標としました。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードではこれまでと同様、自作ライブラリの Volcanus_FileUploader を使います。

今回はJavaScriptがOFFの場合はこれまで通りのフォーム送信ですが、ONの場合はAjaxで送信されるとして、いくつか処理を追加しました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * Ajax化
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name]['size']) && $_FILES[$name]['size'] > 0) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

    if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&  $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {

        header('Content-Type: application/json; charset=UTF-8');
        header('X-Content-Type-Options: nosniff');

        if (!empty($invalidFiles)) {
            header('HTTP/1.1 400 Bad Request');
        } else {
            header('HTTP/1.1 200 OK');
        }

        echo json_encode(['form' => $form], JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS);
        exit;

    }

}
include __DIR__ . '/template.php';

jQueryAjax通信では X-Requested-With リクエストヘッダに XMLHttpRequest という値をセットしてきますので、サーバ側では $_SERVER['HTTP_X_REQUESTED_WITH'] を見てAjaxかどうかを判断、Ajaxリクエストに対してはレスポンスをJSONで返しています。

実際には json_encode() 時には json_last_error() を見てエラーであればエラー用のレスポンスを返したりしますが、今回は省略してます。(そもそもエラーや例外処理も省略してますし…あくまで画像アップロード処理の例示のためのコードということで)

送信されたファイルのバリデーション結果にエラーがあれば、ステータス 400 を返します。(クライアントの入力に起因するエラーなので、400を返す方が良いとの判断です)

レスポンスのJSONは、バリデーション結果に関わらずそのまま返しています。

<?php
$form['files'][$name]['name'] = $uploadedFile->getClientFilename();
$form['files'][$name]['size'] = $uploadedFile->getSize();
$form['files'][$name]['type'] = $uploadedFile->getMimeType();
$form['files'][$name]['width'] = $width;
$form['files'][$name]['height'] = $height;

なお、ここの連想配列のキーを以前のコードから変えたのは、表示側のJavaScriptで手抜きをするための布石です。

アップローダのテンプレート (PHP)

画面のPHPテンプレートとCSS定義はこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}
.hidden {
    display: none;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/4/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />
<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" />
            <input type="text" class="filename hidden" />
            <button type="button" name="doSelect" class="btn btn-sm hidden">選択</button>
            <button type="button" name="doReset" class="btn btn-sm hidden">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <div class="droparea<?php if (!isset($file['dataUri'])) : ?> hidden<?php endif ?>">
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
        </div>
        <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
            <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($file['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
            </ul>
        </div>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

前回の input[type="file"] 巨大化&透明化をやめたほか、ファイル入力欄を初期状態で表示させる代わりに、JavaScriptのみで利用する物は初期状態で非表示としています。(class="hidden" が設定されている要素を非表示)

JavaScript無効の環境ではこういう風に見えます。

ダミーのテキスト入力欄やボタン、サムネイル(ドラッグ&ドロップ)領域が表示されません。

バリデーションはサーバ側のみになります。エラー後にファイル入力欄の選択状態を復元できないのは困りものですね…。

送信後の戻りでサムネイル(ドラッグ&ドロップ)領域が表示され、サーバが返したDataURIが画像として表示されます。

アップローダJavaScript

単純な機能なのに、JavaScriptが結構長くなってしまいました。後で部分ごとに見ていきますが、とりあえず全部。

uploader.js

$(function() {

    $('.uploader .hidden').removeClass('hidden');
    $('.uploader input[type="file"]').addClass('hidden');

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();
    var ALLOWABLE_TYPE = $('#ALLOWABLE_TYPE').val();

    var filesToSend = {};

    var clearFile = function(uploader) {
        var fileId = $(uploader).find('input[type="file"]').attr('name');
        if (typeof filesToSend[fileId] !== 'undefined') {
            delete filesToSend[fileId];
        }
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var createFileinfo = function(uploader, file) {
        if (typeof file.dataUri !== 'undefined') {
            $(uploader).find('.thumbnail').html($('<img>').attr('src', file.dataUri));
        }
        var ul = $('<ul>');
        if (typeof file.size !== 'undefined') {
            ul.append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)));
        }
        if (typeof file.type !== 'undefined') {
            ul.append($('<li>').text('ファイル種別:' + file.type));
        }
        if (typeof file.width !== 'undefined') {
            ul.append($('<li>').text('横幅:' + file.width));
        }
        if (typeof file.height !== 'undefined') {
            ul.append($('<li>').text('高さ:' + file.height));
        }
        if (typeof file.lastModifiedDate !== 'undefined') {
            ul.append($('<li>').text('更新日時:' + file.lastModifiedDate));
        }
        if (typeof file.moved !== 'undefined') {
            if (typeof file.moved.path !== 'undefined') {
                ul.append($('<li>').text('配置先:' + file.moved.path));
            }
        }
        $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(ul);
    };

    var preview = function(uploader, file) {
        var allowableTypes = ALLOWABLE_TYPE.split(',');
        if (allowableTypes.length > 0) {
            var found = false;
            var matches = file.name.split(/\.(?=[^.]+$)/);
            if (matches && typeof matches[1] !== 'undefined') {
                var extension = matches[1].toLowerCase();
                for (var i = 0; i < allowableTypes.length; i++) {
                    if (extension === allowableTypes[i]) {
                        found = true;
                        break;
                    }
                    if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                        found = true;
                        break;
                    }
                }
            }
            if (!found) {
                appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
                clearFile(uploader);
                return;
            }
        }
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var fileId = $(uploader).find('input[type="file"]').attr('name');
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    createFileinfo(uploader, {
                        name: file.name,
                        size: file.size,
                        type: file.type,
                        lastModifiedDate: file.lastModifiedDate.toUTCString(),
                        width: image.width,
                        height: image.height,
                        dataUri: canvas.toDataURL('image/png')
                    });
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
            filesToSend[fileId] = file;
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

    $('.uploader .droparea').on('click', function() {
        var uploader = $(this).parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader .droparea').on('dragover dragenter', function(event) {
        event.stopPropagation();
        event.preventDefault();
        return false;
    });

    $('.uploader .droparea').on('drop', function(event) {
        var uploader = $(this).parent()[0];
        event.stopPropagation();
        event.preventDefault();
        clearErrors(uploader);
        preview(uploader, event.originalEvent.dataTransfer.files[0]);
    });

    $('form').on('submit', function(event) {
        event.stopPropagation();
        event.preventDefault();
        var url = $(this).attr('action');
        var formData = new FormData();
        for (var fileId in filesToSend) {
            formData.append(fileId, filesToSend[fileId]);
        }
        $.ajax({
            type: 'POST',
            timeout: 30000,
            url: url,
            dataType: 'json',
            processData: false,
            contentType: false,
            data: formData
        }).done(function(data, textStatus, jqXHR) {
            update(data);
            return false;
        }).fail(function(jqXHR, textStatus, errorThrown) {
            if (jqXHR.responseText) {
                update($.parseJSON(jqXHR.responseText));
            }
            return false;
        });
    });

    var update = function(data) {
        if (typeof data.form !== 'undefined' &&
            typeof data.form.files !== 'undefined'
        ) {
            for (var key in data.form.files) {
                if ($('input[name="' + key + '"]').length) {
                    var file = data.form.files[key];
                    var uploader = $('input[name="' + key + '"]').parent().parent()[0];
                    clearErrors(uploader);
                    if (typeof file.errors !== 'undefined') {
                        for (var i = 0; i < file.errors.length; i++) {
                            appendError(uploader, file.errors[i]);
                        }
                    } else {
                        createFileinfo(uploader, file);
                    }
                }
            }
        }
    };

});

JavaScriptが有効な場合は初期表示がこういう風に変わります。

まあ、見た目は前回の画面と一緒なんですけどね。

以下、前回から変わった部分をピックアップします。

$('.uploader .hidden').removeClass('hidden');
$('.uploader input[type="file"]').addClass('hidden');

ここは初期状態で class="hidden" を指定した、JavaScript有効時のみ利用する要素を表示しています。

ファイル入力欄はドラッグ&ドロップのみにするなら不要なんですが、ファイル選択ダイアログを起動するために、要素としては存在するけど非表示にしています。

var filesToSend = {};

var clearFile = function(uploader) {
    var fileId = $(uploader).find('input[type="file"]').attr('name');
    if (typeof filesToSend[fileId] !== 'undefined') {
        delete filesToSend[fileId];
    }
    $(uploader).find('input[type="file"]').val('');
    $(uploader).find('input.filename').val('');
    $(uploader).find('.thumbnail').html('');
    $(uploader).find('.fileinfo').html('');
};

送信対象のファイルオブジェクトは filesToSend というオブジェクトに <input type="file"> のname属性値をプロパティ名としてセットしています。(PHPer脳ですみません…)

なので、「リセット」ボタンなどから呼ばれる clearFile() にはこれをクリアする処理を追加しました。delete演算子でオブジェクトのプロパティごと削除しています。

var createFileinfo = function(uploader, file) {
    if (typeof file.dataUri !== 'undefined') {
        $(uploader).find('.thumbnail').html($('<img>').attr('src', file.dataUri));
    }
    var ul = $('<ul>');
    if (typeof file.size !== 'undefined') {
        ul.append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)));
    }
    if (typeof file.type !== 'undefined') {
        ul.append($('<li>').text('ファイル種別:' + file.type));
    }
    if (typeof file.width !== 'undefined') {
        ul.append($('<li>').text('横幅:' + file.width));
    }
    if (typeof file.height !== 'undefined') {
        ul.append($('<li>').text('高さ:' + file.height));
    }
    if (typeof file.lastModifiedDate !== 'undefined') {
        ul.append($('<li>').text('更新日時:' + file.lastModifiedDate));
    }
    if (typeof file.moved !== 'undefined') {
        if (typeof file.moved.path !== 'undefined') {
            ul.append($('<li>').text('配置先:' + file.moved.path));
        }
    }
    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(ul);
};

ファイル送信のAjax化に伴い、ファイル情報の表示がファイル選択時とフォーム送信完後の両方で発生するようになるため、処理を関数で共通化しました。

(サーバ側の連想配列のキーを変更したのは、これを利用するためです)

また、画像の横幅と高さを同じオブジェクトから取得するようにしたり、細かいところを変更しています。

var preview = function(uploader, file) {
    var allowableTypes = ALLOWABLE_TYPE.split(',');
    if (allowableTypes.length > 0) {
        var found = false;
        var matches = file.name.split(/\.(?=[^.]+$)/);
        if (matches && typeof matches[1] !== 'undefined') {
            var extension = matches[1].toLowerCase();
            for (var i = 0; i < allowableTypes.length; i++) {
                if (extension === allowableTypes[i]) {
                    found = true;
                    break;
                }
                if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                    found = true;
                    break;
                }
            }
        }
        if (!found) {
            appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
            clearFile(uploader);
            return;
        }
    }
    if (file.size > parseInt(MAX_FILESIZE, 10)) {
        appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
        clearFile(uploader);
        return;
    }
    if (file.type.substring(0, 6) === 'image/') {
        var fileId = $(uploader).find('input[type="file"]').attr('name');
        var thumbnail = Volcanus_CanvasResizer.create({
            file: file
            ,maxWidth: 400
            ,maxHeight: 400
            ,onLoad: function(image, canvas) {
                createFileinfo(uploader, {
                    name: file.name,
                    size: file.size,
                    type: file.type,
                    lastModifiedDate: file.lastModifiedDate.toUTCString(),
                    width: image.width,
                    height: image.height,
                    dataUri: canvas.toDataURL('image/png')
                });
                if (image.width > parseInt(MAX_WIDTH, 10)) {
                    appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                }
                if (image.height > parseInt(MAX_HEIGHT, 10)) {
                    appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                }
            }
        });
        $(uploader).find('input.filename').val(file.name);
        filesToSend[fileId] = file;
    }
};

ドラッグ&ドロップやファイル選択から呼ばれるプレビューの処理です。

前述のファイル情報表示用の要素を生成する関数を呼んでいますが、処理の共通化のため新しいオブジェクトに詰め替えています。

また、送信対象のファイルオブジェクトを filesToSend というオブジェクトにセットしています。

    $('.uploader .droparea').on('click', function() {
        var uploader = $(this).parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader .droparea').on('dragover dragenter', function(event) {
        event.stopPropagation();
        event.preventDefault();
        return false;
    });

    $('.uploader .droparea').on('drop', function(event) {
        var uploader = $(this).parent()[0];
        event.stopPropagation();
        event.preventDefault();
        clearErrors(uploader);
        preview(uploader, event.originalEvent.dataTransfer.files[0]);
    });

ドラッグ&ドロップ領域へのハンドラ定義です。

クリックするとファイル選択ダイアログを開き、ドラッグするとデフォルトイベントを無効化し、ドロップするとエラーメッセージを消した上で前述のプレビュー処理を呼んでいます。

イベント処理のコードは Web アプリケーションからファイルを扱う | MDN を参考にしました。

event.stopPropagation()event.preventDefault()jQueryのイベントオブジェクトでも有効ですが、ドラッグ&ドロップでセットしたファイルが格納されている dataTransfer にはアクセスできないため、event.originalEvent.dataTransfer として素のイベントオブジェクトにアクセスしています。

参考

    $('form').on('submit', function(event) {
        event.stopPropagation();
        event.preventDefault();
        var url = $(this).attr('action');
        var formData = new FormData();
        for (var fileId in filesToSend) {
            formData.append(fileId, filesToSend[fileId]);
        }
        $.ajax({
            type: 'POST',
            timeout: 30000,
            url: url,
            dataType: 'json',
            processData: false,
            contentType: false,
            data: formData
        }).done(function(data, textStatus, jqXHR) {
            update(data);
            return false;
        }).fail(function(jqXHR, textStatus, errorThrown) {
            if (jqXHR.responseText) {
                update($.parseJSON(jqXHR.responseText));
            }
            return false;
        });
    });

    var update = function(data) {
        if (typeof data.form !== 'undefined' &&
            typeof data.form.files !== 'undefined'
        ) {
            for (var key in data.form.files) {
                if ($('input[name="' + key + '"]').length) {
                    var file = data.form.files[key];
                    var uploader = $('input[name="' + key + '"]').parent().parent()[0];
                    clearErrors(uploader);
                    if (typeof file.errors !== 'undefined') {
                        for (var i = 0; i < file.errors.length; i++) {
                            appendError(uploader, file.errors[i]);
                        }
                    } else {
                        createFileinfo(uploader, file);
                    }
                }
            }
        }
    };

最後にフォーム送信時のイベントハンドラ定義と、送信結果としてJSONを受け取った後の処理です。

デフォルトのイベント(つまりフォーム送信)を無効化した後、FormDataを使って送信対象のファイルオブジェクトをAjaxで送信します。

FormDataによるファイル送信については以下の記事が参考になります。

サーバ側ではファイル受付時とバリデーションエラー時ともに form.files というオブジェクトのリストをJSONで返していますので、jQuery.ajax() の結果に関わらず共通の処理を呼ぶようにしました。

また、サーバ側ではバリデーションエラーに対してステータス400を返していますので、バリデーションエラー時は通信に成功した場合でも失敗時のハンドラが呼ばれることになるわけですが、失敗時のレスポンスはdataTypeの指定に関わらず明示的にパースする必要があるため、 $.parseJSON(jqXHR.responseText) としています。

動作確認

クライアント側バリデーションのエラー時はこんな感じ。

クライアント側エラーを無視して送信すると、サーバ側バリデーションでエラーが返されます。

見た目は以前と同じですが、Ajax化しているので画面がリロードされません。ということは、JavaScriptをOFFにした時のように、ファイル入力欄の選択状態がクリアされません。やはり、こちらの方が使いやすいですね。

ドラッグ&ドロップして…

送信結果です。成功でも何もそれっぽいメッセージを表示していないので、ちょっと分かりづらいですが…。

今回は省略しましたが、他にもAjax化した場合は、送信中に再送信できないようにボタンを無効化したり、色々と面倒な処理が必要になってくると思います。

いわゆるシングルページアプリケーションとか、jQueryだけではちょっと作るのはしんどそうですね。(jQueryしか使えませんが…)

次はいよいよ確認画面の実装でしょうか。

画像アップロード(3)ドラッグ&ドロップ対応(IE除く)

前回の記事 画像アップロード(2)クライアント側で簡易バリデーション、複数ファイル対応 の続きです。

今回は前回以上にトリッキーな手法を使って、ドラッグ&ドロップに対応してみます。

ただしこの方法は、InternetExplolerでは現時点で最新の11でも期待通りに動作しません。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードでは前回と同様、自作ライブラリの Volcanus_FileUploader を使います。というか、今回は基本的にクライアント側の変更のみで対応しますので、前回と全く同じコードですが、一応掲載しておきます。

※2015-03-19追記:Volcanus_FileUploaderの更新に伴い、一部コードを書き換えました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * ドラッグ&ドロップ対応 (IE非対応)
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name]['size']) && $_FILES[$name]['size'] > 0) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

}
include __DIR__ . '/template.php';

アップローダのテンプレート (PHP)

画面のPHPテンプレートはこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.uploader input[type="file"] {
    width: 412px;
    height: 412px;
    opacity: 0;
    position: absolute;
    z-index: 1;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/3/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />
<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="text" class="filename" />
            <button type="button" name="doSelect" class="btn btn-sm">選択</button>
            <button type="button" name="doReset" class="btn btn-sm">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <p>
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" />
        </p>
        <div class="droparea">
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
        </div>
        <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
            <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($file['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
            </ul>
        </div>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

今回はサムネイル表示領域の一つ上の階層に、横幅・高さとも固定値 (410px)の領域を設置しています。

更に今回はファイル入力欄を透明にして、その領域に重ねるように配置しています。

.uploader input[type="file"] {
    width: 412px;
    height: 412px;
    opacity: 0;
    position: absolute;
    z-index: 1;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}

ファイル入力欄の412pxの2pxは枠線のぶんだけ長く設定したものです。opacityは透明度を指定するプロパティですが、0で完全に透明になります。

position:absolute で他の要素の配置に影響を与えないようにして親のボックスに基準位置を合わせ、z-index:1 で重ねるように配置します。

サムネイル表示は親のボックスを display:table で表形式にした上で、サムネイル表示領域を display:table-cell vertical-align:middle text-align:center として中身の画像をセンタリングします。

またドラッグ&ドロップの場合、accept属性によるファイルダイアログのフィルタリングが働かなくなってしまうため、JavaScript拡張子のチェックを行うようにします。

<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" /> の箇所はそのために入れています。

こうしてできたフォームがこれです。

opacityプロパティとか使ったのは初めてですが、まさしくクリックジャッキングの手法ですよね、これ…。

ちなみにopacity:0.5にするとこうなりました。

重なって配置されていることが分かります。

アップローダJavaScript

JavaScriptは前回とあまり変わりませんが、前述のファイル拡張子のチェック処理を追加しています。

uploader.js

$(function() {

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();
    var ALLOWABLE_TYPE = $('#ALLOWABLE_TYPE').val();

    var clearFile = function(uploader) {
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var preview = function(uploader, file) {
        var allowableTypes = ALLOWABLE_TYPE.split(',');
        if (allowableTypes.length > 0) {
            var found = false;
            var matches = file.name.split(/\.(?=[^.]+$)/);
            if (matches && typeof matches[1] !== 'undefined') {
                var extension = matches[1].toLowerCase();
                for (var i = 0; i < allowableTypes.length; i++) {
                    if (extension === allowableTypes[i]) {
                        found = true;
                        break;
                    }
                    if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                        found = true;
                        break;
                    }
                }
            }
            if (!found) {
                appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
                clearFile(uploader);
                return;
            }
        }
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html($('<img>').attr('src', canvas.toDataURL('image/png')));
                    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

});

通常、ドラッグ&ドロップでファイルを扱おうとすると dragover, dragenter に加えて drop イベントを捕捉して event.dataTransfer から files プロパティを参照します。

今回の場合、実際にはファイル入力欄に直接ドロップしている形となるため、イベント処理については何も追加する必要はありませんでした。

前回と同様、クライアント側のバリデーションでエラーになった場合はこんな感じ。メッセージの表示箇所は変えてます。

拡張子のエラー。

アップロード完了後はDataURI形式に変換した画像と移動先のパスを表示します。

今回の手法については以下のコードを参考にしました。

修正箇所は少なく済んだのですが、初めに述べた通り、InternetExplolerではファイル入力欄へのドロップが動作しないため、あまり現実的ではありません。

次はInternetExplolerでも動くように、通常のドラッグ&ドロップ対応を行おうと思います。ファイル入力欄への値のセットはセキュリティ上の理由で禁じられているようなので、かなりの変更が必要になるでしょう…。

画像アップロード(2)クライアント側で簡易バリデーション、複数ファイル対応

前回の記事 画像アップロード(1)Canvasでプレビューして普通に画像をアップロード の続きです。

今回は純正のファイル入力欄を隠してダミーのテキスト入力欄とボタンを配置することでスタイルシートによる見た目の変更に対応するとともに、クライアント側で簡易バリデーション(ファイルサイズ、横幅、高さのチェック)を行います。

コンテントタイプについてはクライアント側ではバリデーションを行わず、input要素にaccept属性を設定して、ファイルダイアログの表示対象を絞り込むようにします。

また、ファイルの入力欄を複数設けることで、複数のファイル送信に対応してみます。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードです。前回と同様、自作ライブラリの Volcanus_FileUploader を使います。

※2015-03-19追記:Volcanus_FileUploaderの更新に伴い、一部コードを書き換えました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * ダミーファイルフォーム,クライアントバリデーション,accept属性,複数ファイル対応
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name])) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

}
include __DIR__ . '/template.php';

複数ファイルになったので、前回と同じアップロードファイルの処理がループになっているほか、諸々の制限値などをHTML上で表示するために $form 変数の中に入れています。

$form['acceptType'] = のところはinput要素のaccept属性値(カンマ区切り)を生成しています。これ指定しておくと、ファイル選択ダイアログで表示するファイルを拡張子で絞り込んでくれるという便利機能です。(元々HTML4.01の頃から仕様にはあったんですが、近年になってようやくブラウザの対応が進んだようです)

アップローダのテンプレート (PHP)

画面のPHPテンプレートはこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/2/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="text" class="filename" />
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" style="visibility:hidden;width:0;height:0" />
            <button type="button" name="doSelect" class="btn btn-sm">選択</button>
            <button type="button" name="doReset" class="btn btn-sm">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <p>
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
            <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
                <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
                <ul>
<?php if (isset($file['size'])) : ?>
                    <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                    <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                    <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                    <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                    <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
                </ul>
            </div>
        </p>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

今回はファイル選択欄にスタイルシートでデザインを適用したいという要望に応えるため、これを隠した上でJavaScriptからテキスト入力欄へのクリックやボタンのクリックを拾って、ファイル選択欄を間接的にクリックさせる方法を採りました。

この手法はMozilla Developer Networkの以下の記事を参考にしました。

一つの入力欄で複数のファイルを選択させることもできますが、私の現場ではそれだと使いづらいという意見が出ているため、今のところこの方法が良いと考えています。

(こういうのもクリックジャッキングの一種と言えますし、あまり推奨される手法ではないとは思うのですが、現状でファイル選択欄の見た目を変える方法がないので…)

また、いくつかスタイル定義を追加したり、ファイル毎にバリデーションエラーを表示する枠を設けています。DOM操作用にclass属性を付与しているところもあります。

こんな感じで複数ファイルを受け付けるフォームが表示されます。

せっかくなので、スタイルシートでちょっとだけCSS3のプロパティを使ったりして、ボタンをナウい感じにしてみました!(ボタンだけ)

マウスカーソルをボタンに乗せたとき

ボタンを押したとき

アップローダJavaScript

JavaScriptは長くなったので別ファイルに定義しました。

multi/uploader.js

$(function() {

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();

    var clearFile = function(uploader) {
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var preview = function(uploader, file) {
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html($('<img>').attr('src', canvas.toDataURL('image/png')));
                    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

});

ダミーフォームのためイベント定義が増えたり、クライアント側バリデーションエラーを扱うようになったことで、エラーメッセージの状態変更なども追加しています。

JavaScriptのコードがダサいのは勘弁してください。自分、MVVMとかよく分からないただのLAMPおじさんですので…。

クライアント側バリデーションエラーの時はこんな感じでエラーメッセージを表示します。

バリデーションエラーが解決されるまで送信ボタンを押せなくする等の工夫も入れた方が良いのでしょうけど、とりあえず表示のみに留めています。

クライアント側バリデーションエラーを無視して送信すると、このようにサーバ側バリデーションのエラーメッセージが表示されます。

クライアント側バリデーションエラーがなければこんな感じ。

サーバ側バリデーションエラーがなければ、前回と同様にそれぞれファイルの情報を表示します。

一時ファイル名をその都度生成してますので、同じファイルを送信してもOKです。

余談ですが、ファイルのレスポンスを返す際、アップロード時のファイル名を Content-Disposition ヘッダで返したいケースも当然あるでしょうし、他にも元画像の横幅や高さ、MIMEタイプなどもサーバ側で管理しておくと色々と便利です。(この辺、どういう風に扱うのが常道なんでしょうか?)

いずれにせよアプリケーションで管理する画像はデータベースから取得した他の情報に紐付けて扱うことになるので、最近はCMS系の小さい画像で良いアプリケーションでは、ファイルの内容をbase64エンコードしてデータベースに突っ込んだりしています。

たとえば「商品」エンティティと「画像」エンティティがあって、「商品画像」連関エンティティがあり、画像ファイル単体のレスポンスに必要な情報は「画像」エンティティに集約するような構造です。

SQLアンチパターンの「ファントムファイル」も解消できるし、SQLファイルでのダンプも可能、DataURIスキームでの利用も容易ということで、この方法を採用してからは表示側の処理もシンプルに作れるようになりました。(巨大なファイルを扱うケースではどうなのか分かりませんが…)

あえて「ファントムファイル」を採用する理由はアクセス負荷なのでしょうけど、単体のファイルレスポンスだったら普通にアプリケーション側でHTTPキャッシュに対応しますよね。それに、CMS系アプリケーションであれば、いずれにせよ紐付けられたエンティティの公開状態なども併せて見ないといけないわけで。

しかし、検索しても「ファントムファイル」を本気でアンチパターンと受け取ってない人が多いのが不思議です。ソーシャルなんとかやコンシューマ向け大規模サービスだとデメリットの方が大きいのかな。(今どきファイルはS3とか外部ストレージに置いてるってことなのかも…というかRDBMS自体使ってない?)

次の課題はドラッグ&ドロップ対応あたりでしょうか。もうちょっとだけ続けます。