Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.95% covered (danger)
13.95%
6 / 43
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FtpServerCommand
13.95% covered (danger)
13.95%
6 / 43
50.00% covered (danger)
50.00%
2 / 4
14.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 execute
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 handleConnection
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare(strict_types=1);
3
4namespace Apie\FtpServer;
5
6use Apie\ApieFileSystem\ApieFilesystem;
7use Apie\ApieFileSystem\ApieFilesystemFactory;
8use Apie\Core\ContextBuilders\ContextBuilderFactory;
9use Apie\FtpServer\Transfers\NoTransferSet;
10use Apie\FtpServer\Transfers\TransferInterface;
11use React\EventLoop\Loop;
12use React\Socket\ConnectionInterface;
13use React\Socket\SocketServer;
14use Symfony\Component\Console\Command\Command;
15use Symfony\Component\Console\Input\InputInterface;
16use Symfony\Component\Console\Input\InputOption;
17use Symfony\Component\Console\Output\OutputInterface;
18use Symfony\Component\Console\Style\SymfonyStyle;
19
20class FtpServerCommand extends Command
21{
22    public function __construct(
23        private readonly FtpServerRunner $runner,
24        private readonly ApieFilesystemFactory $filesystemFactory,
25        private readonly ContextBuilderFactory $contextBuilder,
26        private readonly string $defaultIpAddress = '127.0.0.1',
27        private readonly string $passiveMinPort = '49152',
28        private readonly string $passiveMaxPort = '65534',
29    ) {
30        parent::__construct('apie:ftp-server');
31    }
32
33    protected function configure(): void
34    {
35        $this
36            ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host to bind to', $this->defaultIpAddress)
37            ->addOption('port', null, InputOption::VALUE_REQUIRED, 'Port to listen on', '2121')
38            ->setDescription('Runs a virtual FTP server to link with Apie')
39            ->setHelp('Start an APIE FTP server.');
40    }
41
42    protected function execute(InputInterface $input, OutputInterface $output): int
43    {
44        $io = new SymfonyStyle($input, $output);
45
46        $host = (string) $input->getOption('host');
47        $port = (int) $input->getOption('port');
48
49        $io->title('APIE FTP server');
50        $io->listing([
51            'Host: ' . $host,
52            'Port: ' . $port,
53        ]);
54
55        $loop = Loop::get();
56
57        $server = new SocketServer("0.0.0.0:$port", [], $loop);
58        $server->on('connection', function (ConnectionInterface $conn) use ($input, $output) {
59            $this->handleConnection($conn, $input, $output);
60        });
61
62        $loop->run();
63        return Command::SUCCESS;
64    }
65
66    private function handleConnection(ConnectionInterface $conn, InputInterface $input, OutputInterface $output)
67    {
68        $conn->write("220 Apie FTP Server Ready\r\n");
69        $context = $this->contextBuilder->createGeneralContext([
70            'ftp' => true,
71            ConnectionInterface::class => $conn,
72            ApieFilesystemFactory::class => $this->filesystemFactory,
73            FtpConstants::CURRENT_PWD => '/',
74            TransferInterface::class => new NoTransferSet(),
75            FtpConstants::PUBLIC_IP => $input->getOption('host'),
76            FtpConstants::PASV_MIN_PORT => $this->passiveMinPort,
77            FtpConstants::PASV_MAX_PORT => $this->passiveMaxPort,
78        ]);
79        $filesystem = $this->filesystemFactory->create($context);
80        $context = $context
81            ->withContext(ApieFilesystem::class, $filesystem)
82            ->withContext(FtpConstants::CURRENT_FOLDER, $filesystem->rootFolder);
83
84        $conn->on('data', function ($data) use ($conn, $output, &$context) {
85            $command = trim($data);
86
87            [$cmd, $arg] = array_pad(explode(' ', $command, 2), 2, null);
88            $cmd = strtoupper($cmd);
89            $output->writeln("Command $cmd $arg");
90
91            $context = $this->runner->run($context, $cmd, $arg ?? '');
92        });
93    }
94}