123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- <?php
- declare(strict_types=1);
- namespace Shlinkio\Shlink\CLI\Command\Visit;
- use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
- use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
- use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
- use Shlinkio\Shlink\CLI\Util\ExitCodes;
- use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
- use Shlinkio\Shlink\Common\Util\IpAddress;
- use Shlinkio\Shlink\Core\Entity\Visit;
- use Shlinkio\Shlink\Core\Entity\VisitLocation;
- use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
- use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
- use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface;
- use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
- use Shlinkio\Shlink\IpGeolocation\Model\Location;
- use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
- use Symfony\Component\Console\Exception\RuntimeException;
- use Symfony\Component\Console\Helper\ProgressBar;
- use Symfony\Component\Console\Input\InputInterface;
- use Symfony\Component\Console\Input\InputOption;
- use Symfony\Component\Console\Output\OutputInterface;
- use Symfony\Component\Console\Style\SymfonyStyle;
- use Symfony\Component\Lock\LockFactory;
- use Throwable;
- use function sprintf;
- class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
- {
- public const NAME = 'visit:locate';
- private VisitLocatorInterface $visitLocator;
- private IpLocationResolverInterface $ipLocationResolver;
- private GeolocationDbUpdaterInterface $dbUpdater;
- private SymfonyStyle $io;
- private ?ProgressBar $progressBar = null;
- public function __construct(
- VisitLocatorInterface $visitLocator,
- IpLocationResolverInterface $ipLocationResolver,
- LockFactory $locker,
- GeolocationDbUpdaterInterface $dbUpdater
- ) {
- parent::__construct($locker);
- $this->visitLocator = $visitLocator;
- $this->ipLocationResolver = $ipLocationResolver;
- $this->dbUpdater = $dbUpdater;
- }
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Resolves visits origin locations.')
- ->addOption(
- 'retry',
- 'r',
- InputOption::VALUE_NONE,
- 'Will retry the location of visits that were located with a not-found location, in case it was due to '
- . 'a temporal issue.',
- )
- ->addOption(
- 'all',
- 'a',
- InputOption::VALUE_NONE,
- 'When provided together with --retry, will locate all existing visits, regardless the fact that they '
- . 'have already been located.',
- );
- }
- protected function initialize(InputInterface $input, OutputInterface $output): void
- {
- $this->io = new SymfonyStyle($input, $output);
- }
- protected function interact(InputInterface $input, OutputInterface $output): void
- {
- $retry = $input->getOption('retry');
- $all = $input->getOption('all');
- if ($all && !$retry) {
- $this->io->writeln(
- '<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
- . 'together with <fg=yellow;options=bold>--retry</>.</comment>',
- );
- }
- if ($all && $retry && ! $this->warnAndVerifyContinue()) {
- throw new RuntimeException('Execution aborted');
- }
- }
- private function warnAndVerifyContinue(): bool
- {
- $this->io->warning([
- 'You are about to process the location of all existing visits your short URLs received.',
- 'Since shlink saves visitors IP addresses anonymized, you could end up losing precision on some of '
- . 'your visits.',
- 'Also, if you have a large amount of visits, this can be a very time consuming process. '
- . 'Continue at your own risk.',
- ]);
- return $this->io->confirm('Do you want to proceed?', false);
- }
- protected function lockedExecute(InputInterface $input, OutputInterface $output): int
- {
- $retry = $input->getOption('retry');
- $all = $retry && $input->getOption('all');
- try {
- $this->checkDbUpdate();
- if ($all) {
- $this->visitLocator->locateAllVisits($this);
- } else {
- $this->visitLocator->locateUnlocatedVisits($this);
- if ($retry) {
- $this->visitLocator->locateVisitsWithEmptyLocation($this);
- }
- }
- $this->io->success('Finished locating visits');
- return ExitCodes::EXIT_SUCCESS;
- } catch (Throwable $e) {
- $this->io->error($e->getMessage());
- if ($e instanceof Throwable && $this->io->isVerbose()) {
- $this->getApplication()->renderThrowable($e, $this->io);
- }
- return ExitCodes::EXIT_FAILURE;
- }
- }
- /**
- * @throws IpCannotBeLocatedException
- */
- public function geolocateVisit(Visit $visit): Location
- {
- if (! $visit->hasRemoteAddr()) {
- $this->io->writeln(
- '<comment>Ignored visit with no IP address</comment>',
- OutputInterface::VERBOSITY_VERBOSE,
- );
- throw IpCannotBeLocatedException::forEmptyAddress();
- }
- $ipAddr = $visit->getRemoteAddr();
- $this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
- if ($ipAddr === IpAddress::LOCALHOST) {
- $this->io->writeln(' [<comment>Ignored localhost address</comment>]');
- throw IpCannotBeLocatedException::forLocalhost();
- }
- try {
- return $this->ipLocationResolver->resolveIpLocation($ipAddr);
- } catch (WrongIpException $e) {
- $this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
- if ($this->io->isVerbose()) {
- $this->getApplication()->renderThrowable($e, $this->io);
- }
- throw IpCannotBeLocatedException::forError($e);
- }
- }
- public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
- {
- $message = ! $visitLocation->isEmpty()
- ? sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName())
- : ' [<comment>Address not found</comment>]';
- $this->io->writeln($message);
- }
- private function checkDbUpdate(): void
- {
- try {
- $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void {
- $this->io->writeln(
- sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading'),
- );
- $this->progressBar = new ProgressBar($this->io);
- }, function (int $total, int $downloaded): void {
- $this->progressBar->setMaxSteps($total);
- $this->progressBar->setProgress($downloaded);
- });
- if ($this->progressBar !== null) {
- $this->progressBar->finish();
- $this->io->newLine();
- }
- } catch (GeolocationDbUpdateFailedException $e) {
- if (! $e->olderDbExists()) {
- $this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
- throw $e;
- }
- $this->io->newLine();
- $this->io->writeln(
- '<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>',
- );
- }
- }
- protected function getLockConfig(): LockedCommandConfig
- {
- return LockedCommandConfig::nonBlocking($this->getName());
- }
- }
|