diff --git a/app/Http/Controllers/Admin/AdminLogViewerController.php b/app/Http/Controllers/Admin/AdminLogViewerController.php new file mode 100644 index 000000000..8d5c6321e --- /dev/null +++ b/app/Http/Controllers/Admin/AdminLogViewerController.php @@ -0,0 +1,81 @@ +setAdminPrefs(); + + $validated = $request->validated(); + $availableLogs = $logViewer->availableLogs(); + $selectedPath = $validated['file'] ?? null; + + if (($selectedPath === null || $selectedPath === '') && $availableLogs !== []) { + $selectedPath = $availableLogs[0]['path']; + } + + if ($selectedPath !== null && $selectedPath !== '' && ! $logViewer->isAvailableLog($selectedPath)) { + return redirect() + ->route('admin.logs.index') + ->with('error', 'Selected log file is not available.'); + } + + $selectedLog = $selectedPath ? $logViewer->findLog($selectedPath) : null; + $search = trim((string) ($validated['search'] ?? '')); + $lineLimit = (int) ($validated['lines'] ?? LogViewerService::DEFAULT_LINES); + $tailView = null; + $searchResults = null; + $searchMatchCount = 0; + + if ($selectedLog !== null) { + try { + if ($search !== '') { + $searchData = $logViewer->searchLog( + $selectedLog['path'], + $search, + $request->integer('page', 1), + $lineLimit, + $request->query() + ); + + $searchResults = $searchData['paginator']; + $searchMatchCount = $searchData['total_matches']; + } else { + $tailView = $logViewer->readLatestLines($selectedLog['path'], $lineLimit); + } + } catch (RuntimeException $exception) { + return redirect() + ->route('admin.logs.index') + ->with('error', $exception->getMessage()); + } + } + + return view('admin.logs.index', [ + 'availableLogs' => $availableLogs, + 'selectedFile' => $selectedLog['path'] ?? '', + 'selectedLog' => $selectedLog, + 'search' => $search, + 'lines' => $lineLimit, + 'lineOptions' => LogViewerService::DISPLAY_LINE_OPTIONS, + 'tailView' => $tailView, + 'searchResults' => $searchResults, + 'searchMatchCount' => $searchMatchCount, + 'isSearchMode' => $search !== '', + 'title' => 'Log Viewer', + 'page_title' => 'Log Viewer', + 'meta_title' => 'Log Viewer', + 'meta_description' => 'Browse and search application logs.', + ]); + } +} diff --git a/app/Http/Requests/Admin/AdminLogViewerRequest.php b/app/Http/Requests/Admin/AdminLogViewerRequest.php new file mode 100644 index 000000000..bb510502b --- /dev/null +++ b/app/Http/Requests/Admin/AdminLogViewerRequest.php @@ -0,0 +1,39 @@ +input('file'); + $search = $this->input('search'); + + $this->merge([ + 'file' => is_string($file) ? trim($file) : $file, + 'search' => is_string($search) ? trim($search) : $search, + ]); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'file' => ['nullable', 'string', 'max:255'], + 'search' => ['nullable', 'string', 'max:255'], + 'lines' => ['nullable', 'integer', 'in:100,200,500,1000'], + 'page' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Services/LogViewerService.php b/app/Services/LogViewerService.php new file mode 100644 index 000000000..ef366db1b --- /dev/null +++ b/app/Services/LogViewerService.php @@ -0,0 +1,281 @@ + + */ + public const array DISPLAY_LINE_OPTIONS = [100, 200, 500, 1000]; + + private string $logsDirectory; + + public function __construct(?string $logsDirectory = null) + { + $this->logsDirectory = rtrim($logsDirectory ?? storage_path('logs'), DIRECTORY_SEPARATOR); + } + + /** + * @return array + */ + public function availableLogs(): array + { + if (! File::isDirectory($this->logsDirectory)) { + return []; + } + + $logs = []; + + foreach (File::allFiles($this->logsDirectory) as $file) { + $absolutePath = $file->getPathname(); + + if (! is_file($absolutePath) || ! is_readable($absolutePath)) { + continue; + } + + $relativePath = $this->relativePath($absolutePath); + $directory = dirname($relativePath); + $size = (int) $file->getSize(); + + $logs[] = [ + 'path' => $relativePath, + 'name' => $file->getFilename(), + 'directory' => $directory === '.' ? null : $directory, + 'size' => $size, + 'human_size' => $this->formatBytes($size), + 'modified_at' => CarbonImmutable::createFromTimestamp($file->getMTime()), + 'absolute_path' => $absolutePath, + ]; + } + + usort($logs, function (array $left, array $right): int { + $timestampComparison = $right['modified_at']->getTimestamp() <=> $left['modified_at']->getTimestamp(); + + if ($timestampComparison !== 0) { + return $timestampComparison; + } + + return strcmp($left['path'], $right['path']); + }); + + return $logs; + } + + public function isAvailableLog(string $relativePath): bool + { + return $this->findLog($relativePath) !== null; + } + + /** + * @return array{ + * path: string, + * name: string, + * directory: string|null, + * size: int, + * human_size: string, + * modified_at: CarbonImmutable, + * absolute_path: string + * }|null + */ + public function findLog(string $relativePath): ?array + { + return $this->availableLogsMap()[$this->normalizeRelativePath($relativePath)] ?? null; + } + + /** + * @return array{content: string, displayed_line_count: int, total_lines: int} + */ + public function readLatestLines(string $relativePath, int $lineLimit): array + { + $file = $this->openLogFile($relativePath); + $lineLimit = $this->normalizeLineLimit($lineLimit); + + $buffer = []; + $totalLines = 0; + + while (! $file->eof()) { + $line = $file->fgets(); + + if ($file->eof() && $line === '') { + break; + } + + $totalLines++; + $buffer[] = $this->trimLineEnding($line); + + if (count($buffer) > $lineLimit) { + array_shift($buffer); + } + } + + return [ + 'content' => implode("\n", $buffer), + 'displayed_line_count' => count($buffer), + 'total_lines' => $totalLines, + ]; + } + + /** + * @param array $query + * @return array{ + * paginator: LengthAwarePaginator, + * total_matches: int + * } + */ + public function searchLog(string $relativePath, string $needle, int $page, int $perPage, array $query): array + { + $file = $this->openLogFile($relativePath); + $needle = trim($needle); + $page = max($page, 1); + $perPage = $this->normalizeLineLimit($perPage); + $startIndex = (($page - 1) * $perPage) + 1; + $endIndex = $startIndex + $perPage - 1; + + $matches = []; + $matchIndex = 0; + $lineNumber = 0; + + while (! $file->eof()) { + $line = $file->fgets(); + + if ($file->eof() && $line === '') { + break; + } + + $lineNumber++; + + if (! $this->lineContains($line, $needle)) { + continue; + } + + $matchIndex++; + + if ($matchIndex < $startIndex || $matchIndex > $endIndex) { + continue; + } + + $matches[] = [ + 'line_number' => $lineNumber, + 'content' => $this->trimLineEnding($line), + ]; + } + + $paginator = new LengthAwarePaginator( + $matches, + $matchIndex, + $perPage, + $page, + [ + 'path' => url()->current(), + 'query' => Arr::except($query, 'page'), + ] + ); + + return [ + 'paginator' => $paginator, + 'total_matches' => $matchIndex, + ]; + } + + /** + * @return array + */ + private function availableLogsMap(): array + { + $map = []; + + foreach ($this->availableLogs() as $log) { + $map[$log['path']] = $log; + } + + return $map; + } + + private function openLogFile(string $relativePath): SplFileObject + { + $log = $this->findLog($relativePath); + + if ($log === null) { + throw new RuntimeException('Selected log file is not available.'); + } + + return new SplFileObject($log['absolute_path'], 'r'); + } + + private function relativePath(string $absolutePath): string + { + $prefix = $this->logsDirectory.DIRECTORY_SEPARATOR; + + return $this->normalizeRelativePath(str_starts_with($absolutePath, $prefix) + ? substr($absolutePath, strlen($prefix)) + : basename($absolutePath)); + } + + private function normalizeRelativePath(string $relativePath): string + { + return ltrim(str_replace('\\', '/', trim($relativePath)), '/'); + } + + private function normalizeLineLimit(int $lineLimit): int + { + return in_array($lineLimit, self::DISPLAY_LINE_OPTIONS, true) + ? $lineLimit + : self::DEFAULT_LINES; + } + + private function trimLineEnding(string $line): string + { + return rtrim($line, "\r\n"); + } + + private function lineContains(string $line, string $needle): bool + { + return mb_stripos($line, $needle) !== false; + } + + private function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes.' B'; + } + + $units = ['KB', 'MB', 'GB', 'TB']; + $value = $bytes / 1024; + $unitIndex = 0; + + while ($value >= 1024 && $unitIndex < count($units) - 1) { + $value /= 1024; + $unitIndex++; + } + + return number_format($value, 1).' '.$units[$unitIndex]; + } +} diff --git a/resources/views/admin/logs/index.blade.php b/resources/views/admin/logs/index.blade.php new file mode 100644 index 000000000..0bd5c3f2f --- /dev/null +++ b/resources/views/admin/logs/index.blade.php @@ -0,0 +1,147 @@ +@extends('layouts.admin') + +@section('content') +
+ + + + + @php + $clearSearchParams = array_filter([ + 'file' => $selectedFile !== '' ? $selectedFile : null, + 'lines' => $lines, + ], fn ($value) => $value !== null && $value !== ''); + @endphp + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if($selectedFile !== '') + + Clear Search + + @endif + + + Reset + +
+
+
+ + @if(empty($availableLogs)) +
+ +

