Transfer.php 15 KB

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