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

# **導入**

PHPの **[cURL関数群](http://www.php.net/manual/ja/book.curl.php)** はC言語で書かれた **[libcurl](http://www.php.net/manual/ja/book.curl.php)** の極薄ラッパーとして実装されていますが、ポストフィールドのファイル取り扱いに関する実装が **クソ仕様** としか思えないようになっています。

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

```php:http&#58;//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);
```

```php:http&#58;//localhost/respond.php
<?php
$headers = apache_request_headers();
var_dump(compact('headers', '_POST', '_FILES'));
```

## `$params` が **文字列** のとき

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

```php
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次元) **連想配列** のとき

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

```php

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関数群には<ins>`@` をエスケープする手段が用意されていません！</ins>

# **問題点**

ファイルをアップロードしなくてもいいものに関しては、単に **[http_build_query](http://www.php.net/manual/ja/function.http-build-query.php)** 関数でクエリ文字列化するなどして送れば解決します。しかし、ファイルのアップロードをしたい、且つ `@` 付きの文字列を送りたいときに困りますね。

具体的には…TwitterのAPI **[POST statuses/update_with_media](https://dev.twitter.com/docs/api/1.1/post/statuses/update_with_media)** を利用して `@` 付きのツイートを送りたいときに…

```php
$params = array(
    'status'  => '@mpyw テストツイート！',
    'media[]' => '@/home/mpyw/test.jpg', 
);
```

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

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

```php
$params = array(
    'status'  => ' @mpyw テストツイート！',
    'media[]' => '@/home/mpyw/test.jpg', 
);
```

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

# **解決策**

```php:目標とする実行結果
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以降

- **[CURLFile](http://www.php.net/manual/ja/class.curlfile.php) クラスを利用する。**

```php:サンプルコード
$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](http://www.php.net/manual/ja/class.curlfile.php) クラスを利用する。**

```php:サンプルコード
$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を変更する。**

```php:サンプルコード
$ch = curl_init('http://localhost/respond.php');
curl_custom_postfields(
    $ch,
    array('status'  => '@mpyw テストツイート！'),
    array('media[]' => '/home/mpyw/test.jpg')
);
curl_exec($ch);
```

```php:関数定義
/**
 * マルチパートボディを組み立てて文字列としてセットする。
 * 
 * @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以前にも対応出来ますが、今更サポートの切れたそんな古いバージョン使わないでください）