1<?php
2
3namespace League\Flysystem\Adapter;
4
5use League\Flysystem\Adapter\Polyfill\StreamedCopyTrait;
6use League\Flysystem\AdapterInterface;
7use League\Flysystem\Config;
8use League\Flysystem\ConnectionErrorException;
9use League\Flysystem\ConnectionRuntimeException;
10use League\Flysystem\InvalidRootException;
11use League\Flysystem\Util;
12use League\Flysystem\Util\MimeType;
13
14class Ftp extends AbstractFtpAdapter
15{
16    use StreamedCopyTrait;
17
18    /**
19     * @var int
20     */
21    protected $transferMode = FTP_BINARY;
22
23    /**
24     * @var null|bool
25     */
26    protected $ignorePassiveAddress = null;
27
28    /**
29     * @var bool
30     */
31    protected $recurseManually = false;
32
33    /**
34     * @var bool
35     */
36    protected $utf8 = false;
37
38    /**
39     * @var array
40     */
41    protected $configurable = [
42        'host',
43        'port',
44        'username',
45        'password',
46        'ssl',
47        'timeout',
48        'root',
49        'permPrivate',
50        'permPublic',
51        'passive',
52        'transferMode',
53        'systemType',
54        'ignorePassiveAddress',
55        'recurseManually',
56        'utf8',
57        'enableTimestampsOnUnixListings',
58    ];
59
60    /**
61     * @var bool
62     */
63    protected $isPureFtpd;
64
65    /**
66     * Set the transfer mode.
67     *
68     * @param int $mode
69     *
70     * @return $this
71     */
72    public function setTransferMode($mode)
73    {
74        $this->transferMode = $mode;
75
76        return $this;
77    }
78
79    /**
80     * Set if Ssl is enabled.
81     *
82     * @param bool $ssl
83     *
84     * @return $this
85     */
86    public function setSsl($ssl)
87    {
88        $this->ssl = (bool) $ssl;
89
90        return $this;
91    }
92
93    /**
94     * Set if passive mode should be used.
95     *
96     * @param bool $passive
97     */
98    public function setPassive($passive = true)
99    {
100        $this->passive = $passive;
101    }
102
103    /**
104     * @param bool $ignorePassiveAddress
105     */
106    public function setIgnorePassiveAddress($ignorePassiveAddress)
107    {
108        $this->ignorePassiveAddress = $ignorePassiveAddress;
109    }
110
111    /**
112     * @param bool $recurseManually
113     */
114    public function setRecurseManually($recurseManually)
115    {
116        $this->recurseManually = $recurseManually;
117    }
118
119    /**
120     * @param bool $utf8
121     */
122    public function setUtf8($utf8)
123    {
124        $this->utf8 = (bool) $utf8;
125    }
126
127    /**
128     * Connect to the FTP server.
129     */
130    public function connect()
131    {
132        $tries = 3;
133        start_connecting:
134
135        if ($this->ssl) {
136            $this->connection = @ftp_ssl_connect($this->getHost(), $this->getPort(), $this->getTimeout());
137        } else {
138            $this->connection = @ftp_connect($this->getHost(), $this->getPort(), $this->getTimeout());
139        }
140
141        if ( ! $this->connection) {
142            $tries--;
143
144            if ($tries > 0) goto start_connecting;
145
146            throw new ConnectionRuntimeException('Could not connect to host: ' . $this->getHost() . ', port:' . $this->getPort());
147        }
148
149        $this->login();
150        $this->setUtf8Mode();
151        $this->setConnectionPassiveMode();
152        $this->setConnectionRoot();
153        $this->isPureFtpd = $this->isPureFtpdServer();
154    }
155
156    /**
157     * Set the connection to UTF-8 mode.
158     */
159    protected function setUtf8Mode()
160    {
161        if ($this->utf8) {
162            $response = ftp_raw($this->connection, "OPTS UTF8 ON");
163            if (substr($response[0], 0, 3) !== '200') {
164                throw new ConnectionRuntimeException(
165                    'Could not set UTF-8 mode for connection: ' . $this->getHost() . '::' . $this->getPort()
166                );
167            }
168        }
169    }
170
171    /**
172     * Set the connections to passive mode.
173     *
174     * @throws ConnectionRuntimeException
175     */
176    protected function setConnectionPassiveMode()
177    {
178        if (is_bool($this->ignorePassiveAddress) && defined('FTP_USEPASVADDRESS')) {
179            ftp_set_option($this->connection, FTP_USEPASVADDRESS, ! $this->ignorePassiveAddress);
180        }
181
182        if ( ! ftp_pasv($this->connection, $this->passive)) {
183            throw new ConnectionRuntimeException(
184                'Could not set passive mode for connection: ' . $this->getHost() . '::' . $this->getPort()
185            );
186        }
187    }
188
189    /**
190     * Set the connection root.
191     */
192    protected function setConnectionRoot()
193    {
194        $root = $this->getRoot();
195        $connection = $this->connection;
196
197        if ($root && ! ftp_chdir($connection, $root)) {
198            throw new InvalidRootException('Root is invalid or does not exist: ' . $this->getRoot());
199        }
200
201        // Store absolute path for further reference.
202        // This is needed when creating directories and
203        // initial root was a relative path, else the root
204        // would be relative to the chdir'd path.
205        $this->root = ftp_pwd($connection);
206    }
207
208    /**
209     * Login.
210     *
211     * @throws ConnectionRuntimeException
212     */
213    protected function login()
214    {
215        set_error_handler(function () {
216        });
217        $isLoggedIn = ftp_login(
218            $this->connection,
219            $this->getUsername(),
220            $this->getPassword()
221        );
222        restore_error_handler();
223
224        if ( ! $isLoggedIn) {
225            $this->disconnect();
226            throw new ConnectionRuntimeException(
227                'Could not login with connection: ' . $this->getHost() . '::' . $this->getPort(
228                ) . ', username: ' . $this->getUsername()
229            );
230        }
231    }
232
233    /**
234     * Disconnect from the FTP server.
235     */
236    public function disconnect()
237    {
238        if (is_resource($this->connection)) {
239            @ftp_close($this->connection);
240        }
241
242        $this->connection = null;
243    }
244
245    /**
246     * @inheritdoc
247     */
248    public function write($path, $contents, Config $config)
249    {
250        $stream = fopen('php://temp', 'w+b');
251        fwrite($stream, $contents);
252        rewind($stream);
253        $result = $this->writeStream($path, $stream, $config);
254        fclose($stream);
255
256        if ($result === false) {
257            return false;
258        }
259
260        $result['contents'] = $contents;
261        $result['mimetype'] = $config->get('mimetype') ?: Util::guessMimeType($path, $contents);
262
263        return $result;
264    }
265
266    /**
267     * @inheritdoc
268     */
269    public function writeStream($path, $resource, Config $config)
270    {
271        $this->ensureDirectory(Util::dirname($path));
272
273        if ( ! ftp_fput($this->getConnection(), $path, $resource, $this->transferMode)) {
274            return false;
275        }
276
277        if ($visibility = $config->get('visibility')) {
278            $this->setVisibility($path, $visibility);
279        }
280
281        $type = 'file';
282
283        return compact('type', 'path', 'visibility');
284    }
285
286    /**
287     * @inheritdoc
288     */
289    public function update($path, $contents, Config $config)
290    {
291        return $this->write($path, $contents, $config);
292    }
293
294    /**
295     * @inheritdoc
296     */
297    public function updateStream($path, $resource, Config $config)
298    {
299        return $this->writeStream($path, $resource, $config);
300    }
301
302    /**
303     * @inheritdoc
304     */
305    public function rename($path, $newpath)
306    {
307        return ftp_rename($this->getConnection(), $path, $newpath);
308    }
309
310    /**
311     * @inheritdoc
312     */
313    public function delete($path)
314    {
315        return ftp_delete($this->getConnection(), $path);
316    }
317
318    /**
319     * @inheritdoc
320     */
321    public function deleteDir($dirname)
322    {
323        $connection = $this->getConnection();
324        $contents = array_reverse($this->listDirectoryContents($dirname, false));
325
326        foreach ($contents as $object) {
327            if ($object['type'] === 'file') {
328                if ( ! ftp_delete($connection, $object['path'])) {
329                    return false;
330                }
331            } elseif ( ! $this->deleteDir($object['path'])) {
332                return false;
333            }
334        }
335
336        return ftp_rmdir($connection, $dirname);
337    }
338
339    /**
340     * @inheritdoc
341     */
342    public function createDir($dirname, Config $config)
343    {
344        $connection = $this->getConnection();
345        $directories = explode('/', $dirname);
346
347        foreach ($directories as $directory) {
348            if (false === $this->createActualDirectory($directory, $connection)) {
349                $this->setConnectionRoot();
350
351                return false;
352            }
353
354            ftp_chdir($connection, $directory);
355        }
356
357        $this->setConnectionRoot();
358
359        return ['type' => 'dir', 'path' => $dirname];
360    }
361
362    /**
363     * Create a directory.
364     *
365     * @param string   $directory
366     * @param resource $connection
367     *
368     * @return bool
369     */
370    protected function createActualDirectory($directory, $connection)
371    {
372        // List the current directory
373        $listing = ftp_nlist($connection, '.') ?: [];
374
375        foreach ($listing as $key => $item) {
376            if (preg_match('~^\./.*~', $item)) {
377                $listing[$key] = substr($item, 2);
378            }
379        }
380
381        if (in_array($directory, $listing, true)) {
382            return true;
383        }
384
385        return (boolean) ftp_mkdir($connection, $directory);
386    }
387
388    /**
389     * @inheritdoc
390     */
391    public function getMetadata($path)
392    {
393        if ($path === '') {
394            return ['type' => 'dir', 'path' => ''];
395        }
396
397        if (@ftp_chdir($this->getConnection(), $path) === true) {
398            $this->setConnectionRoot();
399
400            return ['type' => 'dir', 'path' => $path];
401        }
402
403        $listing = $this->ftpRawlist('-A', $path);
404
405        if (empty($listing) || in_array('total 0', $listing, true)) {
406            return false;
407        }
408
409        if (preg_match('/.* not found/', $listing[0])) {
410            return false;
411        }
412
413        if (preg_match('/^total [0-9]*$/', $listing[0])) {
414            array_shift($listing);
415        }
416
417        return $this->normalizeObject($listing[0], '');
418    }
419
420    /**
421     * @inheritdoc
422     */
423    public function getMimetype($path)
424    {
425        if ( ! $metadata = $this->getMetadata($path)) {
426            return false;
427        }
428
429        $metadata['mimetype'] = MimeType::detectByFilename($path);
430
431        return $metadata;
432    }
433
434    /**
435     * @inheritdoc
436     */
437    public function getTimestamp($path)
438    {
439        $timestamp = ftp_mdtm($this->getConnection(), $path);
440
441        return ($timestamp !== -1) ? ['path' => $path, 'timestamp' => $timestamp] : false;
442    }
443
444    /**
445     * @inheritdoc
446     */
447    public function read($path)
448    {
449        if ( ! $object = $this->readStream($path)) {
450            return false;
451        }
452
453        $object['contents'] = stream_get_contents($object['stream']);
454        fclose($object['stream']);
455        unset($object['stream']);
456
457        return $object;
458    }
459
460    /**
461     * @inheritdoc
462     */
463    public function readStream($path)
464    {
465        $stream = fopen('php://temp', 'w+b');
466        $result = ftp_fget($this->getConnection(), $stream, $path, $this->transferMode);
467        rewind($stream);
468
469        if ( ! $result) {
470            fclose($stream);
471
472            return false;
473        }
474
475        return ['type' => 'file', 'path' => $path, 'stream' => $stream];
476    }
477
478    /**
479     * @inheritdoc
480     */
481    public function setVisibility($path, $visibility)
482    {
483        $mode = $visibility === AdapterInterface::VISIBILITY_PUBLIC ? $this->getPermPublic() : $this->getPermPrivate();
484
485        if ( ! ftp_chmod($this->getConnection(), $mode, $path)) {
486            return false;
487        }
488
489        return compact('path', 'visibility');
490    }
491
492    /**
493     * @inheritdoc
494     *
495     * @param string $directory
496     */
497    protected function listDirectoryContents($directory, $recursive = true)
498    {
499        if ($recursive && $this->recurseManually) {
500            return $this->listDirectoryContentsRecursive($directory);
501        }
502
503        $options = $recursive ? '-alnR' : '-aln';
504        $listing = $this->ftpRawlist($options, $directory);
505
506        return $listing ? $this->normalizeListing($listing, $directory) : [];
507    }
508
509    /**
510     * @inheritdoc
511     *
512     * @param string $directory
513     */
514    protected function listDirectoryContentsRecursive($directory)
515    {
516        $listing = $this->normalizeListing($this->ftpRawlist('-aln', $directory) ?: [], $directory);
517        $output = [];
518
519        foreach ($listing as $item) {
520            $output[] = $item;
521            if ($item['type'] !== 'dir') {
522                continue;
523            }
524            $output = array_merge($output, $this->listDirectoryContentsRecursive($item['path']));
525        }
526
527        return $output;
528    }
529
530    /**
531     * Check if the connection is open.
532     *
533     * @return bool
534     *
535     * @throws ConnectionErrorException
536     */
537    public function isConnected()
538    {
539        return is_resource($this->connection)
540            && $this->getRawExecResponseCode('NOOP') === 200;
541    }
542
543    /**
544     * @return bool
545     */
546    protected function isPureFtpdServer()
547    {
548        $response = ftp_raw($this->connection, 'HELP');
549
550        return stripos(implode(' ', $response), 'Pure-FTPd') !== false;
551    }
552
553    /**
554     * The ftp_rawlist function with optional escaping.
555     *
556     * @param string $options
557     * @param string $path
558     *
559     * @return array
560     */
561    protected function ftpRawlist($options, $path)
562    {
563        $connection = $this->getConnection();
564
565        if ($this->isPureFtpd) {
566            $path = str_replace(' ', '\ ', $path);
567            $this->escapePath($path);
568        }
569
570        return ftp_rawlist($connection, $options . ' ' . $path);
571    }
572
573    private function getRawExecResponseCode($command)
574    {
575        $response = @ftp_raw($this->connection, trim($command));
576
577        return (int) preg_replace('/\D/', '', implode(' ', $response));
578    }
579}
580