Add log viewer to admin area

This commit is contained in:
DariusIII
2026-03-10 13:04:51 +01:00
parent 7115b55e56
commit a87a764f02
7 changed files with 873 additions and 0 deletions
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\BasePageController;
use App\Http\Requests\Admin\AdminLogViewerRequest;
use App\Services\LogViewerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use RuntimeException;
class AdminLogViewerController extends BasePageController
{
public function index(AdminLogViewerRequest $request, LogViewerService $logViewer): View|RedirectResponse
{
$this->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.',
]);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class AdminLogViewerRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$file = $this->input('file');
$search = $this->input('search');
$this->merge([
'file' => is_string($file) ? trim($file) : $file,
'search' => is_string($search) ? trim($search) : $search,
]);
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}
+281
View File
@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\CarbonImmutable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use RuntimeException;
use SplFileObject;
class LogViewerService
{
public const int DEFAULT_LINES = 200;
/**
* @var list<int>
*/
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<int, array{
* path: string,
* name: string,
* directory: string|null,
* size: int,
* human_size: string,
* modified_at: CarbonImmutable,
* absolute_path: string
* }>
*/
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<string, mixed> $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<string, array{
* path: string,
* name: string,
* directory: string|null,
* size: int,
* human_size: string,
* modified_at: CarbonImmutable,
* absolute_path: string
* }>
*/
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];
}
}
+147
View File
@@ -0,0 +1,147 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6">
<x-admin.card>
<x-admin.page-header :title="$title" icon="fas fa-file-lines" subtitle="Browse files from storage/logs and search inside the selected log." />
<x-admin.search-bar>
@php
$clearSearchParams = array_filter([
'file' => $selectedFile !== '' ? $selectedFile : null,
'lines' => $lines,
], fn ($value) => $value !== null && $value !== '');
@endphp
<form method="GET" action="{{ route('admin.logs.index') }}" class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div class="lg:col-span-2">
<label for="file" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Log File</label>
<select id="file"
name="file"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500"
@disabled(empty($availableLogs))>
@forelse($availableLogs as $log)
<option value="{{ $log['path'] }}" @selected($selectedFile === $log['path'])>{{ $log['path'] }}</option>
@empty
<option value="">No log files found</option>
@endforelse
</select>
</div>
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input type="text"
id="search"
name="search"
value="{{ $search }}"
placeholder="Search selected log"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="lines" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Count</label>
<select id="lines"
name="lines"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500">
@foreach($lineOptions as $option)
<option value="{{ $option }}" @selected($lines === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="lg:col-span-4 flex flex-wrap gap-2">
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
<i class="fas fa-search mr-2"></i>Apply
</button>
@if($selectedFile !== '')
<a href="{{ route('admin.logs.index', $clearSearchParams) }}"
class="inline-flex items-center px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">
<i class="fas fa-eraser mr-2"></i>Clear Search
</a>
@endif
<a href="{{ route('admin.logs.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition">
<i class="fas fa-rotate-left mr-2"></i>Reset
</a>
</div>
</form>
</x-admin.search-bar>
@if(empty($availableLogs))
<div class="px-6 py-12 text-center">
<i class="fas fa-file-circle-xmark text-gray-400 dark:text-gray-600 text-5xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No log files found</h3>
<p class="text-gray-500 dark:text-gray-400">The `storage/logs` directory does not currently contain any readable files.</p>
</div>
@elseif($selectedLog !== null)
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400 uppercase tracking-wide text-xs font-semibold">Selected File</p>
<p class="text-gray-900 dark:text-gray-100 font-medium break-all">{{ $selectedLog['path'] }}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400 uppercase tracking-wide text-xs font-semibold">Directory</p>
<p class="text-gray-900 dark:text-gray-100 font-medium">{{ $selectedLog['directory'] ?? 'storage/logs' }}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400 uppercase tracking-wide text-xs font-semibold">File Size</p>
<p class="text-gray-900 dark:text-gray-100 font-medium">{{ $selectedLog['human_size'] }}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400 uppercase tracking-wide text-xs font-semibold">Last Modified</p>
<p class="text-gray-900 dark:text-gray-100 font-medium">{{ $selectedLog['modified_at']->format('Y-m-d H:i:s') }}</p>
</div>
</div>
</div>
@if($isSearchMode)
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300">
@if($searchMatchCount > 0)
Found {{ number_format($searchMatchCount) }} matching line{{ $searchMatchCount === 1 ? '' : 's' }} for <span class="font-semibold">"{{ $search }}"</span>.
@else
No matching lines found for <span class="font-semibold">"{{ $search }}"</span>.
@endif
</div>
@if($searchResults !== null && $searchResults->count() > 0)
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($searchResults as $match)
<div class="px-6 py-4">
<div class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400 mb-2">
Line {{ number_format($match['line_number']) }}
</div>
<pre class="font-mono text-sm whitespace-pre-wrap break-words text-gray-800 dark:text-gray-200">{{ $match['content'] !== '' ? $match['content'] : ' ' }}</pre>
</div>
@endforeach
</div>
<x-admin.pagination :paginator="$searchResults" />
@else
<div class="px-6 py-12 text-center">
<i class="fas fa-magnifying-glass text-gray-400 dark:text-gray-600 text-4xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No search results</h3>
<p class="text-gray-500 dark:text-gray-400">Try a different search term or clear the search to view the latest lines.</p>
</div>
@endif
@elseif($tailView !== null)
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300">
Showing the latest {{ number_format($tailView['displayed_line_count']) }} of {{ number_format($tailView['total_lines']) }} line{{ $tailView['total_lines'] === 1 ? '' : 's' }}.
</div>
<div class="bg-gray-950 text-green-400 p-6 overflow-x-auto rounded-b-xl">
<pre class="font-mono text-sm whitespace-pre-wrap break-words">{{ $tailView['content'] !== '' ? $tailView['content'] : 'Log file is empty.' }}</pre>
</div>
@endif
@else
<div class="px-6 py-12 text-center">
<i class="fas fa-file-lines text-gray-400 dark:text-gray-600 text-5xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Select a log file</h3>
<p class="text-gray-500 dark:text-gray-400">Choose a file from the dropdown above to inspect its contents.</p>
</div>
@endif
</x-admin.card>
</div>
@endsection
@@ -300,6 +300,9 @@
<a href="{{ url('/admin/site-stats') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
<i class="fas fa-chart-bar mr-2 text-purple-400"></i>Statistics
</a>
<a href="{{ route('admin.logs.index') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
<i class="fas fa-file-lines mr-2 text-amber-400"></i>Logs
</a>
<a href="{{ url(config('horizon.path', 'horizon')) }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
<i class="fas fa-stream mr-2 text-cyan-400"></i>Horizon
</a>
+2
View File
@@ -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');
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Http\Middleware\Google2FAMiddleware;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class AdminLogViewerControllerTest extends TestCase
{
private string $logNamespace;
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => '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<string> $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;
}
}