PHP 构造 multipart/form-data 格式 POST 请求体的方法

引言

最近在尝试基于 PHP 做一个反向代理 HTTP 的程序,其中一个需求是将程序收到的HTTP请求还原回 RFC2616 的原始格式。

在处理的过程中遇到的问题主要在请求体的处理上。利用PHP的封装协议机制,我们可以通过读取 php://input 访问原始的POST信息。但这种方式有一个局限,对于 multipart/form-data 的请求来说,为了支持文件上传的操作,PHP会预先把请求体中的文件暂存到临时文件夹,并把参数解析到变量 $_POST$_FILES 中, php://input 获取原始请求的功能也随之失效。

Stack Overflow 上的相关问题给出的 解决办法 是修改服务器配置,把发到 PHP 脚本的 Content-Type: multipart/form-data; boundary=xxxx 修改为其它格式,使其不经过PHP的 form-data 解析;或是把 php.ini 配置关于POST数据解析的 enable_post_data_reading = Off 选项关闭。然而这两种方法并不非常具有普遍性,在某些PHP配置文件不可控的共享主机的环境下并不适用。

于是引出了本文讨论的话题 — 如何重新组装 multipart/form-data 格式的原始 POST 请求体。

multipart/form-data 格式

在POST请求中,一般表单会通过 application/x-www-form-urlencoded 格式上传,但此格式的数据仅支持文本格式,不支持二进制文件的上传。为了支持表单 POST 文件上传,RFC1867 定义了 multipart/form-data 的数据格式,实现了通过POST请求上传表单的内容以及二进制文件数据,关于数据的形态,参考 四种常见的 POST 提交数据方式 | JerryQu 的小站

RFC1867 对于 multipart/form-data 的数据格式主要在MIME RFC1521 7.2.1 小节定义的。另外,在MIME 标准 Media Types 部分 RFC2046 的 5.1.1 节中,对于 multipart-body 的格式有一个较为清晰的 BNF 范式的语法定义,简短总结如下(来自 Stack Overflow) :

multipart-body := [preamble CRLF]
                  dash-boundary CRLF
                  body-part *encapsulation
                  close-delimiter
                  [CRLF epilogue]

dash-boundary := "--" boundary

body-part := MIME-part-headers [CRLF *OCTET]

encapsulation := delimiter
                 CRLF body-part

delimiter := CRLF dash-boundary

close-delimiter := delimiter "--"

还原 multipart/form-data 的代码

写代码前搜索前人的经验,在 SegmentFault 看到了一位前辈的实现,参考前辈的代码,以及 RFC2046 的 BNF 语法定义,写了以下代码:

// 还原 rfc1867, rfc2046 格式的FormData
function getFormData() {
  // body-part array
  $body = array();

  // 普通参数
  foreach ($_POST as $key => $value) {
    $body_part = "Content-Disposition: form-data; name=\"$key\"\r\n";
    $body_part .= "\r\n$value";
    $body[] = $body_part;
  }

  // 上传文件处理
  foreach ($_FILES as $key => $value) {
    $body_part = "Content-Disposition: form-data; name=\"$key\"; filename=\"{$value['name']}\"\r\n";
    $body_part .= "Content-type: {$value['type']}\r\n";
    $body_part .= "\r\n".file_get_contents($value['tmp_name']);
    $body[] = $body_part;
  }

  // 提取boundary
  $boundary = substr($_SERVER['CONTENT_TYPE'], strpos($_SERVER['CONTENT_TYPE'], "=") + 1);
  // multipart-body
  $multipart_body = "--$boundary\r\n";
  // 拼接各个域
  $multipart_body .= implode("\r\n--$boundary\r\n", $body);
  // 最后一个不同的 boundary
  $multipart_body .= "\r\n--$boundary--";

  return $multipart_body;
}

数组类型参数的支持

以上代码在大多数情况下工作正常,但未考虑到请求参数的类型为数组的情况。

在PHP解释器源码的测试用例中,我们可以找到许多数组类型参数的测试,部分摘录如下:

