FTP.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-only
  5. */
  6. namespace OCA\Files_External\Lib\Storage;
  7. use Icewind\Streams\CallbackWrapper;
  8. use Icewind\Streams\CountWrapper;
  9. use Icewind\Streams\IteratorDirectory;
  10. use OC\Files\Storage\Common;
  11. use OC\Files\Storage\PolyFill\CopyDirectory;
  12. use OCP\Constants;
  13. use OCP\Files\FileInfo;
  14. use OCP\Files\StorageNotAvailableException;
  15. use Psr\Log\LoggerInterface;
  16. class FTP extends Common {
  17. use CopyDirectory;
  18. private $root;
  19. private $host;
  20. private $password;
  21. private $username;
  22. private $secure;
  23. private $port;
  24. private $utf8Mode;
  25. /** @var FtpConnection|null */
  26. private $connection;
  27. public function __construct(array $parameters) {
  28. if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) {
  29. $this->host = $parameters['host'];
  30. $this->username = $parameters['user'];
  31. $this->password = $parameters['password'];
  32. if (isset($parameters['secure'])) {
  33. if (is_string($parameters['secure'])) {
  34. $this->secure = ($parameters['secure'] === 'true');
  35. } else {
  36. $this->secure = (bool)$parameters['secure'];
  37. }
  38. } else {
  39. $this->secure = false;
  40. }
  41. $this->root = isset($parameters['root']) ? '/' . ltrim($parameters['root']) : '/';
  42. $this->port = $parameters['port'] ?? 21;
  43. $this->utf8Mode = isset($parameters['utf8']) && $parameters['utf8'];
  44. } else {
  45. throw new \Exception('Creating ' . self::class . ' storage failed, required parameters not set');
  46. }
  47. }
  48. public function __destruct() {
  49. $this->connection = null;
  50. }
  51. protected function getConnection(): FtpConnection {
  52. if (!$this->connection) {
  53. try {
  54. $this->connection = new FtpConnection(
  55. $this->secure,
  56. $this->host,
  57. $this->port,
  58. $this->username,
  59. $this->password
  60. );
  61. } catch (\Exception $e) {
  62. throw new StorageNotAvailableException('Failed to create ftp connection', 0, $e);
  63. }
  64. if ($this->utf8Mode) {
  65. if (!$this->connection->setUtf8Mode()) {
  66. throw new StorageNotAvailableException('Could not set UTF-8 mode');
  67. }
  68. }
  69. }
  70. return $this->connection;
  71. }
  72. public function getId(): string {
  73. return 'ftp::' . $this->username . '@' . $this->host . '/' . $this->root;
  74. }
  75. protected function buildPath(string $path): string {
  76. return rtrim($this->root . '/' . $path, '/');
  77. }
  78. public static function checkDependencies(): array|bool {
  79. if (function_exists('ftp_login')) {
  80. return true;
  81. } else {
  82. return ['ftp'];
  83. }
  84. }
  85. public function filemtime(string $path): int|false {
  86. $result = $this->getConnection()->mdtm($this->buildPath($path));
  87. if ($result === -1) {
  88. if ($this->is_dir($path)) {
  89. $list = $this->getConnection()->mlsd($this->buildPath($path));
  90. if (!$list) {
  91. \OC::$server->get(LoggerInterface::class)->warning("Unable to get last modified date for ftp folder ($path), failed to list folder contents");
  92. return time();
  93. }
  94. $currentDir = current(array_filter($list, function ($item) {
  95. return $item['type'] === 'cdir';
  96. }));
  97. if ($currentDir) {
  98. [$modify] = explode('.', $currentDir['modify'] ?? '', 2);
  99. $time = \DateTime::createFromFormat('YmdHis', $modify);
  100. if ($time === false) {
  101. throw new \Exception("Invalid date format for directory: $currentDir");
  102. }
  103. return $time->getTimestamp();
  104. } else {
  105. \OC::$server->get(LoggerInterface::class)->warning("Unable to get last modified date for ftp folder ($path), folder contents doesn't include current folder");
  106. return time();
  107. }
  108. } else {
  109. return false;
  110. }
  111. } else {
  112. return $result;
  113. }
  114. }
  115. public function filesize(string $path): false|int|float {
  116. $result = $this->getConnection()->size($this->buildPath($path));
  117. if ($result === -1) {
  118. return false;
  119. } else {
  120. return $result;
  121. }
  122. }
  123. public function rmdir(string $path): bool {
  124. if ($this->is_dir($path)) {
  125. $result = $this->getConnection()->rmdir($this->buildPath($path));
  126. // recursive rmdir support depends on the ftp server
  127. if ($result) {
  128. return $result;
  129. } else {
  130. return $this->recursiveRmDir($path);
  131. }
  132. } elseif ($this->is_file($path)) {
  133. return $this->unlink($path);
  134. } else {
  135. return false;
  136. }
  137. }
  138. private function recursiveRmDir(string $path): bool {
  139. $contents = $this->getDirectoryContent($path);
  140. $result = true;
  141. foreach ($contents as $content) {
  142. if ($content['mimetype'] === FileInfo::MIMETYPE_FOLDER) {
  143. $result = $result && $this->recursiveRmDir($path . '/' . $content['name']);
  144. } else {
  145. $result = $result && $this->getConnection()->delete($this->buildPath($path . '/' . $content['name']));
  146. }
  147. }
  148. $result = $result && $this->getConnection()->rmdir($this->buildPath($path));
  149. return $result;
  150. }
  151. public function test(): bool {
  152. try {
  153. return $this->getConnection()->systype() !== false;
  154. } catch (\Exception $e) {
  155. return false;
  156. }
  157. }
  158. public function stat(string $path): array|false {
  159. if (!$this->file_exists($path)) {
  160. return false;
  161. }
  162. return [
  163. 'mtime' => $this->filemtime($path),
  164. 'size' => $this->filesize($path),
  165. ];
  166. }
  167. public function file_exists(string $path): bool {
  168. if ($path === '' || $path === '.' || $path === '/') {
  169. return true;
  170. }
  171. return $this->filetype($path) !== false;
  172. }
  173. public function unlink(string $path): bool {
  174. switch ($this->filetype($path)) {
  175. case 'dir':
  176. return $this->rmdir($path);
  177. case 'file':
  178. return $this->getConnection()->delete($this->buildPath($path));
  179. default:
  180. return false;
  181. }
  182. }
  183. public function opendir(string $path) {
  184. $files = $this->getConnection()->nlist($this->buildPath($path));
  185. return IteratorDirectory::wrap($files);
  186. }
  187. public function mkdir(string $path): bool {
  188. if ($this->is_dir($path)) {
  189. return false;
  190. }
  191. return $this->getConnection()->mkdir($this->buildPath($path)) !== false;
  192. }
  193. public function is_dir(string $path): bool {
  194. if ($path === '') {
  195. return true;
  196. }
  197. if ($this->getConnection()->chdir($this->buildPath($path)) === true) {
  198. $this->getConnection()->chdir('/');
  199. return true;
  200. } else {
  201. return false;
  202. }
  203. }
  204. public function is_file(string $path): bool {
  205. return $this->filesize($path) !== false;
  206. }
  207. public function filetype(string $path): string|false {
  208. if ($this->is_dir($path)) {
  209. return 'dir';
  210. } elseif ($this->is_file($path)) {
  211. return 'file';
  212. } else {
  213. return false;
  214. }
  215. }
  216. public function fopen(string $path, string $mode) {
  217. $useExisting = true;
  218. switch ($mode) {
  219. case 'r':
  220. case 'rb':
  221. return $this->readStream($path);
  222. case 'w':
  223. case 'w+':
  224. case 'wb':
  225. case 'wb+':
  226. $useExisting = false;
  227. // no break
  228. case 'a':
  229. case 'ab':
  230. case 'r+':
  231. case 'a+':
  232. case 'x':
  233. case 'x+':
  234. case 'c':
  235. case 'c+':
  236. //emulate these
  237. if ($useExisting and $this->file_exists($path)) {
  238. if (!$this->isUpdatable($path)) {
  239. return false;
  240. }
  241. $tmpFile = $this->getCachedFile($path);
  242. } else {
  243. if (!$this->isCreatable(dirname($path))) {
  244. return false;
  245. }
  246. $tmpFile = \OC::$server->getTempManager()->getTemporaryFile();
  247. }
  248. $source = fopen($tmpFile, $mode);
  249. return CallbackWrapper::wrap($source, null, null, function () use ($tmpFile, $path): void {
  250. $this->writeStream($path, fopen($tmpFile, 'r'));
  251. unlink($tmpFile);
  252. });
  253. }
  254. return false;
  255. }
  256. public function writeStream(string $path, $stream, ?int $size = null): int {
  257. if ($size === null) {
  258. $stream = CountWrapper::wrap($stream, function ($writtenSize) use (&$size): void {
  259. $size = $writtenSize;
  260. });
  261. }
  262. $this->getConnection()->fput($this->buildPath($path), $stream);
  263. fclose($stream);
  264. return $size;
  265. }
  266. public function readStream(string $path) {
  267. $stream = fopen('php://temp', 'w+');
  268. $result = $this->getConnection()->fget($stream, $this->buildPath($path));
  269. rewind($stream);
  270. if (!$result) {
  271. fclose($stream);
  272. return false;
  273. }
  274. return $stream;
  275. }
  276. public function touch(string $path, ?int $mtime = null): bool {
  277. if ($this->file_exists($path)) {
  278. return false;
  279. } else {
  280. $this->file_put_contents($path, '');
  281. return true;
  282. }
  283. }
  284. public function rename(string $source, string $target): bool {
  285. $this->unlink($target);
  286. return $this->getConnection()->rename($this->buildPath($source), $this->buildPath($target));
  287. }
  288. public function getDirectoryContent(string $directory): \Traversable {
  289. $files = $this->getConnection()->mlsd($this->buildPath($directory));
  290. $mimeTypeDetector = \OC::$server->getMimeTypeDetector();
  291. foreach ($files as $file) {
  292. $name = $file['name'];
  293. if ($file['type'] === 'cdir' || $file['type'] === 'pdir') {
  294. continue;
  295. }
  296. $permissions = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
  297. $isDir = $file['type'] === 'dir';
  298. if ($isDir) {
  299. $permissions += Constants::PERMISSION_CREATE;
  300. }
  301. $data = [];
  302. $data['mimetype'] = $isDir ? FileInfo::MIMETYPE_FOLDER : $mimeTypeDetector->detectPath($name);
  303. // strip fractional seconds
  304. [$modify] = explode('.', $file['modify'], 2);
  305. $mtime = \DateTime::createFromFormat('YmdGis', $modify);
  306. $data['mtime'] = $mtime === false ? time() : $mtime->getTimestamp();
  307. if ($isDir) {
  308. $data['size'] = -1; //unknown
  309. } elseif (isset($file['size'])) {
  310. $data['size'] = $file['size'];
  311. } else {
  312. $data['size'] = $this->filesize($directory . '/' . $name);
  313. }
  314. $data['etag'] = uniqid();
  315. $data['storage_mtime'] = $data['mtime'];
  316. $data['permissions'] = $permissions;
  317. $data['name'] = $name;
  318. yield $data;
  319. }
  320. }
  321. }