diff --git a/.gitignore b/.gitignore index 9426e5f3..bf3d5ae0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ composer.phar vendor/ .env data/database.sqlite +data/GeoLite2-City.mmdb docs/swagger-ui docker-compose.override.yml diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php new file mode 100644 index 00000000..0536a0be --- /dev/null +++ b/config/autoload/geolite2.global.php @@ -0,0 +1,10 @@ + [ + 'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb', + ], + +]; diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 371c9175..2976e5b0 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI; -use Shlinkio\Shlink\Common\Service\IpApiLocationResolver; +use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface; use Shlinkio\Shlink\Common\Service\PreviewGenerator; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -65,7 +65,7 @@ return [ Command\Visit\ProcessVisitsCommand::class => [ Service\VisitService::class, - IpApiLocationResolver::class, + IpLocationResolverInterface::class, 'translator', ], diff --git a/module/CLI/src/Command/Visit/ProcessVisitsCommand.php b/module/CLI/src/Command/Visit/ProcessVisitsCommand.php index f1f70e35..3d36695c 100644 --- a/module/CLI/src/Command/Visit/ProcessVisitsCommand.php +++ b/module/CLI/src/Command/Visit/ProcessVisitsCommand.php @@ -68,10 +68,10 @@ class ProcessVisitsCommand extends Command } $ipAddr = $visit->getRemoteAddr(); - $io->write(sprintf('%s %s', $this->translator->translate('Processing IP'), $ipAddr)); + $io->write(sprintf('%s %s', $this->translator->translate('Processing IP'), $ipAddr)); if ($ipAddr === IpAddress::LOCALHOST) { $io->writeln( - sprintf(' (%s)', $this->translator->translate('Ignored localhost address')) + sprintf(' [%s]', $this->translator->translate('Ignored localhost address')) ); continue; } @@ -85,12 +85,15 @@ class ProcessVisitsCommand extends Command $this->visitService->saveVisit($visit); $io->writeln(sprintf( - ' (' . $this->translator->translate('Address located at "%s"') . ')', + ' [' . $this->translator->translate('Address located at "%s"') . ']', $location->getCityName() )); } catch (WrongIpException $e) { $io->writeln( - sprintf(' %s', $this->translator->translate('An error occurred while locating IP')) + sprintf( + ' [%s]', + $this->translator->translate('An error occurred while locating IP. Skipped') + ) ); if ($io->isVerbose()) { $this->getApplication()->renderException($e, $output); diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php index 6278d63b..d8df79aa 100644 --- a/module/Common/config/dependencies.config.php +++ b/module/Common/config/dependencies.config.php @@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Common; use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManager; +use GeoIp2\Database\Reader; use GuzzleHttp\Client as GuzzleClient; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -23,6 +24,7 @@ return [ Cache::class => Factory\CacheFactory::class, 'Logger_Shlink' => Factory\LoggerFactory::class, Filesystem::class => InvokableFactory::class, + Reader::class => ConfigAbstractFactory::class, Translator::class => Factory\TranslatorFactory::class, Template\Extension\TranslatorExtension::class => ConfigAbstractFactory::class, @@ -33,6 +35,7 @@ return [ Image\ImageBuilder::class => Image\ImageBuilderFactory::class, Service\IpApiLocationResolver::class => ConfigAbstractFactory::class, + Service\GeoLite2LocationResolver::class => ConfigAbstractFactory::class, Service\PreviewGenerator::class => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -42,6 +45,7 @@ return [ 'logger' => LoggerInterface::class, Logger::class => 'Logger_Shlink', LoggerInterface::class => 'Logger_Shlink', + Service\IpLocationResolverInterface::class => Service\GeoLite2LocationResolver::class, ], 'abstract_factories' => [ Factory\DottedAccessConfigAbstractFactory::class, @@ -49,9 +53,12 @@ return [ ], ConfigAbstractFactory::class => [ + Reader::class => ['config.geolite2.db_location'], + Template\Extension\TranslatorExtension::class => ['translator'], Middleware\LocaleMiddleware::class => ['translator'], Service\IpApiLocationResolver::class => ['httpClient'], + Service\GeoLite2LocationResolver::class => [Reader::class], Service\PreviewGenerator::class => [ Image\ImageBuilder::class, Filesystem::class, diff --git a/module/Common/src/Service/GeoLite2LocationResolver.php b/module/Common/src/Service/GeoLite2LocationResolver.php new file mode 100644 index 00000000..104a1ab3 --- /dev/null +++ b/module/Common/src/Service/GeoLite2LocationResolver.php @@ -0,0 +1,73 @@ +geoLiteDbReader = $geoLiteDbReader; + } + + /** + * @param string $ipAddress + * @return array + * @throws WrongIpException + */ + public function resolveIpLocation(string $ipAddress): array + { + try { + $city = $this->geoLiteDbReader->city($ipAddress); + return $this->mapFields($city); + } catch (AddressNotFoundException $e) { + throw WrongIpException::fromIpAddress($ipAddress, $e); + } catch (InvalidDatabaseException $e) { + throw new WrongIpException('Provided GeoLite2 db file is invalid', 0, $e); + } + } + + private function mapFields(City $city): array + { + return [ + 'country_code' => $city->country->isoCode ?? '', + 'country_name' => $city->country->name ?? '', + 'region_name' => $city->mostSpecificSubdivision->name ?? '', + 'city' => $city->city->name ?? '', + 'latitude' => (string) $city->location->latitude, // FIXME Cast to string for BC compatibility + 'longitude' => (string) $city->location->longitude, // FIXME Cast to string for BC compatibility + 'time_zone' => $city->location->timeZone ?? '', + ]; + } + + /** + * Returns the interval in seconds that needs to be waited when the API limit is reached + * + * @return int + */ + public function getApiInterval(): int + { + return 0; + } + + /** + * Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists + * + * @return int|null + */ + public function getApiLimit(): ?int + { + return null; + } +}