No log files found

+

The `storage/logs` directory does not currently contain any readable files.

+
+ @elseif($selectedLog !== null) +
+
+
+

Selected File

+

{{ $selectedLog['path'] }}

+
+
+

Directory

+

{{ $selectedLog['directory'] ?? 'storage/logs' }}

+
+
+

File Size

+

{{ $selectedLog['human_size'] }}

+
+
+

Last Modified

+

{{ $selectedLog['modified_at']->format('Y-m-d H:i:s') }}

+
+
+
+ + @if($isSearchMode) +
+ @if($searchMatchCount > 0) + Found {{ number_format($searchMatchCount) }} matching line{{ $searchMatchCount === 1 ? '' : 's' }} for "{{ $search }}". + @else + No matching lines found for "{{ $search }}". + @endif +
+ + @if($searchResults !== null && $searchResults->count() > 0) +
+ @foreach($searchResults as $match) +
+
+ Line {{ number_format($match['line_number']) }} +
+
{{ $match['content'] !== '' ? $match['content'] : ' ' }}
+
+ @endforeach +
+ + + @else +
+ +

No search results

+

Try a different search term or clear the search to view the latest lines.

+
+ @endif + @elseif($tailView !== null) +
+ Showing the latest {{ number_format($tailView['displayed_line_count']) }} of {{ number_format($tailView['total_lines']) }} line{{ $tailView['total_lines'] === 1 ? '' : 's' }}. +
+ +
+
{{ $tailView['content'] !== '' ? $tailView['content'] : 'Log file is empty.' }}
+
+ @endif + @else +
+ +

