Merge pull request 'security: add rate limiting to admin action endpoints' (#20) from security/rate-limit-admin-actions into main
Some checks are pending
CI / PHP 8.2 (push) Waiting to run
CI / PHP 8.3 (push) Waiting to run
CI / PHP 8.4 (push) Waiting to run
CI / Assets (push) Waiting to run

This commit is contained in:
Charon 2026-02-20 12:10:47 +00:00
commit 498bceab88
5 changed files with 690 additions and 292 deletions

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Website\Hub\Concerns;
use Illuminate\Support\Facades\RateLimiter;
/**
* Provides rate limiting helpers for Livewire admin components.
*
* Usage:
* $this->rateLimit('tier-change', 10, function () { ... });
* $this->rateLimit('waitlist-export', 5, function () { ... });
*/
trait HasRateLimiting
{
/**
* Execute a callback with rate limiting for mutation actions.
*
* @param string $action Short action identifier (e.g. 'tier-change')
* @param int $maxAttempts Maximum attempts per minute
* @param callable $callback The action to execute if within limits
* @param int $decaySeconds Rate limit window in seconds
*/
protected function rateLimit(string $action, int $maxAttempts, callable $callback, int $decaySeconds = 60): mixed
{
$key = $this->rateLimitKey($action);
$executed = false;
$result = null;
RateLimiter::attempt(
$key,
$maxAttempts,
function () use ($callback, &$executed, &$result) {
$executed = true;
$result = $callback();
},
$decaySeconds,
);
if (! $executed) {
$this->onRateLimited($action, $key);
}
return $result;
}
/**
* Build a rate limit key scoped to the authenticated user.
*/
protected function rateLimitKey(string $action): string
{
return $action.':'.auth()->id();
}
/**
* Handle rate limit exceeded - sets action message on the component.
*/
protected function onRateLimited(string $action, string $key): void
{
$seconds = RateLimiter::availableIn($key);
if (property_exists($this, 'actionMessage') && property_exists($this, 'actionType')) {
$this->actionMessage = "Too many requests. Please wait {$seconds} seconds before trying again.";
$this->actionType = 'error';
} else {
session()->flash('error', "Too many requests. Please wait {$seconds} seconds before trying again.");
}
}
}

View file

@ -14,9 +14,12 @@ use Core\Tenant\Models\Package;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementService;
use Website\Hub\Concerns\HasRateLimiting;
class PlatformUser extends Component
{
use HasRateLimiting;
public User $user;
// Editable fields
@ -80,27 +83,31 @@ class PlatformUser extends Component
public function saveTier(): void
{
$this->user->tier = UserTier::from($this->editingTier);
$this->user->save();
$this->rateLimit('admin-tier-change', 10, function () {
$this->user->tier = UserTier::from($this->editingTier);
$this->user->save();
$this->actionMessage = "Tier updated to {$this->editingTier}.";
$this->actionType = 'success';
$this->actionMessage = "Tier updated to {$this->editingTier}.";
$this->actionType = 'success';
});
}
public function saveVerification(): void
{
if ($this->editingVerified && ! $this->user->email_verified_at) {
$this->user->email_verified_at = now();
} elseif (! $this->editingVerified) {
$this->user->email_verified_at = null;
}
$this->rateLimit('admin-verification', 10, function () {
if ($this->editingVerified && ! $this->user->email_verified_at) {
$this->user->email_verified_at = now();
} elseif (! $this->editingVerified) {
$this->user->email_verified_at = null;
}
$this->user->save();
$this->user->save();
$this->actionMessage = $this->editingVerified
? 'Email marked as verified.'
: 'Email verification removed.';
$this->actionType = 'success';
$this->actionMessage = $this->editingVerified
? 'Email marked as verified.'
: 'Email verification removed.';
$this->actionType = 'success';
});
}
public function resendVerification(): void
@ -123,21 +130,23 @@ class PlatformUser extends Component
*/
public function exportUserData()
{
$data = $this->collectUserData();
return $this->rateLimit('admin-export', 5, function () {
$data = $this->collectUserData();
$filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json';
$filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json';
Log::info('GDPR data export performed by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
]);
Log::info('GDPR data export performed by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
]);
return response()->streamDownload(function () use ($data) {
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}, $filename, [
'Content-Type' => 'application/json',
]);
return response()->streamDownload(function () use ($data) {
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}, $filename, [
'Content-Type' => 'application/json',
]);
});
}
/**
@ -240,32 +249,34 @@ class PlatformUser extends Component
*/
public function scheduleDelete(): void
{
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot delete your own Hades account from here.';
$this->actionType = 'error';
$this->rateLimit('admin-deletion', 3, function () {
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot delete your own Hades account from here.';
$this->actionType = 'error';
$this->showDeleteConfirm = false;
return;
}
$request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request');
Log::warning('GDPR deletion scheduled by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
'immediate' => $this->immediateDelete,
'reason' => $this->deleteReason,
]);
if ($this->immediateDelete) {
$this->executeImmediateDelete($request);
} else {
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
$this->actionType = 'warning';
}
$this->showDeleteConfirm = false;
return;
}
$request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request');
Log::warning('GDPR deletion scheduled by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
'immediate' => $this->immediateDelete,
'reason' => $this->deleteReason,
]);
if ($this->immediateDelete) {
$this->executeImmediateDelete($request);
} else {
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
$this->actionType = 'warning';
}
$this->showDeleteConfirm = false;
});
}
/**
@ -340,50 +351,52 @@ class PlatformUser extends Component
*/
public function anonymizeUser(): void
{
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot anonymize your own account.';
$this->actionType = 'error';
$this->rateLimit('admin-deletion', 3, function () {
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot anonymize your own account.';
$this->actionType = 'error';
return;
}
$originalEmail = $this->user->email;
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
DB::transaction(function () use ($anonymizedId) {
$this->user->update([
'name' => 'Anonymized User',
'email' => $anonymizedId.'@anonymized.local',
'password' => bcrypt(str()->random(64)),
'tier' => UserTier::FREE,
'email_verified_at' => null,
'cached_stats' => null,
]);
// Remove from all workspaces
if (method_exists($this->user, 'hostWorkspaces')) {
$this->user->hostWorkspaces()->detach();
return;
}
// Cancel any pending deletions
AccountDeletionRequest::where('user_id', $this->user->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->update(['cancelled_at' => now()]);
$originalEmail = $this->user->email;
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
DB::transaction(function () use ($anonymizedId) {
$this->user->update([
'name' => 'Anonymized User',
'email' => $anonymizedId.'@anonymized.local',
'password' => bcrypt(str()->random(64)),
'tier' => UserTier::FREE,
'email_verified_at' => null,
'cached_stats' => null,
]);
// Remove from all workspaces
if (method_exists($this->user, 'hostWorkspaces')) {
$this->user->hostWorkspaces()->detach();
}
// Cancel any pending deletions
AccountDeletionRequest::where('user_id', $this->user->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->update(['cancelled_at' => now()]);
});
Log::warning('User anonymized by admin (GDPR)', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'original_email' => $originalEmail,
]);
$this->user->refresh();
$this->editingTier = $this->user->tier?->value ?? 'free';
$this->editingVerified = false;
$this->actionMessage = 'User data has been anonymized.';
$this->actionType = 'success';
});
Log::warning('User anonymized by admin (GDPR)', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'original_email' => $originalEmail,
]);
$this->user->refresh();
$this->editingTier = $this->user->tier?->value ?? 'free';
$this->editingVerified = false;
$this->actionMessage = 'User data has been anonymized.';
$this->actionType = 'success';
}
/**
@ -448,33 +461,35 @@ class PlatformUser extends Component
*/
public function provisionPackage(): void
{
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
$this->actionMessage = 'Please select a workspace and package.';
$this->actionType = 'warning';
$this->rateLimit('admin-entitlement', 10, function () {
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
$this->actionMessage = 'Please select a workspace and package.';
$this->actionType = 'warning';
return;
}
return;
}
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
$entitlements = app(EntitlementService::class);
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
'source' => 'admin',
]);
$entitlements = app(EntitlementService::class);
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
'source' => 'admin',
]);
Log::info('Package provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $this->selectedPackageCode,
]);
Log::info('Package provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $this->selectedPackageCode,
]);
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->closePackageModal();
unset($this->workspaces); // Clear computed cache
$this->closePackageModal();
unset($this->workspaces); // Clear computed cache
});
}
/**
@ -482,34 +497,36 @@ class PlatformUser extends Component
*/
public function revokePackage(int $workspaceId, string $packageCode): void
{
$workspace = Workspace::findOrFail($workspaceId);
$this->rateLimit('admin-entitlement', 10, function () use ($workspaceId, $packageCode) {
$workspace = Workspace::findOrFail($workspaceId);
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
return;
}
return;
}
$package = Package::where('code', $packageCode)->first();
$packageName = $package?->name ?? $packageCode;
$workspaceName = $workspace->name;
$package = Package::where('code', $packageCode)->first();
$packageName = $package?->name ?? $packageCode;
$workspaceName = $workspace->name;
$entitlements = app(EntitlementService::class);
$entitlements->revokePackage($workspace, $packageCode, 'admin');
$entitlements = app(EntitlementService::class);
$entitlements->revokePackage($workspace, $packageCode, 'admin');
Log::info('Package revoked by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $packageCode,
]);
Log::info('Package revoked by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $packageCode,
]);
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
$this->actionType = 'success';
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
$this->actionType = 'success';
unset($this->workspaces); // Clear computed cache
unset($this->workspaces); // Clear computed cache
});
}
// ─────────────────────────────────────────────────────────────
@ -586,68 +603,70 @@ class PlatformUser extends Component
*/
public function provisionEntitlement(): void
{
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
$this->actionMessage = 'Please select a workspace and feature.';
$this->actionType = 'warning';
$this->rateLimit('admin-entitlement', 10, function () {
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
$this->actionMessage = 'Please select a workspace and feature.';
$this->actionType = 'warning';
return;
}
return;
}
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
if (! $feature) {
$this->actionMessage = 'Feature not found.';
$this->actionType = 'error';
if (! $feature) {
$this->actionMessage = 'Feature not found.';
$this->actionType = 'error';
return;
}
return;
}
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
return;
}
return;
}
$options = [
'source' => 'admin',
'boost_type' => match ($this->entitlementType) {
'enable' => Boost::BOOST_TYPE_ENABLE,
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
default => Boost::BOOST_TYPE_ENABLE,
},
'duration_type' => $this->entitlementDuration === 'permanent'
? Boost::DURATION_PERMANENT
: Boost::DURATION_DURATION,
];
$options = [
'source' => 'admin',
'boost_type' => match ($this->entitlementType) {
'enable' => Boost::BOOST_TYPE_ENABLE,
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
default => Boost::BOOST_TYPE_ENABLE,
},
'duration_type' => $this->entitlementDuration === 'permanent'
? Boost::DURATION_PERMANENT
: Boost::DURATION_DURATION,
];
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
$options['limit_value'] = $this->entitlementLimit;
}
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
$options['limit_value'] = $this->entitlementLimit;
}
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
$options['expires_at'] = $this->entitlementExpiresAt;
}
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
$options['expires_at'] = $this->entitlementExpiresAt;
}
$entitlements = app(EntitlementService::class);
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
$entitlements = app(EntitlementService::class);
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
Log::info('Entitlement provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'feature_code' => $this->entitlementFeatureCode,
'type' => $this->entitlementType,
]);
Log::info('Entitlement provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'feature_code' => $this->entitlementFeatureCode,
'type' => $this->entitlementType,
]);
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->closeEntitlementModal();
unset($this->workspaceEntitlements);
$this->closeEntitlementModal();
unset($this->workspaceEntitlements);
});
}
/**
@ -655,34 +674,36 @@ class PlatformUser extends Component
*/
public function removeBoost(int $boostId): void
{
$boost = Boost::findOrFail($boostId);
$this->rateLimit('admin-entitlement', 10, function () use ($boostId) {
$boost = Boost::findOrFail($boostId);
// Verify this belongs to one of the user's workspaces
$workspace = $boost->workspace;
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This boost does not belong to this user.';
$this->actionType = 'error';
// Verify this belongs to one of the user's workspaces
$workspace = $boost->workspace;
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This boost does not belong to this user.';
$this->actionType = 'error';
return;
}
return;
}
$featureCode = $boost->feature_code;
$workspaceName = $workspace->name;
$featureCode = $boost->feature_code;
$workspaceName = $workspace->name;
$boost->update(['status' => Boost::STATUS_CANCELLED]);
$boost->update(['status' => Boost::STATUS_CANCELLED]);
Log::info('Boost removed by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'boost_id' => $boostId,
'feature_code' => $featureCode,
]);
Log::info('Boost removed by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'boost_id' => $boostId,
'feature_code' => $featureCode,
]);
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
$this->actionType = 'success';
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
$this->actionType = 'success';
unset($this->workspaceEntitlements);
unset($this->workspaceEntitlements);
});
}
public function render()

