diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 6c34f19c..6a6e4122 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -9,6 +9,7 @@ return [ Command\ResolveUrlCommand::class, Command\ListShortcodesCommand::class, Command\GetVisitsCommand::class, + Command\ProcessVisitsCommand::class, ] ], diff --git a/module/CLI/config/services.config.php b/module/CLI/config/services.config.php index 839e92f8..5c5931a4 100644 --- a/module/CLI/config/services.config.php +++ b/module/CLI/config/services.config.php @@ -13,6 +13,7 @@ return [ CLI\Command\ResolveUrlCommand::class => AnnotatedFactory::class, CLI\Command\ListShortcodesCommand::class => AnnotatedFactory::class, CLI\Command\GetVisitsCommand::class => AnnotatedFactory::class, + CLI\Command\ProcessVisitsCommand::class => AnnotatedFactory::class, ], ], diff --git a/module/CLI/src/Command/ProcessVisitsCommand.php b/module/CLI/src/Command/ProcessVisitsCommand.php new file mode 100644 index 00000000..19692e85 --- /dev/null +++ b/module/CLI/src/Command/ProcessVisitsCommand.php @@ -0,0 +1,74 @@ +visitService = $visitService; + $this->ipLocationResolver = $ipLocationResolver; + } + + public function configure() + { + $this->setName('visit:process') + ->setDescription('Processes visits where location is not set already'); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $visits = $this->visitService->getUnlocatedVisits(); + + foreach ($visits as $visit) { + $ipAddr = $visit->getRemoteAddr(); + $output->write(sprintf('Processing IP %s', $ipAddr)); + if ($ipAddr === self::LOCALHOST) { + $output->writeln(' (Ignored localhost address)'); + continue; + } + + try { + $result = $this->ipLocationResolver->resolveIpLocation($ipAddr); + $location = new VisitLocation(); + $location->exchangeArray($result); + $visit->setVisitLocation($location); + $this->visitService->saveVisit($visit); + $output->writeln(sprintf(' (Address located at "%s")', $location->getCityName())); + } catch (WrongIpException $e) { + continue; + } + } + + $output->writeln('Finished processing all IPs'); + } +} diff --git a/module/Common/config/services.config.php b/module/Common/config/services.config.php index b3c4e14d..838b7896 100644 --- a/module/Common/config/services.config.php +++ b/module/Common/config/services.config.php @@ -4,6 +4,7 @@ use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManager; use Shlinkio\Shlink\Common\Factory\CacheFactory; use Shlinkio\Shlink\Common\Factory\EntityManagerFactory; +use Shlinkio\Shlink\Common\Service\IpLocationResolver; use Zend\ServiceManager\Factory\InvokableFactory; return [ @@ -13,6 +14,7 @@ return [ EntityManager::class => EntityManagerFactory::class, GuzzleHttp\Client::class => InvokableFactory::class, Cache::class => CacheFactory::class, + IpLocationResolver::class => AnnotatedFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, diff --git a/module/Common/src/Exception/ExceptionInterface.php b/module/Common/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000..81255f6a --- /dev/null +++ b/module/Common/src/Exception/ExceptionInterface.php @@ -0,0 +1,6 @@ +httpClient = $httpClient; + } + + /** + * @param $ipAddress + * @return array + */ + public function resolveIpLocation($ipAddress) + { + try { + $response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress)); + return json_decode($response->getBody(), true); + } catch (GuzzleException $e) { + throw WrongIpException::fromIpAddress($ipAddress, $e); + } + } +} diff --git a/module/Common/src/Service/IpLocationResolverInterface.php b/module/Common/src/Service/IpLocationResolverInterface.php new file mode 100644 index 00000000..350c2b9c --- /dev/null +++ b/module/Common/src/Service/IpLocationResolverInterface.php @@ -0,0 +1,11 @@ + AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, Service\ShortUrlService::class => AnnotatedFactory::class, + Service\VisitService::class => AnnotatedFactory::class, // Middleware RedirectMiddleware::class => AnnotatedFactory::class, diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 04cfbe68..a95c61b0 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; * @author * @link * - * @ORM\Entity + * @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\VisitRepository") * @ORM\Table(name="visits") */ class Visit extends AbstractEntity implements \JsonSerializable @@ -40,6 +40,12 @@ class Visit extends AbstractEntity implements \JsonSerializable * @ORM\JoinColumn(name="short_url_id", referencedColumnName="id") */ protected $shortUrl; + /** + * @var VisitLocation + * @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"}) + * @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true) + */ + protected $visitLocation; public function __construct() { @@ -136,6 +142,24 @@ class Visit extends AbstractEntity implements \JsonSerializable return $this; } + /** + * @return VisitLocation + */ + public function getVisitLocation() + { + return $this->visitLocation; + } + + /** + * @param VisitLocation $visitLocation + * @return $this + */ + public function setVisitLocation($visitLocation) + { + $this->visitLocation = $visitLocation; + return $this; + } + /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php @@ -150,6 +174,7 @@ class Visit extends AbstractEntity implements \JsonSerializable 'date' => isset($this->date) ? $this->date->format(\DateTime::ISO8601) : null, 'remoteAddr' => $this->remoteAddr, 'userAgent' => $this->userAgent, + 'visitLocation' => $this->visitLocation, ]; } } diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php new file mode 100644 index 00000000..3b9851ac --- /dev/null +++ b/module/Core/src/Entity/VisitLocation.php @@ -0,0 +1,240 @@ +countryCode; + } + + /** + * @param string $countryCode + * @return $this + */ + public function setCountryCode($countryCode) + { + $this->countryCode = $countryCode; + return $this; + } + + /** + * @return string + */ + public function getCountryName() + { + return $this->countryName; + } + + /** + * @param string $countryName + * @return $this + */ + public function setCountryName($countryName) + { + $this->countryName = $countryName; + return $this; + } + + /** + * @return string + */ + public function getRegionName() + { + return $this->regionName; + } + + /** + * @param string $regionName + * @return $this + */ + public function setRegionName($regionName) + { + $this->regionName = $regionName; + return $this; + } + + /** + * @return string + */ + public function getCityName() + { + return $this->cityName; + } + + /** + * @param string $cityName + * @return $this + */ + public function setCityName($cityName) + { + $this->cityName = $cityName; + return $this; + } + + /** + * @return string + */ + public function getLatitude() + { + return $this->latitude; + } + + /** + * @param string $latitude + * @return $this + */ + public function setLatitude($latitude) + { + $this->latitude = $latitude; + return $this; + } + + /** + * @return string + */ + public function getLongitude() + { + return $this->longitude; + } + + /** + * @param string $longitude + * @return $this + */ + public function setLongitude($longitude) + { + $this->longitude = $longitude; + return $this; + } + + /** + * @return string + */ + public function getTimezone() + { + return $this->timezone; + } + + /** + * @param string $timezone + * @return $this + */ + public function setTimezone($timezone) + { + $this->timezone = $timezone; + return $this; + } + + /** + * Exchange internal values from provided array + * + * @param array $array + * @return void + */ + public function exchangeArray(array $array) + { + if (array_key_exists('country_code', $array)) { + $this->setCountryCode($array['country_code']); + } + if (array_key_exists('country_name', $array)) { + $this->setCountryName($array['country_name']); + } + if (array_key_exists('region_name', $array)) { + $this->setRegionName($array['region_name']); + } + if (array_key_exists('city', $array)) { + $this->setCityName($array['city']); + } + if (array_key_exists('latitude', $array)) { + $this->setLatitude($array['latitude']); + } + if (array_key_exists('longitude', $array)) { + $this->setLongitude($array['longitude']); + } + if (array_key_exists('time_zone', $array)) { + $this->setTimezone($array['time_zone']); + } + } + + /** + * Return an array representation of the object + * + * @return array + */ + public function getArrayCopy() + { + return [ + 'countryCode' => $this->countryCode, + 'countryName' => $this->countryName, + 'regionName' => $this->regionName, + 'cityName' => $this->cityName, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'timezone' => $this->timezone, + ]; + } + + /** + * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return $this->getArrayCopy(); + } +} diff --git a/module/Core/src/Exception/ExceptionInterface.php b/module/Core/src/Exception/ExceptionInterface.php deleted file mode 100644 index 4d61c4d3..00000000 --- a/module/Core/src/Exception/ExceptionInterface.php +++ /dev/null @@ -1,6 +0,0 @@ -createQueryBuilder('v'); + $qb->where($qb->expr()->isNull('v.visitLocation')); + + return $qb->getQuery()->getResult(); + } +} diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php new file mode 100644 index 00000000..6534d7ea --- /dev/null +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -0,0 +1,13 @@ +em = $em; + } + + /** + * @return Visit[] + */ + public function getUnlocatedVisits() + { + /** @var VisitRepository $repo */ + $repo = $this->em->getRepository(Visit::class); + return $repo->findUnlocatedVisits(); + } + + /** + * @param Visit $visit + */ + public function saveVisit(Visit $visit) + { + $this->em->persist($visit); + $this->em->flush(); + } +} diff --git a/module/Core/src/Service/VisitServiceInterface.php b/module/Core/src/Service/VisitServiceInterface.php new file mode 100644 index 00000000..8347fdb3 --- /dev/null +++ b/module/Core/src/Service/VisitServiceInterface.php @@ -0,0 +1,17 @@ +