Featured artworks thumbnails
This commit is contained in:
@@ -1233,7 +1233,7 @@ final class HomepageService
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
$xsSources = collect(['xs', 'mobile_sm'])
|
||||
$xsSources = collect(['mobile_xs', 'mobile_sm'])
|
||||
->map(function (string $variant) use ($variantUrls, $variants): ?string {
|
||||
$url = $variantUrls[$variant] ?? null;
|
||||
|
||||
|
||||
217
app/Services/News/NewsCoverImageService.php
Normal file
217
app/Services/News/NewsCoverImageService.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\News;
|
||||
|
||||
use App\Support\News\NewsCoverImage;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class NewsCoverImageService
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const MAX_WIDTH = 2200;
|
||||
|
||||
private const MAX_HEIGHT = 1400;
|
||||
|
||||
private const MIN_WIDTH = 1200;
|
||||
|
||||
private const MIN_HEIGHT = 630;
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function maxFileSizeKb(): int
|
||||
{
|
||||
return self::MAX_FILE_SIZE_KB;
|
||||
}
|
||||
|
||||
public function storeUploadedFile(UploadedFile $file): array
|
||||
{
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$raw = $this->readUploadBytes($file);
|
||||
$this->assertSupportedMimeType($raw);
|
||||
$this->assertMinimumDimensions($raw);
|
||||
|
||||
$masterImage = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT);
|
||||
$masterEncoded = (string) $masterImage->encode(new WebpEncoder(85));
|
||||
|
||||
$path = NewsCoverImage::path(hash('sha256', $masterEncoded));
|
||||
|
||||
$this->writeImage($path, $masterEncoded);
|
||||
|
||||
foreach (NewsCoverImage::VARIANTS as $variant => $config) {
|
||||
$variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']);
|
||||
$variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality']));
|
||||
$this->writeImage(NewsCoverImage::variantPath($path, $variant), $variantEncoded);
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'url' => NewsCoverImage::url($path),
|
||||
'width' => (int) $masterImage->width(),
|
||||
'height' => (int) $masterImage->height(),
|
||||
'size_bytes' => strlen($masterEncoded),
|
||||
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
|
||||
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
|
||||
'srcset' => NewsCoverImage::srcset($path),
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureVariants(string $path, bool $force = false): array
|
||||
{
|
||||
$trimmed = NewsCoverImage::normalizePath($path);
|
||||
|
||||
if (! NewsCoverImage::isManagedPath($trimmed)) {
|
||||
return ['generated' => 0, 'skipped' => count(NewsCoverImage::VARIANTS)];
|
||||
}
|
||||
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$disk = Storage::disk($this->mediaDiskName());
|
||||
if (! $disk->exists($trimmed)) {
|
||||
throw new RuntimeException('Managed cover image is missing from object storage.');
|
||||
}
|
||||
|
||||
$raw = $disk->get($trimmed);
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
throw new RuntimeException('Unable to read managed cover image from object storage.');
|
||||
}
|
||||
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach (NewsCoverImage::VARIANTS as $variant => $config) {
|
||||
$variantPath = NewsCoverImage::variantPath($trimmed, $variant);
|
||||
|
||||
if (! $force && $disk->exists($variantPath)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']);
|
||||
$variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality']));
|
||||
$this->writeImage($variantPath, $variantEncoded);
|
||||
$generated++;
|
||||
}
|
||||
|
||||
return ['generated' => $generated, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
public function deleteManagedFiles(string $path): void
|
||||
{
|
||||
$paths = NewsCoverImage::managedPaths($path);
|
||||
|
||||
if ($paths === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($paths);
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function writeImage(string $path, string $encoded): void
|
||||
{
|
||||
$written = Storage::disk($this->mediaDiskName())->put($path, $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store image in object storage.');
|
||||
}
|
||||
}
|
||||
|
||||
private function readUploadBytes(UploadedFile $file): string
|
||||
{
|
||||
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($uploadPath === '' || ! is_readable($uploadPath)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded image path.');
|
||||
}
|
||||
|
||||
$raw = file_get_contents($uploadPath);
|
||||
if ($raw === false || $raw === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image.');
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function assertSupportedMimeType(string $raw): void
|
||||
{
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($raw));
|
||||
|
||||
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported image mime type.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertMinimumDimensions(string $raw): void
|
||||
{
|
||||
$size = @getimagesizefromstring($raw);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
|
||||
$width = (int) ($size[0] ?? 0);
|
||||
$height = (int) ($size[1] ?? 0);
|
||||
|
||||
if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Image is too small. Minimum required size is %dx%d.',
|
||||
self::MIN_WIDTH,
|
||||
self::MIN_HEIGHT,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
158
app/Services/Traffic/BotClassifier.php
Normal file
158
app/Services/Traffic/BotClassifier.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Traffic;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class BotClassifier
|
||||
{
|
||||
/**
|
||||
* @return array{is_bot: bool, type: ?string, family: ?string}
|
||||
*/
|
||||
public function classify(Request $request): array
|
||||
{
|
||||
$userAgent = trim((string) $request->userAgent());
|
||||
|
||||
if ($userAgent === '') {
|
||||
return $this->bot('suspicious_bot', 'Empty UA');
|
||||
}
|
||||
|
||||
$normalized = strtolower($userAgent);
|
||||
|
||||
if ($family = $this->matchFamily($normalized, [
|
||||
'curl' => ['curl'],
|
||||
'wget' => ['wget'],
|
||||
'python-requests' => ['python-requests'],
|
||||
'libwww-perl' => ['libwww-perl'],
|
||||
'Go-http-client' => ['go-http-client'],
|
||||
'Java' => ['java/'],
|
||||
'scrapy' => ['scrapy'],
|
||||
'httpclient' => ['httpclient'],
|
||||
'masscan' => ['masscan'],
|
||||
'nikto' => ['nikto'],
|
||||
'sqlmap' => ['sqlmap'],
|
||||
])) {
|
||||
return $this->bot('suspicious_bot', $family);
|
||||
}
|
||||
|
||||
if ($family = $this->matchFamily($normalized, [
|
||||
'Googlebot' => ['googlebot'],
|
||||
'Bingbot' => ['bingbot'],
|
||||
'DuckDuckBot' => ['duckduckbot'],
|
||||
'YandexBot' => ['yandexbot'],
|
||||
'Baiduspider' => ['baiduspider'],
|
||||
'Applebot' => ['applebot'],
|
||||
'Slurp' => ['slurp'],
|
||||
])) {
|
||||
return $this->bot('search_bot', $family);
|
||||
}
|
||||
|
||||
if ($family = $this->matchFamily($normalized, [
|
||||
'GPTBot' => ['gptbot'],
|
||||
'ChatGPT-User' => ['chatgpt-user'],
|
||||
'OAI-SearchBot' => ['oai-searchbot'],
|
||||
'ClaudeBot' => ['claudebot'],
|
||||
'PerplexityBot' => ['perplexitybot'],
|
||||
'Bytespider' => ['bytespider'],
|
||||
'CCBot' => ['ccbot'],
|
||||
'Google-Extended' => ['google-extended'],
|
||||
'anthropic-ai' => ['anthropic-ai'],
|
||||
'cohere-ai' => ['cohere-ai'],
|
||||
])) {
|
||||
return $this->bot('ai_bot', $family);
|
||||
}
|
||||
|
||||
if ($family = $this->matchFamily($normalized, [
|
||||
'AhrefsBot' => ['ahrefsbot'],
|
||||
'SemrushBot' => ['semrushbot'],
|
||||
'MJ12bot' => ['mj12bot'],
|
||||
'DotBot' => ['dotbot'],
|
||||
'PetalBot' => ['petalbot'],
|
||||
'DataForSeoBot' => ['dataforseobot'],
|
||||
'BLEXBot' => ['blexbot'],
|
||||
'MauiBot' => ['mauibot'],
|
||||
'serpstatbot' => ['serpstatbot'],
|
||||
])) {
|
||||
return $this->bot('seo_bot', $family);
|
||||
}
|
||||
|
||||
if ($family = $this->matchFamily($normalized, [
|
||||
'facebookexternalhit' => ['facebookexternalhit'],
|
||||
'Twitterbot' => ['twitterbot'],
|
||||
'LinkedInBot' => ['linkedinbot'],
|
||||
'Slackbot' => ['slackbot'],
|
||||
'Discordbot' => ['discordbot'],
|
||||
'TelegramBot' => ['telegrambot'],
|
||||
'WhatsApp' => ['whatsapp'],
|
||||
'Pinterestbot' => ['pinterestbot'],
|
||||
])) {
|
||||
return $this->bot('social_bot', $family);
|
||||
}
|
||||
|
||||
if ($family = $this->matchFamily($normalized, [
|
||||
'UptimeRobot' => ['uptimerobot'],
|
||||
'Pingdom' => ['pingdom'],
|
||||
'StatusCake' => ['statuscake'],
|
||||
'Better Stack' => ['better stack', 'betterstack'],
|
||||
'BetterUptime' => ['betteruptime'],
|
||||
])) {
|
||||
return $this->bot('monitoring_bot', $family);
|
||||
}
|
||||
|
||||
if (strlen($userAgent) < 8) {
|
||||
return $this->bot('suspicious_bot', 'Short UA');
|
||||
}
|
||||
|
||||
if ($this->containsAny($normalized, ['bot', 'crawler', 'spider', 'crawl', 'preview'])) {
|
||||
return $this->bot('unknown_bot', 'Unknown crawler');
|
||||
}
|
||||
|
||||
return [
|
||||
'is_bot' => false,
|
||||
'type' => null,
|
||||
'family' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $families
|
||||
*/
|
||||
private function matchFamily(string $normalizedUserAgent, array $families): ?string
|
||||
{
|
||||
foreach ($families as $family => $keywords) {
|
||||
if ($this->containsAny($normalizedUserAgent, $keywords)) {
|
||||
return $family;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keywords
|
||||
*/
|
||||
private function containsAny(string $haystack, array $keywords): bool
|
||||
{
|
||||
foreach ($keywords as $keyword) {
|
||||
if ($keyword !== '' && str_contains($haystack, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{is_bot: bool, type: string, family: string}
|
||||
*/
|
||||
private function bot(string $type, string $family): array
|
||||
{
|
||||
return [
|
||||
'is_bot' => true,
|
||||
'type' => $type,
|
||||
'family' => $family,
|
||||
];
|
||||
}
|
||||
}
|
||||
361
app/Services/Traffic/OnlineVisitorRepository.php
Normal file
361
app/Services/Traffic/OnlineVisitorRepository.php
Normal file
@@ -0,0 +1,361 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user