71
72

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2014-06-04

導入

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以前にも対応出来ますが、今更サポートの切れたそんな古いバージョン使わないでください)

71
72
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
71
72

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?