Statistics Overhaul: Initial Database Changes (#5300)

This commit is contained in:
Buster "Silver Eagle" Neece 2022-04-21 01:31:23 -05:00 committed by GitHub
parent bd29e0c4ee
commit 019c2fa92f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 509 additions and 230 deletions

View File

@ -64,20 +64,25 @@
{{ row.item.connected_time }}
</template>
<template #cell(user_agent)="row">
<span v-if="row.item.is_mobile">
<icon icon="smartphone"></icon>
<span class="sr-only">
<translate key="lang_device_mobile">Mobile Device</translate>
<div>
<span v-if="row.item.is_mobile">
<icon icon="smartphone"></icon>
<span class="sr-only">
<translate key="lang_device_mobile">Mobile Device</translate>
</span>
</span>
</span>
<span v-else>
<icon icon="desktop_windows"></icon>
<span class="sr-only">
<translate key="lang_device_desktop">Desktop Device</translate>
<span v-else>
<icon icon="desktop_windows"></icon>
<span class="sr-only">
<translate key="lang_device_desktop">Desktop Device</translate>
</span>
</span>
</span>
{{ row.item.user_agent }} <br>
<small>{{ row.item.client }}</small>
{{ row.item.user_agent }}
</div>
<div v-if="row.item.device.client">
<small>{{ row.item.device.client }}</small>
</div>
</template>
<template #cell(stream)="row">
<span v-if="row.item.mount_name == ''">
@ -94,11 +99,8 @@
</span>
</template>
<template #cell(location)="row">
<span v-if="row.item.location.status == 'success'">
{{ row.item.location.region }}, {{ row.item.location.country }}
</span>
<span v-else-if="row.item.location.message">
{{ row.item.location.message }}
<span v-if="row.item.location.description">
{{ row.item.location.description }}
</span>
<span v-else>
<translate key="lang_location_unknown">Unknown</translate>

View File

@ -58,7 +58,7 @@ export default {
computed: {
mapPoints() {
return _.filter(this.listeners, function (l) {
return l.location.status === 'success';
return null !== l.location.lat && null !== l.location.lon;
});
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Controller\Api\Stations;
use App\Entity;
use App\Enums\SupportedLocales;
use App\Environment;
use App\Http\Response;
use App\Http\ServerRequest;
@ -13,8 +12,8 @@ use App\OpenApi;
use App\Service\DeviceDetector;
use App\Service\IpGeolocation;
use App\Utilities\File;
use Azura\DoctrineBatchUtils\ReadOnlyBatchIteratorAggregate;
use Carbon\CarbonImmutable;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\Writer;
use OpenApi\Attributes as OA;
@ -51,14 +50,13 @@ class ListenersAction
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
Entity\Repository\ListenerRepository $listenerRepo,
Entity\Repository\StationMountRepository $mountRepo,
Entity\Repository\StationRemoteRepository $remoteRepo,
IpGeolocation $geoLite,
DeviceDetector $deviceDetector,
Environment $environment
): ResponseInterface {
set_time_limit($environment->getSyncLongExecutionTime());
$station = $request->getStation();
$stationTz = $station->getTimezoneObject();
@ -67,19 +65,12 @@ class ListenersAction
$isLive = empty($params['start']);
$now = CarbonImmutable::now($stationTz);
$qb = $em->createQueryBuilder()
->select('l')
->from(Entity\Listener::class, 'l')
->where('l.station = :station')
->setParameter('station', $station)
->orderBy('l.timestamp_start', 'ASC');
if ($isLive) {
$range = 'live';
$startTimestamp = $now->getTimestamp();
$endTimestamp = $now->getTimestamp();
$qb = $qb->andWhere('l.timestamp_end = 0');
$listenersIterator = $listenerRepo->iterateLiveListenersArray($station);
} else {
$start = CarbonImmutable::parse($params['start'], $stationTz)
->setSecond(0);
@ -91,44 +82,48 @@ class ListenersAction
$range = $start->format('Y-m-d_H-i-s') . '_to_' . $end->format('Y-m-d_H-i-s');
$qb = $qb->andWhere('l.timestamp_start < :time_end')
->andWhere('(l.timestamp_end = 0 OR l.timestamp_end > :time_start)')
$listenersIterator = $em->createQuery(
<<<'DQL'
SELECT l
FROM App\Entity\Listener l
WHERE l.station = :station
AND l.timestamp_start < :time_end
AND (l.timestamp_end = 0 OR l.timestamp_end > :time_start)
ORDER BY l.timestamp_start ASC
DQL
)->setParameter('station', $station)
->setParameter('time_start', $startTimestamp)
->setParameter('time_end', $endTimestamp);
->setParameter('time_end', $endTimestamp)
->toIterable([], AbstractQuery::HYDRATE_ARRAY);
}
$locale = $request->getAttribute(ServerRequest::ATTR_LOCALE)
?? SupportedLocales::default();
$mountNames = $mountRepo->getDisplayNames($station);
$remoteNames = $remoteRepo->getDisplayNames($station);
$listenersIterator = ReadOnlyBatchIteratorAggregate::fromQuery($qb->getQuery(), 250);
/** @var Entity\Api\Listener[] $listeners */
$listeners = [];
$listenersByHash = [];
$groupByUnique = ('false' !== ($params['unique'] ?? 'true'));
$nowTimestamp = $now->getTimestamp();
foreach ($listenersIterator as $listener) {
/** @var Entity\Listener $listener */
$listenerStart = $listener->getTimestampStart();
$listenerStart = $listener['timestamp_start'];
if ($isLive) {
$listenerEnd = $now->getTimestamp();
$listenerEnd = $nowTimestamp;
} else {
if ($listenerStart < $startTimestamp) {
$listenerStart = $startTimestamp;
}
$listenerEnd = $listener->getTimestampEnd();
$listenerEnd = $listener['timestamp_end'];
if (0 === $listenerEnd || $listenerEnd > $endTimestamp) {
$listenerEnd = $endTimestamp;
}
}
$hash = $listener->getListenerHash();
$hash = $listener['listener_hash'];
if ($groupByUnique && isset($listenersByHash[$hash])) {
$listenersByHash[$hash]['intervals'][] = [
'start' => $listenerStart,
@ -137,30 +132,16 @@ class ListenersAction
continue;
}
$userAgent = $listener->getListenerUserAgent();
$dd = $deviceDetector->parse($userAgent);
$api = new Entity\Api\Listener();
$api->ip = $listener->getListenerIp();
$api->user_agent = $userAgent;
$api->hash = $hash;
$api->client = $dd->getClient() ?? 'Unknown';
$api->is_mobile = $dd->isMobile();
if ($listener->getMountId()) {
$mountId = $listener->getMountId();
$api = Entity\Api\Listener::fromArray($listener);
if (null !== $listener['mount_id']) {
$api->mount_is_local = true;
$api->mount_name = $mountNames[$mountId];
} elseif ($listener->getRemoteId()) {
$remoteId = $listener->getRemoteId();
$api->mount_name = $mountNames[$listener['mount_id']];
} elseif (null !== $listener['remote_id']) {
$api->mount_is_local = false;
$api->mount_name = $remoteNames[$remoteId];
$api->mount_name = $remoteNames[$listener['remote_id']];
}
$api->location = $geoLite->getLocationInfo($api->ip, $locale);
if ($groupByUnique) {
$listenersByHash[$hash] = [
'api' => $api,
@ -183,7 +164,7 @@ class ListenersAction
foreach ($listenersByHash as $listenerInfo) {
$intervals = (array)$listenerInfo['intervals'];
$startTime = $now->getTimestamp();
$startTime = $nowTimestamp;
$endTime = 0;
foreach ($intervals as $interval) {
$startTime = min($interval['start'], $startTime);
@ -239,14 +220,20 @@ class ListenersAction
'End Time',
'Seconds Connected',
'User Agent',
'Client',
'Is Mobile',
'Mount Type',
'Mount Name',
'Location',
'Country',
'Region',
'City',
'Device: Client',
'Device: Is Mobile',
'Device: Is Browser',
'Device: Is Bot',
'Device: Browser Family',
'Device: OS Family',
'Location: Description',
'Location: Country',
'Location: Region',
'Location: City',
'Location: Latitude',
'Location: Longitude',
]
);
@ -260,31 +247,22 @@ class ListenersAction
$endTime->toIso8601String(),
$listener->connected_time,
$listener->user_agent,
$listener->client,
$listener->is_mobile ? 'True' : 'False',
($listener->mount_is_local) ? 'Local' : 'Remote',
$listener->mount_name,
$listener->device['client'],
$listener->device['is_mobile'] ? 'True' : 'False',
$listener->device['is_browser'] ? 'True' : 'False',
$listener->device['is_bot'] ? 'True' : 'False',
$listener->device['browser_family'],
$listener->device['os_family'],
$listener->location['description'],
$listener->location['country'],
$listener->location['region'],
$listener->location['city'],
$listener->location['lat'],
$listener->location['lon'],
];
if ('' === $listener->mount_name) {
$exportRow[] = 'Unknown';
$exportRow[] = 'Unknown';
} else {
$exportRow[] = ($listener->mount_is_local) ? 'Local' : 'Remote';
$exportRow[] = $listener->mount_name;
}
$location = $listener->location;
if ('success' === $location['status']) {
$exportRow[] = $location['region'] . ', ' . $location['country'];
$exportRow[] = $location['country'];
$exportRow[] = $location['region'];
$exportRow[] = $location['city'];
} else {
$exportRow[] = $location['message'] ?? 'N/A';
$exportRow[] = '';
$exportRow[] = '';
$exportRow[] = '';
}
$csv->insertOne($exportRow);
}

View File

@ -32,18 +32,6 @@ class Listener
)]
public string $hash = '';
#[OA\Property(
description: 'The listener\'s client details (extracted from user-agent)',
example: ''
)]
public string $client = '';
#[OA\Property(
description: 'Whether the user-agent is likely a mobile browser.',
example: true
)]
public bool $is_mobile = false;
#[OA\Property(
description: 'Whether the user is connected to a local mount point or a remote one.',
example: false
@ -74,9 +62,41 @@ class Listener
)]
public int $connected_time = 0;
#[OA\Property(
description: 'Device metadata, if available',
items: new OA\Items()
)]
public array $device = [];
#[OA\Property(
description: 'Location metadata, if available',
items: new OA\Items()
)]
public array $location = [];
public static function fromArray(array $row): self
{
$api = new self();
$api->ip = $row['listener_ip'];
$api->user_agent = $row['listener_user_agent'];
$api->hash = $row['listener_hash'];
$api->connected_on = $row['timestamp_start'];
$api->connected_until = $row['timestamp_end'];
$api->connected_time = $api->connected_until - $api->connected_on;
$device = [];
$location = [];
foreach ($row as $key => $val) {
if (str_starts_with($key, 'device.')) {
$device[str_replace('device.', '', $key)] = $val;
} elseif (str_starts_with($key, 'location.')) {
$location[str_replace('location.', '', $key)] = $val;
}
}
$api->device = $device;
$api->location = $location;
return $api;
}
}

