686 lines
25 KiB
PHP
686 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Admin\Pages;
|
|
|
|
use App\Models\Setting;
|
|
use App\Services\Agent\AgentClient;
|
|
use BackedEnum;
|
|
use Exception;
|
|
use Filament\Forms\Components\Repeater;
|
|
use Filament\Forms\Components\Repeater\TableColumn as RepeaterTableColumn;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Support\Icons\Heroicon;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Str;
|
|
|
|
class Waf extends Page implements HasForms, HasTable
|
|
{
|
|
use InteractsWithForms;
|
|
use InteractsWithTable;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
|
|
|
protected static ?int $navigationSort = 20;
|
|
|
|
protected static ?string $slug = 'waf';
|
|
|
|
protected string $view = 'filament.admin.pages.waf';
|
|
|
|
public bool $wafInstalled = false;
|
|
|
|
public array $wafFormData = [];
|
|
|
|
public array $auditEntries = [];
|
|
|
|
public bool $auditLoaded = false;
|
|
|
|
public function getTitle(): string|Htmlable
|
|
{
|
|
return __('ModSecurity / WAF');
|
|
}
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('ModSecurity / WAF');
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->wafInstalled = $this->detectWaf();
|
|
$whitelistRaw = Setting::get('waf_whitelist_rules', '[]');
|
|
$whitelistRules = json_decode($whitelistRaw, true);
|
|
if (! is_array($whitelistRules)) {
|
|
$whitelistRules = [];
|
|
}
|
|
$this->wafFormData = [
|
|
'enabled' => Setting::get('waf_enabled', '0') === '1',
|
|
'paranoia' => Setting::get('waf_paranoia', '1'),
|
|
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
|
|
'whitelist_rules' => $whitelistRules,
|
|
];
|
|
|
|
$this->loadAuditLogs(false);
|
|
}
|
|
|
|
protected function detectWaf(): bool
|
|
{
|
|
$paths = [
|
|
'/etc/nginx/modsec/main.conf',
|
|
'/etc/nginx/modsecurity.conf',
|
|
'/etc/modsecurity/modsecurity.conf',
|
|
'/etc/modsecurity/modsecurity.conf-recommended',
|
|
];
|
|
|
|
foreach ($paths as $path) {
|
|
if (file_exists($path)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function getForms(): array
|
|
{
|
|
return ['wafForm'];
|
|
}
|
|
|
|
public function wafForm(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
|
|
{
|
|
return $schema
|
|
->statePath('wafFormData')
|
|
->schema([
|
|
Section::make(__('WAF Settings'))
|
|
->schema([
|
|
Toggle::make('enabled')
|
|
->label(__('Enable ModSecurity'))
|
|
->disabled(fn () => ! $this->wafInstalled)
|
|
->helperText(fn () => $this->wafInstalled ? null : __('ModSecurity is not installed. Install it to enable WAF.')),
|
|
Select::make('paranoia')
|
|
->label(__('Paranoia Level'))
|
|
->options([
|
|
'1' => '1 - Basic',
|
|
'2' => '2 - Moderate',
|
|
'3' => '3 - Strict',
|
|
'4' => '4 - Very Strict',
|
|
])
|
|
->default('1'),
|
|
Toggle::make('audit_log')
|
|
->label(__('Enable Audit Log')),
|
|
])
|
|
->columns(2),
|
|
Section::make(__('Whitelist Rules'))
|
|
->description(__('Exclude trusted traffic from specific ModSecurity rule IDs.'))
|
|
->schema([
|
|
Repeater::make('whitelist_rules')
|
|
->label(__('Whitelist Entries'))
|
|
->table([
|
|
RepeaterTableColumn::make(__('Name'))->width('18%'),
|
|
RepeaterTableColumn::make(__('Match Type'))->width('16%'),
|
|
RepeaterTableColumn::make(__('Match Value'))->width('28%'),
|
|
RepeaterTableColumn::make(__('Rule IDs'))->width('26%'),
|
|
RepeaterTableColumn::make(__('Enabled'))->width('12%')->alignCenter(),
|
|
])
|
|
->schema([
|
|
TextInput::make('label')
|
|
->label(__('Name'))
|
|
->placeholder(__('Admin API allowlist'))
|
|
->maxLength(80),
|
|
Select::make('match_type')
|
|
->label(__('Match Type'))
|
|
->options([
|
|
'ip' => __('IP Address or CIDR'),
|
|
'uri_exact' => __('Exact URI'),
|
|
'uri_prefix' => __('URI Prefix'),
|
|
'host' => __('Host Header'),
|
|
])
|
|
->required(),
|
|
TextInput::make('match_value')
|
|
->label(__('Match Value'))
|
|
->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php'))
|
|
->required(),
|
|
TextInput::make('rule_ids')
|
|
->label(__('Rule IDs'))
|
|
->placeholder(__('Example: 942100,949110'))
|
|
->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.'))
|
|
->required(),
|
|
Toggle::make('enabled')
|
|
->label(__('Enabled'))
|
|
->default(true),
|
|
])
|
|
->itemLabel(fn (array $state): ?string => $state['label'] ?? $state['match_value'] ?? null)
|
|
->addActionLabel(__('Add Whitelist Rule'))
|
|
->collapsible()
|
|
->columns(['default' => 2, 'md' => 2]),
|
|
])
|
|
->columns(1),
|
|
]);
|
|
}
|
|
|
|
public function saveWafSettings(): void
|
|
{
|
|
$data = $this->wafForm->getState();
|
|
$requestedEnabled = ! empty($data['enabled']);
|
|
if ($requestedEnabled && ! $this->wafInstalled) {
|
|
$requestedEnabled = false;
|
|
}
|
|
|
|
Setting::set('waf_enabled', $requestedEnabled ? '1' : '0');
|
|
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
|
|
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
|
|
Setting::set('waf_whitelist_rules', json_encode(array_values($data['whitelist_rules'] ?? []), JSON_UNESCAPED_SLASHES));
|
|
|
|
try {
|
|
$agent = new AgentClient;
|
|
$agent->wafApplySettings(
|
|
$requestedEnabled,
|
|
(string) ($data['paranoia'] ?? '1'),
|
|
! empty($data['audit_log']),
|
|
$data['whitelist_rules'] ?? []
|
|
);
|
|
|
|
if (! $this->wafInstalled && ! empty($data['enabled'])) {
|
|
Notification::make()
|
|
->title(__('ModSecurity is not installed'))
|
|
->body(__('WAF was disabled automatically. Install ModSecurity to enable it.'))
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title(__('WAF settings applied'))
|
|
->success()
|
|
->send();
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('WAF settings saved, but apply failed'))
|
|
->body($e->getMessage())
|
|
->warning()
|
|
->send();
|
|
}
|
|
}
|
|
|
|
public function loadAuditLogs(bool $notify = true): void
|
|
{
|
|
try {
|
|
$agent = new AgentClient;
|
|
$response = $agent->wafAuditLogList();
|
|
$entries = $response['entries'] ?? [];
|
|
if (! is_array($entries)) {
|
|
$entries = [];
|
|
}
|
|
|
|
$this->auditEntries = $this->markWhitelisted($entries);
|
|
$this->auditLoaded = true;
|
|
|
|
if ($notify) {
|
|
Notification::make()
|
|
->title(__('WAF logs refreshed'))
|
|
->success()
|
|
->send();
|
|
}
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Failed to load WAF logs'))
|
|
->body($e->getMessage())
|
|
->warning()
|
|
->send();
|
|
}
|
|
}
|
|
|
|
protected function getWhitelistRules(): array
|
|
{
|
|
$raw = Setting::get('waf_whitelist_rules', '[]');
|
|
$rules = json_decode($raw, true);
|
|
|
|
return is_array($rules) ? $rules : [];
|
|
}
|
|
|
|
protected function markWhitelisted(array $entries): array
|
|
{
|
|
$rules = $this->getWhitelistRules();
|
|
|
|
foreach ($entries as &$entry) {
|
|
$entry['whitelisted'] = $this->matchesWhitelist($entry, $rules);
|
|
}
|
|
|
|
return $entries;
|
|
}
|
|
|
|
protected function matchesWhitelist(array $entry, array $rules): bool
|
|
{
|
|
$ruleId = (string) ($entry['rule_id'] ?? '');
|
|
$uri = (string) ($entry['uri'] ?? '');
|
|
$uriPath = $this->stripQueryString($uri);
|
|
$host = (string) ($entry['host'] ?? '');
|
|
$ip = (string) ($entry['remote_ip'] ?? '');
|
|
|
|
foreach ($rules as $rule) {
|
|
if (!is_array($rule)) {
|
|
continue;
|
|
}
|
|
if (isset($rule['enabled']) && !$rule['enabled']) {
|
|
continue;
|
|
}
|
|
|
|
$idsRaw = (string) ($rule['rule_ids'] ?? '');
|
|
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
$ids = array_map('trim', $ids);
|
|
if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) {
|
|
continue;
|
|
}
|
|
|
|
$matchType = (string) ($rule['match_type'] ?? '');
|
|
$matchValue = (string) ($rule['match_value'] ?? '');
|
|
|
|
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
|
|
return true;
|
|
}
|
|
if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) {
|
|
return true;
|
|
}
|
|
if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) {
|
|
return true;
|
|
}
|
|
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function ruleMatchesEntry(array $rule, array $entry): bool
|
|
{
|
|
if (!is_array($rule)) {
|
|
return false;
|
|
}
|
|
if (isset($rule['enabled']) && !$rule['enabled']) {
|
|
return false;
|
|
}
|
|
|
|
$ruleId = (string) ($entry['rule_id'] ?? '');
|
|
$uri = (string) ($entry['uri'] ?? '');
|
|
$uriPath = $this->stripQueryString($uri);
|
|
$host = (string) ($entry['host'] ?? '');
|
|
$ip = (string) ($entry['remote_ip'] ?? '');
|
|
|
|
$idsRaw = (string) ($rule['rule_ids'] ?? '');
|
|
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
$ids = array_map('trim', $ids);
|
|
if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) {
|
|
return false;
|
|
}
|
|
|
|
$matchType = (string) ($rule['match_type'] ?? '');
|
|
$matchValue = (string) ($rule['match_value'] ?? '');
|
|
|
|
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
|
|
return true;
|
|
}
|
|
if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) {
|
|
return true;
|
|
}
|
|
if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) {
|
|
return true;
|
|
}
|
|
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function ipMatches(string $ip, string $rule): bool
|
|
{
|
|
if ($ip === '' || $rule === '') {
|
|
return false;
|
|
}
|
|
|
|
if (! str_contains($rule, '/')) {
|
|
return $ip === $rule;
|
|
}
|
|
|
|
[$subnet, $bits] = array_pad(explode('/', $rule, 2), 2, null);
|
|
$bits = is_numeric($bits) ? (int) $bits : null;
|
|
if ($bits === null || $bits < 0 || $bits > 32) {
|
|
return $ip === $rule;
|
|
}
|
|
|
|
$ipLong = ip2long($ip);
|
|
$subnetLong = ip2long($subnet);
|
|
if ($ipLong === false || $subnetLong === false) {
|
|
return $ip === $rule;
|
|
}
|
|
|
|
$mask = -1 << (32 - $bits);
|
|
|
|
return ($ipLong & $mask) === ($subnetLong & $mask);
|
|
}
|
|
|
|
public function whitelistEntry(array $record): void
|
|
{
|
|
$rules = $this->getWhitelistRules();
|
|
$matchType = 'uri_prefix';
|
|
$rawUri = (string) ($record['uri'] ?? '');
|
|
$matchValue = $this->stripQueryString($rawUri);
|
|
if ($matchValue === '') {
|
|
$matchType = 'ip';
|
|
$matchValue = (string) ($record['remote_ip'] ?? '');
|
|
}
|
|
|
|
if ($matchValue === '' || empty($record['rule_id'])) {
|
|
Notification::make()
|
|
->title(__('Unable to whitelist entry'))
|
|
->body(__('Missing URI/IP or rule ID for this entry.'))
|
|
->warning()
|
|
->send();
|
|
return;
|
|
}
|
|
|
|
$rules[] = [
|
|
'label' => __('Whitelist rule {rule}', ['rule' => $record['rule_id'] ?? '']),
|
|
'match_type' => $matchType,
|
|
'match_value' => $matchValue,
|
|
'rule_ids' => $record['rule_id'] ?? '',
|
|
'enabled' => true,
|
|
];
|
|
|
|
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
|
|
$this->wafFormData['whitelist_rules'] = $rules;
|
|
|
|
try {
|
|
$agent = new AgentClient;
|
|
$agent->wafApplySettings(
|
|
Setting::get('waf_enabled', '0') === '1',
|
|
(string) Setting::get('waf_paranoia', '1'),
|
|
Setting::get('waf_audit_log', '1') === '1',
|
|
$rules
|
|
);
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Whitelist saved, but apply failed'))
|
|
->body($e->getMessage())
|
|
->warning()
|
|
->send();
|
|
}
|
|
|
|
$this->loadAuditLogs(false);
|
|
$this->resetTable();
|
|
|
|
Notification::make()
|
|
->title(__('Rule whitelisted'))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
protected function stripQueryString(string $uri): string
|
|
{
|
|
$pos = strpos($uri, '?');
|
|
if ($pos === false) {
|
|
return $uri;
|
|
}
|
|
|
|
return substr($uri, 0, $pos);
|
|
}
|
|
|
|
public function removeWhitelistEntry(array $record): void
|
|
{
|
|
$rules = $this->getWhitelistRules();
|
|
$beforeCount = count($rules);
|
|
|
|
$rules = array_values(array_filter($rules, function (array $rule) use ($record): bool {
|
|
return ! $this->ruleMatchesEntry($rule, $record);
|
|
}));
|
|
|
|
if (count($rules) === $beforeCount) {
|
|
Notification::make()
|
|
->title(__('No matching whitelist rule found'))
|
|
->warning()
|
|
->send();
|
|
return;
|
|
}
|
|
|
|
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
|
|
$this->wafFormData['whitelist_rules'] = $rules;
|
|
|
|
try {
|
|
$agent = new AgentClient;
|
|
$agent->wafApplySettings(
|
|
Setting::get('waf_enabled', '0') === '1',
|
|
(string) Setting::get('waf_paranoia', '1'),
|
|
Setting::get('waf_audit_log', '1') === '1',
|
|
$rules
|
|
);
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Whitelist updated, but apply failed'))
|
|
->body($e->getMessage())
|
|
->warning()
|
|
->send();
|
|
}
|
|
|
|
$this->loadAuditLogs(false);
|
|
$this->resetTable();
|
|
|
|
Notification::make()
|
|
->title(__('Whitelist removed'))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->persistColumnsInSession(false)
|
|
->paginated([25, 50, 100])
|
|
->defaultPaginationPageOption(25)
|
|
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
|
|
if (! $this->auditLoaded) {
|
|
$this->loadAuditLogs(false);
|
|
}
|
|
|
|
$records = $this->auditEntries;
|
|
|
|
$records = $this->filterRecords($records, $search);
|
|
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
|
|
|
|
return $this->paginateRecords($records, $page, $recordsPerPage);
|
|
})
|
|
->columns([
|
|
TextColumn::make('timestamp')
|
|
->label(__('Time'))
|
|
->formatStateUsing(function (array $record): string {
|
|
$timestamp = (int) ($record['timestamp'] ?? 0);
|
|
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
|
|
})
|
|
->sortable(),
|
|
TextColumn::make('rule_id')
|
|
->label(__('Rule ID'))
|
|
->fontFamily('mono')
|
|
->sortable()
|
|
->searchable(),
|
|
TextColumn::make('event_type')
|
|
->label(__('Type'))
|
|
->badge()
|
|
->getStateUsing(function (array $record): string {
|
|
if (!empty($record['blocked'])) {
|
|
return __('Blocked');
|
|
}
|
|
|
|
$severity = (int) ($record['severity'] ?? 0);
|
|
if ($severity >= 4) {
|
|
return __('Error');
|
|
}
|
|
|
|
return __('Warning');
|
|
})
|
|
->color(function (array $record): string {
|
|
if (!empty($record['blocked'])) {
|
|
return 'danger';
|
|
}
|
|
|
|
$severity = (int) ($record['severity'] ?? 0);
|
|
if ($severity >= 4) {
|
|
return 'warning';
|
|
}
|
|
|
|
return 'gray';
|
|
})
|
|
->sortable()
|
|
->toggleable(),
|
|
TextColumn::make('message')
|
|
->label(__('Message'))
|
|
->wrap()
|
|
->limit(80)
|
|
->searchable(),
|
|
TextColumn::make('uri')
|
|
->label(__('URI'))
|
|
->getStateUsing(function (array $record): string {
|
|
$host = (string) ($record['host'] ?? '');
|
|
$uri = (string) ($record['uri'] ?? '');
|
|
if ($host !== '' && $uri !== '') {
|
|
return $host.$uri;
|
|
}
|
|
return $uri !== '' ? $uri : $host;
|
|
})
|
|
->tooltip(function (array $record): string {
|
|
$host = (string) ($record['host'] ?? '');
|
|
$uri = (string) ($record['uri'] ?? '');
|
|
if ($host !== '' && $uri !== '') {
|
|
return $host.$uri;
|
|
}
|
|
return $uri !== '' ? $uri : $host;
|
|
})
|
|
->limit(60)
|
|
->copyable()
|
|
->copyableState(function (array $record): string {
|
|
$host = (string) ($record['host'] ?? '');
|
|
$uri = (string) ($record['uri'] ?? '');
|
|
if ($host !== '' && $uri !== '') {
|
|
return $host.$uri;
|
|
}
|
|
return $uri !== '' ? $uri : $host;
|
|
})
|
|
->extraAttributes(['class' => 'max-w-[240px] truncate'])
|
|
->wrap(false)
|
|
->searchable(),
|
|
TextColumn::make('remote_ip')
|
|
->label(__('Source IP'))
|
|
->fontFamily('mono')
|
|
->copyable()
|
|
->toggleable(isToggledHiddenByDefault: false),
|
|
TextColumn::make('host')
|
|
->label(__('Host'))
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
TextColumn::make('whitelisted')
|
|
->label(__('Whitelisted'))
|
|
->badge()
|
|
->formatStateUsing(fn (array $record): string => !empty($record['whitelisted']) ? __('Yes') : __('No'))
|
|
->color(fn (array $record): string => !empty($record['whitelisted']) ? 'success' : 'gray'),
|
|
])
|
|
->recordActions([
|
|
\Filament\Actions\Action::make('whitelist')
|
|
->label(__('Whitelist'))
|
|
->icon('heroicon-o-check-badge')
|
|
->color('primary')
|
|
->visible(fn (array $record): bool => empty($record['whitelisted']))
|
|
->action(fn (array $record) => $this->whitelistEntry($record)),
|
|
\Filament\Actions\Action::make('removeWhitelist')
|
|
->label(__('Remove whitelist'))
|
|
->icon('heroicon-o-x-mark')
|
|
->color('danger')
|
|
->visible(fn (array $record): bool => !empty($record['whitelisted']))
|
|
->requiresConfirmation()
|
|
->action(fn (array $record) => $this->removeWhitelistEntry($record)),
|
|
])
|
|
->emptyStateHeading(__('No blocked rules found'))
|
|
->emptyStateDescription(__('No ModSecurity denials found in the audit log.'))
|
|
->headerActions([
|
|
\Filament\Actions\Action::make('refresh')
|
|
->label(__('Refresh Logs'))
|
|
->icon('heroicon-o-arrow-path')
|
|
->action(fn () => $this->loadAuditLogs()),
|
|
]);
|
|
}
|
|
|
|
protected function filterRecords(array $records, ?string $search): array
|
|
{
|
|
if (! $search) {
|
|
return $records;
|
|
}
|
|
|
|
return array_values(array_filter($records, function (array $record) use ($search) {
|
|
$haystack = implode(' ', array_filter([
|
|
(string) ($record['rule_id'] ?? ''),
|
|
(string) ($record['message'] ?? ''),
|
|
(string) ($record['uri'] ?? ''),
|
|
(string) ($record['remote_ip'] ?? ''),
|
|
(string) ($record['host'] ?? ''),
|
|
]));
|
|
|
|
return str_contains(Str::lower($haystack), Str::lower($search));
|
|
}));
|
|
}
|
|
|
|
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
|
|
{
|
|
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
|
|
|
|
if (! $sortColumn) {
|
|
return $records;
|
|
}
|
|
|
|
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
|
|
$aValue = $a[$sortColumn] ?? null;
|
|
$bValue = $b[$sortColumn] ?? null;
|
|
|
|
if (is_numeric($aValue) && is_numeric($bValue)) {
|
|
$result = (float) $aValue <=> (float) $bValue;
|
|
} else {
|
|
$result = strcmp((string) $aValue, (string) $bValue);
|
|
}
|
|
|
|
return $direction === 'asc' ? $result : -$result;
|
|
});
|
|
|
|
return $records;
|
|
}
|
|
|
|
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
|
|
{
|
|
$page = max(1, (int) $page);
|
|
$perPage = max(1, (int) $recordsPerPage);
|
|
|
|
$total = count($records);
|
|
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
|
|
|
|
return new LengthAwarePaginator(
|
|
$items,
|
|
$total,
|
|
$perPage,
|
|
$page,
|
|
[
|
|
'path' => request()->url(),
|
|
'pageName' => $this->getTablePaginationPageName(),
|
|
],
|
|
);
|
|
}
|
|
}
|