View file

@ -15,9 +15,12 @@ use Core\Tenant\Mail\AccountDeletionRequested;
use Core\Tenant\Models\AccountDeletionRequest;
use Core\Tenant\Models\User;
use Core\Tenant\Services\UserStatsService;
use Website\Hub\Concerns\HasRateLimiting;
class Settings extends Component
{
use HasRateLimiting;
// Active section for sidebar navigation
#[Url(as: 'tab')]
public string $activeSection = 'profile';
@ -108,6 +111,12 @@ class Settings extends Component
$this->timezones = UserStatsService::getTimezoneList();
}
protected function onRateLimited(string $action, string $key): void
{
$seconds = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
Flux::toast(text: "Too many requests. Please wait {$seconds} seconds before trying again.", variant: 'danger');
}
protected function getUserSetting(string $name, mixed $default = null): mixed
{
$setting = Setting::where('user_id', Auth::id())
@ -119,19 +128,21 @@ class Settings extends Component
public function updateProfile(): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
]);
$this->rateLimit('profile-update', 20, function () {
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
]);
$user = User::findOrFail(Auth::id());
$user->update([
'name' => $this->name,
'email' => $this->email,
]);
$user = User::findOrFail(Auth::id());
$user->update([
'name' => $this->name,
'email' => $this->email,
]);
$this->dispatch('profile-updated');
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
$this->dispatch('profile-updated');
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
});
}
public function updatePreferences(): void
@ -163,22 +174,24 @@ class Settings extends Component
public function updatePassword(): void
{
$this->validate([
'current_password' => ['required', 'current_password'],
'new_password' => ['required', 'confirmed', Password::defaults()],
]);
$this->rateLimit('password-change', 5, function () {
$this->validate([
'current_password' => ['required', 'current_password'],
'new_password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::findOrFail(Auth::id());
$user->update([
'password' => Hash::make($this->new_password),
]);
$user = User::findOrFail(Auth::id());
$user->update([
'password' => Hash::make($this->new_password),
]);
$this->current_password = '';
$this->new_password = '';
$this->new_password_confirmation = '';
$this->current_password = '';
$this->new_password = '';
$this->new_password_confirmation = '';
$this->dispatch('password-updated');
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
$this->dispatch('password-updated');
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
});
}
public function enableTwoFactor(): void
@ -213,20 +226,22 @@ class Settings extends Component
public function requestAccountDeletion(): void
{
// Get the base user model for the app
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
$this->rateLimit('account-deletion', 3, function () {
// Get the base user model for the app
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
// Create the deletion request
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
// Create the deletion request
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
// Send confirmation email
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
// Send confirmation email
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
$this->pendingDeletion = $deletionRequest;
$this->showDeleteConfirmation = false;
$this->deleteReason = '';
$this->pendingDeletion = $deletionRequest;
$this->showDeleteConfirmation = false;
$this->deleteReason = '';
Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning');
Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning');
});
}
public function cancelAccountDeletion(): void

View file

@ -10,10 +10,12 @@ use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
use Website\Hub\Concerns\HasRateLimiting;
#[Title('Waitlist')]
class WaitlistManager extends Component
{
use HasRateLimiting;
use WithPagination;
// Filters
@ -68,19 +70,21 @@ class WaitlistManager extends Component
*/
public function sendInvite(int $id): void
{
$entry = WaitlistEntry::findOrFail($id);
$this->rateLimit('admin-mutation', 10, function () use ($id) {
$entry = WaitlistEntry::findOrFail($id);
if ($entry->isInvited()) {
session()->flash('error', 'This person has already been invited.');
if ($entry->isInvited()) {
session()->flash('error', 'This person has already been invited.');
return;
}
return;
}
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
session()->flash('message', "Invite sent to {$entry->email}");
$this->refreshStats();
session()->flash('message', "Invite sent to {$entry->email}");
$this->refreshStats();
});
}
/**
@ -88,28 +92,30 @@ class WaitlistManager extends Component
*/
public function sendBulkInvites(): void
{
$entries = WaitlistEntry::whereIn('id', $this->selected)
->whereNull('invited_at')
->get();
$this->rateLimit('admin-mutation', 10, function () {
$entries = WaitlistEntry::whereIn('id', $this->selected)
->whereNull('invited_at')
->get();
if ($entries->isEmpty()) {
session()->flash('error', 'No pending entries selected.');
if ($entries->isEmpty()) {
session()->flash('error', 'No pending entries selected.');
return;
}
return;
}
$count = 0;
foreach ($entries as $entry) {
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
$count++;
}
$count = 0;
foreach ($entries as $entry) {
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
$count++;
}
$this->selected = [];
$this->selectAll = false;
$this->selected = [];
$this->selectAll = false;
session()->flash('message', "Sent {$count} invite(s) successfully.");
$this->refreshStats();
session()->flash('message', "Sent {$count} invite(s) successfully.");
$this->refreshStats();
});
}
/**
@ -141,18 +147,20 @@ class WaitlistManager extends Component
*/
public function delete(int $id): void
{
$entry = WaitlistEntry::findOrFail($id);
$this->rateLimit('admin-mutation', 10, function () use ($id) {
$entry = WaitlistEntry::findOrFail($id);
if ($entry->hasConverted()) {
session()->flash('error', 'Cannot delete entries that have converted to users.');
if ($entry->hasConverted()) {
session()->flash('error', 'Cannot delete entries that have converted to users.');
return;
}
return;
}
$entry->delete();
$entry->delete();
session()->flash('message', 'Entry deleted.');
$this->refreshStats();
session()->flash('message', 'Entry deleted.');
$this->refreshStats();
});
}
/**
@ -171,30 +179,32 @@ class WaitlistManager extends Component
*/
public function export()
{
$entries = $this->getFilteredQuery()->get();
return $this->rateLimit('admin-export', 5, function () {
$entries = $this->getFilteredQuery()->get();
$csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n";
$csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n";
foreach ($entries as $entry) {
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s,%s\n",
$entry->email,
$entry->name ?? '',
$entry->interest ?? '',
$entry->source ?? '',
$status,
$entry->created_at->format('Y-m-d'),
$entry->invited_at?->format('Y-m-d') ?? '',
$entry->registered_at?->format('Y-m-d') ?? ''
);
}
foreach ($entries as $entry) {
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s,%s\n",
$entry->email,
$entry->name ?? '',
$entry->interest ?? '',
$entry->source ?? '',
$status,
$entry->created_at->format('Y-m-d'),
$entry->invited_at?->format('Y-m-d') ?? '',
$entry->registered_at?->format('Y-m-d') ?? ''
);
}
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
'Content-Type' => 'text/csv',
]);
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
'Content-Type' => 'text/csv',
]);
});
}
protected function refreshStats(): void

