PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.3
1.6.6 1.6.5 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 All 49 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.3, at app/Services/FileSystem/Drivers/S3/S3FileUploader.php

278 lines 8.7 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 FluentCart\Framework\Support\Str;
8 use WP_Error;
9
10 class S3FileUploader
11 {
12 /**
13 * Value of the If-None-Match header. "*" means "only if no object exists
14 * under this key", which S3 answers with 412 when the key is taken.
15 */
16 private const IF_NONE_MATCH = '*';
17
18 private string $accessKey;
19 private string $secretKey;
20 private string $bucket;
21 private string $region;
22 private string $hashAlgorithm = 'sha256';
23 private string $httpMethod;
24 private string $localFilePath;
25 private string $s3FilePath;
26 private string $signature;
27 private string $requestUrl;
28 private string $timeStamp;
29 private string $date;
30 private bool $preventOverwrite;
31
32 /**
33 * @throws Exception
34 */
35 public function __construct(string $secret, string $accessKey, string $bucket, string $region, string $localFilePath, string $s3FilePath)
36 {
37 $this->accessKey = $accessKey;
38 $this->secretKey = $secret;
39 $this->bucket = $bucket;
40 $this->region = S3::getBucketRegion($bucket);
41 $this->localFilePath = $localFilePath;
42 $this->s3FilePath = $s3FilePath;
43 $this->httpMethod = "PUT";
44
45 $this->timeStamp = gmdate('Ymd\THis\Z');
46 $this->date = substr($this->timeStamp, 0, 8);
47
48 // �
49 Correct Regional Endpoint
50 // $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$this->s3FilePath}";
51
52 $hasDot = strpos($this->bucket, '.') !== false;
53
54 if ($hasDot) {
55 // Path-style URL
56 $this->requestUrl = "https://s3.{$this->region}.amazonaws.com/{$this->bucket}/{$this->s3FilePath}";
57 } else {
58 // Virtual-hosted style
59 $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$this->s3FilePath}";
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 * @throws Exception
157 */
158 private function createCanonicalUrl(): string
159 {
160 // Ensure file path begins with /
161 $s3FilePath = Str::startsWith($this->s3FilePath, '/')
162 ? $this->s3FilePath
163 : "/{$this->s3FilePath}";
164
165 $contentHash = $this->getContentHash();
166
167 // If bucket has dot, use path-style URL in canonical request
168 if (strpos($this->bucket, '.') !== false) {
169 $canonicalUri = "/{$this->bucket}{$s3FilePath}";
170 } else {
171 $canonicalUri = $s3FilePath;
172 }
173
174 $canonicalHeaders = '';
175 foreach ($this->getCanonicalHeaders($contentHash) as $name => $value) {
176 $canonicalHeaders .= "{$name}:{$value}\n";
177 }
178
179 return "{$this->httpMethod}\n"
180 . "{$canonicalUri}\n\n"
181 . $canonicalHeaders
182 . "\n"
183 . "{$this->getSignedHeaders()}\n"
184 . "{$contentHash}";
185 }
186
187 /**
188 * Headers covered by the signature, keyed by lowercase name and sorted as
189 * SigV4 requires. Single source for both the canonical request and the
190 * SignedHeaders list in the Authorization header — if the two ever
191 * disagree, S3 rejects every upload with 403.
192 *
193 * @return array<string, string>
194 */
195 private function getCanonicalHeaders(string $contentHash): array
196 {
197 $headers = [
198 'host' => $this->getUploadHost(),
199 'x-amz-content-sha256' => $contentHash,
200 'x-amz-date' => $this->timeStamp,
201 ];
202
203 if ($this->preventOverwrite) {
204 $headers['if-none-match'] = self::IF_NONE_MATCH;
205 }
206
207 ksort($headers);
208
209 return $headers;
210 }
211
212 /**
213 * The semicolon-separated SignedHeaders value. Built from the same names
214 * as the canonical request, without re-reading the file to hash it.
215 */
216 private function getSignedHeaders(): string
217 {
218 return implode(';', array_keys($this->getCanonicalHeaders('')));
219 }
220
221 private function getUploadHostOld(): string
222 {
223 // �
224 Regional host
225 return "{$this->bucket}.s3.{$this->region}.amazonaws.com";
226 }
227
228 private function getUploadHost(): string
229 {
230 if ($this->bucket === '') {
231 return "s3.{$this->region}.amazonaws.com";
232 }
233
234 // If bucket contains dot, use path-style host
235 if (strpos($this->bucket, '.') !== false) {
236 return "s3.{$this->region}.amazonaws.com";
237 }
238
239 return "{$this->bucket}.s3.{$this->region}.amazonaws.com";
240 }
241
242 /**
243 * @throws Exception
244 */
245 private function createStringToSign(): string
246 {
247 $hash = hash($this->hashAlgorithm, $this->createCanonicalUrl());
248 return "AWS4-HMAC-SHA256\n{$this->timeStamp}\n{$this->createScope()}\n{$hash}";
249 }
250
251 private function getSigningKey()
252 {
253 $dateKey = hash_hmac($this->hashAlgorithm, $this->date, "AWS4{$this->secretKey}", true);
254 $regionKey = hash_hmac($this->hashAlgorithm, $this->region, $dateKey, true);
255 $serviceKey = hash_hmac($this->hashAlgorithm, 's3', $regionKey, true);
256
257 return hash_hmac($this->hashAlgorithm, 'aws4_request', $serviceKey, true);
258 }
259
260 /**
261 * @throws Exception
262 */
263 public function getHeaders(): array
264 {
265 $headers = [
266 'x-amz-content-sha256' => $this->getContentHash(),
267 'x-amz-date' => $this->timeStamp,
268 'Authorization' => "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders={$this->getSignedHeaders()}, Signature={$this->getSignature()}"
269 ];
270
271 if ($this->preventOverwrite) {
272 $headers['If-None-Match'] = self::IF_NONE_MATCH;
273 }
274
275 return $headers;
276 }
277 }
278