Edited at

PHPでcURLのクソ仕様 "@" を回避する


導入

PHPの cURL関数群 はC言語で書かれた libcurl の極薄ラッパーとして実装されていますが、ポストフィールドのファイル取り扱いに関する実装が クソ仕様 としか思えないようになっています。

以下の2つのファイルを用意します。


http://localhost/send.php

<?php

$params = /* something */ ;
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => 'http://localhost/respond.php',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params,
));
curl_exec($ch);


http://localhost/respond.php

<?php

$headers = apache_request_headers();
var_dump(compact('headers', '_POST', '_FILES'));


$params文字列 のとき

$params = 'a=b.jpg&c=@d.jpg';

array(3) {

["headers"]=>
array(4) {
["Host"]=>
string(9) "localhost"
["Accept"]=>
string(3) "*/*"
["Content-Length"]=>
string(2) "16"
["Content-Type"]=>
string(33) "application/x-www-form-urlencoded"
}
["_POST"]=>
array(2) {
["a"]=>
string(5) "b.jpg"
["c"]=>
string(6) "@d.jpg"
}
["_FILES"]=>
array(0) {
}
}

全てが文字列のパラメータとして application/x-www-form-urlencoded で送信されます。こっちは特に問題なし。


$params が (1次元) 連想配列 のとき

$params = array('a' => 'b.jpg', 'c' => '@d.jpg');


array(3) {
["headers"]=>
array(5) {
["Host"]=>
string(9) "localhost"
["Accept"]=>
string(3) "*/*"
["Content-Length"]=>
string(6) "219587"
["Expect"]=>
string(12) "100-continue"
["Content-Type"]=>
string(70) "multipart/form-data; boundary=----------------------------300acbbc7ca5"
}
["_POST"]=>
array(1) {
["a"]=>
string(5) "b.jpg"
}
["_FILES"]=>
array(1) {
["c"]=>
array(5) {
["name"]=>
string(5) "d.jpg"
["type"]=>
string(24) "application/octet-stream"
["tmp_name"]=>
string(24) "/var/tmp/phpC9D4.tmp"
["error"]=>
int(0)
["size"]=>
int(219264)
}
}
}

(PHPバージョン5.5以降は非推奨とされていますが)@ を先頭につけたものはファイル名と見なされ、ファイルのアップロードが行われます。しかし何と… PHP5.4以前 のcURL関数群には@ をエスケープする手段が用意されていません!


問題点

ファイルをアップロードしなくてもいいものに関しては、単に http_build_query 関数でクエリ文字列化するなどして送れば解決します。しかし、ファイルのアップロードをしたい、且つ @ 付きの文字列を送りたいときに困りますね。

具体的には…TwitterのAPI POST statuses/update_with_media を利用して @ 付きのツイートを送りたいときに…

$params = array(

'status' => '@mpyw テストツイート!',
'media[]' => '@/home/mpyw/test.jpg',
);

真っ先に思いつくのはこういう方法です。

statusパラメータに関しては、送信するツイートの頭に半角スペースを付加し、Twitter側にトリミングさせることで解決する。

$params = array(

'status' => ' @mpyw テストツイート!',
'media[]' => '@/home/mpyw/test.jpg',
);

しかし、ライブラリを使用する側でそんな面倒なことしたくないし、それで解決したとしてもアプリケーション設計という観点からみればやはり大問題です。また、cURLを利用している多くのTwitterAPI用のライブラリは、このことを一切考慮しない実装となっていることが多いです。


解決策


目標とする実行結果

