1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\StreamInterface;
6: use Salient\Contract\Http\HttpMultipartStreamPartInterface;
7: use Salient\Contract\Http\MimeType;
8: use Salient\Core\Concern\ImmutableTrait;
9: use Salient\Utility\Exception\InvalidArgumentTypeException;
10: use Salient\Utility\Exception\InvalidRuntimeConfigurationException;
11: use Salient\Utility\File;
12: use Salient\Utility\Regex;
13: use Salient\Utility\Str;
14: use InvalidArgumentException;
15: use LogicException;
16:
17: /**
18: * Part of a PSR-7 multipart data stream
19: *
20: * @api
21: */
22: class HttpMultipartStreamPart implements HttpMultipartStreamPartInterface
23: {
24: use ImmutableTrait;
25:
26: protected ?string $Name;
27: protected ?string $Filename;
28: protected ?string $FallbackFilename;
29: protected ?string $MediaType;
30: protected StreamInterface $Content;
31:
32: /**
33: * @param StreamInterface|resource|string|null $content
34: */
35: public function __construct(
36: $content,
37: ?string $name = null,
38: ?string $filename = null,
39: ?string $mediaType = null,
40: ?string $fallbackFilename = null
41: ) {
42: $this->Name = $name;
43: $this->Filename = Str::coalesce($filename, null);
44: $this->FallbackFilename = $this->filterFallbackFilename(
45: Str::coalesce($fallbackFilename, null),
46: $this->Filename
47: );
48: $this->MediaType = Str::coalesce($mediaType, null);
49: $this->Content = $this->filterContent($content);
50: }
51:
52: /**
53: * Creates a new HttpMultipartStreamPart object backed by a local file
54: *
55: * @param string|null $uploadFilename Default: `basename($filename)`
56: * @param string|null $mediaType Default: `mime_content_type($filename)`,
57: * `application/octet-stream` on failure.
58: */
59: public static function fromFile(
60: string $filename,
61: ?string $name = null,
62: ?string $uploadFilename = null,
63: ?string $mediaType = null,
64: ?string $fallbackFilename = null
65: ): self {
66: if (!is_file($filename)) {
67: throw new InvalidArgumentException(sprintf(
68: 'File not found: %s',
69: $filename,
70: ));
71: }
72:
73: return new self(
74: File::open($filename, 'r'),
75: $name,
76: $uploadFilename ?? basename($filename),
77: self::getFileMediaType($filename, $mediaType),
78: $fallbackFilename,
79: );
80: }
81:
82: /**
83: * Get $filename's MIME type if $mediaType is null, $mediaType otherwise
84: */
85: protected static function getFileMediaType(string $filename, ?string $mediaType = null): string
86: {
87: if ($mediaType === null) {
88: if (!extension_loaded('fileinfo')) {
89: // @codeCoverageIgnoreStart
90: throw new InvalidRuntimeConfigurationException(
91: "'fileinfo' extension required for MIME type detection"
92: );
93: // @codeCoverageIgnoreEnd
94: }
95: $mediaType = @mime_content_type($filename);
96: if ($mediaType === false) {
97: // @codeCoverageIgnoreStart
98: $mediaType = MimeType::BINARY;
99: // @codeCoverageIgnoreEnd
100: }
101: }
102:
103: return $mediaType;
104: }
105:
106: /**
107: * @inheritDoc
108: */
109: public function getName(): string
110: {
111: if ($this->Name === null) {
112: throw new LogicException('Name is not set');
113: }
114: return $this->Name;
115: }
116:
117: /**
118: * @inheritDoc
119: */
120: public function getFilename(): ?string
121: {
122: return $this->Filename;
123: }
124:
125: /**
126: * @inheritDoc
127: */
128: public function getFallbackFilename(): ?string
129: {
130: return $this->FallbackFilename;
131: }
132:
133: /**
134: * @inheritDoc
135: */
136: public function getMediaType(): ?string
137: {
138: return $this->MediaType;
139: }
140:
141: /**
142: * @inheritDoc
143: */
144: public function getContent(): StreamInterface
145: {
146: return $this->Content;
147: }
148:
149: /**
150: * @inheritDoc
151: */
152: public function withName(string $name): HttpMultipartStreamPartInterface
153: {
154: return $this->with('Name', $name);
155: }
156:
157: /**
158: * Get $filename if $fallbackFilename is null and $filename is valid per
159: * [RFC6266] Appendix D, $fallbackFilename otherwise
160: *
161: * @throws InvalidArgumentException if `$fallbackFilename` is not a valid
162: * ASCII string.
163: */
164: protected function filterFallbackFilename(?string $fallbackFilename, ?string $filename): ?string
165: {
166: $filename = $fallbackFilename ?? $filename;
167: if ($filename === null) {
168: return null;
169: }
170: if (
171: !Str::isAscii($filename)
172: || Regex::match('/%[0-9a-f]{2}|\\\\|"/i', $filename)
173: ) {
174: if ($fallbackFilename === null) {
175: return null;
176: }
177: throw new InvalidArgumentException(
178: sprintf('Invalid fallback filename: %s', $filename)
179: );
180: }
181: return $filename;
182: }
183:
184: /**
185: * @param StreamInterface|resource|string|null $content
186: */
187: protected function filterContent($content): StreamInterface
188: {
189: if ($content instanceof StreamInterface) {
190: return $content;
191: }
192: if (is_string($content) || $content === null) {
193: return HttpStream::fromString((string) $content);
194: }
195: try {
196: return new HttpStream($content);
197: } catch (InvalidArgumentException $ex) {
198: throw new InvalidArgumentTypeException(
199: 1,
200: 'content',
201: 'StreamInterface|resource|string|null',
202: $content
203: );
204: }
205: }
206: }
207: