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: * Creates a new HttpMultipartStream object
33: *
34: * @param HttpMultipartStreamPartInterface[] $parts
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: * @param HttpMultipartStreamPartInterface[] $parts
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: // For the stream to be seekable, every part must be seekable
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: * @inheritDoc
100: */
101: public function isReadable(): bool
102: {
103: return true;
104: }
105:
106: /**
107: * @inheritDoc
108: */
109: public function isWritable(): bool
110: {
111: return false;
112: }
113:
114: /**
115: * @inheritDoc
116: */
117: public function isSeekable(): bool
118: {
119: return $this->IsSeekable;
120: }
121:
122: /**
123: * @inheritDoc
124: */
125: public function getBoundary(): string
126: {
127: return $this->Boundary;
128: }
129:
130: /**
131: * @inheritDoc
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: // @codeCoverageIgnoreStart
140: return null;
141: // @codeCoverageIgnoreEnd
142: }
143: $total += $size;
144: }
145: return $total;
146: }
147:
148: /**
149: * @inheritDoc
150: */
151: public function getMetadata(?string $key = null)
152: {
153: return $key === null ? [] : null;
154: }
155:
156: /**
157: * @inheritDoc
158: */
159: public function __toString(): string
160: {
161: if ($this->IsSeekable) {
162: $this->rewind();
163: }
164: return $this->getContents();
165: }
166:
167: /**
168: * @inheritDoc
169: */
170: public function getContents(): string
171: {
172: return HttpStream::copyToString($this);
173: }
174:
175: /**
176: * @inheritDoc
177: */
178: public function tell(): int
179: {
180: return $this->Pos;
181: }
182:
183: /**
184: * @inheritDoc
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: * @inheritDoc
195: */
196: public function rewind(): void
197: {
198: $this->seek(0);
199: }
200:
201: /**
202: * @inheritDoc
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: * @inheritDoc
248: */
249: public function write(string $string): int
250: {
251: throw new StreamInvalidRequestException('Stream is not writable');
252: }
253:
254: /**
255: * @inheritDoc
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: // No break
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: // @codeCoverageIgnoreStart
285: throw new InvalidArgumentException(
286: sprintf('Invalid whence: %d', $whence)
287: );
288: // @codeCoverageIgnoreEnd
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: // @codeCoverageIgnoreStart
306: } catch (Throwable $ex) {
307: throw new StreamException(sprintf('Error seeking stream %d', $i), $ex);
308: }
309: // @codeCoverageIgnoreEnd
310: }
311:
312: while ($this->Pos < $offsetPos && !$this->eof()) {
313: $data = $this->read(min(static::CHUNK_SIZE, $offsetPos - $this->Pos));
314: if ($data === '') {
315: // @codeCoverageIgnoreStart
316: break;
317: // @codeCoverageIgnoreEnd
318: }
319: }
320: }
321:
322: /**
323: * @inheritDoc
324: */
325: public function close(): void
326: {
327: foreach ($this->Streams as $stream) {
328: $stream->close();
329: }
330: $this->reset();
331: }
332:
333: /**
334: * @inheritDoc
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: * Percent-encode characters as per [RFC5987] Section 3.2 ("Parameter Value
356: * Character Set and Language Information")
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: