diff --git a/packages/actions/docs/07-advanced.md b/packages/actions/docs/07-advanced.md index 10034b8686..a3f99a60d5 100644 --- a/packages/actions/docs/07-advanced.md +++ b/packages/actions/docs/07-advanced.md @@ -87,3 +87,107 @@ function (Request $request, array $arguments) { // ... } ``` + +## Rate limiting actions + +You can rate limit actions by using the `rateLimit()` method. This method accepts the number of attempts per minute that a user IP address can make. If the user exceeds this limit, the action will not run and a notification will be shown: + +```php +use Filament\Actions\Action; + +Action::make('delete') + ->rateLimit(5) +``` + +If the action opens a modal, the rate limit will be applied when the modal is submitted. + +If an action is opened with arguments or for a specific Eloquent record, the rate limit will apply to each unique combination of arguments or record for each action. The rate limit is also unique to the current Livewire component / page in a panel. + +## Customizing the rate limited notification + +When an action is rate limited, a notification is dispatched to the user, which indicates the rate limit. + +To customize the title of this notification, use the `rateLimitedNotificationTitle()` method: + +```php +use Filament\Actions\DeleteAction; + +DeleteAction::make() + ->rateLimit(5) + ->rateLimitedNotificationTitle('Slow down!') +``` + +You may customize the entire notification using the `rateLimitedNotification()` method: + +```php +use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; +use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; + +DeleteAction::make() + ->rateLimit(5) + ->rateLimitedNotification( + fn (TooManyRequestsException $exception): Notification => Notification::make() + ->warning() + ->title('Slow down!') + ->body("You can try deleting again in {$exception->secondsUntilAvailable} seconds."), + ) +``` + +### Customizing the rate limit behaviour + +If you wish to customize the rate limit behaviour, you can use Laravel's [rate limiting](https://laravel.com/docs/rate-limiting#basic-usage) features and Filament's [flash notifications](../notifications/sending-notifications) together in the action. + +If you want to rate limit immediately when an action modal is opened, you can do so in the `mountUsing()` method: + +```php +use Filament\Actions\Action; +use Filament\Notifications\Notification; +use Illuminate\Support\Facades\RateLimiter; + +Action::make('delete') + ->mountUsing(function () { + if (RateLimiter::tooManyAttempts( + $rateLimitKey = 'delete:' . auth()->id(), + maxAttempts: 5, + )) { + Notification::make() + ->title('Too many attempts') + ->body('Please try again in ' . RateLimiter::availableIn($rateLimitKey) . ' seconds.') + ->danger() + ->send(); + + return; + } + + RateLimiter::hit($rateLimitKey); + }) +``` + +If you want to rate limit when an action is run, you can do so in the `action()` method: + +```php +use Filament\Actions\Action; +use Filament\Notifications\Notification; +use Illuminate\Support\Facades\RateLimiter; + +Action::make('delete') + ->action(function () { + if (RateLimiter::tooManyAttempts( + $rateLimitKey = 'delete:' . auth()->id(), + maxAttempts: 5, + )) { + Notification::make() + ->title('Too many attempts') + ->body('Please try again in ' . RateLimiter::availableIn($rateLimitKey) . ' seconds.') + ->danger() + ->send(); + + return; + } + + RateLimiter::hit($rateLimitKey); + + // ... + }) +``` diff --git a/packages/actions/resources/lang/en/notifications.php b/packages/actions/resources/lang/en/notifications.php new file mode 100644 index 0000000000..b5a707e7f8 --- /dev/null +++ b/packages/actions/resources/lang/en/notifications.php @@ -0,0 +1,10 @@ + [ + 'title' => 'Too many attempts', + 'body' => 'Please try again in :seconds seconds.', + ], + +]; diff --git a/packages/actions/src/Action.php b/packages/actions/src/Action.php index a22a9efc21..518f706855 100644 --- a/packages/actions/src/Action.php +++ b/packages/actions/src/Action.php @@ -40,6 +40,7 @@ class Action extends ViewComponent implements Arrayable use Concerns\CanBeLabeledFrom; use Concerns\CanBeMounted; use Concerns\CanBeOutlined; + use Concerns\CanBeRateLimited; use Concerns\CanBeSorted; use Concerns\CanCallParentAction; use Concerns\CanClose; diff --git a/packages/actions/src/Concerns/CanBeRateLimited.php b/packages/actions/src/Concerns/CanBeRateLimited.php new file mode 100644 index 0000000000..1a24033ac6 --- /dev/null +++ b/packages/actions/src/Concerns/CanBeRateLimited.php @@ -0,0 +1,22 @@ +rateLimit = $maxAttempts; + + return $this; + } + + public function getRateLimit(): ?int + { + return $this->evaluate($this->rateLimit); + } +} diff --git a/packages/actions/src/Concerns/CanNotify.php b/packages/actions/src/Concerns/CanNotify.php index f11b8619cf..9dc9ffddb9 100644 --- a/packages/actions/src/Concerns/CanNotify.php +++ b/packages/actions/src/Concerns/CanNotify.php @@ -3,6 +3,7 @@ namespace Filament\Actions\Concerns; use Closure; +use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; use Filament\Notifications\Notification; use Illuminate\Auth\Access\Response; @@ -14,12 +15,16 @@ trait CanNotify protected Notification | Closure | null $unauthorizedNotification = null; + protected Notification | Closure | null $rateLimitedNotification = null; + protected string | Closure | null $failureNotificationTitle = null; protected string | Closure | null $successNotificationTitle = null; protected string | Closure | null $unauthorizedNotificationTitle = null; + protected string | Closure | null $rateLimitedNotificationTitle = null; + protected string | Closure | null $failureNotificationBody = null; protected string | Closure | null $failureNotificationMissingMessage = null; @@ -131,8 +136,9 @@ public function sendUnauthorizedNotification(Response $response): static $notification = $this->evaluate($this->unauthorizedNotification, [ 'notification' => $notification = Notification::make() ->danger() - ->title($this->getUnauthorizedNotificationTitle() ?? $response->message()) + ->title($this->getUnauthorizedNotificationTitle($response) ?? $response->message()) ->persistent(), + 'response' => $response, ]) ?? $notification; if (filled($notification?->getTitle())) { @@ -156,6 +162,43 @@ public function unauthorizedNotificationTitle(string | Closure | null $title): s return $this; } + public function sendRateLimitedNotification(TooManyRequestsException $exception): static + { + $notification = $this->evaluate($this->rateLimitedNotification, [ + 'exception' => $exception, + 'minutes' => $exception->minutesUntilAvailable, + 'notification' => $notification = Notification::make() + ->danger() + ->title($this->getRateLimitedNotificationTitle($exception) ?? __('filament-actions::notifications.throttled.title', [ + 'seconds' => $exception->secondsUntilAvailable, + 'minutes' => $exception->minutesUntilAvailable, + ])) + ->body(__('filament-actions::notifications.throttled.body', [ + 'seconds' => $exception->secondsUntilAvailable, + 'minutes' => $exception->minutesUntilAvailable, + ])), + 'seconds' => $exception->secondsUntilAvailable, + ]) ?? $notification; + + $notification->send(); + + return $this; + } + + public function rateLimitedNotification(Notification | Closure | null $notification): static + { + $this->rateLimitedNotification = $notification; + + return $this; + } + + public function rateLimitedNotificationTitle(string | Closure | null $title): static + { + $this->rateLimitedNotificationTitle = $title; + + return $this; + } + public function getSuccessNotificationTitle(): ?string { return $this->evaluate($this->successNotificationTitle); @@ -209,8 +252,19 @@ public function getFailureNotificationMissingMessage(int $successCount = 0, int ]); } - public function getUnauthorizedNotificationTitle(): ?string + public function getUnauthorizedNotificationTitle(Response $response): ?string + { + return $this->evaluate($this->unauthorizedNotificationTitle, [ + 'response' => $response, + ]); + } + + public function getRateLimitedNotificationTitle(TooManyRequestsException $exception): ?string { - return $this->evaluate($this->unauthorizedNotificationTitle); + return $this->evaluate($this->rateLimitedNotificationTitle, [ + 'exception' => $exception, + 'minutes' => $exception->minutesUntilAvailable, + 'seconds' => $exception->secondsUntilAvailable, + ]); } } diff --git a/packages/actions/src/Concerns/InteractsWithActions.php b/packages/actions/src/Concerns/InteractsWithActions.php index 0d0b1ecf36..ee79590983 100644 --- a/packages/actions/src/Concerns/InteractsWithActions.php +++ b/packages/actions/src/Concerns/InteractsWithActions.php @@ -3,6 +3,8 @@ namespace Filament\Actions\Concerns; use Closure; +use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; +use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Filament\Actions\Action; use Filament\Actions\Exceptions\ActionNotResolvableException; use Filament\Schemas\Components\Contracts\ExposesStateToActionData; @@ -24,6 +26,8 @@ trait InteractsWithActions { + use WithRateLimiting; + /** * @var array> | null */ @@ -161,6 +165,8 @@ public function callMountedAction(array $arguments = []): mixed return null; } + $action->mergeArguments($arguments); + if ($action->isDisabled()) { return null; } @@ -172,7 +178,15 @@ public function callMountedAction(array $arguments = []): mixed return null; } - $action->mergeArguments($arguments); + if ($rateLimit = $action->getRateLimit()) { + try { + $this->rateLimit($rateLimit, method: json_encode(array_map(fn (array $action): array => Arr::except($action, ['data']), $this->mountedActions))); + } catch (TooManyRequestsException $exception) { + $action->sendRateLimitedNotification($exception); + + return null; + } + } $schema = $this->getMountedActionSchema(mountedAction: $action); diff --git a/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/RemoveEmailCodeAuthenticationAction.php b/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/RemoveEmailCodeAuthenticationAction.php index 2dc2b824aa..d584b0f8b2 100644 --- a/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/RemoveEmailCodeAuthenticationAction.php +++ b/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/RemoveEmailCodeAuthenticationAction.php @@ -75,6 +75,7 @@ public static function make(EmailCodeAuthentication $emailCodeAuthentication): A ->success() ->icon('heroicon-o-lock-open') ->send(); - }); + }) + ->rateLimit(5); } } diff --git a/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/SetUpEmailCodeAuthenticationAction.php b/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/SetUpEmailCodeAuthenticationAction.php index 28f13d3b2f..6d6a8c8c3a 100644 --- a/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/SetUpEmailCodeAuthenticationAction.php +++ b/packages/panels/src/MultiFactorAuthentication/EmailCode/Actions/SetUpEmailCodeAuthenticationAction.php @@ -94,6 +94,7 @@ public static function make(EmailCodeAuthentication $emailCodeAuthentication): A ->success() ->icon('heroicon-o-lock-closed') ->send(); - }); + }) + ->rateLimit(5); } } diff --git a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RegenerateGoogleTwoFactorAuthenticationRecoveryCodesAction.php b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RegenerateGoogleTwoFactorAuthenticationRecoveryCodesAction.php index ab4e30d172..aabe049b4d 100644 --- a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RegenerateGoogleTwoFactorAuthenticationRecoveryCodesAction.php +++ b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RegenerateGoogleTwoFactorAuthenticationRecoveryCodesAction.php @@ -88,6 +88,7 @@ public static function make(GoogleTwoFactorAuthentication $googleTwoFactorAuthen ->color('danger')) ->modalCancelAction(false) ->cancelParentActions(), - ]); + ]) + ->rateLimit(5); } } diff --git a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RemoveGoogleTwoFactorAuthenticationAction.php b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RemoveGoogleTwoFactorAuthenticationAction.php index 0bbd7fdc7e..2bf622df88 100644 --- a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RemoveGoogleTwoFactorAuthenticationAction.php +++ b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/RemoveGoogleTwoFactorAuthenticationAction.php @@ -90,6 +90,7 @@ public static function make(GoogleTwoFactorAuthentication $googleTwoFactorAuthen ->success() ->icon('heroicon-o-lock-open') ->send(); - }); + }) + ->rateLimit(5); } } diff --git a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/SetUpGoogleTwoFactorAuthenticationAction.php b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/SetUpGoogleTwoFactorAuthenticationAction.php index a882d77f69..8422349728 100644 --- a/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/SetUpGoogleTwoFactorAuthenticationAction.php +++ b/packages/panels/src/MultiFactorAuthentication/GoogleTwoFactor/Actions/SetUpGoogleTwoFactorAuthenticationAction.php @@ -95,6 +95,7 @@ public static function make(GoogleTwoFactorAuthentication $googleTwoFactorAuthen ->success() ->icon('heroicon-o-lock-closed') ->send(); - }); + }) + ->rateLimit(5); } } diff --git a/tests/src/Actions/RateLimitingTest.php b/tests/src/Actions/RateLimitingTest.php new file mode 100644 index 0000000000..5a94db0e63 --- /dev/null +++ b/tests/src/Actions/RateLimitingTest.php @@ -0,0 +1,42 @@ +callAction('rate-limited') + ->assertDispatched('rate-limited-called') + ->assertNotNotified('Too many attempts') + ->callAction('rate-limited') + ->assertDispatched('rate-limited-called') + ->assertNotNotified('Too many attempts') + ->callAction('rate-limited') + ->assertDispatched('rate-limited-called') + ->assertNotNotified('Too many attempts') + ->callAction('rate-limited') + ->assertDispatched('rate-limited-called') + ->assertNotNotified('Too many attempts') + ->callAction('rate-limited') + ->assertDispatched('rate-limited-called') + ->assertNotNotified('Too many attempts') + ->callAction('rate-limited') + ->assertNotDispatched('rate-limited-called') + ->assertNotified('Too many attempts'); + + livewire(Actions::class) + ->callAction('rate-limited') + ->assertNotDispatched('rate-limited-called') + ->assertNotified('Too many attempts'); + + cache()->clear(); + + livewire(Actions::class) + ->callAction('rate-limited') + ->assertDispatched('rate-limited-called') + ->assertNotNotified('Too many attempts'); +}); diff --git a/tests/src/Fixtures/Pages/Actions.php b/tests/src/Fixtures/Pages/Actions.php index 3bc888d3c1..0ec587b9bd 100644 --- a/tests/src/Fixtures/Pages/Actions.php +++ b/tests/src/Fixtures/Pages/Actions.php @@ -96,7 +96,7 @@ protected function getHeaderActions(): array baz: $mountedActions[2]->getRawFormData()['baz'], )), Action::make('testArguments') - ->action(function (array $mountedActions, Action $action) { + ->action(function (array $mountedActions) { $this->dispatch( 'arguments-test-called', foo: $mountedActions[0]->getArguments()['foo'], @@ -159,6 +159,11 @@ protected function getHeaderActions(): array ->success() ->send(); }), + Action::make('rate-limited') + ->rateLimit(5) + ->action(fn () => $this->dispatch( + 'rate-limited-called', + )), ]; } }