FtpConnection.php 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Files_External\Lib\Storage;
  8. /**
  9. * Low level wrapper around the ftp functions that smooths over some difference between servers
  10. */
  11. class FtpConnection {
  12. private \FTP\Connection $connection;
  13. public function __construct(bool $secure, string $hostname, int $port, string $username, string $password) {
  14. if ($secure) {
  15. $connection = ftp_ssl_connect($hostname, $port);
  16. } else {
  17. $connection = ftp_connect($hostname, $port);
  18. }
  19. if ($connection === false) {
  20. throw new \Exception('Failed to connect to ftp');
  21. }
  22. if (ftp_login($connection, $username, $password) === false) {
  23. throw new \Exception('Failed to connect to login to ftp');
  24. }
  25. ftp_pasv($connection, true);
  26. $this->connection = $connection;
  27. }
  28. public function __destruct() {
  29. ftp_close($this->connection);
  30. }
  31. public function setUtf8Mode(): bool {
  32. $response = ftp_raw($this->connection, 'OPTS UTF8 ON');
  33. return substr($response[0], 0, 3) === '200';
  34. }
  35. public function fput(string $path, $handle) {
  36. return @ftp_fput($this->connection, $path, $handle, FTP_BINARY);
  37. }
  38. public function fget($handle, string $path) {
  39. return @ftp_fget($this->connection, $handle, $path, FTP_BINARY);
  40. }
  41. public function mkdir(string $path) {
  42. return @ftp_mkdir($this->connection, $path);
  43. }
  44. public function chdir(string $path) {
  45. return @ftp_chdir($this->connection, $path);
  46. }
  47. public function delete(string $path) {
  48. return @ftp_delete($this->connection, $path);
  49. }
  50. public function rmdir(string $path) {
  51. return @ftp_rmdir($this->connection, $path);
  52. }
  53. public function rename(string $source, string $target) {
  54. return @ftp_rename($this->connection, $source, $target);
  55. }
  56. public function mdtm(string $path): int {
  57. $result = @ftp_mdtm($this->connection, $path);
  58. // filezilla doesn't like empty path with mdtm
  59. if ($result === -1 && $path === '') {
  60. $result = @ftp_mdtm($this->connection, '/');
  61. }
  62. return $result;
  63. }
  64. public function size(string $path) {
  65. return @ftp_size($this->connection, $path);
  66. }
  67. public function systype() {
  68. return @ftp_systype($this->connection);
  69. }
  70. public function nlist(string $path) {
  71. $files = @ftp_nlist($this->connection, $path);
  72. return array_map(function ($name) {
  73. if (str_contains($name, '/')) {
  74. $name = basename($name);
  75. }
  76. return $name;
  77. }, $files);
  78. }
  79. public function mlsd(string $path) {
  80. $files = @ftp_mlsd($this->connection, $path);
  81. if ($files !== false) {
  82. return array_map(function ($file) {
  83. if (str_contains($file['name'], '/')) {
  84. $file['name'] = basename($file['name']);
  85. }
  86. return $file;
  87. }, $files);
  88. } else {
  89. // not all servers support mlsd, in those cases we parse the raw list ourselves
  90. $rawList = @ftp_rawlist($this->connection, '-aln ' . $path);
  91. if ($rawList === false) {
  92. return false;
  93. }
  94. return $this->parseRawList($rawList, $path);
  95. }
  96. }
  97. // rawlist parsing logic is based on the ftp implementation from https://github.com/thephpleague/flysystem
  98. private function parseRawList(array $rawList, string $directory): array {
  99. return array_map(function ($item) use ($directory) {
  100. return $this->parseRawListItem($item, $directory);
  101. }, $rawList);
  102. }
  103. private function parseRawListItem(string $item, string $directory): array {
  104. $isWindows = preg_match('/^[0-9]{2,4}-[0-9]{2}-[0-9]{2}/', $item);
  105. return $isWindows ? $this->parseWindowsItem($item, $directory) : $this->parseUnixItem($item, $directory);
  106. }
  107. private function parseUnixItem(string $item, string $directory): array {
  108. $item = preg_replace('#\s+#', ' ', $item, 7);
  109. if (count(explode(' ', $item, 9)) !== 9) {
  110. throw new \RuntimeException("Metadata can't be parsed from item '$item' , not enough parts.");
  111. }
  112. [$permissions, /* $number */, /* $owner */, /* $group */, $size, $month, $day, $time, $name] = explode(' ', $item, 9);
  113. if ($name === '.') {
  114. $type = 'cdir';
  115. } elseif ($name === '..') {
  116. $type = 'pdir';
  117. } else {
  118. $type = substr($permissions, 0, 1) === 'd' ? 'dir' : 'file';
  119. }
  120. $parsedDate = (new \DateTime())
  121. ->setTimestamp(strtotime("$month $day $time"));
  122. $tomorrow = (new \DateTime())->add(new \DateInterval('P1D'));
  123. // since the provided date doesn't include the year, we either set it to the correct year
  124. // or when the date would otherwise be in the future (by more then 1 day to account for timezone errors)
  125. // we use last year
  126. if ($parsedDate > $tomorrow) {
  127. $parsedDate = $parsedDate->sub(new \DateInterval('P1Y'));
  128. }
  129. $formattedDate = $parsedDate
  130. ->format('YmdHis');
  131. return [
  132. 'type' => $type,
  133. 'name' => $name,
  134. 'modify' => $formattedDate,
  135. 'perm' => $this->normalizePermissions($permissions),
  136. 'size' => (int)$size,
  137. ];
  138. }
  139. private function normalizePermissions(string $permissions) {
  140. $isDir = substr($permissions, 0, 1) === 'd';
  141. // remove the type identifier and only use owner permissions
  142. $permissions = substr($permissions, 1, 4);
  143. // map the string rights to the ftp counterparts
  144. $filePermissionsMap = ['r' => 'r', 'w' => 'fadfw'];
  145. $dirPermissionsMap = ['r' => 'e', 'w' => 'flcdmp'];
  146. $map = $isDir ? $dirPermissionsMap : $filePermissionsMap;
  147. return array_reduce(str_split($permissions), function ($ftpPermissions, $permission) use ($map) {
  148. if (isset($map[$permission])) {
  149. $ftpPermissions .= $map[$permission];
  150. }
  151. return $ftpPermissions;
  152. }, '');
  153. }
  154. private function parseWindowsItem(string $item, string $directory): array {
  155. $item = preg_replace('#\s+#', ' ', trim($item), 3);
  156. if (count(explode(' ', $item, 4)) !== 4) {
  157. throw new \RuntimeException("Metadata can't be parsed from item '$item' , not enough parts.");
  158. }
  159. [$date, $time, $size, $name] = explode(' ', $item, 4);
  160. // Check for the correct date/time format
  161. $format = strlen($date) === 8 ? 'm-d-yH:iA' : 'Y-m-dH:i';
  162. $formattedDate = \DateTime::createFromFormat($format, $date . $time)->format('YmdGis');
  163. if ($name === '.') {
  164. $type = 'cdir';
  165. } elseif ($name === '..') {
  166. $type = 'pdir';
  167. } else {
  168. $type = ($size === '<DIR>') ? 'dir' : 'file';
  169. }
  170. return [
  171. 'type' => $type,
  172. 'name' => $name,
  173. 'modify' => $formattedDate,
  174. 'perm' => ($type === 'file') ? 'adfrw' : 'flcdmpe',
  175. 'size' => (int)$size,
  176. ];
  177. }
  178. }