← 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 | } |