LocateVisitsCommand.php 7.7 KB

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