View File

@ -11,7 +11,10 @@ use NowPlaying\Result\Client;
#[
ORM\Entity,
ORM\Table(name: 'listener'),
ORM\Index(columns: ['timestamp_end', 'timestamp_start'], name: 'idx_timestamps')
ORM\Index(columns: ['timestamp_end', 'timestamp_start'], name: 'idx_timestamps'),
ORM\Index(columns: ['location_country'], name: 'idx_statistics_country'),
ORM\Index(columns: ['device_os_family'], name: 'idx_statistics_os'),
ORM\Index(columns: ['device_browser_family'], name: 'idx_statistics_browser')
]
class Listener implements IdentifiableEntityInterface
{
@ -57,6 +60,12 @@ class Listener implements IdentifiableEntityInterface
#[ORM\Column]
protected int $timestamp_end;
#[ORM\Embedded(class: ListenerLocation::class, columnPrefix: 'location_')]
protected ListenerLocation $location;
#[ORM\Embedded(class: ListenerDevice::class, columnPrefix: 'device_')]
protected ListenerDevice $device;
public function __construct(Station $station, Client $client)
{
$this->station = $station;
@ -68,6 +77,9 @@ class Listener implements IdentifiableEntityInterface
$this->listener_user_agent = $this->truncateString($client->userAgent);
$this->listener_ip = $client->ip;
$this->listener_hash = self::calculateListenerHash($client);
$this->location = new ListenerLocation();
$this->device = new ListenerDevice();
}
public function getStation(): Station
@ -150,6 +162,16 @@ class Listener implements IdentifiableEntityInterface
return $this->timestamp_end - $this->timestamp_start;
}
public function getLocation(): ListenerLocation
{
return $this->location;
}
public function getDevice(): ListenerDevice
{
return $this->device;
}
/**
* Filter clients to exclude any listeners that shouldn't be included (i.e. relays).
*

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable]
class ListenerDevice implements \JsonSerializable
{
#[ORM\Column(length: 255)]
protected ?string $client = null;
#[ORM\Column]
protected bool $is_browser = false;
#[ORM\Column]
protected bool $is_mobile = false;
#[ORM\Column]
protected bool $is_bot = false;
#[ORM\Column(length: 150)]
protected ?string $browser_family = null;
#[ORM\Column(length: 150)]
protected ?string $os_family = null;
public function getClient(): ?string
{
return $this->client;
}
public function isBrowser(): bool
{
return $this->is_browser;
}
public function isMobile(): bool
{
return $this->is_mobile;
}
public function isBot(): bool
{
return $this->is_bot;
}
public function getBrowserFamily(): ?string
{
return $this->browser_family;
}
public function getOsFamily(): ?string
{
return $this->os_family;
}
public function jsonSerialize(): mixed
{
return [
'client' => $this->client,
'is_browser' => $this->is_browser,
'is_mobile' => $this->is_mobile,
'is_bot' => $this->is_bot,
'browser_family' => $this->browser_family,
'os_family' => $this->os_family,
];
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable]
class ListenerLocation implements \JsonSerializable
{
#[ORM\Column(length: 255)]
protected string $description;
#[ORM\Column(length: 150, nullable: true)]
protected ?string $region = null;
#[ORM\Column(length: 150, nullable: true)]
protected ?string $city = null;
#[ORM\Column(length: 2, nullable: true)]
protected ?string $country = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 6, nullable: true)]
protected ?float $lat = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 6, nullable: true)]
protected ?float $lon = null;
public function getDescription(): string
{
return $this->description;
}
public function getRegion(): ?string
{
return $this->region;
}
public function getCity(): ?string
{
return $this->city;
}
public function getCountry(): ?string
{
return $this->country;
}
public function getLat(): ?float
{
return $this->lat;
}
public function getLon(): ?float
{
return $this->lon;
}
public function jsonSerialize(): mixed
{
return [
'description' => $this->description,
'region' => $this->region,
'city' => $this->city,
'country' => $this->country,
'lat' => $this->lat,
'lon' => $this->lon,
];
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220415093355 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add device and location metadata to Listeners table.';
}
public function up(Schema $schema): void
{
$this->addSql(
'ALTER TABLE listener ADD location_description VARCHAR(255) NOT NULL, ADD location_region VARCHAR(150) DEFAULT NULL, ADD location_city VARCHAR(150) DEFAULT NULL, ADD location_country VARCHAR(2) DEFAULT NULL, ADD location_lat NUMERIC(10, 6) DEFAULT NULL, ADD location_lon NUMERIC(10, 6) DEFAULT NULL, ADD device_client VARCHAR(255) NOT NULL, ADD device_is_browser TINYINT(1) NOT NULL, ADD device_is_mobile TINYINT(1) NOT NULL, ADD device_is_bot TINYINT(1) NOT NULL, ADD device_browser_family VARCHAR(150) NOT NULL, ADD device_os_family VARCHAR(150) NOT NULL'
);
$this->addSql('CREATE INDEX idx_statistics_country ON listener (location_country)');
$this->addSql('CREATE INDEX idx_statistics_os ON listener (device_os_family)');
$this->addSql('CREATE INDEX idx_statistics_browser ON listener (device_browser_family)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_statistics_country ON listener');
$this->addSql('DROP INDEX idx_statistics_os ON listener');
$this->addSql('DROP INDEX idx_statistics_browser ON listener');
$this->addSql(
'ALTER TABLE listener DROP location_description, DROP location_region, DROP location_city, DROP location_country, DROP location_lat, DROP location_lon, DROP device_client, DROP device_is_browser, DROP device_is_mobile, DROP device_is_bot, DROP device_browser_family, DROP device_os_family'
);
}
}

View File

@ -4,17 +4,44 @@ declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity;
use App\Environment;
use App\Service\DeviceDetector;
use App\Service\IpGeolocation;
use Carbon\CarbonImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Connection;
use NowPlaying\Result\Client;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @extends Repository<Entity\Listener>
*/
class ListenerRepository extends Repository
{
use Entity\Traits\TruncateStrings;
protected string $tableName;
protected Connection $conn;
public function __construct(
protected DeviceDetector $deviceDetector,
protected IpGeolocation $ipGeolocation,
ReloadableEntityManagerInterface $em,
Serializer $serializer,
Environment $environment,
LoggerInterface $logger
) {
parent::__construct($em, $serializer, $environment, $logger);
$this->tableName = $this->em->getClassMetadata(Entity\Listener::class)->getTableName();
$this->conn = $this->em->getConnection();
}
/**
* Get the number of unique listeners for a station during a specified time period.
*
@ -56,6 +83,7 @@ class ListenerRepository extends Repository
FROM App\Entity\Listener l
WHERE l.station = :station
AND l.timestamp_end = 0
ORDER BY l.timestamp_start ASC
DQL
)->setParameter('station', $station);
@ -88,10 +116,6 @@ class ListenerRepository extends Repository
$existingClients[$identifier] = $client['id'];
}
$listenerTable = $this->em->getClassMetadata(Entity\Listener::class)->getTableName();
$conn = $this->em->getConnection();
foreach ($clients as $client) {
$listenerHash = Entity\Listener::calculateListenerHash($client);
$identifier = $client->uid . '_' . $listenerHash;
@ -101,32 +125,7 @@ class ListenerRepository extends Repository
unset($existingClients[$identifier]);
} else {
// Create a new record.
$record = [
'station_id' => $station->getId(),
'timestamp_start' => time(),
'timestamp_end' => 0,
'listener_uid' => (int)$client->uid,
'listener_user_agent' => mb_substr(
$client->userAgent ?? '',
0,
255,
'UTF-8'
),
'listener_ip' => $client->ip,
'listener_hash' => Entity\Listener::calculateListenerHash($client),
];
if (!empty($client->mount)) {
[$mountType, $mountId] = explode('_', $client->mount, 2);
if ('local' === $mountType) {
$record['mount_id'] = (int)$mountId;
} elseif ('remote' === $mountType) {
$record['remote_id'] = (int)$mountId;
}
}
$conn->insert($listenerTable, $record);
$this->batchAddRow($station, $client);
}
}
@ -146,6 +145,82 @@ class ListenerRepository extends Repository
);
}
public function batchAddRow(Entity\Station $station, Client $client): array
{
$record = [
'station_id' => $station->getId(),
'timestamp_start' => time(),
'timestamp_end' => 0,
'listener_uid' => (int)$client->uid,
'listener_user_agent' => $this->truncateString($client->userAgent ?? ''),
'listener_ip' => $client->ip,
'listener_hash' => Entity\Listener::calculateListenerHash($client),
];
if (!empty($client->mount)) {
[$mountType, $mountId] = explode('_', $client->mount, 2);
if ('local' === $mountType) {
$record['mount_id'] = (int)$mountId;
} elseif ('remote' === $mountType) {
$record['remote_id'] = (int)$mountId;
}
}
$record = $this->batchAddDeviceDetails($record);
$record = $this->batchAddLocationDetails($record);
$this->conn->insert($this->tableName, $record);
return $record;
}
protected function batchAddDeviceDetails(array $record): array
{
$userAgent = $record['listener_user_agent'];
try {
$browserResult = $this->deviceDetector->parse($userAgent);
$record['device_client'] = $this->truncateNullableString($browserResult->client);
$record['device_is_browser'] = $browserResult->isBrowser ? 1 : 0;
$record['device_is_mobile'] = $browserResult->isMobile ? 1 : 0;
$record['device_is_bot'] = $browserResult->isBot ? 1 : 0;
$record['device_browser_family'] = $this->truncateNullableString($browserResult->browserFamily, 150);
$record['device_os_family'] = $this->truncateNullableString($browserResult->osFamily, 150);
} catch (\Throwable $e) {
$this->logger->error('Device Detector error: ' . $e->getMessage(), [
'user_agent' => $userAgent,
'exception' => $e,
]);
}
return $record;
}
protected function batchAddLocationDetails(array $record): array
{
$ip = $record['listener_ip'];
try {
$ipInfo = $this->ipGeolocation->getLocationInfo($ip);
$record['location_description'] = $this->truncateString($ipInfo->description);
$record['location_region'] = $this->truncateNullableString($ipInfo->region, 150);
$record['location_city'] = $this->truncateNullableString($ipInfo->city, 150);
$record['location_country'] = $this->truncateNullableString($ipInfo->country, 2);
$record['location_lat'] = $ipInfo->lat;
$record['location_lon'] = $ipInfo->lon;
} catch (\Throwable $e) {
$this->logger->error('IP Geolocation error: ' . $e->getMessage(), [
'ip' => $ip,
'exception' => $e,
]);
}
return $record;
}
public function clearAll(): void
{
$this->em->createQuery(

View File

@ -73,7 +73,7 @@ class StationRequestRepository extends Repository
// Forbid web crawlers from using this feature.
$dd = $this->deviceDetector->parse($userAgent);
if ($dd->isBot()) {
if ($dd->isBot) {
throw new Exception(__('Search engine crawlers are not permitted to use this feature.'));
}

View File

@ -5,12 +5,10 @@ declare(strict_types=1);
namespace App\Radio\Frontend\Blocklist;
use App\Entity;
use App\Enums\SupportedLocales;
use App\Radio\Enums\FrontendAdapters;
use App\Service\IpGeolocation;
use PhpIP\IP;
use PhpIP\IPBlock;
use Symfony\Component\Intl\Countries;
class BlocklistParser
{
@ -106,22 +104,11 @@ class BlocklistParser
return false;
}
$listenerLocation = $this->ipGeolocation->getLocationInfo($listenerIp, SupportedLocales::default());
if ('success' === $listenerLocation['status']) {
$listenerCountry = $listenerLocation['country'];
$countries = Countries::getNames(SupportedLocales::default()->value);
$listenerCountryCode = '';
foreach ($countries as $countryCode => $countryName) {
if ($countryName === $listenerCountry) {
$listenerCountryCode = $countryCode;
break;
}
}
$listenerLocation = $this->ipGeolocation->getLocationInfo($listenerIp);
if (null !== $listenerLocation->country) {
foreach ($bannedCountries as $countryCode) {
if ($countryCode === $listenerCountryCode) {
if ($countryCode === $listenerLocation->country) {
return true;
}
}

View File

@ -44,7 +44,7 @@ class DeviceDetector
$this->dd->setUserAgent($userAgent);
$this->dd->parse();
return DeviceResult::fromDeviceDetector($this->dd);
return DeviceResult::fromDeviceDetector($userAgent, $this->dd);
}
);

View File

@ -3,56 +3,57 @@
namespace App\Service\DeviceDetector;
use DeviceDetector\DeviceDetector;
use DeviceDetector\Parser\Client\Browser;
use DeviceDetector\Parser\OperatingSystem;
final class DeviceResult
{
public function __construct(
protected bool $isBot = false,
protected bool $isMobile = false,
protected ?string $client = null
public readonly string $userAgent
) {
}
public function isBot(): bool
{
return $this->isBot;
}
public ?string $client = null;
public function isMobile(): bool
{
return $this->isMobile;
}
public bool $isBrowser = false;
public function getClient(): ?string
{
return $this->client;
}
public bool $isMobile = false;
public static function fromDeviceDetector(DeviceDetector $dd): self
{
$isBot = $dd->isBot();
public bool $isBot = false;
if ($isBot) {
public ?string $browserFamily = null;
public ?string $osFamily = null;
public static function fromDeviceDetector(string $userAgent, DeviceDetector $dd): self
{
$record = new self($userAgent);
$record->isBot = $dd->isBot();
if ($record->isBot) {
$clientBot = (array)$dd->getBot();
$clientBotName = $clientBot['name'] ?? 'Unknown Crawler';
$clientBotType = $clientBot['category'] ?? 'Generic Crawler';
$client = $clientBotName . ' (' . $clientBotType . ')';
$record->client = $clientBotName . ' (' . $clientBotType . ')';
$record->browserFamily = 'Crawler';
$record->osFamily = 'Crawler';
} else {
$record->isMobile = $dd->isMobile();
$record->isBrowser = $dd->isBrowser();
$clientInfo = (array)$dd->getClient();
$clientBrowser = $clientInfo['name'] ?? 'Unknown Browser';
$clientVersion = $clientInfo['version'] ?? '0.00';
$record->browserFamily = Browser::getBrowserFamily($clientBrowser);
$clientOsInfo = (array)$dd->getOs();
$clientOs = $clientOsInfo['name'] ?? 'Unknown OS';
$record->osFamily = OperatingSystem::getOsFamily($clientOs);
$client = $clientBrowser . ' ' . $clientVersion . ', ' . $clientOs;
$record->client = $clientBrowser . ' ' . $clientVersion . ', ' . $clientOs;
}
return new self(
isBot: $isBot,
isMobile: $dd->isMobile(),
client: $client
);
return $record;
}
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\SupportedLocales;
use App\Service\IpGeolocator;
use Exception;
use MaxMind\Db\Reader;
@ -69,10 +68,7 @@ class IpGeolocation
return $this->attribution;
}
/**
* @return mixed[]
*/
public function getLocationInfo(string $ip, SupportedLocales $locale): array
public function getLocationInfo(string $ip): IpGeolocator\IpResult
{
if (!$this->isInitialized) {
$this->initialize();
@ -80,10 +76,7 @@ class IpGeolocation
$reader = $this->reader;
if (null === $reader) {
return [
'status' => 'error',
'message' => $this->getAttribution(),
];
throw new \RuntimeException('No IP Geolocation reader available.');
}
$cacheKey = $this->readerShortName . '_' . str_replace([':', '.'], '_', $ip);
@ -101,50 +94,18 @@ class IpGeolocation
}
return [
'status' => 'error',
'status' => 'error',
'message' => 'Internal/Reserved IP',
];
} catch (Exception $e) {
return [
'status' => 'error',
'status' => 'error',
'message' => $e->getMessage(),
];
}
}
);
if (isset($ipInfo['status']) && $ipInfo['status'] === 'error') {
return $ipInfo;
}
return [
'status' => 'success',
'lat' => $ipInfo['location']['latitude'] ?? 0.0,
'lon' => $ipInfo['location']['longitude'] ?? 0.0,
'timezone' => $ipInfo['location']['time_zone'] ?? '',
'region' => $this->getLocalizedString($ipInfo['subdivisions'][0]['names'] ?? null, $locale),
'country' => $this->getLocalizedString($ipInfo['country']['names'] ?? null, $locale),
'city' => $this->getLocalizedString($ipInfo['city']['names'] ?? null, $locale),
'message' => $this->attribution,
];
}
protected function getLocalizedString(?array $names, SupportedLocales $locale): string
{
if (empty($names)) {
return '';
}
// Convert "en_US" to "en-US", the format MaxMind uses.
$localeStr = str_replace('_', '-', $locale->value);
// Check for an exact match.
if (isset($names[$localeStr])) {
return $names[$localeStr];
}
// Check for a match of the first portion, i.e. "en"
$localeStr = strtolower(substr($localeStr, 0, 2));
return $names[$localeStr] ?? $names['en'];
return IpGeolocator\IpResult::fromIpInfo($ip, $ipInfo);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Service\IpGeolocator;
final class IpResult
{
public function __construct(
public readonly string $ip
) {
}
public string $description;
public ?string $region = null;
public ?string $city = null;
public ?string $country = null;
public ?float $lat = null;
public ?float $lon = null;
public static function fromIpInfo(string $ip, array $ipInfo = []): self
{
$record = new self($ip);
if (isset($ipInfo['status']) && $ipInfo['status'] === 'error') {
$record->description = 'Internal/Reserved IP';
return $record;
}
$record->region = $ipInfo['subdivisions'][0]['names']['en'] ?? null;
$record->city = $ipInfo['city'][0]['names']['en'] ?? null;
$record->country = $ipInfo['country']['iso_code'] ?? null;
$record->description = implode(
', ',
array_filter([
$record->region,
$record->city,
$record->country,
])
);
$record->lat = $ipInfo['location']['latitude'] ?? null;
$record->lon = $ipInfo['location']['longitude'] ?? null;
return $record;
}
}

View File

@ -60,14 +60,20 @@ class Api_Stations_ReportsCest extends CestAbstract
'End Time',
'Seconds Connected',
'User Agent',
'Client',
'Is Mobile',
'Mount Type',
'Mount Name',
'Location',
'Country',
'Region',
'City',
'Device: Client',
'Device: Is Mobile',
'Device: Is Browser',
'Device: Is Bot',
'Device: Browser Family',
'Device: OS Family',
'Location: Description',
'Location: Country',
'Location: Region',
'Location: City',
'Location: Latitude',
'Location: Longitude',
];
$this->testReportCsv($I, $requestUrl, $csvHeaders);