Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

導入

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

mpyw
古い記事はそのまま参考にしないようにご注意ください
synapse
Synapseは、オンラインサロンサービスにおけるパイオニアとして、かつて存在していたスタートアップです。
https://synapseam.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away