mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-11 02:50:29 +00:00
✨ Add restore_point functionality to Meetups
- 💾 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.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Database;
|
||||
|
||||
use App\Models\Meetup;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('meetups:restore {meetup : ID des wiederherzustellenden Meetups}')]
|
||||
#[Description('Restore the master data of a meetup from its saved restore point.')]
|
||||
class RestoreMeetupFromRestorePoint extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$meetupId = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Database;
|
||||
|
||||
use App\Models\Meetup;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('meetups:snapshot {meetup? : ID eines einzelnen Meetups; ohne Angabe werden alle Meetups gesichert}')]
|
||||
#[Description('Save the current master data of meetups as a restore point (restore_point JSON column).')]
|
||||
class SnapshotMeetupRestorePoints extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$meetupId = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('meetups', function (Blueprint $table) {
|
||||
$table->json('restore_point')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('meetups', function (Blueprint $table) {
|
||||
$table->dropColumn('restore_point');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
@error('rateLimit')
|
||||
<flux:text class="text-red-600 dark:text-red-400 font-medium">
|
||||
{{ $message }}
|
||||
</flux:text>
|
||||
@enderror
|
||||
|
||||
<flux:button class="cursor-pointer" variant="primary" type="submit">
|
||||
{{ __('Meetup aktualisieren') }}
|
||||
</flux:button>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use App\Models\City;
|
||||
use App\Models\Country;
|
||||
use App\Models\Meetup;
|
||||
|
||||
beforeEach(function () {
|
||||
$country = Country::factory()->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();
|
||||
});
|
||||
Reference in New Issue
Block a user