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: | 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: | |
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: | |
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: | |
98: | |
99: | public function isReadable(): bool |
100: | { |
101: | return true; |
102: | } |
103: | |
104: | |
105: | |
106: | |
107: | public function isWritable(): bool |
108: | { |
109: | return false; |
110: | } |
111: | |
112: | |
113: | |
114: | |
115: | public function isSeekable(): bool |
116: | { |
117: | return $this->IsSeekable; |
118: | } |
119: | |
120: | |
121: | |
122: | |
123: | public function getBoundary(): string |
124: | { |
125: | return $this->Boundary; |
126: | } |
127: | |
128: | |
129: | |
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: | |
138: | return null; |
139: | |
140: | } |
141: | $total += $size; |
142: | } |
143: | return $total; |
144: | } |
145: | |
146: | |
147: | |
148: | |
149: | public function getMetadata(?string $key = null) |
150: | { |
151: | return $key === null ? [] : null; |
152: | } |
153: | |
154: | |
155: | |
156: | |
157: | public function __toString(): string |
158: | { |
159: | if ($this->IsSeekable) { |
160: | $this->rewind(); |
161: | } |
162: | return $this->getContents(); |
163: | } |
164: | |
165: | |
166: | |
167: | |
168: | public function getContents(): string |
169: | { |
170: | return HttpStream::copyToString($this); |
171: | } |
172: | |
173: | |
174: | |
175: | |
176: | public function tell(): int |
177: | { |
178: | return $this->Pos; |
179: | } |
180: | |
181: | |
182: | |
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: | |
193: | |
194: | public function rewind(): void |
195: | { |
196: | $this->seek(0); |
197: | } |
198: | |
199: | |
200: | |
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: | |
246: | |
247: | public function write(string $string): int |
248: | { |
249: | throw new StreamInvalidRequestException('Stream is not writable'); |
250: | } |
251: | |
252: | |
253: | |
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: | |
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: | |
283: | throw new InvalidArgumentException( |
284: | sprintf('Invalid whence: %d', $whence) |
285: | ); |
286: | |
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: | |
304: | } catch (Throwable $ex) { |
305: | throw new StreamException(sprintf('Error seeking stream %d', $i), $ex); |
306: | } |
307: | |
308: | } |
309: | |
310: | while ($this->Pos < $offsetPos && !$this->eof()) { |
311: | $data = $this->read(min(static::CHUNK_SIZE, $offsetPos - $this->Pos)); |
312: | if ($data === '') { |
313: | |
314: | break; |
315: | |
316: | } |
317: | } |
318: | } |
319: | |
320: | |
321: | |
322: | |
323: | public function close(): void |
324: | { |
325: | foreach ($this->Streams as $stream) { |
326: | $stream->close(); |
327: | } |
328: | $this->reset(); |
329: | } |
330: | |
331: | |
332: | |
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: | |
354: | |
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: | |