193 lines
5.8 KiB
PHP
193 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class PageVisit extends Model
|
|
{
|
|
public $timestamps = false;
|
|
|
|
public const CHAIN_CORUNA = 0;
|
|
|
|
public const CHAIN_DARKSWORD = 1;
|
|
|
|
protected $fillable = [
|
|
'channel_id',
|
|
'client_uid',
|
|
'chain',
|
|
'session_id',
|
|
'user_agent',
|
|
'os',
|
|
'os_version',
|
|
'browser',
|
|
'browser_version',
|
|
'ip',
|
|
'domain',
|
|
'referer',
|
|
'created_at',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'chain' => 'integer',
|
|
'created_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
public static function chainLabel(int $chain): string
|
|
{
|
|
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
|
|
}
|
|
|
|
/**
|
|
* t.js landings: iOS > 17.2.1 and < 18.7 count as DarkSword.
|
|
*/
|
|
public static function chainFromIosVersion(?string $os, ?string $osVersion): int
|
|
{
|
|
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
|
|
return self::CHAIN_CORUNA;
|
|
}
|
|
$version = trim((string) $osVersion);
|
|
if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) {
|
|
return self::CHAIN_CORUNA;
|
|
}
|
|
if (version_compare($version, '17.2.1', '>') && version_compare($version, '18.7', '<')) {
|
|
return self::CHAIN_DARKSWORD;
|
|
}
|
|
|
|
return self::CHAIN_CORUNA;
|
|
}
|
|
|
|
public static function normalizeDomain(?string $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
$value = preg_replace('#^https?://#i', '', $value) ?? $value;
|
|
$value = explode('/', $value, 2)[0];
|
|
$value = strtolower(rtrim($value));
|
|
if ($value === '' || ! str_contains($value, '.')) {
|
|
return null;
|
|
}
|
|
|
|
return substr($value, 0, 255);
|
|
}
|
|
|
|
public const VISITOR_UID_LENGTH = 16;
|
|
|
|
public const LANDING_MERGE_SECONDS = 60;
|
|
|
|
/**
|
|
* One landing = one row. t.js and DS register share this so the same
|
|
* channel+IP within {@see LANDING_MERGE_SECONDS} is merged, not doubled.
|
|
* DarkSword wins on chain; register may replace client_uid with the device UUID.
|
|
*
|
|
* @param array<string, mixed> $attrs
|
|
*/
|
|
public static function recordLanding(array $attrs, int $chain): self
|
|
{
|
|
$channel = (string) ($attrs['channel_id'] ?? '');
|
|
$ip = self::normalizeIp(isset($attrs['ip']) ? (string) $attrs['ip'] : null);
|
|
$existing = null;
|
|
if ($channel !== '' && $ip !== '') {
|
|
$existing = static::query()
|
|
->where('channel_id', $channel)
|
|
->whereIn('ip', self::ipLookupKeys($ip))
|
|
->where('created_at', '>=', now()->subSeconds(self::LANDING_MERGE_SECONDS))
|
|
->orderByDesc('id')
|
|
->first();
|
|
}
|
|
|
|
if ($existing) {
|
|
$updates = [];
|
|
if ($chain === self::CHAIN_DARKSWORD && (int) $existing->chain !== self::CHAIN_DARKSWORD) {
|
|
$updates['chain'] = self::CHAIN_DARKSWORD;
|
|
}
|
|
if ($chain === self::CHAIN_DARKSWORD) {
|
|
$uid = trim((string) ($attrs['client_uid'] ?? ''));
|
|
if ($uid !== '' && $uid !== (string) $existing->client_uid) {
|
|
$updates['client_uid'] = substr($uid, 0, 64);
|
|
}
|
|
}
|
|
foreach (['user_agent', 'os', 'os_version', 'browser', 'browser_version', 'domain', 'referer'] as $field) {
|
|
$incoming = $attrs[$field] ?? null;
|
|
if (! is_string($incoming) || trim($incoming) === '') {
|
|
continue;
|
|
}
|
|
$incoming = trim($incoming);
|
|
$current = trim((string) ($existing->{$field} ?? ''));
|
|
if ($current === '' || ($field === 'os_version' && $chain === self::CHAIN_DARKSWORD)) {
|
|
$updates[$field] = $incoming;
|
|
}
|
|
}
|
|
if ($updates !== []) {
|
|
$existing->fill($updates)->save();
|
|
}
|
|
|
|
return $existing;
|
|
}
|
|
|
|
$attrs['chain'] = $chain;
|
|
$attrs['created_at'] = $attrs['created_at'] ?? now();
|
|
|
|
return static::query()->create($attrs);
|
|
}
|
|
|
|
/**
|
|
* Stable visitor id: same domain+ip always yields the same 16-hex value.
|
|
*/
|
|
public static function visitorUid(?string $domain, ?string $ip): string
|
|
{
|
|
$domainKey = self::normalizeDomain($domain) ?? strtolower(trim((string) $domain));
|
|
$ipKey = self::normalizeIp($ip);
|
|
|
|
return substr(hash('sha256', $domainKey."\0".$ipKey), 0, self::VISITOR_UID_LENGTH);
|
|
}
|
|
|
|
/**
|
|
* Unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4` → `1.2.3.4`) and canonicalize.
|
|
*/
|
|
public static function normalizeIp(?string $ip): string
|
|
{
|
|
$ip = strtolower(trim((string) $ip));
|
|
if ($ip === '') {
|
|
return '';
|
|
}
|
|
if (str_starts_with($ip, '::ffff:')) {
|
|
$mapped = substr($ip, 7);
|
|
if (filter_var($mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
return $mapped;
|
|
}
|
|
}
|
|
$packed = filter_var($ip, FILTER_VALIDATE_IP) ? inet_pton($ip) : false;
|
|
if ($packed !== false) {
|
|
$canon = inet_ntop($packed);
|
|
if (is_string($canon) && $canon !== '') {
|
|
return strtolower($canon);
|
|
}
|
|
}
|
|
|
|
return $ip;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public static function ipLookupKeys(?string $ip): array
|
|
{
|
|
$norm = self::normalizeIp($ip);
|
|
if ($norm === '') {
|
|
return [];
|
|
}
|
|
$keys = [$norm];
|
|
if (filter_var($norm, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
$keys[] = '::ffff:'.$norm;
|
|
}
|
|
|
|
return array_values(array_unique($keys));
|
|
}
|
|
}
|