a[]=1
a[]=1&a[]=1
a[]=1&a[0]=5
a[a]=1&a[b]=3
a[]=1&a[a]=1&a[b]=3
a[][]=1&a[][]=3&b[a][b][c]=1&b[a][b][d]=1
a=1&b=ZYX&c[][][][][][][][][][][][][][][][][][][][][][]=123&d=123&e[][]][]=3
Content-Type: multipart/form-data; boundary=---------------------------20896060251896012921717172737
-----------------------------20896060251896012921717172737
Content-Disposition: form-data; name="file[]"; filename="file1.txt"
Content-Type: text/plain-file1

1
-----------------------------20896060251896012921717172737
Content-Disposition: form-data; name="file[2]"; filename="file2.txt"
Content-Type: text/plain-file2

2
-----------------------------20896060251896012921717172737
Content-Disposition: form-data; name="file[]"; filename="file3.txt"
Content-Type: text/plain-file3

3
-----------------------------20896060251896012921717172737--

在PHP源码的 main/php_variables.c 中的 php_register_variable_ex 函数中,我们可以看到相关的处理:

/* 99-110行 */
/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
for (p = var; *p; p++) {
    if (*p == ' ' || *p == '.') {
        *p='_';
    } else if (*p == '[') {
        is_array = 1;
        ip = p;
        *p = 0;
        break;
    }
}
var_len = p - var;

/* 229-235行 */
ip++;
if (*ip == '[') {
    is_array = 1;
    *ip = 0;
} else {
    goto plain_var;
}

可见,在还原POST数据的时候,我们还需要考虑到参数为数组的情况。

这里通过一个简单的 DFS 算法深度优先遍历数组,生成类似 a[0], a[1][1] 的字符串来实现:

<?php

$arr = [
  'key1' => [
    '_key1' => 23333,
    '_key2' => 66666,
  ],
  'key2' => "hahah",
  "test",
];

var_dump($arr);

function dfs(&$node, $prefix, &$result) {
  if (!is_array($node)) {
    $result[$prefix] = $node;
  } else {
    foreach ($node as $key => $value) {
      dfs($value, "{$prefix}[{$key}]", $result);
    }
  }
}

dfs($arr, "arr", $result);

foreach ($result as $key => $value) {
  echo "$key = $value\n";
}

运行结果:

array(3) {
  ["key1"]=>
  array(2) {
    ["_key1"]=>
    int(23333)
    ["_key2"]=>
    int(66666)
  }
  ["key2"]=>
  string(5) "hahah"
  [0]=>
  string(4) "test"
}
arr[key1][_key1] = 23333
arr[key1][_key2] = 66666
arr[key2] = hahah
arr[0] = test

至于 $_FILES 数组,这里有一个反直觉的情况,具体在文档中也有人提出: PHP: POST method uploads - Manual

简单地说,当表单中文件域的key为数组形式时,拿到的 $_FILES 数组类似如下的格式:

array(1) {
  ["key"]=>
  array(5) {
    ["name"]=>
    array(2) {
      [0]=>
      string(8) "test.txt"
      [1]=>
      array(1) {
        [0]=>
        string(8) "test.txt"
      }
    }
    ["type"]=>
    array(2) {
      [0]=>
      string(10) "text/plain"
      [1]=>
      array(1) {
        [0]=>
        string(10) "text/plain"
      }
    }
    ["tmp_name"]=>
    array(2) {
      [0]=>
      string(14) "/tmp/phpKHCoSt"
      [1]=>
      array(1) {
        [0]=>
        string(14) "/tmp/phpSgtRHe"
      }
    }
    ["error"]=>
    array(2) {
      [0]=>
      int(0)
      [1]=>
      array(1) {
        [0]=>
        int(0)
      }
    }
    ["size"]=>
    array(2) {
      [0]=>
      int(8)
      [1]=>
      array(1) {
        [0]=>
        int(8)
      }
    }
  }
}