Select a log file

+

Choose a file from the dropdown above to inspect its contents.

+
+ @endif +
+
+@endsection diff --git a/resources/views/partials/admin-menu.blade.php b/resources/views/partials/admin-menu.blade.php index a1d637236..c15343142 100644 --- a/resources/views/partials/admin-menu.blade.php +++ b/resources/views/partials/admin-menu.blade.php @@ -300,6 +300,9 @@ Statistics + + Logs + Horizon diff --git a/routes/web.php b/routes/web.php index 44e676fdf..99d1b99a3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,6 +24,7 @@ use App\Http\Controllers\Admin\AdminContentController; use App\Http\Controllers\Admin\AdminFailedReleasesController; use App\Http\Controllers\Admin\AdminGameController; use App\Http\Controllers\Admin\AdminGroupController; +use App\Http\Controllers\Admin\AdminLogViewerController; use App\Http\Controllers\Admin\AdminMovieController; use App\Http\Controllers\Admin\AdminMusicController; use App\Http\Controllers\Admin\AdminPageController; @@ -213,6 +214,7 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { Route::get('user-role-history/{userId}', [AdminUserRoleHistoryController::class, 'show'])->name('admin.user-role-history.show'); Route::match(['GET', 'POST'], 'site-edit', [AdminSiteController::class, 'edit'])->name('admin.site-edit'); Route::get('site-stats', [AdminSiteController::class, 'stats'])->name('admin.site-stats'); + Route::get('logs', [AdminLogViewerController::class, 'index'])->name('admin.logs.index'); Route::get('role-list', [AdminRoleController::class, 'index'])->name('admin.role-list'); Route::match(['GET', 'POST'], 'role-add', [AdminRoleController::class, 'create'])->name('admin.role-add'); Route::match(['GET', 'POST'], 'role-edit', [AdminRoleController::class, 'edit'])->name('admin.role-edit'); diff --git a/tests/Feature/AdminLogViewerControllerTest.php b/tests/Feature/AdminLogViewerControllerTest.php new file mode 100644 index 000000000..446f7b2a1 --- /dev/null +++ b/tests/Feature/AdminLogViewerControllerTest.php @@ -0,0 +1,320 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'mail.from.address' => '', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + Queue::fake(); + + $this->createSchema(); + $this->resetGlobalComposerState(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $this->withoutMiddleware(Google2FAMiddleware::class); + + $this->logNamespace = 'admin-log-viewer-tests/'.Str::uuid()->toString(); + File::ensureDirectoryExists(storage_path('logs/'.$this->logNamespace)); + } + + protected function tearDown(): void + { + if ($this->logNamespace !== '') { + File::deleteDirectory(storage_path('logs/'.$this->logNamespace)); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + parent::tearDown(); + } + + public function test_admin_can_open_log_viewer_and_see_available_logs(): void + { + $selectedLog = $this->createLogFile('application.log', [ + '[2026-03-10 09:00:00] local.INFO: first entry', + '[2026-03-10 09:01:00] local.WARNING: second entry', + '[2026-03-10 09:02:00] local.ERROR: third entry', + ]); + $otherLog = $this->createLogFile('secondary.log', [ + '[2026-03-10 10:00:00] local.INFO: secondary log entry', + ]); + + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.logs.index', [ + 'file' => $selectedLog, + 'lines' => 100, + ])); + + $response->assertOk(); + $response->assertSee('Log Viewer'); + $response->assertSee($selectedLog); + $response->assertSee($otherLog); + $response->assertSee('Showing the latest 3 of 3 lines.'); + $response->assertSee('[2026-03-10 09:02:00] local.ERROR: third entry'); + } + + public function test_non_admin_user_is_forbidden_from_log_viewer(): void + { + $user = $this->createUserWithRole('User'); + /** @var Authenticatable $authenticatedUser */ + $authenticatedUser = $user; + + $this->actingAs($authenticatedUser) + ->get(route('admin.logs.index')) + ->assertForbidden(); + } + + public function test_admin_can_search_within_selected_log_file_only(): void + { + $selectedLog = $this->createLogFile('search-target.log', [ + 'boot sequence started', + 'match alpha', + 'continuing process', + 'match beta', + ]); + $this->createLogFile('other.log', [ + 'match from other file', + ]); + + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.logs.index', [ + 'file' => $selectedLog, + 'search' => 'match', + 'lines' => 100, + ])); + + $response->assertOk(); + $response->assertSee('Found 2 matching lines'); + $response->assertSee('Line 2'); + $response->assertSee('Line 4'); + $response->assertSee('match alpha'); + $response->assertSee('match beta'); + $response->assertDontSee('match from other file'); + } + + public function test_unknown_log_file_redirects_with_error(): void + { + $this->createLogFile('known.log', [ + 'known entry', + ]); + + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $this->actingAs($authenticatedAdmin) + ->get(route('admin.logs.index', [ + 'file' => $this->logNamespace.'/missing.log', + ])) + ->assertRedirect(route('admin.logs.index')) + ->assertSessionHas('error', 'Selected log file is not available.'); + } + + public function test_path_traversal_is_rejected(): void + { + $this->createLogFile('known.log', [ + 'known entry', + ]); + + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $this->actingAs($authenticatedAdmin) + ->get(route('admin.logs.index', [ + 'file' => '../bootstrap/app.php', + ])) + ->assertRedirect(route('admin.logs.index')) + ->assertSessionHas('error', 'Selected log file is not available.'); + } + + private function createSchema(): void + { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->integer('rate_limit')->default(60); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->boolean('can_post')->default(true); + $table->integer('rate_limit')->default(60); + $table->string('theme_preference', 10)->default('light'); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('lastlogin')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->unsignedInteger('root_categories_id')->nullable(); + $table->integer('status')->default(1); + }); + + Schema::create('root_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->integer('status')->default(1); + }); + + Schema::create('user_excluded_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + }); + + Schema::create('content', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->string('url')->nullable(); + $table->text('body')->nullable(); + $table->text('metadescription')->nullable(); + $table->text('metakeywords')->nullable(); + $table->integer('contenttype')->default(1); + $table->integer('status')->default(1); + $table->integer('ordinal')->nullable(); + $table->integer('role')->default(0); + }); + + Schema::create('user_activities', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('username'); + $table->string('activity_type', 50); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + private function createUserWithRole(string $roleName): User + { + $role = Role::query()->firstOrCreate( + [ + 'name' => $roleName, + 'guard_name' => 'web', + ], + [ + 'rate_limit' => 60, + ] + ); + + $user = User::query()->create([ + 'username' => strtolower($roleName).'_'.Str::random(8), + 'email' => Str::random(12).'@example.test', + 'password' => bcrypt('password'), + 'roles_id' => $role->id, + 'api_token' => Str::random(32), + 'verified' => true, + 'email_verified_at' => now(), + 'lastlogin' => now(), + ]); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + return $user->fresh(); + } + + private function resetGlobalComposerState(): void + { + $reflection = new \ReflectionClass(\App\View\Composers\GlobalDataComposer::class); + $property = $reflection->getProperty('resolvedData'); + $property->setAccessible(true); + $property->setValue(null, null); + } + + /** + * @param list $lines + */ + private function createLogFile(string $filename, array $lines): string + { + $relativePath = $this->logNamespace.'/'.$filename; + $absolutePath = storage_path('logs/'.$relativePath); + + File::ensureDirectoryExists(dirname($absolutePath)); + File::put($absolutePath, implode(PHP_EOL, $lines).PHP_EOL); + clearstatcache(true, $absolutePath); + + return $relativePath; + } +}