ある程度の規模があるシステムを組むとなると画像や映像ファイル専用のサーバーを立ててアクセス負荷を分散させるのはよくある話ですよね。
サイトを更新する管理者がFTPソフトを用いて専用サーバーにファイルをアップしてくれるのであれば話は簡単なのですが、大抵は管理画面を作って、そこからファイルをアップロードさせます。ついでにDBも更新するケースか多いでしょう。
さて、管理画面からファイルをアップするときは
クライアント(formやAjax) -> サーバー(phpとかrubyとか)
という処理をたどるのですが、ファイルをアップする場所が別サーバーだとすると
クライアント -> サーバー(メインのシステム) -> サーバー(ファイル専用サーバー)
というサーバーからサーバーへアクセスする処理が必要になってきます。
いつもは受け身のサーバーサイドが一転攻勢に出る胸熱展開ですね。お前やるときゃグイグイ行くタイプなんだな、とお父さん感動です。
ただ、ぶっちゃけストレートに面倒ですよね、こういうの。
なので今日はこれら一連のフローを我らがアイドルCodeigniterさんで開発したいかと思います。
*ごめんなさい、フロント側はあんまり説明しません。
注意
- サーバーからサーバーへファイルを投げるときはHTTP通信かFTP通信の二択から選ぶことになりますが、今回はHTTP通信を使っていきたいかと思います。
- FTPで送信することができれば受け取り側のスクリプトを書く必要がありませんが、FTPサーバーを立てる必要があるのでサーバーの要件によっては使用できない可能性があるためです。
- 逆に、HTTPを使用した場合は自前でセキュリティ対策をする必要があるので、今回はIP制限をかけていきます。
- サーバーからサーバーへ通信するときの手段として
file_get_contents
関数を使用します。-
cURL
関数群を使いたいところですが、使えないサーバーもあるため汎用性を重視しました。 - 使える場合は
cURL
のほうがパフォーマンスが良い(ことが多い)ので是非改造してやってください。
-
- 今回作成するシステムは同じコードで送信側と受け取り側の両方を兼任します。つまり、両方のサーバーで同じコードでOKです。
- 実際にCodeigniterでシステムを作成する場合はURLに
/index.php
を含めないように設定することが殆どですが、この記事ではは初期設定のまま開発を進めていくので適宜読み替えてください。
フローを雑に説明
この記事で紹介するシステムはざっと以下の流れで行われます。
*送信側と受け取り側が同じコードで動作したり、外部サーバーを使うかどうかが設定で変えられる等、柔軟な設計(?)を目指したらあんまりパフォーマンスがよろしくなさ気なフローになったので、もし使うならケースバイケースで改造してください。
共通部分
- クライアント側からPOST送信でファイルがアップロードされる
- コントローラがアップロードライブラリを用いてバリデーション、通過したらファイルをアップロード
- モデルを呼んでDB操作
- モデルの中で外部サーバーが設定されているか確認 → されていない場合は以下フローA、されている場合はフローBへ
フローA
- リネーム・サムネイル作成処理
- クライアント側へファイルが上書き去れたかどうか兼、無事に処理が成功したという情報を送る
- クライアント側でまだアップロードするファイルがある場合は共通部分1へ戻る
フローB
- 受け取り側サーバーへ自前でHeaderを作成し、HTTP通信を試みる
- 受け取り側サーバー内のコントローラが送信側サーバーのIPを判定し、OKならアップロード開始
- 受け取り側サーバー内でリネーム・サムネイル作成処理
- 送信側サーバー内のファイルを削除
- 送信側サーバーがクライアント側へファイルが上書き去れたかどうか兼、無事に処理が成功したという情報を送る
- クライアント側でまだアップロードするファイルがある場合は共通部分1へ戻る
設定ファイルを作成・更新
独自設定ファイルをconfigディレクトリに作成
Codeigniter(3系)にはapplication
ディレクトリの中に、システムの設定を行うphpファイルを格納しているconfig
ディレクトリがあります。
この中にはメインの設定ができるconfig.php
やルーティングを設定するroutes.php
がありますが、自分で勝手に設定ファイルを作成して、
this->load->config('設定ファイルの名前');
みたいにロードして使うことができます。
そこで、今回は画像サーバーの設定を独自ファイルから読み出すことにしましょう。
<?php
//画像サーバーのドメイン(''にすればファイルサーバー無しと判断する)、末尾の/は付けないこと
//デフォルトのCodeigniterで扱うURLにくっついている'index.php'を外す処理をしているなら末尾の'/index.php'は削除
$config['img_server'] = 'https://○○○.com/index.php';
//ホワイトリストIP番号
//この配列の中に存在しないIPアドレスからのアップロードを受け付けない
$config['ip_white_list'] = array('111.111.11.11');
二つの設定を独自に追加しました。
注:独自設定を増やす場合には既存設定と名前を衝突させないようにしましょう。
独自に追加した設定の名前は既存の$this->config->item('名前');
で呼び出せる設定と同じ名前空間に属しているので、既存設定と名前が衝突する場合もあります。
それら仕様の詳細と回避策、または違う名前空間で扱う方法は公式マニュアルを参照してください。
設定クラス 手動での読み込み
設定クラス 手動での読み込み
config.phpでCSRF対策をする
有難いことにCodeigniterにはCSRF対策をするための設定が存在します。
先ほどの説明で出てきたconfig.php
の中にある$config['csrf_protection']
をTRUE
に設定しましょう。
これだけでCodeigniterのフォームヘルパーが提供するform_open
関数が勝手にトークン用の値を持った<input>
を吐き出し、POST送信を検証してくれます。
通常はこれだけでいいのですが、今回のケースにおいては外部サーバーからのアクセスを受け入れる必要があるため$config['csrf_exclude_uris']
に検証対象外のURLを設定する必要があります(後でIP検証によってカバーします)。
さらに、<form>
を利用してファイルを一個ずつアップロードするならいいのですが、Ajaxなどを利用して何個かのファイルを順々にアプロードするためには$config['csrf_regenerate']
の値がFALSE
である必要があります。
以上を踏まえて、config.php
の中身は以下のようになるでしょう。
//略
$config['csrf_protection'] = TRUE;
$config['csrf_token_name'] = 'csrf_test_name';
$config['csrf_cookie_name'] = 'csrf_cookie_name';
$config['csrf_expire'] = 7200;
$config['csrf_regenerate'] = FALSE;
$config['csrf_exclude_uris'] = array(
'media/external_upload'
);
//略
コントローラの作成
設定ファイルを更新し終わったら今度はコントローラーを用意しましょう。
以下、今回使用するコントローラーの全貌です。
<?php
/**
* Class Media
* @property CI_Loader $load
* @property CI_Input $input
* @property CI_Upload $upload
* @property Media_model $media_model
*/
class Media extends CI_Controller {
/**
* 必要なモノをロード
* @return void
*/
public function __construct()
{
parent::__construct();
//モデルのロード
$this->load->model('media_model');
//ヘルパーをロード
$this->load->helper(array('url', 'form'));
//画像サーバー設定読込
$this->config->load('img_server');
//アップロードライブラリの読み込み&設定
$config = array(
// ファイルのアップロード制限
"allowed_types"=>"jpg|jpeg|gif|png",
//ファイル名が被ったときは上書きする
'overwrite' => TRUE,
// ファイルのアップロード先を決める
"upload_path"=>APPPATH.'../uploads'
);
$this->load->library('upload', $config);
}
/**
* 設定されているホワイトリストIP以外のアクセスを弾くメソッド
* @return void
*/
private function _ip_filter()
{
foreach ($this->config->item('ip_white_list') as $ip)
{
if(isset($_SERVER['REMOTE_ADDR']) && $ip === $_SERVER['REMOTE_ADDR']) {
return;
}
}
die('IPがやべーぞ');
}
/**
* ファイルをCI_Uploadクラスでアップロードする
* $upload->dataの返り値を返す
* @return array
*/
private function _up()
{
try
{
if (!isset($_FILES['asset']['error']) || !is_int($_FILES['asset']['error'])) {
throw new Exception('パラメータが不正です');
}
if($this->upload->do_upload('asset'))
{
return $this->upload->data();
}
//ファイルのアップロードに失敗した場合
throw new Exception($this->upload->display_errors('<p>', '</p>'));
}
catch (Exception $e)
{
die($e->getMessage());
}
}
/**
* フォームから値を受け取る
* _up()を呼び出したらモデルへ
* @return void
*/
public function upload()
{
//モデルへ情報を投げる
print $this->media_model->upload($this->_up());
}
/**
* 外部からデータを受け取る
* 指定されたIPアドレス以外は弾く
*/
public function external_upload()
{
//外部からのアクセス専用なので、許可なきIPは滅
$this->_ip_filter();
//モデルへ情報を投げる
print $this->media_model->external_upload($this->_up());
}
/**
* 画面の表示
* @return void
*/
public function index()
{
//画像パスの設定を読み込む
$path = base_url().'media';
if($this->config->item('img_server'))
{
$path = $this->config->item('img_server').'/media';
}
//CSRF TOKENの名前
$token_name = $this->security->get_csrf_token_name();
$this->load->view('media', array(
'path' => $path,
'token_name' => $token_name
));
}
}
メソッドごとに説明していきます。
__construct()
コンストラクタではコントローラーの相方であるモデルに加え、ヘルパー、画像サーバー設定、アップロードライブラリ,画像操作ライブラリのロードをします。
また、アップロードライブラリの設定もここで行います。
-
allowed_types
- '|'区切りでアップロードを許可するファイルの種類を設定します。画像を許可するなら上記通りでいいかと思いますが、ほかにもpdfやmp4なんかを設定する場合もあるかと思います。
-
overwrite
- 同名のファイルがあった場合に上書きするかどうかを
TURE
orFALSE
で設定します。今回は上書きしてしまいますが、FALSE
に設定した場合はファイル名へ連番が付きます。
- 同名のファイルがあった場合に上書きするかどうかを
-
upload_path
- ファイルのアップロード先です。今回は
application
と同じトップディレクトリにuploads
というフォルダを作成しました。
- ファイルのアップロード先です。今回は
_ip_filter()
_ip_filter
メソッドは送信側のサーバー以外からのアクセスを弾くメソッドです。
先ほどconfig.php
でhttps://○○○.com/inde.php/media/external_upload
というURLに対するCSRF脆弱性の検証を無効化してしまったのでimg_server.php
内の$cfig['ip_white_list']
に設定されているIP以外は全部die()
で強制終了させちゃいましょう。
_up(), upload(), external_upload()
_up
メソッドでCodeigniterのアップロードクラスを呼び出すメソッドを作成し、内部でのアップロード用にupload
を、外部からのアップロード用にexternal_upload
を作成します。
_up
はモデルで使いたいアップロードされたファイル情報を持っている$this->upload->data();
で取得できる値を返すようにしました(返り値の参考:CI_Upload::data)。
upload
ではクライアントサイドから送られてきたデータを_up
を使いアップロード、後の仕事はモデルに丸投げします。
external_upload
も呼び出すモデルのメソッドが違うだけでupload
と同じ流れですが、処理を実行する前に先述した_ip_filter
メソッドでIPの検証をします。
index()
フォーム画面を表示するためにindex
メソッドでViewを呼び出します。
*詳しく説明はしませんが、この記事の最後らへんでjsも含んだViewファイルをオマケとして載せてあります。
ファイルをアップロードするモデルを作る
モデルの作成
コントローラが作成できたら処理の大部分を司るモデルを作成しましょう。
<?php
/**
* Class Media_model
* @property CI_DB $db
* @property CI_Image_lib $image_lib
*/
class Media_model extends CI_Model {
/**
* コンストラクタ
* 必要なクラスのロード
* @return void
*/
public function __construct()
{
parent::__construct();
$this->load->database();
}
/**
* DBにデータをinsertする
* 上書きが発生したかを判断し、発生したらTRUE、発生しなかったらFALSEを返す
* @param string $new_name
* @param string $original_name
* @return bool
*/
private function _input_db($new_name, $original_name)
{
//data_mediaというテーブルにデータを突っ込みたいとする
//さらに、同名のデータが既にあったら何もせずTRUEを返したいとする
//テーブル名
$table = 'data_media';
//既存の被りデータ探し
$this->db->where('new_name', $new_name);
$query = $this->db->get($table);
//被りがあるかどうかの判定
$data = $query->result();
if(isset($data[0]))
{
//被りがあったので上書きフラグTRUE
return TRUE;
}
//被りが無かったのでDBにinsert
$this->db->insert($table, array(
'original_name' => $original_name,
'new_name' => $new_name
));
//上書きフラグはFALSE
return FALSE;
}
/**
* リネーム処理
* @param array $data
* @return string
*/
private function _rename($data)
{
//例えば、先頭の0を消す処理
try
{
$new_name = ltrim($data['file_name'], '0');
if(rename($data['full_path'], $data['file_path'].$new_name))
{
return $new_name;
}
throw new Exception('ファイルのリネームに失敗しました');
}
catch (Exception $exception)
{
die($exception->getMessage());
}
}
/**
* サムネイルを生成する
* @param string $file_path
* @param string $new_name
* @return void
*/
private function _create_thumb($file_path, $new_name)
{
//パスを変数に格納
$path = $file_path.$new_name;
//画像ファイルでなかったら処理を中断
$extension = strtolower(pathinfo(realpath($path), PATHINFO_EXTENSION));
if (
$extension !== 'jpeg' &&
$extension !== 'jpg' &&
$extension !== 'gif' &&
$extension !== 'png'
)
{
return;
}
//作成するサムネイルの種類を設定
$thumbConfigs = array(
array(
'suffix' => '_big',
'width' => 1000
),
array(
'suffix' => '_middle',
'width' => 400
),
array(
'suffix' => '_small',
'width' => 130
)
);
foreach ($thumbConfigs as $thumbConfig)
{
//イメージライブラリの設定
$config = array(
'source_image' => $path,
'create_thumb' => TRUE,
'thumb_marker' => $thumbConfig['suffix'],
'width' => $thumbConfig['width']
);
$this->image_lib->initialize($config);
//サムネイル生成
try
{
if( ! $this->image_lib->resize())
{
throw new Exception($this->image_lib->display_errors());
}
}
catch (Exception $exception)
{
die($exception->getMessage());
}
}
}
/**
* 外部ファイルサーバー指定があった場合は現地のコントローラを叩く
* @param array $data
* @return string
*/
public function upload($data)
{
//以下三行はブラウザに接続を切らせないための処理
//長時間(30秒とか)のアップロード時のみ必要なので、極端な場合を除きいらないかも
echo '';
@ob_flush();
flush();
//元のファイル名
$original_name = $data['client_name'];
//リネーム処理が必要ならこのタイミングで
$new_name = $this->_rename($data);
//既に存在しているファイル名ならば上書きフラグをonに、そうでなければDBに登録
$override = $this->_input_db($new_name, $original_name);
//外部画像サーバーが設定されているかどうか
if($this->config->item('img_server'))
{
//外部画像サーバーが設定されていたらそこのMediaコントローラーに狙いを定める
$url = $this->config->item('img_server').'/media/external_upload';
//ファイルをhttpで送れるよう加工
$file = file_get_contents($data['file_path'].$new_name);
//ファイルタイプを取得
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$type = finfo_file($finfo, $data['file_path'].$new_name);
finfo_close($finfo);
//改行文字の設定
$rn = "\r\n";
//画像サーバーに投げるヘッダーを作成
$boundary = '--------------------------'.microtime(TRUE);
$headers = 'Accept-language: ja'.$rn.
'Content-Type: multipart/form-data; boundary='.$boundary.$rn;
//画像サーバーに投げるcontent部分の作成
$content = '--'.$boundary.$rn.
'Content-Disposition: form-data; name="asset"; filename="'.$new_name. '"' .$rn.
'Content-Type: '.$type.$rn.$rn.
$file .$rn.
'--'.$boundary.'--'.$rn;
//投げる内容をまとめる
$opts['http'] = [
'method' => 'POST',
'header' => $headers,
'content' => $content
];
$context = stream_context_create($opts);
//お待たせしました、投げます
file_get_contents($url, FALSE, $context);
//このサーバーの画像ファイル削除
unlink($data['file_path'].$new_name);
}
else
{
//外部サーバーが設定されていなかったのでサムネイル作成
$this->_create_thumb($data['file_path'], $new_name);
}
//上書き情報をControllerに返す
return $override;
}
/**
* 外部からのファイルアップロードに対応する
* リネームとサムネイル作成
* @param array $data
* @return void
*/
public function external_upload($data)
{
//リネーム処理が必要ならこのタイミングで
$new_name = $this->_rename($data);
//サムネイル作成
$this->_create_thumb($data['file_path'], $new_name);
}
}
こちらも長いのでコントローラーと同じくメソッドごとに解説します。
__construct()
大体の必要なモノはコントローラーで呼び出したので、ここではデータベースのロードだけを行います。
特にデータベースは使わないし、database.php
の設定もしてないぜ! という方はエラーが起きてしまうので
$this->load->database();
の行はコメントアウトしておきましょう。
_input_db($new_name, $original_name)
ファイルがアップロードされたときにDBを操作するメソッドです。
後述するリネーム処理後のファイル名が$new_name
に、もともとのファイル名が$original_nameに入ってきますので、インサートなりアップデートなり好きにやっちゃってください。
返り値として上書きフラグを設定できますが、特に使う予定がなかったり、DBを弄らない場合はTRUE
を返しておけばとりあえず平和です。
_rename($data)
リネーム処理用のメソッドです。
$dataにCodeigniterのUploadライブラリで使えるdataメソッドの返り値が入ってくるので、好きなリネーム処理をして、その文字列をreturn
してください。
特にリネーム処理が必要ない場合は
return $data['file_name'];
と一行だけ書けば何事も起こりません。
_create_thumb($file_path, $new_name)
アップロードされた画像ファイルのサムネイルを作成するメソッドです。
$file_path
にはアップロードされたファイルの存在するディレクトリまでのパスが、$new_name
にはリネーム処理後のファイル名が入ります。
メソッド内に用意されている$thumbConfigs
配列の内容に従ってサムネイルを作成します。
先述したコード中、$thumbConfigs
の0番目を例とすると、オリジナル画像と同じディレクトリに縦横比を保ったまま横幅1000pxの画像をオリジナルファイル名.'_big'.拡張子
で作成する、という処理が実行されます。
一応、拡張子からサムネイルを作成できないファイル(画像でないファイル)を弾くコードも足したものの、コントローラのコンストラクタで設定したアップロードライブラリのallowed_types
で画像ファイル以外を弾いているなら冗長な部分なので削ってもOKです。
foreach
の中で画像操作ライブラリの設定をします。設定の意味は以下の通りです。
-
source_image
- 元となるオリジナルファイルへのパスです。
-
create_thumb
-
TRUE
にするとファイルを上書きせず、サムネイルを作成します。
-
-
thumb_marker
- 上記
create_thumb
をTRUE
にした場合、サムネイルとして生成されたファイルの末尾に付ける文字列を指定できます。
- 上記
-
width
- 横幅を何pxにするかの設定です。
height
を指定しなかった場合は縦横比を保ったまま拡大・縮小してくれます。
- 横幅を何pxにするかの設定です。
例によってサムネイルが必要ない場合はただ一行return;
とだけ書いておけば何もしません。
upload($data)
ファイルがアップロードされた直後に実行される、このクラスの心臓部分です。
$data
には_rename($data)
と同じくCI_Upload::data
の返り値が入ってきます。
大雑把に言えば、上記三つのプライベートメソッドをええタイミングで実行するメソッドです。
さらにimg_server.php
で設定した値を元に内部へファイルを保存するのか外部サーバーへ投げるのかを判断し、後者であれば外部サーバーのコントローラを叩きに行く役割もこのメソッドが担います。
external_upload($data)
外部からのファイルアップロードがコントローラで正常だと判断された場合に呼ばれるメソッドです。
upload($data)
と同じような役割を担いますが、この上記コードではDB操作(\_input_db
メソッドの実行)をしません。
多分、ファイル専用サーバーでDBを管理するメリットがあんまり無いんじゃないかと思ってこうなりましたが、もし受け取り側でも何らかのDB操作をするのであれば改造してしまいしょう。
オマケ: ビューを作成する
説明はしませんが、jsも一緒に仕込んだ雑なビューを用意したので手っ取り早く動かしたい人は使ってやってください。
「参照」ボタンをクリックするか、その周辺にファイル(複数可)をドラック&ドロップすればアップロードが始まります。
*fetch APIが動かないブラウザでは残念なコードなので注意してください
*「なんでPHP側はスネークでオールマンなのにJS側はキャメルでカーネルなんだよ!!」という声が聞こえますが、聞こえません。許してください。
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
?><!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>アップロードDEMO</title>
</head>
<body data-url="<?php echo $path; ?>">
<div class="wrap">
<div class="dropArea">
<?php echo form_open_multipart('/media/upload/'); ?>
<h4 class="managementFormBoxTitle">ファイルをアップロードする</h4>
<div class="fileDropArea">
<p class="fileDropComment">ファイルをドロップ<br/>
または<br/>
<label class="fileChooseBtn">
ファイルを選択
<?php echo form_upload(array(
'name' => 'asset',
'class' => 'file',
'value' => 'ファイルを選択'
)); ?>
</label>
<span id="numbers" style="display: none;"><span id="numberNow"></span>/<span id="numberCount"></span> 件処理中...</span>
</p>
</div>
<p id="alerts"></p>
<?php echo form_close(); ?>
</div>
<div class="managementTableSpace picArea">
<ul class="mediaDisplayTypeTileListBox picBox" style="position: relative;"></ul>
</div>
</div>
<script>
(function(){
//トークン取得
var token = document.querySelector('[name = <?php echo $token_name; ?>]').value;
//パス取得
var path = window.location.href;
//進行状況部分
var numbers = document.getElementById('numbers');
//処理中のファイル番号部分
var numberNow = document.getElementById('numberNow');
//処理中のファイル全体数部分
var numberCount = document.getElementById('numberCount');
//警告部分
var alerts = document.getElementById('alerts');
//画像投稿
function upload(targets){
numbers.style.display = 'block';
//投稿された画像らしきファイルの数
var length = targets.length;
numberCount.innerHTML = length;
//カウント変数
var i = 0;
//サーバーエラー確認
var handleErrors = function(response) {
if (!response.ok) {
throw Error(response.statusText);
}
return response;
}
//まだアップロードファイルがあるか確認する
var loop = function (response) {
console.log(response);
i++;
if(i < length){
upload();
}
else{
numbers.style.display = 'none';
}
}
//サーバーエラー発生時処理
var reject = function() {
alerts.innerHTML = alerts.innerHTML + '<span>'+(i + 1)+'番目:'+targets[i].name + 'のアップロードに失敗しました</span><br />';
}
//アップロード処理
var up = function(){
if(targets[i].type.indexOf('image/') === 0){
//進捗表示
numberNow.innerHTML = i;
//ファイル
var fd = new FormData();
fd.append('asset', targets[i]);
fd.append('<?php echo $token_name; ?>', token);
//アップする
fetch(path + '/upload', {
method: 'POST',
body: fd,
mode: 'cors',
credentials: 'include'
})
.then(handleErrors)
.then(loop)
.catch(reject);
return;
}
//画像でないファイルをアップロード
alerts.innerHTML = alerts.innerHTML + '<span>'+(i + 1)+'番目:'+targets[i].name + 'は画像ではありません</span><br />';
}
//アップロード開始
up();
}
//画像投稿
var fileBtn = document.getElementsByClassName('file')[0];
fileBtn.addEventListener('change', function (e) {
upload(e.target.files);
});
//画像ドロップ
var dropArea = document.getElementsByClassName('dropArea')[0];
//ドラッグ途中
dropArea.addEventListener('dragover', function (e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
//ドロップ
dropArea.addEventListener('drop', function (e) {
e.preventDefault();
upload(e.dataTransfer.files);
});
})();
</script>
</body>
</html>
まとめ
やっぱり面倒ですね。
でも作るのは結構楽しかったです。まる。やっぱりCodeigniterはいいなぁ。
なにか問題点があったりしたらご意見お待ちしております……