假设我的目标是 key[1][0] 的 name 属性,在PHP中我们需要通过 $_FILES["key"]["name"][1][0] 来访问,而在 $_FILES["key"]["name"] 中,后面的索引的层级并不确定的,我们也不能简单地指定 [1][0] 来访问 $_FILES["key"]["name"][1][0]。所以这里得有一些 hack 来优化一下这个过程,这里我实现了一个 query_multidimensional_array 函数,具体看最终的代码。

getFormData() 代码实现

以下是整个函数的完整实现:

// 还原 rfc1867, rfc2046 格式的FormData
function getFormData() {
  // body-part array
  $body = array();

  // 普通参数
  foreach ($_POST as $key => $value) {
    if (!is_array($value)) {
      $body_part = "Content-Disposition: form-data; name=\"$key\"\r\n";
      $body_part .= "\r\n$value";
      $body[] = $body_part;
    } else {
      // 数组的情况处理 如 param1[]=xxxx
      $result = array();
      convert_array_key($value, $key, $result);
      foreach ($result as $k => $v) {
        $body_part = "Content-Disposition: form-data; name=\"$k\"\r\n";
        $body_part .= "\r\n$v";
        $body[] = $body_part;
      }
    }
  }

  // 上传文件处理
  foreach ($_FILES as $key => $value) {
    if (!is_array($value['type'])) {
      $body_part = "Content-Disposition: form-data; name=\"$key\"; filename=\"{$value['name']}\"\r\n";
      $body_part .= "Content-type: {$value['type']}\r\n";
      $body_part .= "\r\n".file_get_contents($value['tmp_name']);
      $body[] = $body_part;
    } else {
      // 文件key是数组的情况 如 file1[]=xxxx
      $result = array();
      convert_array_key($value['type'], "", $result);
      foreach ($result as $k => $v) {
        $filename = query_multidimensional_array($value['name'], $k);
        $type = query_multidimensional_array($value['type'], $k);
        $tmp_name = query_multidimensional_array($value['tmp_name'], $k);
        $body_part = "Content-Disposition: form-data; name=\"{$key}{$k}\"; filename=\"{$filename}\"\r\n";
        $body_part .= "Content-type: {$type}\r\n";
        $body_part .= "\r\n".file_get_contents($tmp_name);
        $body[] = $body_part;
      }
    }
  }

  // 提取boundary
  $boundary = substr($_SERVER['CONTENT_TYPE'], strpos($_SERVER['CONTENT_TYPE'], "=") + 1);
  // multipart-body
  $multipart_body = "--$boundary\r\n";
  // 拼接各个域
  $multipart_body .= implode("\r\n--$boundary\r\n", $body);
  // 最后一个不同的 boundary
  $multipart_body .= "\r\n--$boundary--";

  return $multipart_body;
}

// 直接访问多维数组元素
// query: [0][0] -> $array[0][0]
function query_multidimensional_array(&$array, $query) {
  $query = explode('][', substr($query, 1, -1));
  $temp = $array;
  foreach ($query as $key) {
    $temp = $temp[$key];
  }
  return $temp;
}

// DFS将数组变为一维形式
function convert_array_key(&$node, $prefix, &$result) {
  if (!is_array($node)) {
    $result[$prefix] = $node;
  } else {
    foreach ($node as $key => $value) {
      convert_array_key($value, "{$prefix}[{$key}]", $result);
    }
  }
}

至此,在PHP脚本中,只需调用 getFormData() ,即可获得 multipart/form-data 请求的原始数据,通过以下代码可以实现一键获取请求原始POST Body。

需要注意的是,若数组类型参数是 a[] 这种形式,经过本函数还原后会补充具体的下标,比如说这里的 a[] 会被处理成 a[0]a[][] 则为 a[0][0]。从而导致了 POST Body 长度发生变化,若结果需要用于发包等操作,我们需要重新计算 Content-Length ,避免请求出现问题。

if (@$_SERVER['CONTENT_TYPE'] && strpos($_SERVER['CONTENT_TYPE'], "multipart/form-data") !== false) {
  $body = getFormData();
  $content_length = strlen($body);
} else {
  $body = file_get_contents('php://input');
}

参考

添加新评论