From f5cf85b43840dcff4b50833629ad1912514bac4b Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:56:38 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20`restore=5Fpoint`=20functiona?= =?UTF-8?q?lity=20to=20Meetups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 💾 Introduced `restore_point` JSON column in `meetups` table for saving and restoring master data. - 🛠️ Added methods `captureRestorePoint` and `restoreFromRestorePoint` to `Meetup` model for managing restore points. - 🔒 Implemented authorization for updating meetups via `updateViaPortal` policy to include pivot members. - 🔗 Created Artisan commands `meetups:snapshot` and `meetups:restore` for managing restore points from CLI. - 🚦 Added rate limiter to restrict excessive update attempts in Livewire meetup editing. - ✅ Developed exhaustive feature tests for snapshot and restore actions, portal editing rules, and rate limiting. --- .../RestoreMeetupFromRestorePoint.php | 36 ++++++++ .../Database/SnapshotMeetupRestorePoints.php | 38 +++++++++ app/Models/Meetup.php | 72 ++++++++++++++++ app/Policies/MeetupPolicy.php | 12 +++ ...708_add_restore_point_to_meetups_table.php | 28 +++++++ .../views/livewire/meetups/edit.blade.php | 29 +++++-- tests/Feature/Api/MeetupWriteApiTest.php | 12 +++ tests/Feature/Meetups/EditMeetupTest.php | 45 +++++++++- .../Meetups/MeetupRestorePointTest.php | 84 +++++++++++++++++++ 9 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 app/Console/Commands/Database/RestoreMeetupFromRestorePoint.php create mode 100644 app/Console/Commands/Database/SnapshotMeetupRestorePoints.php create mode 100644 database/migrations/2026_06_10_084708_add_restore_point_to_meetups_table.php create mode 100644 tests/Feature/Meetups/MeetupRestorePointTest.php diff --git a/app/Console/Commands/Database/RestoreMeetupFromRestorePoint.php b/app/Console/Commands/Database/RestoreMeetupFromRestorePoint.php new file mode 100644 index 0000000..8caa291 --- /dev/null +++ b/app/Console/Commands/Database/RestoreMeetupFromRestorePoint.php @@ -0,0 +1,36 @@ +argument('meetup'); + $meetup = Meetup::query()->find($meetupId); + + if ($meetup === null) { + $this->error("Meetup [{$meetupId}] not found."); + + return Command::FAILURE; + } + + if (! $meetup->restoreFromRestorePoint()) { + $this->error("Meetup [{$meetup->id}] {$meetup->name} has no restore point. Run meetups:snapshot first."); + + return Command::FAILURE; + } + + $capturedAt = $meetup->restore_point['captured_at'] ?? 'unknown'; + $this->info("Meetup [{$meetup->id}] {$meetup->name} restored from restore point (captured at {$capturedAt})."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Database/SnapshotMeetupRestorePoints.php b/app/Console/Commands/Database/SnapshotMeetupRestorePoints.php new file mode 100644 index 0000000..fc1307f --- /dev/null +++ b/app/Console/Commands/Database/SnapshotMeetupRestorePoints.php @@ -0,0 +1,38 @@ +argument('meetup'); + + $count = 0; + Meetup::query() + ->when($meetupId, fn ($query) => $query->whereKey($meetupId)) + ->chunkById(200, function ($meetups) use (&$count) { + foreach ($meetups as $meetup) { + $meetup->captureRestorePoint(); + $count++; + } + }); + + if ($meetupId && $count === 0) { + $this->error("Meetup [{$meetupId}] not found."); + + return Command::FAILURE; + } + + $this->info("Restore points saved for {$count} meetups."); + + return Command::SUCCESS; + } +} diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index d02ba95..321388a 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -59,8 +59,64 @@ class Meetup extends Model implements HasMedia 'simplified_geojson' => 'array', 'is_active' => 'boolean', 'last_event_at' => 'datetime', + 'restore_point' => 'array', ]; + /** + * Stammdaten, die im Restore-Point gesichert und wiederhergestellt werden. + * + * @var list + */ + public const RESTORE_POINT_ATTRIBUTES = [ + 'name', + 'slug', + 'city_id', + 'intro', + 'telegram_link', + 'webpage', + 'twitter_username', + 'matrix_group', + 'nostr', + 'simplex', + 'signal', + 'community', + 'github_data', + 'visible_on_map', + ]; + + /** + * Sichert den aktuellen Stand der Stammdaten als Restore-Point. + * restore_point ist bewusst nicht fillable und nur per Command erreichbar. + */ + public function captureRestorePoint(): void + { + $this->forceFill([ + 'restore_point' => [ + 'captured_at' => now()->toIso8601String(), + 'attributes' => $this->only(self::RESTORE_POINT_ATTRIBUTES), + ], + ])->saveQuietly(); + } + + /** + * Stellt die Stammdaten aus dem Restore-Point wieder her. + * Liefert false, wenn noch kein Restore-Point gesichert wurde. + */ + public function restoreFromRestorePoint(): bool + { + $attributes = $this->restore_point['attributes'] ?? null; + + if (! is_array($attributes)) { + return false; + } + + $this->forceFill( + collect($attributes)->only(self::RESTORE_POINT_ATTRIBUTES)->all() + )->save(); + + return true; + } + protected static function booted() { static::creating(function ($model) { @@ -121,6 +177,22 @@ class Meetup extends Model implements HasMedia return $this->belongsToMany(User::class); } + /** + * Ist der Nutzer über die meetup_user-Pivot Mitglied dieses Meetups? + */ + public function hasMember(User $user): bool + { + return $this->users()->whereKey($user->id)->exists(); + } + + /** + * RateLimiter-Key für Meetup-Stammdaten-Updates über das Portal-Frontend. + */ + public static function updateRateLimitKey(int $userId): string + { + return 'meetup-update:'.$userId; + } + /** * Meetups, die dem Nutzer zugeordnet sind: selbst erstellt (created_by) ODER * Mitglied über die meetup_user-Pivot. Entspricht „Meine Meetups" im Portal. diff --git a/app/Policies/MeetupPolicy.php b/app/Policies/MeetupPolicy.php index 9d07a0a..1c5471c 100644 --- a/app/Policies/MeetupPolicy.php +++ b/app/Policies/MeetupPolicy.php @@ -29,4 +29,16 @@ class MeetupPolicy { return $this->owns($user, $meetup); } + + /** + * Gelockerte Update-Regel ausschließlich für das Portal-Frontend (Livewire): + * Neben dem Ersteller darf auch jedes Mitglied der meetup_user-Pivot + * („Meine Meetups" im Dashboard) die Stammdaten bearbeiten. REST-API und + * MCP nutzen weiterhin die strikte update()-Ability. Übergangslösung, bis + * ein echtes Rollen-/Freigabekonzept existiert. + */ + public function updateViaPortal(User $user, Meetup $meetup): bool + { + return $this->owns($user, $meetup) || $meetup->hasMember($user); + } } diff --git a/database/migrations/2026_06_10_084708_add_restore_point_to_meetups_table.php b/database/migrations/2026_06_10_084708_add_restore_point_to_meetups_table.php new file mode 100644 index 0000000..de38b6f --- /dev/null +++ b/database/migrations/2026_06_10_084708_add_restore_point_to_meetups_table.php @@ -0,0 +1,28 @@ +json('restore_point')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('meetups', function (Blueprint $table) { + $table->dropColumn('restore_point'); + }); + } +}; diff --git a/resources/views/livewire/meetups/edit.blade.php b/resources/views/livewire/meetups/edit.blade.php index 19d0ce8..a7a3928 100644 --- a/resources/views/livewire/meetups/edit.blade.php +++ b/resources/views/livewire/meetups/edit.blade.php @@ -5,6 +5,7 @@ use App\Models\City; use App\Models\Country; use App\Models\Meetup; use App\Traits\SeoTrait; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rule; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -84,14 +85,15 @@ class extends Component { } /** - * Stammdaten eines Meetups dürfen ausschließlich vom Ersteller (created_by) oder - * einem Super-Admin bearbeitet werden – einheitlich mit MeetupPolicy, der REST-API - * und den MCP-Tools. Reine Mitglieder (meetup_user-Pivot) dürfen nur Termine anlegen - * (siehe meetups.create-edit-events), nicht aber die Stammdaten ändern. + * Portal-Frontend nutzt die gelockerte updateViaPortal-Ability: Ersteller, + * Super-Admins UND Mitglieder der meetup_user-Pivot („Meine Meetups") dürfen + * die Stammdaten bearbeiten. REST-API und MCP-Tools bleiben auf der strikten + * update()-Ability (nur Ersteller/Super-Admin). Übergangslösung, bis ein + * echtes Rollen-/Freigabekonzept existiert. */ protected function authorizeAccess(): void { - if (auth()->guest() || auth()->user()->cannot('update', $this->meetup)) { + if (auth()->guest() || auth()->user()->cannot('updateViaPortal', $this->meetup)) { abort(403); } } @@ -163,6 +165,15 @@ class extends Component { { $this->authorizeAccess(); + $rateLimiterKey = Meetup::updateRateLimitKey(auth()->id()); + + if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) { + $this->addError('rateLimit', + __('Zu viele Änderungen in kurzer Zeit. Bitte versuche es in einer Stunde erneut.')); + + return; + } + $validated = $this->validate([ 'name' => ['required', 'string', 'max:255', Rule::unique('meetups')->ignore($this->meetup->id)], 'city_id' => ['nullable', 'exists:cities,id'], @@ -178,6 +189,8 @@ class extends Component { 'github_data' => ['nullable', 'json'], ]); + RateLimiter::hit($rateLimiterKey, 3600); + $validated['github_data'] = $this->sanitizeGithubData($validated['github_data'] ?? null); $this->meetup->update($validated); @@ -415,6 +428,12 @@ class extends Component { @endif + @error('rateLimit') + + {{ $message }} + + @enderror + {{ __('Meetup aktualisieren') }} diff --git a/tests/Feature/Api/MeetupWriteApiTest.php b/tests/Feature/Api/MeetupWriteApiTest.php index ef74f37..e434c71 100644 --- a/tests/Feature/Api/MeetupWriteApiTest.php +++ b/tests/Feature/Api/MeetupWriteApiTest.php @@ -58,6 +58,18 @@ it('forbids updating someone elses', function () { ])->assertForbidden(); }); +it('forbids updating as a pivot member who is not the creator', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs($member = User::factory()->create()); + $meetup->users()->attach($member); + + $this->patchJson('/api/meetup/'.$meetup->id, [ + 'name' => 'Plan B Lugano', + ])->assertForbidden(); +}); + it('returns only own in mine index', function () { Sanctum::actingAs($user = User::factory()->create()); $other = User::factory()->create(); diff --git a/tests/Feature/Meetups/EditMeetupTest.php b/tests/Feature/Meetups/EditMeetupTest.php index 75569e4..3bef10f 100644 --- a/tests/Feature/Meetups/EditMeetupTest.php +++ b/tests/Feature/Meetups/EditMeetupTest.php @@ -4,6 +4,7 @@ use App\Models\City; use App\Models\Country; use App\Models\Meetup; use App\Models\User; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Livewire; beforeEach(function () { @@ -62,7 +63,26 @@ it('allows update when name is unchanged (Rule::unique ignores own id)', functio ->assertHasNoErrors(); }); -it('blocks updateMeetup when the user is not the creator', function () { +it('allows updateMeetup for a member of the meetup_user pivot who is not the creator', function () { + $member = actingAsUser(); + $meetup = Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'name' => 'Original Name', + 'created_by' => User::factory()->create()->id, + ]); + $meetup->users()->attach($member); + + Livewire::test('meetups.edit', ['meetup' => $meetup]) + ->set('name', 'Updated By Member') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->call('updateMeetup') + ->assertHasNoErrors(); + + expect($meetup->refresh()->name)->toBe('Updated By Member'); +}); + +it('blocks updateMeetup when the user is neither creator nor pivot member', function () { actingAsUser(); $meetup = Meetup::factory()->create([ 'city_id' => $this->city->id, @@ -76,6 +96,29 @@ it('blocks updateMeetup when the user is not the creator', function () { expect($meetup->refresh()->name)->toBe('Original Name'); }); +it('blocks updateMeetup after exceeding the hourly rate limit', function () { + $creator = actingAsUser(); + $meetup = Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'name' => 'Original Name', + 'created_by' => $creator->id, + ]); + $meetup->users()->attach($creator); + + for ($i = 0; $i < 10; $i++) { + RateLimiter::hit(Meetup::updateRateLimitKey($creator->id), 3600); + } + + Livewire::test('meetups.edit', ['meetup' => $meetup]) + ->set('name', 'Updated Name') + ->set('city_id', $this->city->id) + ->set('community', 'einundzwanzig') + ->call('updateMeetup') + ->assertHasErrors(['rateLimit']); + + expect($meetup->refresh()->name)->toBe('Original Name'); +}); + it('redirects guests when accessing meetup-edit', function () { $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); diff --git a/tests/Feature/Meetups/MeetupRestorePointTest.php b/tests/Feature/Meetups/MeetupRestorePointTest.php new file mode 100644 index 0000000..d68eae9 --- /dev/null +++ b/tests/Feature/Meetups/MeetupRestorePointTest.php @@ -0,0 +1,84 @@ +create(['code' => 'de']); + $this->city = City::factory()->create(['country_id' => $country->id]); +}); + +it('snapshots the current master data of all meetups', function () { + $meetup = Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'name' => 'Einundzwanzig Cham', + ]); + + $this->artisan('meetups:snapshot') + ->expectsOutputToContain('Restore points saved for 1 meetups.') + ->assertSuccessful(); + + $restorePoint = $meetup->refresh()->restore_point; + expect($restorePoint['attributes']['name'])->toBe('Einundzwanzig Cham') + ->and($restorePoint['captured_at'])->not->toBeNull(); +}); + +it('snapshots a single meetup by id', function () { + $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); + $other = Meetup::factory()->create(['city_id' => $this->city->id]); + + $this->artisan('meetups:snapshot', ['meetup' => $meetup->id]) + ->assertSuccessful(); + + expect($meetup->refresh()->restore_point)->not->toBeNull() + ->and($other->refresh()->restore_point)->toBeNull(); +}); + +it('restores master data from the restore point', function () { + $meetup = Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'name' => 'Einundzwanzig Cham', + 'community' => 'einundzwanzig', + 'telegram_link' => 'https://t.me/original', + ]); + + $this->artisan('meetups:snapshot', ['meetup' => $meetup->id])->assertSuccessful(); + + $meetup->update([ + 'name' => 'Vandalized Name', + 'community' => 'other', + 'telegram_link' => 'https://t.me/spam', + ]); + + $this->artisan('meetups:restore', ['meetup' => $meetup->id]) + ->expectsOutputToContain('restored from restore point') + ->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->name)->toBe('Einundzwanzig Cham') + ->and($meetup->community)->toBe('einundzwanzig') + ->and($meetup->telegram_link)->toBe('https://t.me/original'); +}); + +it('fails to restore when no restore point exists', function () { + $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); + + $this->artisan('meetups:restore', ['meetup' => $meetup->id]) + ->expectsOutputToContain('has no restore point') + ->assertFailed(); +}); + +it('fails to restore an unknown meetup', function () { + $this->artisan('meetups:restore', ['meetup' => 999999]) + ->expectsOutputToContain('not found') + ->assertFailed(); +}); + +it('does not allow mass-assigning the restore point via update', function () { + $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); + + $meetup->update(['restore_point' => ['attributes' => ['name' => 'Hacked']]]); + + expect($meetup->refresh()->restore_point)->toBeNull(); +});