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