PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 trunk All 48 releases
← All changes | app/Services/FileSystem/Drivers/S3/S3FileUploader.php +102 -12 1.3.19 → 1.6.5 View file →
@@ -3,13 +3,18 @@
3 3 namespace FluentCart\App\Services\FileSystem\Drivers\S3;
4 4
5 5 use Exception;
6 6 use FluentCart\App\Modules\StorageDrivers\S3\S3;
7 -use FluentCart\Framework\Support\Str;
8 7 use WP_Error;
9 8
10 9 class S3FileUploader
11 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 +
12 17 private string $accessKey;
13 18 private string $secretKey;
14 19 private string $bucket;
15 20 private string $region;
@@ -20,8 +25,9 @@
20 25 private string $signature;
21 26 private string $requestUrl;
22 27 private string $timeStamp;
23 28 private string $date;
29 + private bool $preventOverwrite;
24 30
25 31 /**
26 32 * @throws Exception
27 33 */
@@ -41,16 +47,26 @@
41 47 // ✅ Correct Regional Endpoint
42 48 // $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$this->s3FilePath}";
43 49
44 50 $hasDot = strpos($this->bucket, '.') !== false;
51 + $encodedFilePath = $this->encodeS3ObjectKey($this->s3FilePath);
45 52
46 53 if ($hasDot) {
47 54 // Path-style URL
48 - $this->requestUrl = "https://s3.{$this->region}.amazonaws.com/{$this->bucket}/{$this->s3FilePath}";
55 + $this->requestUrl = "https://s3.{$this->region}.amazonaws.com/{$this->bucket}/{$encodedFilePath}";
49 56 } else {
50 57 // Virtual-hosted style
51 - $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$this->s3FilePath}";
58 + $this->requestUrl = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$encodedFilePath}";
52 59 }
60 +
61 + // Opt-in: when enabled the upload is refused instead of replacing an
62 + // existing object. Resolved before signing because the header is part
63 + // of the canonical request.
64 + $this->preventOverwrite = (bool)apply_filters('fluent_cart/storage/s3_prevent_overwrite', false, [
65 + 'bucket' => $this->bucket,
66 + 's3_file_path' => $this->s3FilePath,
67 + ]);
68 +
53 69 $this->signature = $this->generateSignature();
54 70 }
55 71
56 72 /**
@@ -84,8 +100,22 @@
84 100 'path' => $this->s3FilePath
85 101 ];
86 102 }
87 103
104 + // S3 refused because the key is already taken. Gated on the opt-in so
105 + // a 412 raised for any other precondition is not misreported as an
106 + // overwrite conflict.
107 + if ($responseCode === 412 && $this->preventOverwrite) {
108 + return new WP_Error(
109 + $responseCode,
110 + sprintf(
111 + /* translators: %s is the file name that already exists in the bucket */
112 + __('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'),
113 + basename($this->s3FilePath)
114 + )
115 + );
116 + }
117 +
88 118 return new WP_Error($responseCode, __('Failed To Upload File', 'fluent-cart'));
89 119 }
90 120
91 121 public function getSignature(): string
@@ -121,16 +151,31 @@
121 151 return hash($this->hashAlgorithm, file_get_contents($this->localFilePath));
122 152 }
123 153
124 154 /**
155 + * Percent-encodes each "/"-separated segment of an S3 object key using
156 + * AWS's UriEncode rules (rawurlencode leaves "/" alone). This keeps the
157 + * canonical signing path and the actual request URL identical to what
158 + * S3 receives on the wire, so multi-byte UTF-8 characters, spaces, and
159 + * reserved characters ("+", "#", "?", literal "%") all round-trip to
160 + * the exact key that was requested instead of a different object.
161 + *
162 + * Deliberately does not ltrim() leading slashes: "foo", "/foo", and
163 + * "//foo" are three distinct S3 keys, and stripping the slash made a
164 + * delete/upload targeting "/foo" silently operate on "foo" instead.
165 + */
166 + private function encodeS3ObjectKey(string $path): string
167 + {
168 + return implode('/', array_map('rawurlencode', explode('/', $path)));
169 + }
170 +
171 + /**
125 172 * @throws Exception
126 173 */
127 174 private function createCanonicalUrl(): string
128 175 {
129 176 // Ensure file path begins with /
130 - $s3FilePath = Str::startsWith($this->s3FilePath, '/')
131 - ? $this->s3FilePath
132 - : "/{$this->s3FilePath}";
177 + $s3FilePath = '/' . $this->encodeS3ObjectKey($this->s3FilePath);
133 178
134 179 $contentHash = $this->getContentHash();
135 180
136 181 // If bucket has dot, use path-style URL in canonical request
@@ -139,16 +184,55 @@
139 184 } else {
140 185 $canonicalUri = $s3FilePath;
141 186 }
142 187
188 + $canonicalHeaders = '';
189 + foreach ($this->getCanonicalHeaders($contentHash) as $name => $value) {
190 + $canonicalHeaders .= "{$name}:{$value}\n";
191 + }
192 +
143 193 return "{$this->httpMethod}\n"
144 194 . "{$canonicalUri}\n\n"
145 - . "host:{$this->getUploadHost()}\n"
146 - . "x-amz-content-sha256:{$contentHash}\n"
147 - . "x-amz-date:{$this->timeStamp}\n\n"
148 - . "host;x-amz-content-sha256;x-amz-date\n"
195 + . $canonicalHeaders
196 + . "\n"
197 + . "{$this->getSignedHeaders()}\n"
149 198 . "{$contentHash}";
150 199 }
200 +
201 + /**
202 + * Headers covered by the signature, keyed by lowercase name and sorted as
203 + * SigV4 requires. Single source for both the canonical request and the
204 + * SignedHeaders list in the Authorization header — if the two ever
205 + * disagree, S3 rejects every upload with 403.
206 + *
207 + * @return array<string, string>
208 + */
209 + private function getCanonicalHeaders(string $contentHash): array
210 + {
211 + $headers = [
212 + 'host' => $this->getUploadHost(),
213 + 'x-amz-content-sha256' => $contentHash,
214 + 'x-amz-date' => $this->timeStamp,
215 + ];
216 +
217 + if ($this->preventOverwrite) {
218 + $headers['if-none-match'] = self::IF_NONE_MATCH;
219 + }
220 +
221 + ksort($headers);
222 +
223 + return $headers;
224 + }
225 +
226 + /**
227 + * The semicolon-separated SignedHeaders value. Built from the same names
228 + * as the canonical request, without re-reading the file to hash it.
229 + */
230 + private function getSignedHeaders(): string
231 + {
232 + return implode(';', array_keys($this->getCanonicalHeaders('')));
233 + }
234 +
151 235 private function getUploadHostOld(): string
152 236 {
153 237 // ✅ Regional host
154 238 return "{$this->bucket}.s3.{$this->region}.amazonaws.com";
@@ -190,11 +274,17 @@
190 274 * @throws Exception
191 275 */
192 276 public function getHeaders(): array
193 277 {
194 - return [
278 + $headers = [
195 279 'x-amz-content-sha256' => $this->getContentHash(),
196 280 'x-amz-date' => $this->timeStamp,
197 - 'Authorization' => "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={$this->getSignature()}"
281 + 'Authorization' => "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders={$this->getSignedHeaders()}, Signature={$this->getSignature()}"
198 282 ];
283 +
284 + if ($this->preventOverwrite) {
285 + $headers['If-None-Match'] = self::IF_NONE_MATCH;
286 + }
287 +
288 + return $headers;
199 289 }
200 290 }