PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / FileSystem / Drivers / S3 / S3FileUploader.php

S3FileUploader.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Services/FileSystem/Drivers/S3/S3FileUploader.php

293 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\FileSystem\Drivers\S3;
4
5 use Exception;
6 use FluentCart\App\Modules\StorageDrivers\S3\S3;
7 use WP_Error;
8
9 class S3FileUploader
10 {
11 /**
12 * Value of the If-None-Match header. "*" means "only if no object exists
13 * under this key", which S3 answers with 412 when the key is taken.
14 */
15 private const IF_NONE_MATCH = '*';
16
17 private string $accessKey;
18 private string $secretKey;
19 private string $bucket;
20 private string $region;
21 private string $hashAlgorithm = 'sha256';
22 private string $httpMethod;
23 private string $localFilePath;
24 private string $s3FilePath;
25 private string $signature;
26 private string $requestUrl;
27 private string $timeStamp;
28 private string $date;
29 private bool $preventOverwrite;
30
31 /**
32 * @throws Exception
33 */
34 public function __construct(string $secret, string $accessKey, string $bucket, string $region, string $localFilePath, string $s3FilePath)
35 {
36 $this->accessKey = $accessKey;
37 $this->secretKey = $secret;
38 $this->bucket = $bucket;
39 $this->region = S3::getBucketRegion($bucket);
40 $this->localFilePath = $localFilePath;
41 $this->s3FilePath = $s3FilePath;
42 $this->httpMethod = "PUT";
43
44 $this->timeStamp = gmdate('Ymd\THis\Z');
45 $this->date = substr($this->timeStamp, 0, 8);
46
47 // �
48 Correct Regional Endpoint
49 // $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$this->s3FilePath}";
50
51 $hasDot = strpos($this->bucket, '.') !== false;
52 $encodedFilePath = $this->encodeS3ObjectKey($this->s3FilePath);
53
54 if ($hasDot) {
55 // Path-style URL
56 $this->requestUrl = "https://s3.{$this->region}.amazonaws.com/{$this->bucket}/{$encodedFilePath}";
57 } else {
58 // Virtual-hosted style
59 $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$encodedFilePath}";
60 }
61
62 // Opt-in: when enabled the upload is refused instead of replacing an
63 // existing object. Resolved before signing because the header is part
64 // of the canonical request.
65 $this->preventOverwrite = (bool)apply_filters('fluent_cart/storage/s3_prevent_overwrite', false, [
66 'bucket' => $this->bucket,
67 's3_file_path' => $this->s3FilePath,
68 ]);
69
70 $this->signature = $this->generateSignature();
71 }
72
73 /**
74 * @throws Exception
75 */
76 public static function upload(string $secret, string $accessKey, string $bucket, string $region, string $localFilePath, string $s3FilePath)
77 {
78 return (new static($secret, $accessKey, $bucket, $region, $localFilePath, $s3FilePath))->uploadFile();
79 }
80
81 /**
82 * @throws Exception
83 */
84 public function uploadFile()
85 {
86 add_filter('http_request_timeout', fn() => 30);
87
88 $args = [
89 'method' => 'PUT',
90 'headers' => $this->getHeaders(),
91 'body' => file_get_contents($this->localFilePath),
92 ];
93
94 $response = wp_remote_request($this->requestUrl, $args);
95 $responseCode = wp_remote_retrieve_response_code($response);
96
97 if ($responseCode === 200) {
98 return [
99 'message' => __('File Uploaded Successfully', 'fluent-cart'),
100 'driver' => 's3',
101 'path' => $this->s3FilePath
102 ];
103 }
104
105 // S3 refused because the key is already taken. Gated on the opt-in so
106 // a 412 raised for any other precondition is not misreported as an
107 // overwrite conflict.
108 if ($responseCode === 412 && $this->preventOverwrite) {
109 return new WP_Error(
110 $responseCode,
111 sprintf(
112 /* translators: %s is the file name that already exists in the bucket */
113 __('A file named "%s" already exists in this bucket and existing files cannot be replaced. Upload it under a new name, for example by increasing the version number.', 'fluent-cart'),
114 basename($this->s3FilePath)
115 )
116 );
117 }
118
119 return new WP_Error($responseCode, __('Failed To Upload File', 'fluent-cart'));
120 }
121
122 public function getSignature(): string
123 {
124 return $this->signature;
125 }
126
127 /**
128 * @throws Exception
129 */
130 public function generateSignature()
131 {
132 return hash_hmac(
133 $this->hashAlgorithm,
134 $this->createStringToSign(),
135 $this->getSigningKey()
136 );
137 }
138
139 private function createScope(): string
140 {
141 return "{$this->date}/{$this->region}/s3/aws4_request";
142 }
143
144 /**
145 * @throws Exception
146 */
147 private function getContentHash(): string
148 {
149 if (!file_exists($this->localFilePath)) {
150 throw new \Exception(esc_html__('File not found', 'fluent-cart'));
151 }
152 return hash($this->hashAlgorithm, file_get_contents($this->localFilePath));
153 }
154
155 /**
156 * Percent-encodes each "/"-separated segment of an S3 object key using
157 * AWS's UriEncode rules (rawurlencode leaves "/" alone). This keeps the
158 * canonical signing path and the actual request URL identical to what
159 * S3 receives on the wire, so multi-byte UTF-8 characters, spaces, and
160 * reserved characters ("+", "#", "?", literal "%") all round-trip to
161 * the exact key that was requested instead of a different object.
162 *
163 * Deliberately does not ltrim() leading slashes: "foo", "/foo", and
164 * "//foo" are three distinct S3 keys, and stripping the slash made a
165 * delete/upload targeting "/foo" silently operate on "foo" instead.
166 */
167 private function encodeS3ObjectKey(string $path): string
168 {
169 return implode('/', array_map('rawurlencode', explode('/', $path)));
170 }
171
172 /**
173 * @throws Exception
174 */
175 private function createCanonicalUrl(): string
176 {
177 // Ensure file path begins with /
178 $s3FilePath = '/' . $this->encodeS3ObjectKey($this->s3FilePath);
179
180 $contentHash = $this->getContentHash();
181
182 // If bucket has dot, use path-style URL in canonical request
183 if (strpos($this->bucket, '.') !== false) {
184 $canonicalUri = "/{$this->bucket}{$s3FilePath}";
185 } else {
186 $canonicalUri = $s3FilePath;
187 }
188
189 $canonicalHeaders = '';
190 foreach ($this->getCanonicalHeaders($contentHash) as $name => $value) {
191 $canonicalHeaders .= "{$name}:{$value}\n";
192 }
193
194 return "{$this->httpMethod}\n"
195 . "{$canonicalUri}\n\n"
196 . $canonicalHeaders
197 . "\n"
198 . "{$this->getSignedHeaders()}\n"
199 . "{$contentHash}";
200 }
201
202 /**
203 * Headers covered by the signature, keyed by lowercase name and sorted as
204 * SigV4 requires. Single source for both the canonical request and the
205 * SignedHeaders list in the Authorization header — if the two ever
206 * disagree, S3 rejects every upload with 403.
207 *
208 * @return array<string, string>
209 */
210 private function getCanonicalHeaders(string $contentHash): array
211 {
212 $headers = [
213 'host' => $this->getUploadHost(),
214 'x-amz-content-sha256' => $contentHash,
215 'x-amz-date' => $this->timeStamp,
216 ];
217
218 if ($this->preventOverwrite) {
219 $headers['if-none-match'] = self::IF_NONE_MATCH;
220 }
221
222 ksort($headers);
223
224 return $headers;
225 }
226
227 /**
228 * The semicolon-separated SignedHeaders value. Built from the same names
229 * as the canonical request, without re-reading the file to hash it.
230 */
231 private function getSignedHeaders(): string
232 {
233 return implode(';', array_keys($this->getCanonicalHeaders('')));
234 }
235
236 private function getUploadHostOld(): string
237 {
238 // �
239 Regional host
240 return "{$this->bucket}.s3.{$this->region}.amazonaws.com";
241 }
242
243 private function getUploadHost(): string
244 {
245 if ($this->bucket === '') {
246 return "s3.{$this->region}.amazonaws.com";
247 }
248
249 // If bucket contains dot, use path-style host
250 if (strpos($this->bucket, '.') !== false) {
251 return "s3.{$this->region}.amazonaws.com";
252 }
253
254 return "{$this->bucket}.s3.{$this->region}.amazonaws.com";
255 }
256
257 /**
258 * @throws Exception
259 */
260 private function createStringToSign(): string
261 {
262 $hash = hash($this->hashAlgorithm, $this->createCanonicalUrl());
263 return "AWS4-HMAC-SHA256\n{$this->timeStamp}\n{$this->createScope()}\n{$hash}";
264 }
265
266 private function getSigningKey()
267 {
268 $dateKey = hash_hmac($this->hashAlgorithm, $this->date, "AWS4{$this->secretKey}", true);
269 $regionKey = hash_hmac($this->hashAlgorithm, $this->region, $dateKey, true);
270 $serviceKey = hash_hmac($this->hashAlgorithm, 's3', $regionKey, true);
271
272 return hash_hmac($this->hashAlgorithm, 'aws4_request', $serviceKey, true);
273 }
274
275 /**
276 * @throws Exception
277 */
278 public function getHeaders(): array
279 {
280 $headers = [
281 'x-amz-content-sha256' => $this->getContentHash(),
282 'x-amz-date' => $this->timeStamp,
283 'Authorization' => "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders={$this->getSignedHeaders()}, Signature={$this->getSignature()}"
284 ];
285
286 if ($this->preventOverwrite) {
287 $headers['If-None-Match'] = self::IF_NONE_MATCH;
288 }
289
290 return $headers;
291 }
292 }
293