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