mirror of
https://github.com/pelican-dev/panel.git
synced 2026-02-24 03:12:01 +03:00
Compare commits
3 Commits
lance/2163
...
lance/1585
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c048ccf2d | ||
|
|
bdf8a9151c | ||
|
|
67e13c646a |
@@ -30,7 +30,6 @@ use App\Traits\Filament\CanCustomizeTabs;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
@@ -1107,13 +1106,9 @@ class EditServer extends EditRecord
|
||||
->modalHeading(trans('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
|
||||
->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
|
||||
->requiresConfirmation()
|
||||
->form(fn (Server $server) => $server->backups()->where('is_locked', false)->exists() ? [
|
||||
Checkbox::make('delete_backups')
|
||||
->label(trans('admin/server.delete_backups', ['count' => $server->backups()->where('is_locked', false)->count()])),
|
||||
] : [])
|
||||
->action(function (array $data, Server $server, ServerDeletionService $service) {
|
||||
->action(function (Server $server, ServerDeletionService $service) {
|
||||
try {
|
||||
$service->withDeleteBackups($data['delete_backups'] ?? false)->handle($server);
|
||||
$service->handle($server);
|
||||
|
||||
return redirect(ListServers::getUrl(panel: 'admin'));
|
||||
} catch (ConnectionException) {
|
||||
@@ -1137,13 +1132,9 @@ class EditServer extends EditRecord
|
||||
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
|
||||
->modalSubmitActionLabel(trans('filament-actions::force-delete.single.label'))
|
||||
->requiresConfirmation()
|
||||
->form(fn (Server $server) => $server->backups()->where('is_locked', false)->exists() ? [
|
||||
Checkbox::make('delete_backups')
|
||||
->label(trans('admin/server.delete_backups', ['count' => $server->backups()->where('is_locked', false)->count()])),
|
||||
] : [])
|
||||
->action(function (array $data, Server $server, ServerDeletionService $service) {
|
||||
->action(function (Server $server, ServerDeletionService $service) {
|
||||
try {
|
||||
$service->withForce()->withDeleteBackups($data['delete_backups'] ?? false)->handle($server);
|
||||
$service->withForce()->handle($server);
|
||||
|
||||
return redirect(ListServers::getUrl(panel: 'admin'));
|
||||
} catch (ConnectionException) {
|
||||
|
||||
72
app/Listeners/AdminActivityListener.php
Normal file
72
app/Listeners/AdminActivityListener.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Facades\Activity;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Events\RecordCreated;
|
||||
use Filament\Resources\Events\RecordUpdated;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AdminActivityListener
|
||||
{
|
||||
protected const REDACTED_FIELDS = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
'token',
|
||||
'secret',
|
||||
'api_key',
|
||||
'daemon_token',
|
||||
'_token',
|
||||
];
|
||||
|
||||
public function handle(RecordCreated|RecordUpdated $event): void
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = $event->getRecord();
|
||||
$page = $event->getPage();
|
||||
$data = $event->getData();
|
||||
|
||||
$resourceClass = $page::getResource();
|
||||
$modelClass = $resourceClass::getModel();
|
||||
$slug = Str::kebab(class_basename($modelClass));
|
||||
|
||||
$action = $event instanceof RecordCreated ? 'create' : 'update';
|
||||
|
||||
$properties = $this->redactSensitiveFields($data);
|
||||
|
||||
Activity::event("admin:$slug.$action")
|
||||
->subject($record)
|
||||
->property($properties)
|
||||
->log();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function redactSensitiveFields(array $data): array
|
||||
{
|
||||
$redacted = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, self::REDACTED_FIELDS, true)) {
|
||||
$redacted[$key] = '[REDACTED]';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$redacted[$key] = $this->redactSensitiveFields($value);
|
||||
} else {
|
||||
$redacted[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $redacted;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\AdminActivityListener;
|
||||
use App\Listeners\DispatchWebhooks;
|
||||
use Filament\Resources\Events\RecordCreated;
|
||||
use Filament\Resources\Events\RecordUpdated;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
@@ -15,5 +18,7 @@ class EventServiceProvider extends ServiceProvider
|
||||
'eloquent.created*' => [DispatchWebhooks::class],
|
||||
'eloquent.deleted*' => [DispatchWebhooks::class],
|
||||
'eloquent.updated*' => [DispatchWebhooks::class],
|
||||
RecordCreated::class => [AdminActivityListener::class],
|
||||
RecordUpdated::class => [AdminActivityListener::class],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ namespace App\Providers\Filament;
|
||||
|
||||
use App\Enums\CustomizationKey;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Facades\Activity;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\View\ActionsIconAlias;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Field;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
@@ -29,8 +31,10 @@ use Filament\Support\View\SupportIconAlias;
|
||||
use Filament\Tables\View\TablesIconAlias;
|
||||
use Filament\View\PanelsIconAlias;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -132,6 +136,18 @@ class FilamentServiceProvider extends ServiceProvider
|
||||
$action->iconButton();
|
||||
$action->iconSize(IconSize::ExtraLarge);
|
||||
}
|
||||
|
||||
$action->before(function (Model $record) {
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = Str::kebab(class_basename($record));
|
||||
|
||||
Activity::event("admin:$slug.delete")
|
||||
->subject($record)
|
||||
->log();
|
||||
});
|
||||
});
|
||||
|
||||
CreateAction::configureUsing(function (CreateAction $action) {
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Services\Servers;
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Models\Server;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
use App\Services\Backups\DeleteBackupService;
|
||||
use App\Services\Databases\DatabaseManagementService;
|
||||
use Exception;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
@@ -17,16 +16,13 @@ class ServerDeletionService
|
||||
{
|
||||
protected bool $force = false;
|
||||
|
||||
protected bool $deleteBackups = false;
|
||||
|
||||
/**
|
||||
* ServerDeletionService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private DaemonServerRepository $daemonServerRepository,
|
||||
private DatabaseManagementService $databaseManagementService,
|
||||
private DeleteBackupService $deleteBackupService
|
||||
private DatabaseManagementService $databaseManagementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -39,16 +35,6 @@ class ServerDeletionService
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if unlocked backups should be deleted from storage when the server is deleted.
|
||||
*/
|
||||
public function withDeleteBackups(bool $bool = true): self
|
||||
{
|
||||
$this->deleteBackups = $bool;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a server from the panel and remove any associated databases from hosts.
|
||||
*
|
||||
@@ -92,22 +78,6 @@ class ServerDeletionService
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->deleteBackups) {
|
||||
foreach ($server->backups()->where('is_locked', false)->get() as $backup) {
|
||||
try {
|
||||
$this->deleteBackupService->handle($backup);
|
||||
} catch (Exception $exception) {
|
||||
if (!$this->force) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$backup->delete();
|
||||
|
||||
logger()->warning($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$server->allocations()->update([
|
||||
'server_id' => null,
|
||||
'notes' => null,
|
||||
|
||||
@@ -122,4 +122,51 @@ return [
|
||||
],
|
||||
'crashed' => 'Server crashed',
|
||||
],
|
||||
'admin' => [
|
||||
'user' => [
|
||||
'create' => 'Created user <b>:username</b>',
|
||||
'update' => 'Updated user <b>:username</b>',
|
||||
'delete' => 'Deleted user <b>:username</b>',
|
||||
],
|
||||
'server' => [
|
||||
'create' => 'Created server <b>:name</b>',
|
||||
'update' => 'Updated server <b>:name</b>',
|
||||
'delete' => 'Deleted server <b>:name</b>',
|
||||
],
|
||||
'node' => [
|
||||
'create' => 'Created node <b>:name</b>',
|
||||
'update' => 'Updated node <b>:name</b>',
|
||||
'delete' => 'Deleted node <b>:name</b>',
|
||||
],
|
||||
'egg' => [
|
||||
'create' => 'Created egg <b>:name</b>',
|
||||
'update' => 'Updated egg <b>:name</b>',
|
||||
'delete' => 'Deleted egg <b>:name</b>',
|
||||
],
|
||||
'role' => [
|
||||
'create' => 'Created role <b>:name</b>',
|
||||
'update' => 'Updated role <b>:name</b>',
|
||||
'delete' => 'Deleted role <b>:name</b>',
|
||||
],
|
||||
'database-host' => [
|
||||
'create' => 'Created database host <b>:name</b>',
|
||||
'update' => 'Updated database host <b>:name</b>',
|
||||
'delete' => 'Deleted database host <b>:name</b>',
|
||||
],
|
||||
'mount' => [
|
||||
'create' => 'Created mount <b>:name</b>',
|
||||
'update' => 'Updated mount <b>:name</b>',
|
||||
'delete' => 'Deleted mount <b>:name</b>',
|
||||
],
|
||||
'webhook-configuration' => [
|
||||
'create' => 'Created webhook <b>:description</b>',
|
||||
'update' => 'Updated webhook <b>:description</b>',
|
||||
'delete' => 'Deleted webhook <b>:description</b>',
|
||||
],
|
||||
'api-key' => [
|
||||
'create' => 'Created API key',
|
||||
'update' => 'Updated API key',
|
||||
'delete' => 'Deleted API key',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -98,7 +98,6 @@ return [
|
||||
'delete_db' => 'Are you sure you want to delete :name ?',
|
||||
'delete_db_heading' => 'Delete Database?',
|
||||
'backups' => 'Backups',
|
||||
'delete_backups' => 'Also delete :count backup(s) from storage',
|
||||
'egg' => 'Egg',
|
||||
'mounts' => 'Mounts',
|
||||
'no_mounts' => 'No Mounts exist for this Node',
|
||||
|
||||
158
tests/Filament/Admin/AdminActivityListenerTest.php
Normal file
158
tests/Filament/Admin/AdminActivityListenerTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Filament\Admin\Resources\Eggs\Pages\CreateEgg;
|
||||
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
|
||||
use App\Filament\Admin\Resources\Nodes\Pages\CreateNode;
|
||||
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
|
||||
use App\Listeners\AdminActivityListener;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Node;
|
||||
use App\Models\Role;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Events\RecordCreated;
|
||||
use Filament\Resources\Events\RecordUpdated;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
function pageInstance(string $class): object
|
||||
{
|
||||
return (new ReflectionClass($class))->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
function createEvent(object $record, array $data, object $page): RecordCreated
|
||||
{
|
||||
return new RecordCreated($record, $data, $page);
|
||||
}
|
||||
|
||||
function updateEvent(object $record, array $data, object $page): RecordUpdated
|
||||
{
|
||||
return new RecordUpdated($record, $data, $page);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
[$this->admin] = generateTestAccount([]);
|
||||
$this->admin = $this->admin->syncRoles(Role::getRootAdmin());
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Filament::setCurrentPanel('admin');
|
||||
});
|
||||
|
||||
it('logs create activity for an egg', function () {
|
||||
$egg = Egg::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(createEvent($egg, ['name' => 'Test Egg'], pageInstance(CreateEgg::class)));
|
||||
|
||||
$this->assertActivityLogged('admin:egg.create');
|
||||
});
|
||||
|
||||
it('logs update activity for an egg', function () {
|
||||
$egg = Egg::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(updateEvent($egg, ['name' => 'Updated Egg'], pageInstance(EditEgg::class)));
|
||||
|
||||
$this->assertActivityLogged('admin:egg.update');
|
||||
});
|
||||
|
||||
it('logs create activity for a node', function () {
|
||||
$node = Node::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(createEvent($node, ['name' => 'Test Node'], pageInstance(CreateNode::class)));
|
||||
|
||||
$this->assertActivityLogged('admin:node.create');
|
||||
});
|
||||
|
||||
it('logs update activity for a node', function () {
|
||||
$node = Node::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(updateEvent($node, ['name' => 'Updated Node'], pageInstance(EditNode::class)));
|
||||
|
||||
$this->assertActivityLogged('admin:node.update');
|
||||
});
|
||||
|
||||
it('does not log activity for non-admin panels', function () {
|
||||
Filament::setCurrentPanel('app');
|
||||
|
||||
$egg = Egg::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));
|
||||
|
||||
Event::assertNotDispatched(ActivityLogged::class);
|
||||
});
|
||||
|
||||
it('sets the record as the activity subject', function () {
|
||||
$egg = Egg::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));
|
||||
|
||||
$this->assertActivityFor('admin:egg.create', $this->admin, $egg);
|
||||
});
|
||||
|
||||
it('redacts sensitive fields from activity properties', function () {
|
||||
$egg = Egg::first();
|
||||
|
||||
$data = [
|
||||
'name' => 'Visible',
|
||||
'password' => 'should-be-redacted',
|
||||
'password_confirmation' => 'should-be-redacted',
|
||||
'token' => 'should-be-redacted',
|
||||
'secret' => 'should-be-redacted',
|
||||
'api_key' => 'should-be-redacted',
|
||||
];
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));
|
||||
|
||||
Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
|
||||
$properties = $event->model->properties;
|
||||
|
||||
expect($properties)->toHaveKey('name', 'Visible')
|
||||
->toHaveKey('password', '[REDACTED]')
|
||||
->toHaveKey('password_confirmation', '[REDACTED]')
|
||||
->toHaveKey('token', '[REDACTED]')
|
||||
->toHaveKey('secret', '[REDACTED]')
|
||||
->toHaveKey('api_key', '[REDACTED]');
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts sensitive fields in nested arrays', function () {
|
||||
$egg = Egg::first();
|
||||
|
||||
$data = [
|
||||
'name' => 'Visible',
|
||||
'nested' => [
|
||||
'safe' => 'value',
|
||||
'password' => 'should-be-redacted',
|
||||
'token' => 'should-be-redacted',
|
||||
],
|
||||
];
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));
|
||||
|
||||
Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
|
||||
$properties = $event->model->properties;
|
||||
|
||||
expect($properties['nested'])->toHaveKey('safe', 'value')
|
||||
->toHaveKey('password', '[REDACTED]')
|
||||
->toHaveKey('token', '[REDACTED]');
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('generates kebab-case event names from model class names', function () {
|
||||
$node = Node::first();
|
||||
|
||||
$listener = new AdminActivityListener();
|
||||
$listener->handle(createEvent($node, ['name' => 'Test'], pageInstance(CreateNode::class)));
|
||||
|
||||
$this->assertActivityLogged('admin:node.create');
|
||||
});
|
||||
Reference in New Issue
Block a user