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: | |
17: | |
18: | class MultipartStream implements MultipartStreamInterface |
19: | { |
20: | protected const CHUNK_SIZE = 8192; |
21: | |
22: | |
23: | private array $Parts; |
24: | private string $Boundary; |
25: | private bool $IsSeekable = true; |
26: | |
27: | private array $Streams = []; |
28: | private int $Stream = 0; |
29: | private int $Pos = 0; |
30: | private bool $IsOpen = true; |
31: | |
32: | |
33: | |
34: | |
35: | |
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: | |
73: | |
74: | Regex::replaceCallback( |
75: | |
76: | |
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: | |
106: | |
107: | public function isReadable(): bool |
108: | { |
109: | return $this->IsOpen; |
110: | } |
111: | |
112: | |
113: | |
114: | |
115: | public function isWritable(): bool |
116: | { |
117: | return false; |
118: | } |
119: | |
120: | |
121: | |
122: | |
123: | public function isSeekable(): bool |
124: | { |
125: | return $this->IsSeekable; |
126: | } |
127: | |
128: | |
129: | |
130: | |
131: | public function getParts(): array |
132: | { |
133: | $this->assertIsOpen(); |
134: | |
135: | return $this->Parts; |
136: | } |
137: | |
138: | |
139: | |
140: | |
141: | public function getBoundary(): string |
142: | { |
143: | $this->assertIsOpen(); |
144: | |
145: | return $this->Boundary; |
146: | } |
147: | |
148: | |
149: | |
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: | |
169: | |
170: | public function getMetadata(?string $key = null) |
171: | { |
172: | $this->assertIsOpen(); |
173: | |
174: | return $key === null ? [] : null; |
175: | } |
176: | |
177: | |
178: | |
179: | |
180: | public function __toString(): string |
181: | { |
182: | if ($this->IsSeekable) { |
183: | $this->rewind(); |
184: | } |
185: | |
186: | return $this->getContents(); |
187: | } |
188: | |
189: | |
190: | |
191: | |
192: | public function getContents(): string |
193: | { |
194: | $this->assertIsOpen(); |
195: | |
196: | return HttpUtil::getStreamContents($this); |
197: | } |
198: | |
199: | |
200: | |
201: | |
202: | public function tell(): int |
203: | { |
204: | $this->assertIsOpen(); |
205: | |
206: | return $this->Pos; |
207: | } |
208: | |
209: | |
210: | |
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: | |
222: | |
223: | public function rewind(): void |
224: | { |
225: | $this->seek(0); |
226: | } |
227: | |
228: | |
229: | |
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: | |
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: | |
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: | |
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: | |
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: | |
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: | |