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 @@
+