前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >PHP 构造 multipart/form-data 格式 POST 请求体的方法

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

作者头像
zgq354
发布2019-11-24 19:04:30
4.4K0
发布2019-11-24 19:04:30
举报
文章被收录于专栏:0x00010x00010x0001

引言

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

参考

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2018-06-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • multipart/form-data 格式
  • 还原 multipart/form-data 的代码
  • 数组类型参数的支持
  • getFormData() 代码实现
  • 参考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档