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