361 lines
12 KiB
PHP
361 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Traffic;
|
|
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Str;
|
|
|
|
class OnlineVisitorRepository
|
|
{
|
|
public const INDEX_KEY = 'skinbase:presence:online:index';
|
|
public const KEY_PREFIX = 'skinbase:presence:online';
|
|
public const TTL_SECONDS = 300;
|
|
|
|
public function __construct(private readonly BotClassifier $classifier)
|
|
{
|
|
}
|
|
|
|
public function track(Request $request): void
|
|
{
|
|
try {
|
|
$classification = $this->classifier->classify($request);
|
|
$visitorKey = $this->resolveVisitorKey($request, $classification);
|
|
$existing = $this->readRecord($visitorKey);
|
|
$user = $request->user();
|
|
$now = now()->toIso8601String();
|
|
|
|
$record = [
|
|
'visitor_key' => $visitorKey,
|
|
'type' => $classification['is_bot']
|
|
? (string) $classification['type']
|
|
: ($user ? 'human_logged' : 'human_guest'),
|
|
'bot_family' => $classification['is_bot'] ? $classification['family'] : null,
|
|
'user_id' => $this->resolveUserId($user),
|
|
'user_name' => $this->resolveUserName($user),
|
|
'ip_masked' => $this->maskIp($this->resolveIp($request)),
|
|
'ip_hash' => hash('sha256', $this->resolveIp($request)),
|
|
'user_agent' => $this->truncate((string) $request->userAgent(), 512),
|
|
'browser' => $this->detectBrowser((string) $request->userAgent()),
|
|
'platform' => $this->detectPlatform((string) $request->userAgent()),
|
|
'current_url' => $this->currentUrl($request),
|
|
'route_name' => $request->route()?->getName(),
|
|
'referer' => $this->truncate((string) $request->headers->get('referer', ''), 512) ?: null,
|
|
'first_seen_at' => is_string($existing['first_seen_at'] ?? null)
|
|
? $existing['first_seen_at']
|
|
: $now,
|
|
'last_seen_at' => $now,
|
|
'hits' => (int) ($existing['hits'] ?? 0) + 1,
|
|
];
|
|
|
|
$this->storeRecord($visitorKey, $record, self::TTL_SECONDS);
|
|
$this->addIndexMember($visitorKey);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Online visitor tracking failed', [
|
|
'error' => $e->getMessage(),
|
|
'path' => $request->path(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function all(): array
|
|
{
|
|
try {
|
|
$visitorKeys = array_values(array_unique(array_filter(array_map('strval', $this->readIndexMembers()))));
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Online visitor index read failed', ['error' => $e->getMessage()]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$records = [];
|
|
$expired = [];
|
|
|
|
foreach ($visitorKeys as $visitorKey) {
|
|
try {
|
|
$record = $this->readRecord($visitorKey);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Online visitor record read failed', [
|
|
'error' => $e->getMessage(),
|
|
'visitor_key' => $visitorKey,
|
|
]);
|
|
$record = null;
|
|
}
|
|
|
|
if ($record === null) {
|
|
$expired[] = $visitorKey;
|
|
continue;
|
|
}
|
|
|
|
$records[] = $record;
|
|
}
|
|
|
|
if ($expired !== []) {
|
|
try {
|
|
$this->removeIndexMembers($expired);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Online visitor index cleanup failed', ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
usort($records, static function (array $left, array $right): int {
|
|
return strtotime((string) ($right['last_seen_at'] ?? '')) <=> strtotime((string) ($left['last_seen_at'] ?? ''));
|
|
});
|
|
|
|
return $records;
|
|
}
|
|
|
|
/**
|
|
* @return array{total:int,logged:int,guests:int,bots:int,search_bots:int,ai_bots:int,social_bots:int,seo_bots:int,suspicious_bots:int}
|
|
*/
|
|
public function summary(): array
|
|
{
|
|
$records = $this->all();
|
|
|
|
return [
|
|
'total' => count($records),
|
|
'logged' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_logged')),
|
|
'guests' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_guest')),
|
|
'bots' => count(array_filter($records, static fn (array $record): bool => str_ends_with((string) ($record['type'] ?? ''), '_bot'))),
|
|
'search_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'search_bot')),
|
|
'ai_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'ai_bot')),
|
|
'social_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'social_bot')),
|
|
'seo_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'seo_bot')),
|
|
'suspicious_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'suspicious_bot')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{url:string, visitors:int}>
|
|
*/
|
|
public function activePages(): array
|
|
{
|
|
$counts = [];
|
|
|
|
foreach ($this->all() as $record) {
|
|
$url = trim((string) ($record['current_url'] ?? ''));
|
|
|
|
if ($url === '') {
|
|
continue;
|
|
}
|
|
|
|
$counts[$url] = ($counts[$url] ?? 0) + 1;
|
|
}
|
|
|
|
arsort($counts);
|
|
|
|
$pages = [];
|
|
|
|
foreach ($counts as $url => $visitors) {
|
|
$pages[] = [
|
|
'url' => $url,
|
|
'visitors' => $visitors,
|
|
];
|
|
}
|
|
|
|
return $pages;
|
|
}
|
|
|
|
public function forget(string $visitorKey): void
|
|
{
|
|
try {
|
|
$this->deleteRecord($visitorKey);
|
|
$this->removeIndexMembers([$visitorKey]);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Online visitor forget failed', [
|
|
'error' => $e->getMessage(),
|
|
'visitor_key' => $visitorKey,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
protected function readIndexMembers(): array
|
|
{
|
|
return array_map('strval', Redis::smembers(self::INDEX_KEY));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
protected function readRecord(string $visitorKey): ?array
|
|
{
|
|
$raw = Redis::get($this->recordKey($visitorKey));
|
|
|
|
if (! is_string($raw) || $raw === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($raw, true);
|
|
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $record
|
|
*/
|
|
protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void
|
|
{
|
|
Redis::setex(
|
|
$this->recordKey($visitorKey),
|
|
$ttlSeconds,
|
|
(string) json_encode($record, JSON_UNESCAPED_SLASHES)
|
|
);
|
|
}
|
|
|
|
protected function addIndexMember(string $visitorKey): void
|
|
{
|
|
Redis::sadd(self::INDEX_KEY, $visitorKey);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $visitorKeys
|
|
*/
|
|
protected function removeIndexMembers(array $visitorKeys): void
|
|
{
|
|
if ($visitorKeys === []) {
|
|
return;
|
|
}
|
|
|
|
Redis::srem(self::INDEX_KEY, ...$visitorKeys);
|
|
}
|
|
|
|
protected function deleteRecord(string $visitorKey): void
|
|
{
|
|
Redis::del($this->recordKey($visitorKey));
|
|
}
|
|
|
|
/**
|
|
* @param array{is_bot: bool, type: ?string, family: ?string} $classification
|
|
*/
|
|
private function resolveVisitorKey(Request $request, array $classification): string
|
|
{
|
|
$user = $request->user();
|
|
|
|
if ($user) {
|
|
return 'user:' . $user->getAuthIdentifier();
|
|
}
|
|
|
|
$ip = $this->resolveIp($request);
|
|
$userAgent = (string) $request->userAgent();
|
|
|
|
if ($classification['is_bot']) {
|
|
return 'bot:' . hash('sha256', $ip . '|' . $userAgent);
|
|
}
|
|
|
|
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
|
|
$sessionCookie = (string) $request->cookies->get($sessionCookieName, '');
|
|
$guestSeed = $sessionCookie !== ''
|
|
? 'session:' . $sessionCookie
|
|
: 'fingerprint:' . $ip . '|' . $userAgent . '|' . (string) $request->header('Accept-Language', '');
|
|
|
|
return 'guest:' . hash('sha256', $guestSeed);
|
|
}
|
|
|
|
private function resolveIp(Request $request): string
|
|
{
|
|
$cloudflareIp = trim((string) $request->headers->get('CF-Connecting-IP', ''));
|
|
|
|
if ($cloudflareIp !== '' && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
|
|
return $cloudflareIp;
|
|
}
|
|
|
|
return (string) ($request->ip() ?: '0.0.0.0');
|
|
}
|
|
|
|
private function maskIp(string $ip): string
|
|
{
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
$parts = explode('.', $ip);
|
|
|
|
return sprintf('%s.%s.xxx.xxx', $parts[0] ?? '0', $parts[1] ?? '0');
|
|
}
|
|
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
$parts = explode(':', $ip);
|
|
$parts = array_pad($parts, 4, '');
|
|
|
|
return sprintf('%s:%s:xxxx:xxxx', $parts[0] ?: '::', $parts[1] ?: '::');
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
private function detectBrowser(string $userAgent): string
|
|
{
|
|
$normalized = strtolower($userAgent);
|
|
|
|
return match (true) {
|
|
str_contains($normalized, 'edg/') => 'Edge',
|
|
str_contains($normalized, 'opr/') || str_contains($normalized, 'opera') => 'Opera',
|
|
str_contains($normalized, 'chrome') && ! str_contains($normalized, 'edg/') => 'Chrome',
|
|
str_contains($normalized, 'firefox') => 'Firefox',
|
|
str_contains($normalized, 'safari') && ! str_contains($normalized, 'chrome') => 'Safari',
|
|
default => 'Unknown',
|
|
};
|
|
}
|
|
|
|
private function detectPlatform(string $userAgent): string
|
|
{
|
|
$normalized = strtolower($userAgent);
|
|
|
|
return match (true) {
|
|
str_contains($normalized, 'windows') => 'Windows',
|
|
str_contains($normalized, 'iphone') || str_contains($normalized, 'ipad') || str_contains($normalized, 'ios') => 'iOS',
|
|
str_contains($normalized, 'android') => 'Android',
|
|
str_contains($normalized, 'mac os') || str_contains($normalized, 'macintosh') => 'macOS',
|
|
str_contains($normalized, 'linux') => 'Linux',
|
|
default => 'Unknown',
|
|
};
|
|
}
|
|
|
|
private function currentUrl(Request $request): string
|
|
{
|
|
$path = '/' . ltrim($request->path(), '/');
|
|
|
|
return $path === '//' ? '/' : $path;
|
|
}
|
|
|
|
private function recordKey(string $visitorKey): string
|
|
{
|
|
return self::KEY_PREFIX . ':' . $visitorKey;
|
|
}
|
|
|
|
private function truncate(string $value, int $limit): string
|
|
{
|
|
return Str::limit($value, $limit, '');
|
|
}
|
|
|
|
private function resolveUserId(?Authenticatable $user): ?int
|
|
{
|
|
if ($user === null) {
|
|
return null;
|
|
}
|
|
|
|
$identifier = $user->getAuthIdentifier();
|
|
|
|
return is_numeric($identifier) ? (int) $identifier : null;
|
|
}
|
|
|
|
private function resolveUserName(?Authenticatable $user): ?string
|
|
{
|
|
if ($user === null) {
|
|
return null;
|
|
}
|
|
|
|
$name = data_get($user, 'name')
|
|
?? data_get($user, 'username')
|
|
?? data_get($user, 'email');
|
|
|
|
return is_string($name) && $name !== '' ? $name : 'User';
|
|
}
|
|
} |