1<?php 2 3declare(strict_types=1); 4 5namespace GuzzleHttp\Psr7; 6 7use Psr\Http\Message\StreamInterface; 8 9/** 10 * Stream that when read returns bytes for a streaming multipart or 11 * multipart/form-data stream. 12 */ 13final class MultipartStream implements StreamInterface 14{ 15 use StreamDecoratorTrait; 16 17 /** @var string */ 18 private $boundary; 19 20 /** @var StreamInterface */ 21 private $stream; 22 23 /** 24 * @param array $elements Array of associative arrays, each containing a 25 * required "name" key mapping to the form field, 26 * name, a required "contents" key mapping to a 27 * StreamInterface/resource/string, an optional 28 * "headers" associative array of custom headers, 29 * and an optional "filename" key mapping to a 30 * string to send as the filename in the part. 31 * @param string $boundary You can optionally provide a specific boundary 32 * 33 * @throws \InvalidArgumentException 34 */ 35 public function __construct(array $elements = [], string $boundary = null) 36 { 37 $this->boundary = $boundary ?: bin2hex(random_bytes(20)); 38 $this->stream = $this->createStream($elements); 39 } 40 41 public function getBoundary(): string 42 { 43 return $this->boundary; 44 } 45 46 public function isWritable(): bool 47 { 48 return false; 49 } 50 51 /** 52 * Get the headers needed before transferring the content of a POST file 53 * 54 * @param string[] $headers 55 */ 56 private function getHeaders(array $headers): string 57 { 58 $str = ''; 59 foreach ($headers as $key => $value) { 60 $str .= "{$key}: {$value}\r\n"; 61 } 62 63 return "--{$this->boundary}\r\n".trim($str)."\r\n\r\n"; 64 } 65 66 /** 67 * Create the aggregate stream that will be used to upload the POST data 68 */ 69 protected function createStream(array $elements = []): StreamInterface 70 { 71 $stream = new AppendStream(); 72 73 foreach ($elements as $element) { 74 if (!is_array($element)) { 75 throw new \UnexpectedValueException('An array is expected'); 76 } 77 $this->addElement($stream, $element); 78 } 79 80 // Add the trailing boundary with CRLF 81 $stream->addStream(Utils::streamFor("--{$this->boundary}--\r\n")); 82 83 return $stream; 84 } 85 86 private function addElement(AppendStream $stream, array $element): void 87 { 88 foreach (['contents', 'name'] as $key) { 89 if (!array_key_exists($key, $element)) { 90 throw new \InvalidArgumentException("A '{$key}' key is required"); 91 } 92 } 93 94 $element['contents'] = Utils::streamFor($element['contents']); 95 96 if (empty($element['filename'])) { 97 $uri = $element['contents']->getMetadata('uri'); 98 if ($uri && \is_string($uri) && \substr($uri, 0, 6) !== 'php://' && \substr($uri, 0, 7) !== 'data://') { 99 $element['filename'] = $uri; 100 } 101 } 102 103 [$body, $headers] = $this->createElement( 104 $element['name'], 105 $element['contents'], 106 $element['filename'] ?? null, 107 $element['headers'] ?? [] 108 ); 109 110 $stream->addStream(Utils::streamFor($this->getHeaders($headers))); 111 $stream->addStream($body); 112 $stream->addStream(Utils::streamFor("\r\n")); 113 } 114 115 /** 116 * @param string[] $headers 117 * 118 * @return array{0: StreamInterface, 1: string[]} 119 */ 120 private function createElement(string $name, StreamInterface $stream, ?string $filename, array $headers): array 121 { 122 // Set a default content-disposition header if one was no provided 123 $disposition = self::getHeader($headers, 'content-disposition'); 124 if (!$disposition) { 125 $headers['Content-Disposition'] = ($filename === '0' || $filename) 126 ? sprintf( 127 'form-data; name="%s"; filename="%s"', 128 $name, 129 basename($filename) 130 ) 131 : "form-data; name=\"{$name}\""; 132 } 133 134 // Set a default content-length header if one was no provided 135 $length = self::getHeader($headers, 'content-length'); 136 if (!$length) { 137 if ($length = $stream->getSize()) { 138 $headers['Content-Length'] = (string) $length; 139 } 140 } 141 142 // Set a default Content-Type if one was not supplied 143 $type = self::getHeader($headers, 'content-type'); 144 if (!$type && ($filename === '0' || $filename)) { 145 $headers['Content-Type'] = MimeType::fromFilename($filename) ?? 'application/octet-stream'; 146 } 147 148 return [$stream, $headers]; 149 } 150 151 /** 152 * @param string[] $headers 153 */ 154 private static function getHeader(array $headers, string $key): ?string 155 { 156 $lowercaseHeader = strtolower($key); 157 foreach ($headers as $k => $v) { 158 if (strtolower((string) $k) === $lowercaseHeader) { 159 return $v; 160 } 161 } 162 163 return null; 164 } 165} 166