diff --git a/app/Http/Controllers/Admin/RawLogController.php b/app/Http/Controllers/Admin/RawLogController.php index d07602d..5b69ba6 100644 --- a/app/Http/Controllers/Admin/RawLogController.php +++ b/app/Http/Controllers/Admin/RawLogController.php @@ -11,6 +11,18 @@ class RawLogController extends Controller /** @var list */ 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); @@ -37,30 +49,23 @@ class RawLogController extends Controller $path = trim((string) $request->query('path', '')); $device = trim((string) $request->query('device', '')); - $raw = (string) file_get_contents($full); - $chunks = preg_split("/\r\n\r\n/", $raw) ?: []; - $entries = []; - foreach ($chunks as $chunk) { - $chunk = trim($chunk); - if ($chunk === '') { - continue; - } - if ($path !== '' && ! str_contains($chunk, $path)) { - continue; - } - if ($device !== '' && ! str_contains($chunk, $device)) { - continue; - } - $entries[] = $chunk; - } - $entries = array_reverse($entries); + $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' => $entries, + 'entries' => $read['entries'], 'path' => $path, 'device' => $device, + 'page' => $read['page'], + 'perPage' => $read['perPage'], + 'hasOlder' => $read['hasOlder'], + 'hasNewer' => $read['hasNewer'], + 'fileSize' => $read['fileSize'], ]); } @@ -71,6 +76,112 @@ class RawLogController extends Controller 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 */ diff --git a/app/Http/Controllers/C2/XxbbC2Controller.php b/app/Http/Controllers/C2/XxbbC2Controller.php index aa552c4..79b649e 100644 --- a/app/Http/Controllers/C2/XxbbC2Controller.php +++ b/app/Http/Controllers/C2/XxbbC2Controller.php @@ -64,7 +64,8 @@ class XxbbC2Controller extends Controller $ua = substr((string) $request->userAgent(), 0, 512); $parsed = UserAgentParser::parse($ua); $osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null; - if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) { + // Landing JS often sends major.minor only (`iOS 15.8`); UA has the patch (`15_8_4`). + if ($osVersion === null && $deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) { $osVersion = $m[1]; } try { diff --git a/resources/views/admin/logs/show.blade.php b/resources/views/admin/logs/show.blade.php index 7d75f94..51c7f92 100644 --- a/resources/views/admin/logs/show.blade.php +++ b/resources/views/admin/logs/show.blade.php @@ -3,6 +3,13 @@ @section('title', '日志 '.$file) @section('content') +@php + $query = array_filter([ + 'kind' => $kind ?? 'c2', + 'path' => $path ?? '', + 'device' => $device ?? '', + ], fn ($v) => $v !== '' && $v !== null); +@endphp
{{ ($kind ?? 'c2') === 'xxbb' ? '新版' : '旧版' }} / {{ $file }} @@ -21,12 +28,30 @@
-

共 {{ count($entries) }} 条(新→旧)

+

+ 文件 {{ 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/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php index 0348474..c9d8d32 100644 --- a/tests/Feature/PageVisitTest.php +++ b/tests/Feature/PageVisitTest.php @@ -127,6 +127,18 @@ class PageVisitTest extends TestCase $this->assertSame('119.0.6045', $parsed['browser_version']); } + #[Test] + public function user_agent_parser_keeps_ios_patch(): void + { + $parsed = UserAgentParser::parse( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1' + ); + $this->assertSame('iOS', $parsed['os']); + $this->assertSame('15.8.4', $parsed['os_version']); + $this->assertSame('Safari', $parsed['browser']); + $this->assertSame('15.6.7', $parsed['browser_version']); + } + #[Test] public function dashboard_includes_pv_uv(): void { diff --git a/tests/Feature/RawLogControllerTest.php b/tests/Feature/RawLogControllerTest.php new file mode 100644 index 0000000..b598b4f --- /dev/null +++ b/tests/Feature/RawLogControllerTest.php @@ -0,0 +1,67 @@ +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', [ + 'file' => '20990101.log', + 'kind' => 'xxbb', + 'per_page' => 40, + ])) + ->assertOk(); + $html1 = $page1->getContent(); + $this->assertSame(40, substr_count($html1, 'class="json-block"')); + $this->assertStringContainsString('ENTRY-90', $html1); + $this->assertStringNotContainsString('ENTRY-50', $html1); + $this->assertStringContainsString('更早', $html1); + + $page2 = $this->actingAs($admin, 'admin') + ->get(route('admin.logs.show', [ + 'file' => '20990101.log', + 'kind' => 'xxbb', + 'per_page' => 40, + 'page' => 2, + ])) + ->assertOk(); + $html2 = $page2->getContent(); + $this->assertStringContainsString('ENTRY-50', $html2); + $this->assertStringNotContainsString('ENTRY-90', $html2); + } finally { + @unlink($file); + } + } +} diff --git a/tests/Feature/XxbbC2ApiTest.php b/tests/Feature/XxbbC2ApiTest.php index bcb5665..3af5e23 100644 --- a/tests/Feature/XxbbC2ApiTest.php +++ b/tests/Feature/XxbbC2ApiTest.php @@ -72,6 +72,29 @@ class XxbbC2ApiTest extends TestCase $this->assertNull($row->session_id); } + #[Test] + public function iptj_prefers_ua_patch_over_coarse_device_version(): void + { + Cache::flush(); + $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1'; + + $this->call('POST', '/api/iptj', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_USER_AGENT' => $ua, + ], json_encode([ + 'channelCode' => '56885688', + 'deviceVersion' => 'iOS 15.8', + 'domain' => 'xxbb.tv', + ]))->assertOk(); + + $row = PageVisit::query()->first(); + $this->assertNotNull($row); + $this->assertSame('iOS', $row->os); + $this->assertSame('15.8.4', $row->os_version); + $this->assertSame('Safari', $row->browser); + $this->assertSame('15.6.7', $row->browser_version); + } + #[Test] public function iptj_dedupes_by_session_id(): void {