LocateVisitsCommand::lockedExecute()   B
last analyzed

Complexity

Conditions 7
Paths 34

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 7

Importance

Changes 0
Metric Value
eloc 17
c 0
b 0
f 0
dl 0
loc 26
ccs 16
cts 16
cp 1
rs 8.8333
cc 7
nc 34
nop 2
crap 7
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Shlinkio\Shlink\CLI\Command\Visit;
6
7
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
8
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
9
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
10
use Shlinkio\Shlink\CLI\Util\ExitCodes;
11
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
12
use Shlinkio\Shlink\Common\Util\IpAddress;
13
use Shlinkio\Shlink\Core\Entity\Visit;
14
use Shlinkio\Shlink\Core\Entity\VisitLocation;
15
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
16
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
17
use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface;
18
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
19
use Shlinkio\Shlink\IpGeolocation\Model\Location;
20
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
21
use Symfony\Component\Console\Exception\RuntimeException;
22
use Symfony\Component\Console\Helper\ProgressBar;
23
use Symfony\Component\Console\Input\InputInterface;
24
use Symfony\Component\Console\Input\InputOption;
25
use Symfony\Component\Console\Output\OutputInterface;
26
use Symfony\Component\Console\Style\SymfonyStyle;
27
use Symfony\Component\Lock\LockFactory;
28
use Throwable;
29
30
use function sprintf;
31
32
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
33
{
34
    public const NAME = 'visit:locate';
35
36
    private VisitLocatorInterface $visitLocator;
37
    private IpLocationResolverInterface $ipLocationResolver;
38
    private GeolocationDbUpdaterInterface $dbUpdater;
39
40
    private SymfonyStyle $io;
41
    private ?ProgressBar $progressBar = null;
42
43 14
    public function __construct(
44
        VisitLocatorInterface $visitLocator,
45
        IpLocationResolverInterface $ipLocationResolver,
46
        LockFactory $locker,
47
        GeolocationDbUpdaterInterface $dbUpdater
48
    ) {
49 14
        parent::__construct($locker);
50 14
        $this->visitLocator = $visitLocator;
51 14
        $this->ipLocationResolver = $ipLocationResolver;
52 14
        $this->dbUpdater = $dbUpdater;
53 14
    }
54
55 14
    protected function configure(): void
56
    {
57
        $this
58 14
            ->setName(self::NAME)
59 14
            ->setDescription('Resolves visits origin locations.')
60 14
            ->addOption(
61 14
                'retry',
62 14
                'r',
63 14
                InputOption::VALUE_NONE,
64
                'Will retry the location of visits that were located with a not-found location, in case it was due to '
65 14
                . 'a temporal issue.',
66
            )
67 14
            ->addOption(
68 14
                'all',
69 14
                'a',
70 14
                InputOption::VALUE_NONE,
71
                'When provided together with --retry, will locate all existing visits, regardless the fact that they '
72 14
                . 'have already been located.',
73
            );
74 14
    }
75
76 14
    protected function initialize(InputInterface $input, OutputInterface $output): void
77
    {
78 14
        $this->io = new SymfonyStyle($input, $output);
79 14
    }
80
81 14
    protected function interact(InputInterface $input, OutputInterface $output): void
82
    {
83 14
        $retry = $input->getOption('retry');
84 14
        $all = $input->getOption('all');
85
86 14
        if ($all && !$retry) {
87 1
            $this->io->writeln(
88
                '<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
89 1
                . 'together with <fg=yellow;options=bold>--retry</>.</comment>',
90
            );
91
        }
92
93 14
        if ($all && $retry && ! $this->warnAndVerifyContinue()) {
94 3
            throw new RuntimeException('Execution aborted');
95
        }
96 11
    }
97
98 4
    private function warnAndVerifyContinue(): bool
99
    {
100 4
        $this->io->warning([
101 4
            'You are about to process the location of all existing visits your short URLs received.',
102
            'Since shlink saves visitors IP addresses anonymized, you could end up losing precision on some of '
103
            . 'your visits.',
104
            'Also, if you have a large amount of visits, this can be a very time consuming process. '
105
            . 'Continue at your own risk.',
106
        ]);
107 4
        return $this->io->confirm('Do you want to proceed?', false);
108
    }
109
110 10
    protected function lockedExecute(InputInterface $input, OutputInterface $output): int
111
    {
112 10
        $retry = $input->getOption('retry');
113 10
        $all = $retry && $input->getOption('all');
114
115
        try {
116 10
            $this->checkDbUpdate();
117
118 9
            if ($all) {
119 1
                $this->visitLocator->locateAllVisits($this);
120
            } else {
121 8
                $this->visitLocator->locateUnlocatedVisits($this);
122 4
                if ($retry) {
123 1
                    $this->visitLocator->locateVisitsWithEmptyLocation($this);
124
                }
125
            }
126
127 5
            $this->io->success('Finished locating visits');
128 5
            return ExitCodes::EXIT_SUCCESS;
129 5
        } catch (Throwable $e) {
130 5
            $this->io->error($e->getMessage());
131 5
            if ($e instanceof Throwable && $this->io->isVerbose()) {
132 4
                $this->getApplication()->renderThrowable($e, $this->io);
133
            }
134
135 5
            return ExitCodes::EXIT_FAILURE;
136
        }
137
    }
138
139
    /**
140
     * @throws IpCannotBeLocatedException
141
     */
142 7
    public function geolocateVisit(Visit $visit): Location
143
    {
144 7
        if (! $visit->hasRemoteAddr()) {
145 2
            $this->io->writeln(
146 2
                '<comment>Ignored visit with no IP address</comment>',
147 2
                OutputInterface::VERBOSITY_VERBOSE,
148
            );
149 2
            throw IpCannotBeLocatedException::forEmptyAddress();
150
        }
151
152 5
        $ipAddr = $visit->getRemoteAddr();
153 5
        $this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
154 5
        if ($ipAddr === IpAddress::LOCALHOST) {
155 1
            $this->io->writeln(' [<comment>Ignored localhost address</comment>]');
156 1
            throw IpCannotBeLocatedException::forLocalhost();
157
        }
158
159
        try {
160 4
            return $this->ipLocationResolver->resolveIpLocation($ipAddr);
161 1
        } catch (WrongIpException $e) {
162 1
            $this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
163 1
            if ($this->io->isVerbose()) {
164 1
                $this->getApplication()->renderThrowable($e, $this->io);
165
            }
166
167 1
            throw IpCannotBeLocatedException::forError($e);
168
        }
169
    }
170
171 3
    public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
172
    {
173 3
        $message = ! $visitLocation->isEmpty()
174
            ? sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName())
175 3
            : ' [<comment>Address not found</comment>]';
176 3
        $this->io->writeln($message);
177 3
    }
