1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http\Message;
4:
5: use Psr\Http\Message\StreamInterface as PsrStreamInterface;
6: use Salient\Contract\Http\Message\StreamPartInterface;
7: use Salient\Core\Concern\ImmutableTrait;
8: use Salient\Utility\File;
9: use Salient\Utility\Regex;
10: use Salient\Utility\Str;
11: use InvalidArgumentException;
12: use LogicException;
13:
14: /**
15: * @api
16: */
17: class StreamPart implements StreamPartInterface
18: {
19: use HasBody;
20: use ImmutableTrait;
21:
22: private ?string $Name;
23: private ?string $Filename;
24: private ?string $AsciiFilename;
25: private ?string $MediaType;
26: private PsrStreamInterface $Body;
27:
28: /**
29: * @api
30: *
31: * @param PsrStreamInterface|resource|string|null $body
32: */
33: public function __construct(
34: $body,
35: ?string $name = null,
36: ?string $filename = null,
37: ?string $mediaType = null,
38: ?string $asciiFilename = null
39: ) {
40: $this->Name = $name;
41: $this->Filename = Str::coalesce($filename, null);
42: $this->AsciiFilename = $this->filterAsciiFilename(
43: Str::coalesce($asciiFilename, null),
44: $this->Filename,
45: );
46: $this->MediaType = Str::coalesce($mediaType, null);
47: $this->Body = $this->filterBody($body);
48: }
49:
50: /**
51: * Get an instance backed by a local file
52: *
53: * @param string|null $uploadFilename Default: `basename($filename)`
54: * @param string|null $mediaType Default: `mime_content_type($filename)`,
55: * `application/octet-stream` on failure.
56: */
57: public static function fromFile(
58: string $filename,
59: ?string $name = null,
60: ?string $uploadFilename = null,
61: ?string $mediaType = null,
62: ?string $asciiFilename = null
63: ): self {
64: return new self(
65: File::open($filename, 'r'),
66: $name,
67: $uploadFilename ?? basename($filename),
68: self::filterFileMediaType($mediaType, $filename),
69: $asciiFilename,
70: );
71: }
72:
73: /**
74: * @internal
75: */
76: protected static function filterFileMediaType(
77: ?string $mediaType,
78: string $filename
79: ): string {
80: if ($mediaType !== null) {
81: return $mediaType;
82: }
83:
84: $mediaType = extension_loaded('fileinfo')
85: ? @mime_content_type($filename)
86: : false;
87: return $mediaType === false
88: ? self::TYPE_BINARY
89: : $mediaType;
90: }
91:
92: /**
93: * @inheritDoc
94: */
95: public function getName(): string
96: {
97: if ($this->Name === null) {
98: throw new LogicException('Name not applied');
99: }
100: return $this->Name;
101: }
102:
103: /**
104: * @inheritDoc
105: */
106: public function getFilename(): ?string
107: {
108: return $this->Filename;
109: }
110:
111: /**
112: * @inheritDoc
113: */
114: public function getAsciiFilename(): ?string
115: {
116: return $this->AsciiFilename;
117: }
118:
119: /**
120: * @inheritDoc
121: */
122: public function getMediaType(): ?string
123: {
124: return $this->MediaType;
125: }
126:
127: /**
128: * @inheritDoc
129: */
130: public function getBody(): PsrStreamInterface
131: {
132: return $this->Body;
133: }
134:
135: /**
136: * @inheritDoc
137: */
138: public function withName(string $name): StreamPartInterface
139: {
140: return $this->with('Name', $name);
141: }
142:
143: private function filterAsciiFilename(?string $asciiFilename, ?string $filename): ?string
144: {
145: $filename = $asciiFilename ?? $filename;
146: if ($filename === null) {
147: return null;
148: }
149:
150: // As per [RFC6266] Appendix D
151: if (
152: !Str::isAscii($filename)
153: || Regex::match('/%[0-9a-f]{2}|\\\\|"/i', $filename)
154: ) {
155: if ($asciiFilename !== null) {
156: throw new InvalidArgumentException(
157: sprintf('Invalid ASCII filename: %s', $filename),
158: );
159: }
160: return null;
161: }
162: return $filename;
163: }
164: }
165: