PluginProbe
Media Cloud Sync / 1.2.0
Media Cloud Sync v1.2.0
1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 All 35 releases
media-cloud-sync / includes / sdk / s3 / Aws / S3 / Transfer.php

Transfer.php in Media Cloud Sync 1.2.0, at includes/sdk/s3/Aws/S3/Transfer.php

337 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Dudlewebs\WPMCS\s3\Aws\S3;
4
5 use Dudlewebs\WPMCS\s3\Aws;
6 use Dudlewebs\WPMCS\s3\Aws\CommandInterface;
7 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
8 use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise;
9 use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise\PromiseInterface;
10 use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise\PromisorInterface;
11 use Iterator;
12 /**
13 * Transfers files from the local filesystem to S3 or from S3 to the local
14 * filesystem.
15 *
16 * This class does not support copying from the local filesystem to somewhere
17 * else on the local filesystem or from one S3 bucket to another.
18 */
19 class Transfer implements PromisorInterface
20 {
21 private $client;
22 private $promise;
23 private $source;
24 private $sourceMetadata;
25 private $destination;
26 private $concurrency;
27 private $mupThreshold;
28 private $before;
29 private $s3Args = [];
30 /**
31 * When providing the $source argument, you may provide a string referencing
32 * the path to a directory on disk to upload, an s3 scheme URI that contains
33 * the bucket and key (e.g., "s3://bucket/key"), or an \Iterator object
34 * that yields strings containing filenames that are the path to a file on
35 * disk or an s3 scheme URI. The bucket portion of the s3 URI may be an S3
36 * access point ARN. The "/key" portion of an s3 URI is optional.
37 *
38 * When providing an iterator for the $source argument, you must also
39 * provide a 'base_dir' key value pair in the $options argument.
40 *
41 * The $dest argument can be the path to a directory on disk or an s3
42 * scheme URI (e.g., "s3://bucket/key").
43 *
44 * The options array can contain the following key value pairs:
45 *
46 * - base_dir: (string) Base directory of the source, if $source is an
47 * iterator. If the $source option is not an array, then this option is
48 * ignored.
49 * - before: (callable) A callback to invoke before each transfer. The
50 * callback accepts a single argument: Aws\CommandInterface $command.
51 * The provided command will be either a GetObject, PutObject,
52 * InitiateMultipartUpload, or UploadPart command.
53 * - mup_threshold: (int) Size in bytes in which a multipart upload should
54 * be used instead of PutObject. Defaults to 20971520 (20 MB).
55 * - concurrency: (int, default=5) Number of files to upload concurrently.
56 * The ideal concurrency value will vary based on the number of files
57 * being uploaded and the average size of each file. Generally speaking,
58 * smaller files benefit from a higher concurrency while larger files
59 * will not.
60 * - debug: (bool) Set to true to print out debug information for
61 * transfers. Set to an fopen() resource to write to a specific stream
62 * rather than writing to STDOUT.
63 *
64 * @param S3ClientInterface $client Client used for transfers.
65 * @param string|Iterator $source Where the files are transferred from.
66 * @param string $dest Where the files are transferred to.
67 * @param array $options Hash of options.
68 */
69 public function __construct(S3ClientInterface $client, $source, $dest, array $options = [])
70 {
71 $this->client = $client;
72 // Prepare the destination.
73 $this->destination = $this->prepareTarget($dest);
74 if ($this->destination['scheme'] === 's3') {
75 $this->s3Args = $this->getS3Args($this->destination['path']);
76 }
77 // Prepare the source.
78 if (\is_string($source)) {
79 $this->sourceMetadata = $this->prepareTarget($source);
80 $this->source = $source;
81 } elseif ($source instanceof Iterator) {
82 if (empty($options['base_dir'])) {
83 throw new \InvalidArgumentException('You must provide the source' . ' argument as a string or provide the "base_dir" option.');
84 }
85 $this->sourceMetadata = $this->prepareTarget($options['base_dir']);
86 $this->source = $source;
87 } else {
88 throw new \InvalidArgumentException('source must be the path to a ' . 'directory or an iterator that yields file names.');
89 }
90 // Validate schemes.
91 if ($this->sourceMetadata['scheme'] === $this->destination['scheme']) {
92 throw new \InvalidArgumentException("You cannot copy from" . " {$this->sourceMetadata['scheme']} to" . " {$this->destination['scheme']}.");
93 }
94 // Handle multipart-related options.
95 $this->concurrency = isset($options['concurrency']) ? $options['concurrency'] : MultipartUploader::DEFAULT_CONCURRENCY;
96 $this->mupThreshold = isset($options['mup_threshold']) ? $options['mup_threshold'] : 16777216;
97 if ($this->mupThreshold < MultipartUploader::PART_MIN_SIZE) {
98 throw new \InvalidArgumentException('mup_threshold must be >= 5MB');
99 }
100 // Handle "before" callback option.
101 if (isset($options['before'])) {
102 $this->before = $options['before'];
103 if (!\is_callable($this->before)) {
104 throw new \InvalidArgumentException('before must be a callable.');
105 }
106 }
107 // Handle "debug" option.
108 if (isset($options['debug'])) {
109 if ($options['debug'] === \true) {
110 $options['debug'] = \fopen('php://output', 'w');
111 }
112 if (\is_resource($options['debug'])) {
113 $this->addDebugToBefore($options['debug']);
114 }
115 }
116 }
117 /**
118 * Transfers the files.
119 *
120 * @return PromiseInterface
121 */
122 public function promise()
123 {
124 // If the promise has been created, just return it.
125 if (!$this->promise) {
126 // Create an upload/download promise for the transfer.
127 $this->promise = $this->sourceMetadata['scheme'] === 'file' ? $this->createUploadPromise() : $this->createDownloadPromise();
128 }
129 return $this->promise;
130 }
131 /**
132 * Transfers the files synchronously.
133 */
134 public function transfer()
135 {
136 $this->promise()->wait();
137 }
138 private function prepareTarget($targetPath)
139 {
140 $target = ['path' => $this->normalizePath($targetPath), 'scheme' => $this->determineScheme($targetPath)];
141 if ($target['scheme'] !== 's3' && $target['scheme'] !== 'file') {
142 throw new \InvalidArgumentException('Scheme must be "s3" or "file".');
143 }
144 return $target;
145 }
146 /**
147 * Creates an array that contains Bucket and Key by parsing the filename.
148 *
149 * @param string $path Path to parse.
150 *
151 * @return array
152 */
153 private function getS3Args($path)
154 {
155 $parts = \explode('/', \str_replace('s3://', '', $path), 2);
156 $args = ['Bucket' => $parts[0]];
157 if (isset($parts[1])) {
158 $args['Key'] = $parts[1];
159 }
160 return $args;
161 }
162 /**
163 * Parses the scheme from a filename.
164 *
165 * @param string $path Path to parse.
166 *
167 * @return string
168 */
169 private function determineScheme($path)
170 {
171 return !\strpos($path, '://') ? 'file' : \explode('://', $path)[0];
172 }
173 /**
174 * Normalize a path so that it has UNIX-style directory separators and no trailing /
175 *
176 * @param string $path
177 *
178 * @return string
179 */
180 private function normalizePath($path)
181 {
182 return \rtrim(\str_replace('\\', '/', $path), '/');
183 }
184 private function resolvesOutsideTargetDirectory($sink, $objectKey)
185 {
186 $resolved = [];
187 $sections = \explode('/', $sink);
188 $targetSectionsLength = \count(\explode('/', $objectKey));
189 $targetSections = \array_slice($sections, -($targetSectionsLength + 1));
190 $targetDirectory = $targetSections[0];
191 foreach ($targetSections as $section) {
192 if ($section === '.' || $section === '') {
193 continue;
194 }
195 if ($section === '..') {
196 \array_pop($resolved);
197 if (empty($resolved) || $resolved[0] !== $targetDirectory) {
198 return \true;
199 }
200 } else {
201 $resolved[] = $section;
202 }
203 }
204 return \false;
205 }
206 private function createDownloadPromise()
207 {
208 $parts = $this->getS3Args($this->sourceMetadata['path']);
209 $prefix = "s3://{$parts['Bucket']}/" . (isset($parts['Key']) ? $parts['Key'] . '/' : '');
210 $commands = [];
211 foreach ($this->getDownloadsIterator() as $object) {
212 // Prepare the sink.
213 $objectKey = \preg_replace('/^' . \preg_quote($prefix, '/') . '/', '', $object);
214 $sink = $this->destination['path'] . '/' . $objectKey;
215 $command = $this->client->getCommand('GetObject', $this->getS3Args($object) + ['@http' => ['sink' => $sink]]);
216 if ($this->resolvesOutsideTargetDirectory($sink, $objectKey)) {
217 throw new AwsException('Cannot download key ' . $objectKey . ', its relative path resolves outside the' . ' parent directory', $command);
218 }
219 // Create the directory if needed.
220 $dir = \dirname($sink);
221 if (!\is_dir($dir) && !\mkdir($dir, 0777, \true)) {
222 throw new \RuntimeException("Could not create dir: {$dir}");
223 }
224 // Create the command.
225 $commands[] = $command;
226 }
227 // Create a GetObject command pool and return the promise.
228 return (new Aws\CommandPool($this->client, $commands, ['concurrency' => $this->concurrency, 'before' => $this->before, 'rejected' => function ($reason, $idx, Promise\PromiseInterface $p) {
229 $p->reject($reason);
230 }]))->promise();
231 }
232 private function createUploadPromise()
233 {
234 // Map each file into a promise that performs the actual transfer.
235 $files = \Dudlewebs\WPMCS\s3\Aws\map($this->getUploadsIterator(), function ($file) {
236 return \filesize($file) >= $this->mupThreshold ? $this->uploadMultipart($file) : $this->upload($file);
237 });
238 // Create an EachPromise, that will concurrently handle the upload
239 // operations' yielded promises from the iterator.
240 return Promise\Each::ofLimitAll($files, $this->concurrency);
241 }
242 /** @return Iterator */
243 private function getUploadsIterator()
244 {
245 if (\is_string($this->source)) {
246 return Aws\filter(Aws\recursive_dir_iterator($this->sourceMetadata['path']), function ($file) {
247 return !\is_dir($file);
248 });
249 }
250 return $this->source;
251 }
252 /** @return Iterator */
253 private function getDownloadsIterator()
254 {
255 if (\is_string($this->source)) {
256 $listArgs = $this->getS3Args($this->sourceMetadata['path']);
257 if (isset($listArgs['Key'])) {
258 $listArgs['Prefix'] = $listArgs['Key'] . '/';
259 unset($listArgs['Key']);
260 }
261 $files = $this->client->getPaginator('ListObjects', $listArgs)->search('Contents[].Key');
262 $files = Aws\map($files, function ($key) use($listArgs) {
263 return "s3://{$listArgs['Bucket']}/{$key}";
264 });
265 return Aws\filter($files, function ($key) {
266 return \substr($key, -1, 1) !== '/';
267 });
268 }
269 return $this->source;
270 }
271 private function upload($filename)
272 {
273 $args = $this->s3Args;
274 $args['SourceFile'] = $filename;
275 $args['Key'] = $this->createS3Key($filename);
276 $command = $this->client->getCommand('PutObject', $args);
277 $this->before and \call_user_func($this->before, $command);
278 return $this->client->executeAsync($command);
279 }
280 private function uploadMultipart($filename)
281 {
282 $args = $this->s3Args;
283 $args['Key'] = $this->createS3Key($filename);
284 $filename = $filename instanceof \SplFileInfo ? $filename->getPathname() : $filename;
285 return (new MultipartUploader($this->client, $filename, ['bucket' => $args['Bucket'], 'key' => $args['Key'], 'before_initiate' => $this->before, 'before_upload' => $this->before, 'before_complete' => $this->before, 'concurrency' => $this->concurrency]))->promise();
286 }
287 private function createS3Key($filename)
288 {
289 $filename = $this->normalizePath($filename);
290 $relative_file_path = \ltrim(\preg_replace('#^' . \preg_quote($this->sourceMetadata['path']) . '#', '', $filename), '/\\');
291 if (isset($this->s3Args['Key'])) {
292 return \rtrim($this->s3Args['Key'], '/') . '/' . $relative_file_path;
293 }
294 return $relative_file_path;
295 }
296 private function addDebugToBefore($debug)
297 {
298 $before = $this->before;
299 $sourcePath = $this->sourceMetadata['path'];
300 $s3Args = $this->s3Args;
301 $this->before = static function (CommandInterface $command) use($before, $debug, $sourcePath, $s3Args) {
302 // Call the composed before function.
303 $before and $before($command);
304 // Determine the source and dest values based on operation.
305 switch ($operation = $command->getName()) {
306 case 'GetObject':
307 $source = "s3://{$command['Bucket']}/{$command['Key']}";
308 $dest = $command['@http']['sink'];
309 break;
310 case 'PutObject':
311 $source = $command['SourceFile'];
312 $dest = "s3://{$command['Bucket']}/{$command['Key']}";
313 break;
314 case 'UploadPart':
315 $part = $command['PartNumber'];
316 case 'CreateMultipartUpload':
317 case 'CompleteMultipartUpload':
318 $sourceKey = $command['Key'];
319 if (isset($s3Args['Key']) && \strpos($sourceKey, $s3Args['Key']) === 0) {
320 $sourceKey = \substr($sourceKey, \strlen($s3Args['Key']) + 1);
321 }
322 $source = "{$sourcePath}/{$sourceKey}";
323 $dest = "s3://{$command['Bucket']}/{$command['Key']}";
324 break;
325 default:
326 throw new \UnexpectedValueException("Transfer encountered an unexpected operation: {$operation}.");
327 }
328 // Print the debugging message.
329 $context = \sprintf('%s -> %s (%s)', $source, $dest, $operation);
330 if (isset($part)) {
331 $context .= " : Part={$part}";
332 }
333 \fwrite($debug, "Transferring {$context}\n");
334 };
335 }
336 }
337