1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\StreamInterface;
6: use Salient\Contract\Http\HttpHeader;
7: use Salient\Contract\Http\HttpMultipartStreamInterface;
8: use Salient\Contract\Http\HttpMultipartStreamPartInterface;
9: use Salient\Http\Exception\StreamException;
10: use Salient\Http\Exception\StreamInvalidRequestException;
11: use Salient\Utility\Regex;
12: use InvalidArgumentException;
13: use Throwable;
14:
15: /**
16: * A PSR-7 multipart data stream wrapper
17: *
18: * @api
19: */
20: class HttpMultipartStream implements HttpMultipartStreamInterface
21: {
22: protected const CHUNK_SIZE = 8192;
23:
24: protected string $Boundary;
25: protected bool $IsSeekable = true;
26: /** @var StreamInterface[] */
27: protected array $Streams = [];
28: protected int $Stream = 0;
29: protected int $Pos = 0;
30:
31: /**
32: * @param HttpMultipartStreamPartInterface[] $parts
33: */
34: public function __construct(array $parts = [], ?string $boundary = null)
35: {
36: $this->Boundary = $boundary ??= '------' . bin2hex(random_bytes(18));
37: $this->applyParts($parts);
38: }
39:
40: /**
41: * @param HttpMultipartStreamPartInterface[] $parts
42: */
43: private function applyParts(array $parts): void
44: {
45: foreach ($parts as $part) {
46: $contentStream = $part->getContent();
47:
48: if (!$contentStream->isReadable()) {
49: throw new InvalidArgumentException('Stream must be readable');
50: }
51:
52: // For the stream to be seekable, every part must be seekable
53: if ($this->IsSeekable && !$contentStream->isSeekable()) {
54: $this->IsSeekable = false;
55: }
56:
57: $disposition = [
58: 'form-data',
59: sprintf('name="%s"', HttpUtil::escapeQuotedString($part->getName())),
60: ];
61: $fallbackFilename = $part->getFallbackFilename();
62: if ($fallbackFilename !== null) {
63: $disposition[] = sprintf(
64: 'filename="%s"',
65: HttpUtil::escapeQuotedString($fallbackFilename),
66: );
67: }
68: $filename = $part->getFilename();
69: if ($filename !== null && $filename !== $fallbackFilename) {
70: $disposition[] = sprintf(
71: "filename*=UTF-8''%s",
72: $this->encode($filename),
73: );
74: }
75: $headers = new HttpHeaders([
76: HttpHeader::CONTENT_DISPOSITION => implode('; ', $disposition),
77: ]);
78: $mediaType = $part->getMediaType();
79: if ($mediaType !== null) {
80: $headers = $headers->set(HttpHeader::CONTENT_TYPE, $mediaType);
81: }
82: $headers = sprintf(
83: "--%s\r\n%s\r\n\r\n",
84: $this->Boundary,
85: (string) $headers,
86: );
87:
88: $this->Streams[] = HttpStream::fromString($headers);
89: $this->Streams[] = $contentStream;
90: $this->Streams[] = HttpStream::fromString("\r\n");
91: }
92:
93: $this->Streams[] = HttpStream::fromString(sprintf("--%s--\r\n", $this->Boundary));
94: }
95:
96: /**
97: * @inheritDoc
98: */
99: public function isReadable(): bool
100: {
101: return true;
102: }
103:
104: /**
105: * @inheritDoc
106: */
107: public function isWritable(): bool
108: {
109: return false;
110: }
111:
112: /**
113: * @inheritDoc
114: */
115: public function isSeekable(): bool
116: {
117: return $this->IsSeekable;
118: }
119:
120: /**
121: * @inheritDoc
122: */
123: public function getBoundary(): string
124: {
125: return $this->Boundary;
126: }
127:
128: /**
129: * @inheritDoc
130: */
131: public function getSize(): ?int
132: {
133: $total = 0;
134: foreach ($this->Streams as $stream) {
135: $size = $stream->getSize();
136: if ($size === null) {
137: // @codeCoverageIgnoreStart
138: return null;
139: // @codeCoverageIgnoreEnd
140: }
141: $total += $size;
142: }
143: return $total;
144: }
145:
146: /**
147: * @inheritDoc
148: */
149: public function getMetadata(?string $key = null)
150: {
151: return $key === null ? [] : null;
152: }
153:
154: /**
155: * @inheritDoc
156: */
157: public function __toString(): string
158: {
159: if ($this->IsSeekable) {
160: $this->rewind();
161: }
162: return $this->getContents();
163: }
164:
165: /**
166: * @inheritDoc
167: */
168: public function getContents(): string
169: {
170: return HttpStream::copyToString($this);
171: }
172:
173: /**
174: * @inheritDoc
175: */
176: public function tell(): int
177: {
178: return $this->Pos;
179: }
180:
181: /**
182: * @inheritDoc
183: */
184: public function eof(): bool
185: {
186: return !$this->Streams
187: || ($this->Stream >= count($this->Streams) - 1
188: && $this->Streams[$this->Stream]->eof());
189: }
190:
191: /**
192: * @inheritDoc
193: */
194: public function rewind(): void
195: {
196: $this->seek(0);
197: }
198:
199: /**
200: * @inheritDoc
201: */
202: public function read(int $length): string
203: {
204: if ($length === 0) {
205: return '';
206: }
207:
208: if ($length < 0) {
209: throw new InvalidArgumentException('Argument #1 ($length) must be greater than or equal to 0');
210: }
211:
212: $buffer = '';
213: $remaining = $length;
214: $last = count($this->Streams) - 1;
215: $eof = false;
216:
217: while ($remaining > 0) {
218: if ($eof || $this->Streams[$this->Stream]->eof()) {
219: if ($this->Stream < $last) {
220: $eof = false;
221: $this->Stream++;
222: } else {
223: break;
224: }
225: }
226:
227: $data = $this->Streams[$this->Stream]->read($remaining);
228:
229: if ($data === '') {
230: $eof = true;
231: continue;
232: }
233:
234: $bytes = strlen($data);
235:
236: $buffer .= $data;
237: $remaining -= $bytes;
238: $this->Pos += $bytes;
239: }
240:
241: return $buffer;
242: }
243:
244: /**
245: * @inheritDoc
246: */
247: public function write(string $string): int
248: {
249: throw new StreamInvalidRequestException('Stream is not writable');
250: }
251:
252: /**
253: * @inheritDoc
254: */
255: public function seek(int $offset, int $whence = \SEEK_SET): void
256: {
257: if (!$this->IsSeekable) {
258: throw new StreamInvalidRequestException('Stream is not seekable');
259: }
260:
261: switch ($whence) {
262: case \SEEK_SET:
263: if ($offset === $this->Pos) {
264: return;
265: }
266: $offsetPos = $offset;
267: $relativeTo = 0;
268: break;
269:
270: case \SEEK_END:
271: $this->getContents();
272: // No break
273: case \SEEK_CUR:
274: if ($offset === 0) {
275: return;
276: }
277: $offsetPos = $this->Pos + $offset;
278: $relativeTo = $this->Pos;
279: break;
280:
281: default:
282: // @codeCoverageIgnoreStart
283: throw new InvalidArgumentException(
284: sprintf('Invalid whence: %d', $whence)
285: );
286: // @codeCoverageIgnoreEnd
287: }
288:
289: if ($offsetPos < 0) {
290: throw new InvalidArgumentException(sprintf(
291: 'Invalid offset relative to position %d: %d',
292: $relativeTo,
293: $offset,
294: ));
295: }
296:
297: $this->Stream = 0;
298: $this->Pos = 0;
299:
300: foreach ($this->Streams as $i => $stream) {
301: try {
302: $stream->rewind();
303: // @codeCoverageIgnoreStart
304: } catch (Throwable $ex) {
305: throw new StreamException(sprintf('Error seeking stream %d', $i), $ex);
306: }
307: // @codeCoverageIgnoreEnd
308: }
309:
310: while ($this->Pos < $offsetPos && !$this->eof()) {
311: $data = $this->read(min(static::CHUNK_SIZE, $offsetPos - $this->Pos));
312: if ($data === '') {
313: // @codeCoverageIgnoreStart
314: break;
315: // @codeCoverageIgnoreEnd
316: }
317: }
318: }
319:
320: /**
321: * @inheritDoc
322: */
323: public function close(): void
324: {
325: foreach ($this->Streams as $stream) {
326: $stream->close();
327: }
328: $this->reset();
329: }
330:
331: /**
332: * @inheritDoc
333: */
334: public function detach()
335: {
336: foreach ($this->Streams as $stream) {
337: $stream->detach();
338: }
339: $this->reset();
340: return null;
341: }
342:
343: private function reset(): void
344: {
345: $this->IsSeekable = true;
346: $this->Streams = [];
347: $this->Stream = 0;
348: $this->Pos = 0;
349: $this->applyParts([]);
350: }
351:
352: /**
353: * Percent-encode characters as per [RFC5987] Section 3.2 ("Parameter Value
354: * Character Set and Language Information")
355: */
356: private function encode(string $string): string
357: {
358: return Regex::replaceCallback(
359: '/[^!#$&+^`|]++/',
360: fn(array $matches) => rawurlencode($matches[0]),
361: $string,
362: );
363: }
364: }
365: