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 / S3Driver.php

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

353 lines 13.0 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 FluentCart\App\Models\ProductDownload;
6 use FluentCart\App\Modules\StorageDrivers\S3\S3;
7 use FluentCart\App\Modules\StorageDrivers\S3\S3 as S3StorageDriver;
8 use FluentCart\App\Modules\StorageDrivers\S3\S3Settings;
9 use FluentCart\App\Services\FileSystem\Drivers\BaseDriver;
10 use FluentCart\Framework\Support\Arr;
11
12 class S3Driver extends BaseDriver
13 {
14 private string $accessKey;
15 private string $secretKey;
16 private string $bucket;
17 private string $region;
18
19
20 public function __construct(?string $dirPath = null, ?string $dirName = null)
21 {
22 parent::__construct($dirPath, $dirName);
23
24 $getSettings = (new S3Settings())->get();
25
26 $this->secretKey = Arr::get($getSettings, 'secret_key', '');
27 $this->accessKey = Arr::get($getSettings, 'access_key', '');
28 $this->bucket = S3Settings::resolveEffectiveBucket($getSettings);
29 $this->region = Arr::get($getSettings, 'region', '');
30 $this->storageDriver = new S3StorageDriver();
31 }
32
33 public function buckets()
34 {
35 return S3BucketList::get(
36 $this->secretKey,
37 $this->accessKey,
38 $this->region
39 );
40 }
41
42 public function uploadFile($localFilePath, $uploadToFilePath, $file, $params = [])
43 {
44 $fileSize = $file->toArray()['size_in_bytes'];
45
46 $response = S3FileUploader::upload(
47 $this->secretKey,
48 $this->accessKey,
49 $this->bucket,
50 $this->region,
51 $localFilePath,
52 $uploadToFilePath
53 );
54
55 if (is_wp_error($response)) {
56 return $response;
57 }
58
59 return [
60 'message' => __('File Uploaded Successfully', 'fluent-cart'),
61 'path' => $response['path'],
62 'file' => [
63 'driver' => 's3',
64 'size' => $fileSize,
65 'bucket' => $this->bucket,
66 'name' => $response['path'],
67 ],
68 ];
69 }
70
71
72 public function listFiles(array $params = [])
73 {
74 return S3FileList::get(
75 $this->secretKey,
76 $this->accessKey,
77 $this->bucket,
78 $this->region,
79 Arr::get($params, 'search', ''),
80 );
81 }
82
83 protected function generatePresignedDownloadUrlOld(string $filePath, $expirationMinutes = 15, $bucket = null, $fileName = null): string
84 {
85 $this->bucket = $bucket ?? $this->bucket;
86 $this->region = S3::getBucketRegion($this->bucket);
87
88
89 if (empty($fileName)) {
90 $fileName = basename($filePath);
91 }
92
93 $fileName = explode('_____fluent-cart_____', $fileName)[0];
94 $fileName = explode('__fluent-cart__', $fileName)[0];
95
96 $expirationMinutes = is_numeric($expirationMinutes) ? (int)$expirationMinutes : 15;
97 $expires = time() + ($expirationMinutes * 60);
98
99 $stringToSign = "GET\n\n\n{$expires}\n/{$this->bucket}/{$filePath}";
100
101 $queryParams = [
102 'AWSAccessKeyId' => $this->accessKey,
103 'Expires' => $expires,
104 ];
105
106 if (!empty($fileName)) {
107 $contentDisposition = "attachment; filename=\"{$fileName}\"";
108 $queryParams['response-content-disposition'] = $contentDisposition;
109
110 // For Signature V1, the parameter value is NOT URL-encoded in string-to-sign
111 $stringToSign .= '?response-content-disposition=' . $contentDisposition;
112 }
113
114 $signature = base64_encode(hash_hmac('sha1', $stringToSign, $this->secretKey, true));
115 $queryParams['Signature'] = $signature;
116
117 $url = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$filePath}";
118 $queryString = http_build_query($queryParams);
119
120 return $url . '?' . $queryString;
121 }
122
123 protected function generatePresignedDownloadUrl(string $filePath, $expirationMinutes = 15, $bucket = null, $fileName = null): string
124 {
125 $this->bucket = $bucket ?? $this->bucket;
126 $this->region = S3::getBucketRegion($this->bucket);
127
128 if (empty($fileName)) {
129 $fileName = basename($filePath);
130 }
131
132 $fileName = explode('_____fluent-cart_____', $fileName)[0];
133 $fileName = explode('__fluent-cart__', $fileName)[0];
134
135 $expirationMinutes = is_numeric($expirationMinutes) ? (int)$expirationMinutes : 15;
136 $expiresInSeconds = $expirationMinutes * 60;
137
138 // AWS Signature V4 requires ISO8601 format
139 $dateTime = gmdate('Ymd\THis\Z');
140 $dateStamp = gmdate('Ymd');
141
142 // Credential scope
143 $credentialScope = "{$dateStamp}/{$this->region}/s3/aws4_request";
144
145 $hasDot = strpos($this->bucket, '.') !== false;
146
147 // Canonical request components. Deliberately does not ltrim() leading
148 // slashes: "foo", "/foo", and "//foo" are distinct S3 keys, and
149 // stripping the slash would redirect the customer to the wrong object.
150 $encodedFilePath = '/' . implode('/', array_map(
151 'rawurlencode',
152 explode('/', $filePath)
153 ));
154
155 // For dotted buckets, use path-style: include bucket in canonical URI
156 if ($hasDot) {
157 $canonicalUri = '/' . $this->bucket . $encodedFilePath;
158 $host = "s3.{$this->region}.amazonaws.com";
159 } else {
160 $canonicalUri = $encodedFilePath;
161 $host = "{$this->bucket}.s3.{$this->region}.amazonaws.com";
162 }
163
164 $canonicalQueryString = [
165 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
166 'X-Amz-Credential' => $this->accessKey . '/' . $credentialScope,
167 'X-Amz-Date' => $dateTime,
168 'X-Amz-Expires' => $expiresInSeconds,
169 'X-Amz-SignedHeaders' => 'host',
170 ];
171
172 if (!empty($fileName)) {
173 $canonicalQueryString['response-content-disposition'] = $this->buildContentDispositionValue($fileName);
174 }
175
176 // Sort query parameters
177 ksort($canonicalQueryString);
178
179 // Build canonical query string
180 $canonicalQueryStringEncoded = [];
181 foreach ($canonicalQueryString as $key => $value) {
182 $canonicalQueryStringEncoded[] = rawurlencode($key) . '=' . rawurlencode($value);
183 }
184 $canonicalQueryStringStr = implode('&', $canonicalQueryStringEncoded);
185
186 // Canonical headers
187 $canonicalHeaders = "host:{$host}\n";
188
189 // Create canonical request
190 $canonicalRequest = "GET\n{$canonicalUri}\n{$canonicalQueryStringStr}\n{$canonicalHeaders}\nhost\nUNSIGNED-PAYLOAD";
191
192 // String to sign
193 $stringToSign = "AWS4-HMAC-SHA256\n{$dateTime}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
194
195 // Calculate signature
196 $kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $this->secretKey, true);
197 $kRegion = hash_hmac('sha256', $this->region, $kDate, true);
198 $kService = hash_hmac('sha256', 's3', $kRegion, true);
199 $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
200 $signature = hash_hmac('sha256', $stringToSign, $kSigning);
201
202 // Build final URL
203 $url = "https://{$host}{$canonicalUri}?{$canonicalQueryStringStr}&X-Amz-Signature={$signature}";
204
205 return $url;
206 }
207
208 /**
209 * Builds an RFC 6266/RFC 5987-compliant Content-Disposition value.
210 * HTTP header values must be ISO-8859-1, so a raw Unicode filename (e.g.
211 * Bengali) in a plain filename="..." parameter makes S3 reject the
212 * request with "Header value cannot be represented using ISO-8859-1."
213 * This supplies both an ASCII-safe filename="..." fallback for clients
214 * that don't understand filename*, and a filename*=UTF-8''... parameter
215 * carrying the exact intended Unicode name that modern browsers use.
216 *
217 * The returned raw value is assigned into $canonicalQueryString as-is;
218 * the existing per-parameter rawurlencode() loop further down encodes
219 * it exactly once for the query string, so this method must not
220 * pre-encode the value itself (only the filename* attr-value, which is
221 * a distinct RFC 5987 encoding layer, not query-string encoding).
222 */
223 private function buildContentDispositionValue(string $fileName): string
224 {
225 // Strip control/CR-LF characters so neither the raw header value nor
226 // the filename* payload can inject extra header content.
227 $sanitized = preg_replace('/[\x00-\x1F\x7F]/', '', $fileName);
228 if ($sanitized === null) {
229 $sanitized = $fileName;
230 }
231
232 $asciiFileName = $this->buildAsciiFallbackFileName($sanitized);
233 // RFC 6266 quoted-string: backslash-escape backslashes and quotes.
234 $asciiFileName = addcslashes($asciiFileName, '\\"');
235
236 $encodedFileName = rawurlencode($sanitized);
237
238 return 'attachment; filename="' . $asciiFileName . '"; filename*=UTF-8\'\'' . $encodedFileName;
239 }
240
241 private function buildAsciiFallbackFileName(string $fileName): string
242 {
243 $extension = preg_replace('/[^\x20-\x7E]/', '', pathinfo($fileName, PATHINFO_EXTENSION));
244
245 // Empty or dot-only names ("", ".", "..", "...") carry no meaningful
246 // identity to preserve; keep the generic fallback for them.
247 if (preg_match('/^\.*$/', $fileName)) {
248 return $extension !== '' ? "download.{$extension}" : 'download';
249 }
250
251 // Already fully ASCII: preserve it exactly, including leading dots,
252 // underscores, hyphens, and extension punctuation (e.g. ".env",
253 // "_report_.txt", "report.a-b") - nothing here needs to change for
254 // a client that ignores filename*.
255 if (preg_replace('/[^\x20-\x7E]/', '', $fileName) === $fileName) {
256 return $fileName;
257 }
258
259 // Otherwise the basename has non-ASCII content to strip. Anything
260 // dropped here (Bengali, Japanese, Arabic, emoji…) is fully
261 // preserved via filename*; legacy clients ignoring filename* never
262 // see this fallback. Trim the separator characters (dots, dashes,
263 // underscores, spaces) that content leaves dangling at the edges
264 // once it's gone.
265 $basename = pathinfo($fileName, PATHINFO_FILENAME);
266 $basename = preg_replace('/[^\x20-\x7E]/', '', $basename);
267 $basename = trim($basename, " \t\n\r\0\x0B-_.");
268
269 if ($basename === '') {
270 return $extension !== '' ? "download.{$extension}" : 'download';
271 }
272
273 return $extension !== '' ? "{$basename}.{$extension}" : $basename;
274 }
275
276 protected function retrieveFileForDownload(string $downloadableFilePath, $bucket = null)
277 {
278 $this->bucket = $bucket;
279 $this->region = S3::getBucketRegion($this->bucket);
280 $hasDot = strpos($this->bucket, '.') !== false;
281
282 if ($hasDot) {
283 $url = "https://s3.{$this->region}.amazonaws.com/{$this->bucket}/{$downloadableFilePath}";
284 } else {
285 $url = "https://{$this->bucket}.s3.{$this->region}.amazonaws.com/{$downloadableFilePath}";
286 }
287
288 $date = gmdate('D, d M Y H:i:s T');
289 $signature = base64_encode(hash_hmac('sha1', "GET\n\n\n{$date}\n/{$this->bucket}/{$downloadableFilePath}", $this->secretKey, true));
290 $response = wp_remote_get($url, [
291 'headers' => [
292 "Date" => $date,
293 "Authorization" => "AWS {$this->accessKey}:{$signature}",
294 ],
295 ]);
296
297 if (is_wp_error($response)) {
298 return $response->get_error_message();
299 }
300
301 return $response['body'];
302 }
303
304 public function getSignedDownloadUrl(string $filePath, $bucket = null, $productDownload = null): string
305 {
306 $expirationMinutes = 7 * 24 * 60;
307 apply_filters('fluent_cart/download_expiration_minutes', $expirationMinutes, [
308 'file_path' => $filePath,
309 'bucket' => $bucket,
310 'driver' => 's3',
311 ]);
312 $fileName = null;
313 if($productDownload instanceof ProductDownload){
314 $fileName = $productDownload->file_name;
315 }
316 return $this->generatePresignedDownloadUrl($filePath, $expirationMinutes, $bucket, $fileName);
317 }
318
319 public function downloadFile(string $filePath, $fileName = null, $bucket = null)
320 {
321 $expirationMinutes = 7 * 24 * 60;
322 apply_filters('fluent_cart/download_expiration_minutes', $expirationMinutes, [
323 'file_path' => $filePath,
324 'bucket' => $bucket,
325 'driver' => 's3',
326 ]);
327 wp_redirect($this->generatePresignedDownloadUrl($filePath, $expirationMinutes, $bucket, $fileName));
328 exit;
329 }
330
331 public function getFilePath(string $filePath, $fileName = null, $bucket = null)
332 {
333 return $this->retrieveFileForDownload($filePath, $bucket);
334 }
335
336 protected function getDefaultDirPath()
337 {
338 return $this->bucket;
339 }
340
341 public function deleteFile($filePath, $bucket = null)
342 {
343 $bucket = $bucket ?: $this->bucket;
344 return S3FileDeleter::delete(
345 $this->secretKey,
346 $this->accessKey,
347 $bucket,
348 $this->region,
349 $filePath
350 );
351 }
352 }
353