Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.33% covered (success)
96.33%
105 / 109
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Multipart
96.33% covered (success)
96.33%
105 / 109
62.50% covered (warning)
62.50%
5 / 8
40
0.00% covered (danger)
0.00%
0 / 1
 toArray
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 toCurlFile
85.71% covered (success)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 generateBoundary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 escapeHeaderValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 writeScalarPart
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 writeFilePart
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 parse
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
16
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Body;
16
17use Pop\Http\Client\Data;
18
19/**
20 * HTTP multipart/form-data builder and parser (RFC 7578)
21 *
22 * @category   Pop
23 * @package    Pop\Http
24 * @author     Nick Sagona, III <dev@noladev.com>
25 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    6.0.0
28 */
29class Multipart
30{
31
32    /**
33     * Convert a request data array into a curl-native array (scalars + CURLFile),
34     * suitable for CURLOPT_POSTFIELDS. Curl streams CURLFile entries directly
35     * from disk and builds the multipart framing itself - no buffering here.
36     *
37     * @param  array $data
38     * @return array
39     */
40    public static function toArray(array $data): array
41    {
42        $result = [];
43
44        foreach ($data as $name => $value) {
45            if (is_array($value) && isset($value['filename'])) {
46                $result[$name] = self::toCurlFile($value);
47            } else if (is_array($value)) {
48                $index = 0;
49                foreach ($value as $item) {
50                    $result[$name . '[' . $index . ']'] = (string)$item;
51                    $index++;
52                }
53            } else {
54                $result[$name] = (string)$value;
55            }
56        }
57
58        return $result;
59    }
60
61    /**
62     * Convert a single file-field value (by path, or by raw contents) into a CURLFile
63     *
64     * @param  array $value
65     * @return \CURLFile
66     */
67    protected static function toCurlFile(array $value): \CURLFile
68    {
69        if (isset($value['contents'])) {
70            $path = tempnam(sys_get_temp_dir(), 'pop-http-multipart-');
71            file_put_contents($path, $value['contents']);
72            $postFilename = $value['filename'];
73            $mimeType     = $value['mimeType'] ?? Data::getMimeTypeFromFilename($value['filename']);
74
75            // Register shutdown cleanup for temp file
76            register_shutdown_function(function () use ($path) {
77                if (file_exists($path)) {
78                    unlink($path);
79                }
80            });
81        } else {
82            $path         = $value['filename'];
83            $postFilename = basename($value['filename']);
84            $mimeType     = $value['contentType'] ?? $value['mimeType'] ?? $value['mime'] ??
85                Data::getMimeTypeFromFilename($value['filename']);
86        }
87
88        return new \CURLFile($path, $mimeType, $postFilename);
89    }
90
91    /**
92     * Generate a random multipart boundary
93     *
94     * @return string
95     */
96    public static function generateBoundary(): string
97    {
98        return '----PopHttpBoundary' . bin2hex(random_bytes(16));
99    }
100
101    /**
102     * Build a rendered multipart/form-data body (RFC 7578) from a request data array.
103     * File fields are streamed from disk into the output, not buffered as strings.
104     *
105     * @param  array   $data
106     * @param  ?string $boundary
107     * @return \Pop\Http\Body
108     */
109    public static function build(array $data, ?string $boundary = null): \Pop\Http\Body
110    {
111        // A caller-supplied boundary is never trusted verbatim - CR/LF in it would break out of
112        // the boundary line and inject arbitrary headers/parts into the rendered body.
113        $boundary = ($boundary !== null) ? str_replace(["\r", "\n"], '', $boundary) : self::generateBoundary();
114        $output   = fopen('php://temp', 'r+');
115
116        foreach ($data as $name => $value) {
117            if (is_array($value) && isset($value['filename'])) {
118                self::writeFilePart($output, $boundary, $name, $value);
119            } else if (is_array($value)) {
120                $index = 0;
121                foreach ($value as $item) {
122                    self::writeScalarPart($output, $boundary, $name . '[' . $index . ']', (string)$item);
123                    $index++;
124                }
125            } else {
126                self::writeScalarPart($output, $boundary, $name, (string)$value);
127            }
128        }
129
130        fwrite($output, '--' . $boundary . "--\r\n");
131        rewind($output);
132
133        $body = new \Pop\Http\Body();
134        $body->setContentFromStream($output);
135
136        return $body;
137    }
138
139    /**
140     * Escape a value for safe use inside a quoted Content-Disposition/Content-Type parameter
141     *
142     * @param  string $value
143     * @return string
144     */
145    protected static function escapeHeaderValue(string $value): string
146    {
147        $value = str_replace(["\r", "\n"], '', $value);
148        return str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
149    }
150
151    /**
152     * Write one non-file part to the output stream
153     *
154     * @param  resource $output
155     * @param  string   $boundary
156     * @param  string   $name
157     * @param  string   $value
158     * @return void
159     */
160    protected static function writeScalarPart(mixed $output, string $boundary, string $name, string $value): void
161    {
162        $escapedName = self::escapeHeaderValue($name);
163        fwrite($output, '--' . $boundary . "\r\n");
164        fwrite($output, 'Content-Disposition: form-data; name="' . $escapedName . '"' . "\r\n\r\n");
165        fwrite($output, $value . "\r\n");
166    }
167
168    /**
169     * Write one file part to the output stream, streaming the file's content
170     * directly rather than reading it into a string first
171     *
172     * @param  resource $output
173     * @param  string   $boundary
174     * @param  string   $name
175     * @param  array    $value
176     * @throws Exception
177     * @return void
178     */
179    protected static function writeFilePart(mixed $output, string $boundary, string $name, array $value): void
180    {
181        $postFilename = isset($value['contents']) ? $value['filename'] : basename($value['filename']);
182        $mimeType     = $value['contentType'] ?? $value['mimeType'] ?? $value['mime'] ??
183            Data::getMimeTypeFromFilename($value['filename']);
184
185        // $name and $postFilename land inside quoted parameters, so they need the full quote-escaping.
186        // $mimeType is an unquoted header value which may legitimately contain quoted parameters of
187        // its own (e.g. charset="utf-8"), so only the actual injection vector (CR/LF) is stripped.
188        $escapedName     = self::escapeHeaderValue($name);
189        $escapedFilename = self::escapeHeaderValue($postFilename);
190        $escapedMimeType = str_replace(["\r", "\n"], '', $mimeType);
191
192        fwrite($output, '--' . $boundary . "\r\n");
193        fwrite($output, 'Content-Disposition: form-data; name="' . $escapedName . '"; filename="' . $escapedFilename . '"' . "\r\n");
194        fwrite($output, 'Content-Type: ' . $escapedMimeType . "\r\n\r\n");
195
196        if (isset($value['contents'])) {
197            fwrite($output, $value['contents']);
198        } else {
199            if (!file_exists($value['filename'])) {
200                throw new Exception("Error: The file '" . $value['filename'] . "' does not exist.");
201            }
202            $file = @fopen($value['filename'], 'rb');
203            if ($file === false) {
204                throw new Exception("Error: Unable to open the file '" . $value['filename'] . "' for reading.");
205            }
206            stream_copy_to_stream($file, $output);
207            fclose($file);
208        }
209
210        fwrite($output, "\r\n");
211    }
212
213    /**
214     * Parse a raw multipart/form-data body into a flat data array
215     *
216     * @param  string $rawBody
217     * @param  string $boundary
218     * @return array
219     */
220    public static function parse(string $rawBody, string $boundary): array
221    {
222        $result       = [];
223        $indexedKeys  = [];
224        $parts        = explode('--' . $boundary, $rawBody);
225
226        foreach ($parts as $part) {
227            // Remove framing: one leading \r\n (after boundary line) and one trailing \r\n (before next boundary)
228            // but preserve any \r\n that's part of the actual content
229            if (strpos($part, "\r\n") === 0) {
230                $part = substr($part, 2);
231            }
232            if (substr($part, -2) === "\r\n") {
233                $part = substr($part, 0, -2);
234            }
235
236            if (($part === '') || ($part === '--')) {
237                continue;
238            }
239
240            [$headerString, $content] = array_pad(explode("\r\n\r\n", $part, 2), 2, '');
241
242            $name         = null;
243            $filename     = null;
244            $contentType  = null;
245
246            foreach (explode("\r\n", $headerString) as $headerLine) {
247                if (stripos($headerLine, 'Content-Disposition:') === 0) {
248                    // Use regex that matches the attribute as a distinct entity (preceded by ; or start of line)
249                    // to avoid matching 'name' inside 'filename'. \\\\. means backslash followed by any character
250                    if (preg_match('/(?:^|;)\s*name="((?:\\\\.|[^"])*)"/i', $headerLine, $match)) {
251                        $name = $match[1];
252                        // Unescape: \\ becomes \, \" becomes "
253                        $name = str_replace(['\\\\', '\\"'], ['\\', '"'], $name);
254                    }
255                    if (preg_match('/(?:^|;)\s*filename="((?:\\\\.|[^"])*)"/i', $headerLine, $match)) {
256                        $filename = $match[1];
257                        // Unescape: \\ becomes \, \" becomes "
258                        $filename = str_replace(['\\\\', '\\"'], ['\\', '"'], $filename);
259                    }
260                } else if (stripos($headerLine, 'Content-Type:') === 0) {
261                    $contentType = trim(substr($headerLine, strlen('Content-Type:')));
262                }
263            }
264
265            if ($name === null) {
266                continue;
267            }
268
269            if ($filename !== null) {
270                $result[$name] = [
271                    'filename'    => $filename,
272                    'contentType' => $contentType,
273                    'contents'    => $content,
274                ];
275            } else if (preg_match('/^(.+)\[(\d*)\]$/', $name, $arrayMatch)) {
276                $key = $arrayMatch[1];
277                if ($arrayMatch[2] === '') {
278                    // Bare 'name[]' convention (e.g. plain HTML form submissions) - append in encounter order
279                    $result[$key][] = $content;
280                } else {
281                    // Indexed 'name[N]' convention (e.g. this class's own build()/toArray() output) -
282                    // place at the explicit index so the array is correct regardless of arrival order
283                    $result[$key][(int)$arrayMatch[2]] = $content;
284                    $indexedKeys[$key] = true;
285                }
286            } else {
287                $result[$name] = $content;
288            }
289        }
290
291        // Indexed entries are placed by key as they're encountered, which may not match the raw
292        // body's arrival order - restore ascending index order so the array reads correctly.
293        foreach (array_keys($indexedKeys) as $key) {
294            ksort($result[$key]);
295        }
296
297        return $result;
298    }
299
300}