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