array(3) {

["headers"]=>
array(5) {
["Host"]=>
string(9) "localhost"
["Accept"]=>
string(3) "*/*"
["Expect"]=>
string(12) "100-continue"
["Content-Type"]=>
string(83) "multipart/form-data; boundary=---------------------e00ed8ddd5d4d50c1dc97be740f0bb27"
["Content-Length"]=>
string(6) "219628"
}
["_POST"]=>
array(1) {
["status"]=>
string(30) "@mpyw テストツイート!"
}
["_FILES"]=>
array(1) {
["media"]=>
array(5) {
["name"]=>
array(1) {
[0]=>
string(5) "test.jpg"
}
["type"]=>
array(1) {
[0]=>
string(24) "application/octet-stream"
}
["tmp_name"]=>
array(1) {
[0]=>
string(24) "/var/tmp/php1EC9.tmp"
}
["error"]=>
array(1) {
[0]=>
int(0)
}
["size"]=>
array(1) {
[0]=>
int(219264)
}
}
}
}


PHP5.6以降


サンプルコード

$ch = curl_init();

curl_setopt_array($ch, [
CURLOPT_URL => 'http://localhost/respond.php',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'status' => '@mpyw テストツイート!',
'media[]' => new CURLFile('/home/mpyw/test.jpg'),
],
]);
curl_exec($ch);

PHP5.6以降ではデフォルトで @ が無効になっています。


PHP5.5以降


  • CURLOPT_SAFE_UPLOAD を有効にする。

  • CURLFile クラスを利用する。


サンプルコード

$ch = curl_init();

curl_setopt_array($ch, [
CURLOPT_URL => 'http://localhost/respond.php',
CURLOPT_POST => true,
CURLOPT_SAFE_UPLOAD => true,
CURLOPT_POSTFIELDS => [
'status' => '@mpyw テストツイート!',
'media[]' => new CURLFile('/home/mpyw/test.jpg'),
],
]);
curl_exec($ch);

CURLOPT_SAFE_UPLOAD を有効にすることで、PHP5.6と同じ状態に出来ます。


PHP5.3以降


  • 自前でマルチパートボディを組み立てて文字列にする。

  • 自前でContent-Typeを変更する。


サンプルコード

$ch = curl_init('http://localhost/respond.php');

curl_custom_postfields(
$ch,
array('status' => '@mpyw テストツイート!'),
array('media[]' => '/home/mpyw/test.jpg')
);
curl_exec($ch);


関数定義

/**

* マルチパートボディを組み立てて文字列としてセットする。
*
* @param resource $ch cURLリソース
* @param array $assoc 「送信する名前 => 送信する値」の形の連想配列
* @param array $files 「送信する名前 => ファイルパス」の形の連想配列
* @return bool 成功 or 失敗
*/

function curl_custom_postfields($ch, array $assoc = array(), array $files = array())
{
$body = array();

foreach ($assoc as $k => $v) {
$k = str_replace(array("\0", "\"", "\r", "\n"), "_", $k);
$body[] = implode("\r\n", array(
"Content-Disposition: form-data; name=\"{$k}\"",
"",
filter_var($v),
));
}

foreach ($files as $k => $v) {
switch (true) {
case false === $v = realpath(filter_var($v)):
case !is_file($v):
case !is_readable($v):
continue; // return false や throw new InvalidArgumentException もアリ
}

$data = file_get_contents($v);
$v = call_user_func("end", explode(DIRECTORY_SEPARATOR, $v));
list($k, $v) = str_replace(array("\0", "\"", "\r", "\n"), "_", array($k, $v));

$body[] = implode("\r\n", array(
"Content-Disposition: form-data; name=\"{$k}\"; filename=\"{$v}\"",
"Content-Type: application/octet-stream",
"",
$data,
));
}

do {
$boundary = "---------------------" . md5(mt_rand() . microtime());
} while (preg_grep("/{$boundary}/", $body));

array_walk($body, function (&$part) use ($boundary) {
$part = "--{$boundary}\r\n{$part}";
});

$body[] = "--{$boundary}--";
$body[] = "";

return curl_setopt_array($ch, array(
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => implode("\r\n", $body),
CURLOPT_HTTPHEADER => array(
"Expect: 100-continue",
"Content-Type: multipart/form-data; boundary={$boundary}",
),
));
}


内部でクロージャを使用しているためPHP5.3以降とさせていただきました。

(書き方次第でPHP5.2以前にも対応出来ますが、今更サポートの切れたそんな古いバージョン使わないでください)