feat: xxbb
This commit is contained in:
@@ -11,6 +11,18 @@ class RawLogController extends Controller
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
private const KINDS = ['c2', 'xxbb'];
|
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)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$kind = $this->kind($request);
|
$kind = $this->kind($request);
|
||||||
@@ -37,30 +49,23 @@ class RawLogController extends Controller
|
|||||||
|
|
||||||
$path = trim((string) $request->query('path', ''));
|
$path = trim((string) $request->query('path', ''));
|
||||||
$device = trim((string) $request->query('device', ''));
|
$device = trim((string) $request->query('device', ''));
|
||||||
$raw = (string) file_get_contents($full);
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$chunks = preg_split("/\r\n\r\n/", $raw) ?: [];
|
$perPage = (int) $request->query('per_page', self::PER_PAGE);
|
||||||
$entries = [];
|
$perPage = min(self::MAX_PER_PAGE, max(10, $perPage));
|
||||||
foreach ($chunks as $chunk) {
|
|
||||||
$chunk = trim($chunk);
|
$read = $this->tailEntries($full, $path, $device, $page, $perPage);
|
||||||
if ($chunk === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($path !== '' && ! str_contains($chunk, $path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($device !== '' && ! str_contains($chunk, $device)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$entries[] = $chunk;
|
|
||||||
}
|
|
||||||
$entries = array_reverse($entries);
|
|
||||||
|
|
||||||
return view('admin.logs.show', [
|
return view('admin.logs.show', [
|
||||||
'kind' => $kind,
|
'kind' => $kind,
|
||||||
'file' => $safe,
|
'file' => $safe,
|
||||||
'entries' => $entries,
|
'entries' => $read['entries'],
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'device' => $device,
|
'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';
|
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<string>,
|
||||||
|
* 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<string> $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<array{name: string, size: int, mtime: string}>
|
* @return list<array{name: string, size: int, mtime: string}>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ class XxbbC2Controller extends Controller
|
|||||||
$ua = substr((string) $request->userAgent(), 0, 512);
|
$ua = substr((string) $request->userAgent(), 0, 512);
|
||||||
$parsed = UserAgentParser::parse($ua);
|
$parsed = UserAgentParser::parse($ua);
|
||||||
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
|
$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];
|
$osVersion = $m[1];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
@section('title', '日志 '.$file)
|
@section('title', '日志 '.$file)
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@php
|
||||||
|
$query = array_filter([
|
||||||
|
'kind' => $kind ?? 'c2',
|
||||||
|
'path' => $path ?? '',
|
||||||
|
'device' => $device ?? '',
|
||||||
|
], fn ($v) => $v !== '' && $v !== null);
|
||||||
|
@endphp
|
||||||
<div class="layui-card">
|
<div class="layui-card">
|
||||||
<div class="layui-card-header">
|
<div class="layui-card-header">
|
||||||
{{ ($kind ?? 'c2') === 'xxbb' ? '新版' : '旧版' }} / {{ $file }}
|
{{ ($kind ?? 'c2') === 'xxbb' ? '新版' : '旧版' }} / {{ $file }}
|
||||||
@@ -21,12 +28,30 @@
|
|||||||
<button class="layui-btn" type="submit">筛选</button>
|
<button class="layui-btn" type="submit">筛选</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="layui-word-aux">共 {{ count($entries) }} 条(新→旧)</p>
|
<p class="layui-word-aux">
|
||||||
|
文件 {{ number_format($fileSize ?? 0) }} B · 第 {{ $page ?? 1 }} 页 · 本页 {{ count($entries) }} / {{ $perPage ?? 40 }} 条(新→旧,避免整文件渲染)
|
||||||
|
</p>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
@if ($hasNewer ?? false)
|
||||||
|
<a class="layui-btn layui-btn-sm layui-btn-primary" href="{{ route('admin.logs.show', array_merge($query, ['file' => $file, 'page' => ($page ?? 1) - 1])) }}">更新</a>
|
||||||
|
@endif
|
||||||
|
@if ($hasOlder ?? false)
|
||||||
|
<a class="layui-btn layui-btn-sm" href="{{ route('admin.logs.show', array_merge($query, ['file' => $file, 'page' => ($page ?? 1) + 1])) }}">更早</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@forelse ($entries as $entry)
|
@forelse ($entries as $entry)
|
||||||
<pre class="json-block" style="margin-bottom:12px;">{{ $entry }}</pre>
|
<pre class="json-block" style="margin-bottom:12px;">{{ $entry }}</pre>
|
||||||
@empty
|
@empty
|
||||||
<p>无匹配记录</p>
|
<p>无匹配记录</p>
|
||||||
@endforelse
|
@endforelse
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
@if ($hasNewer ?? false)
|
||||||
|
<a class="layui-btn layui-btn-sm layui-btn-primary" href="{{ route('admin.logs.show', array_merge($query, ['file' => $file, 'page' => ($page ?? 1) - 1])) }}">更新</a>
|
||||||
|
@endif
|
||||||
|
@if ($hasOlder ?? false)
|
||||||
|
<a class="layui-btn layui-btn-sm" href="{{ route('admin.logs.show', array_merge($query, ['file' => $file, 'page' => ($page ?? 1) + 1])) }}">更早</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -127,6 +127,18 @@ class PageVisitTest extends TestCase
|
|||||||
$this->assertSame('119.0.6045', $parsed['browser_version']);
|
$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]
|
#[Test]
|
||||||
public function dashboard_includes_pv_uv(): void
|
public function dashboard_includes_pv_uv(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RawLogControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function superAdmin(): Admin
|
||||||
|
{
|
||||||
|
return Admin::query()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,29 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
$this->assertNull($row->session_id);
|
$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]
|
#[Test]
|
||||||
public function iptj_dedupes_by_session_id(): void
|
public function iptj_dedupes_by_session_id(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user