178
179 10
    private function checkDbUpdate(): void
180
    {
181
        try {
182
            $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void {
183 2
                $this->io->writeln(
184 2
                    sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading'),
185
                );
186 2
                $this->progressBar = new ProgressBar($this->io);
187
            }, function (int $total, int $downloaded): void {
188 2
                $this->progressBar->setMaxSteps($total);
0 ignored issues
show
Bug introduced by
The method setMaxSteps() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

188
                $this->progressBar->/** @scrutinizer ignore-call */ 
189
                                    setMaxSteps($total);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
189 2
                $this->progressBar->setProgress($downloaded);
190 10
            });
191
192 8
            if ($this->progressBar !== null) {
193
                $this->progressBar->finish();
194 8
                $this->io->newLine();
195
            }
196 2
        } catch (GeolocationDbUpdateFailedException $e) {
197 2
            if (! $e->olderDbExists()) {
198 1
                $this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
199 1
                throw $e;
200
            }
201
202 1
            $this->io->newLine();
203 1
            $this->io->writeln(
204 1
                '<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>',
205
            );
206
        }
207 9
    }
208
209 11
    protected function getLockConfig(): LockedCommandConfig
210
    {
211 11
        return new LockedCommandConfig($this->getName());
0 ignored issues
show
Bug introduced by
It seems like $this->getName() can also be of type null; however, parameter $lockName of Shlinkio\Shlink\CLI\Comm...ndConfig::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

211
        return new LockedCommandConfig(/** @scrutinizer ignore-type */ $this->getName());
Loading history...
212
    }
213
}
214