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

date
Jun 5, 2018
note
slug
php-build-multipart-form-data
type
Post
status
Published
tags
技术
Web
summary
最近在尝试基于 PHP 做一个反向代理 HTTP 的程序,其中一个需求是将程序收到的HTTP请求还原回 RFC2616 的原始格式。

引言

最近在尝试基于 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'); }

参考

 

© zgq354 2014 - 2024 | CC BY-NC-SA 4.0 | RSS