View file

@ -0,0 +1,280 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
use Livewire\Livewire;
use Website\Hub\Concerns\HasRateLimiting;
// =============================================================================
// Test Double Components
// =============================================================================
/**
* Component with actionMessage/actionType properties (PlatformUser pattern).
*/
class RateLimitedActionComponent extends Component
{
use HasRateLimiting;
public string $actionMessage = '';
public string $actionType = '';
public int $executionCount = 0;
public function mutate(): void
{
$this->rateLimit('test-mutation', 3, function () {
$this->executionCount++;
$this->actionMessage = 'Action executed.';
$this->actionType = 'success';
});
}
public function export()
{
return $this->rateLimit('test-export', 2, function () {
$this->executionCount++;
return 'export-data';
});
}
public function destroy(): void
{
$this->rateLimit('test-deletion', 1, function () {
$this->executionCount++;
$this->actionMessage = 'Deleted.';
$this->actionType = 'success';
});
}
public function render(): string
{
return <<<'HTML'
<div>
<span>Executions: {{ $executionCount }}</span>
<span>Message: {{ $actionMessage }}</span>
<span>Type: {{ $actionType }}</span>
</div>
HTML;
}
}
/**
* Component without actionMessage/actionType (session flash fallback).
*/
class RateLimitedSessionComponent extends Component
{
use HasRateLimiting;
public int $executionCount = 0;
public function mutate(): void
{
$this->rateLimit('test-session-mutation', 2, function () {
$this->executionCount++;
});
}
public function render(): string
{
return <<<'HTML'
<div>
<span>Executions: {{ $executionCount }}</span>
</div>
HTML;
}
}
// =============================================================================
// Rate Limiting Enforcement Tests
// =============================================================================
beforeEach(function () {
RateLimiter::clear('test-mutation:1');
RateLimiter::clear('test-export:1');
RateLimiter::clear('test-deletion:1');
RateLimiter::clear('test-session-mutation:1');
});
describe('Rate limiting enforcement', function () {
it('allows actions within the rate limit', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
Livewire::test(RateLimitedActionComponent::class)
->call('mutate')
->assertSet('executionCount', 1)
->assertSet('actionMessage', 'Action executed.')
->assertSet('actionType', 'success')
->call('mutate')
->assertSet('executionCount', 2)
->call('mutate')
->assertSet('executionCount', 3);
});
it('blocks actions exceeding the rate limit', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Execute up to the limit
$component->call('mutate')
->call('mutate')
->call('mutate')
->assertSet('executionCount', 3);
// Fourth call should be blocked
$component->call('mutate')
->assertSet('executionCount', 3) // Not incremented
->assertSet('actionType', 'error')
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests'));
});
it('blocks export actions exceeding the rate limit', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Execute up to the limit (2 for exports)
$component->call('export')
->assertSet('executionCount', 1)
->call('export')
->assertSet('executionCount', 2);
// Third call should be blocked
$component->call('export')
->assertSet('executionCount', 2) // Not incremented
->assertSet('actionType', 'error');
});
it('enforces strict limits on destructive actions', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Execute up to the limit (1 for deletions)
$component->call('destroy')
->assertSet('executionCount', 1)
->assertSet('actionMessage', 'Deleted.');
// Second call should be blocked
$component->call('destroy')
->assertSet('executionCount', 1) // Not incremented
->assertSet('actionType', 'error')
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests'));
});
});
// =============================================================================
// Rate Limit Key Scoping Tests
// =============================================================================
describe('Rate limit key scoping', function () {
it('scopes rate limits per user', function () {
// User 1 exhausts their limit
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
RateLimiter::clear('test-deletion:1');
RateLimiter::clear('test-deletion:2');
$component1 = Livewire::test(RateLimitedActionComponent::class);
$component1->call('destroy')
->assertSet('executionCount', 1);
$component1->call('destroy')
->assertSet('executionCount', 1); // Blocked
// User 2 should not be affected
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 2]));
Livewire::test(RateLimitedActionComponent::class)
->call('destroy')
->assertSet('executionCount', 1) // User 2's own count
->assertSet('actionMessage', 'Deleted.');
});
it('uses separate limits for different action types', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Exhaust deletion limit (1)
$component->call('destroy')
->assertSet('executionCount', 1);
// Mutation limit (3) should still be available
$component->call('mutate')
->assertSet('executionCount', 2)
->assertSet('actionMessage', 'Action executed.')
->assertSet('actionType', 'success');
});
});
// =============================================================================
// User Feedback Tests
// =============================================================================
describe('User feedback when rate limited', function () {
it('shows error message with retry time via actionMessage', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Exhaust limit
$component->call('destroy');
// Next call should show error with seconds
$component->call('destroy')
->assertSet('actionType', 'error')
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')
&& str_contains($msg, 'seconds'));
});
it('flashes error to session when component lacks actionMessage property', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedSessionComponent::class);
// Exhaust limit (2)
$component->call('mutate')->call('mutate');
// Third call should be blocked and flash to session
$component->call('mutate')
->assertSet('executionCount', 2); // Not incremented
});
});
// =============================================================================
// Rate Limit Reset Tests
// =============================================================================
describe('Rate limit reset', function () {
it('allows actions after rate limit window resets', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Exhaust limit
$component->call('destroy')
->assertSet('executionCount', 1);
$component->call('destroy')
->assertSet('executionCount', 1); // Blocked
// Clear the rate limiter (simulates window expiry)
RateLimiter::clear('test-deletion:1');
// Should work again
$component->call('destroy')
->assertSet('executionCount', 2)
->assertSet('actionMessage', 'Deleted.')
->assertSet('actionType', 'success');
});
});