diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index aad032b..0fdc6cd 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -36,7 +36,7 @@ {"id":"fotospiel-app-9mj","title":"Ops mail default + withdrawal legal routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:02.167387589+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:07.783737036+01:00","closed_at":"2026-01-01T16:10:07.783737036+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-a1n","title":"Paddle migration: define mobile/native billing strategy (RevenueCat vs Paddle)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:40.030226023+01:00","created_by":"soeren","updated_at":"2026-01-01T15:56:40.030226023+01:00"} {"id":"fotospiel-app-agz","title":"Tenant admin onboarding: update PRP/docs + handoff notes","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:51.748367378+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:51.748367378+01:00"} -{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-01T14:18:52.931017783+01:00"} +{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","notes":"Implemented GuestPolicySetting (cached singleton + migration) and SuperAdmin guest policy settings page. Wired guest defaults + rate limits into EventPublicController, share-link TTL, per-device upload limit, and guest notification default TTL. Added tests for gallery defaults, policy device limit, and notification TTL. Ran vendor/bin/pint --dirty; php artisan test tests/Feature/Api/EventGuestUploadLimitTest.php; php artisan test tests/Feature/GuestJoinTokenFlowTest.php; php artisan test tests/Feature/Tenant/GuestNotificationBroadcastTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-01T20:25:05.509507365+01:00","closed_at":"2026-01-01T20:25:05.509507365+01:00","close_reason":"Completed"} {"id":"fotospiel-app-auq","title":"Security review checklist: Media pipeline/storage dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:57.616770583+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:57.616770583+01:00"} {"id":"fotospiel-app-b0h","title":"Security review: trust boundaries/entrypoints mapped","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:43.175087637+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:48.799343248+01:00","closed_at":"2026-01-01T16:03:48.799343248+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-bep","title":"SEC-IO-01 Document PAT revocation/rotation playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:44.568780967+01:00","created_by":"soeren","updated_at":"2026-01-01T15:51:44.568780967+01:00"} diff --git a/.beads/last-touched b/.beads/last-touched index 0350cd1..792a5db 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-ihd +fotospiel-app-arp diff --git a/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php b/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php new file mode 100644 index 0000000..ec17f3a --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php @@ -0,0 +1,170 @@ +guest_downloads_enabled = (bool) $settings->guest_downloads_enabled; + $this->guest_sharing_enabled = (bool) $settings->guest_sharing_enabled; + $this->guest_upload_visibility = $settings->guest_upload_visibility ?? 'review'; + $this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50); + $this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10); + $this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5); + $this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120); + $this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1); + $this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60); + $this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1); + $this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48); + $this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours; + } + + public function form(Form $form): Form + { + return $form->schema([ + Forms\Components\Section::make(__('admin.guest_policy.sections.toggles')) + ->schema([ + Forms\Components\Toggle::make('guest_downloads_enabled') + ->label(__('admin.guest_policy.fields.guest_downloads_enabled')), + Forms\Components\Toggle::make('guest_sharing_enabled') + ->label(__('admin.guest_policy.fields.guest_sharing_enabled')), + Forms\Components\Select::make('guest_upload_visibility') + ->label(__('admin.guest_policy.fields.guest_upload_visibility')) + ->options([ + 'review' => __('admin.guest_policy.fields.upload_visibility_review'), + 'immediate' => __('admin.guest_policy.fields.upload_visibility_immediate'), + ]) + ->required(), + ]) + ->columns(2), + Forms\Components\Section::make(__('admin.guest_policy.sections.rate_limits')) + ->schema([ + Forms\Components\TextInput::make('per_device_upload_limit') + ->label(__('admin.guest_policy.fields.per_device_upload_limit')) + ->numeric() + ->minValue(0) + ->helperText(__('admin.guest_policy.help.zero_disables')), + Forms\Components\TextInput::make('join_token_failure_limit') + ->label(__('admin.guest_policy.fields.join_token_failure_limit')) + ->numeric() + ->minValue(1), + Forms\Components\TextInput::make('join_token_failure_decay_minutes') + ->label(__('admin.guest_policy.fields.join_token_failure_decay_minutes')) + ->numeric() + ->minValue(1), + Forms\Components\TextInput::make('join_token_access_limit') + ->label(__('admin.guest_policy.fields.join_token_access_limit')) + ->numeric() + ->minValue(0) + ->helperText(__('admin.guest_policy.help.zero_disables')), + Forms\Components\TextInput::make('join_token_access_decay_minutes') + ->label(__('admin.guest_policy.fields.join_token_access_decay_minutes')) + ->numeric() + ->minValue(1), + Forms\Components\TextInput::make('join_token_download_limit') + ->label(__('admin.guest_policy.fields.join_token_download_limit')) + ->numeric() + ->minValue(0) + ->helperText(__('admin.guest_policy.help.zero_disables')), + Forms\Components\TextInput::make('join_token_download_decay_minutes') + ->label(__('admin.guest_policy.fields.join_token_download_decay_minutes')) + ->numeric() + ->minValue(1), + ]) + ->columns(2), + Forms\Components\Section::make(__('admin.guest_policy.sections.retention')) + ->schema([ + Forms\Components\TextInput::make('share_link_ttl_hours') + ->label(__('admin.guest_policy.fields.share_link_ttl_hours')) + ->numeric() + ->minValue(1), + Forms\Components\TextInput::make('guest_notification_ttl_hours') + ->label(__('admin.guest_policy.fields.guest_notification_ttl_hours')) + ->numeric() + ->minValue(1) + ->nullable() + ->helperText(__('admin.guest_policy.help.notification_ttl')), + ]) + ->columns(2), + ]); + } + + public function save(): void + { + $this->validate(); + + $settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]); + $settings->guest_downloads_enabled = (bool) $this->guest_downloads_enabled; + $settings->guest_sharing_enabled = (bool) $this->guest_sharing_enabled; + $settings->guest_upload_visibility = $this->guest_upload_visibility; + $settings->per_device_upload_limit = (int) $this->per_device_upload_limit; + $settings->join_token_failure_limit = (int) $this->join_token_failure_limit; + $settings->join_token_failure_decay_minutes = (int) $this->join_token_failure_decay_minutes; + $settings->join_token_access_limit = (int) $this->join_token_access_limit; + $settings->join_token_access_decay_minutes = (int) $this->join_token_access_decay_minutes; + $settings->join_token_download_limit = (int) $this->join_token_download_limit; + $settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes; + $settings->share_link_ttl_hours = (int) $this->share_link_ttl_hours; + $settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours; + $settings->save(); + + Notification::make() + ->title(__('admin.guest_policy.notifications.saved')) + ->success() + ->send(); + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 8e74508..65c7664 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -12,6 +12,7 @@ use App\Models\Event; use App\Models\EventJoinToken; use App\Models\EventMediaAsset; use App\Models\GuestNotification; +use App\Models\GuestPolicySetting; use App\Models\Photo; use App\Models\PhotoShareLink; use App\Services\Analytics\JoinTokenAnalyticsRecorder; @@ -47,6 +48,8 @@ class EventPublicController extends BaseController private const BRANDING_SIGNED_TTL_SECONDS = 3600; + private ?GuestPolicySetting $guestPolicy = null; + public function __construct( private readonly EventJoinTokenService $joinTokenService, private readonly EventStorageManager $eventStorageManager, @@ -283,8 +286,9 @@ class EventPublicController extends BaseController ?string $rawToken = null, ?EventJoinToken $joinToken = null ): JsonResponse { - $failureLimit = max(1, (int) config('join_tokens.failure_limit', 10)); - $failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5)); + $policy = $this->guestPolicy(); + $failureLimit = max(1, (int) ($policy->join_token_failure_limit ?? config('join_tokens.failure_limit', 10))); + $failureDecay = max(1, (int) ($policy->join_token_failure_decay_minutes ?? config('join_tokens.failure_decay_minutes', 5))); if (RateLimiter::tooManyAttempts($rateLimiterKey, $failureLimit)) { Log::warning('Join token rate limit exceeded', array_merge([ @@ -369,12 +373,13 @@ class EventPublicController extends BaseController private function enforceAccessThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse { - $limit = (int) config('join_tokens.access_limit', 0); + $policy = $this->guestPolicy(); + $limit = (int) ($policy->join_token_access_limit ?? config('join_tokens.access_limit', 0)); if ($limit <= 0) { return null; } - $decay = max(1, (int) config('join_tokens.access_decay_minutes', 1)); + $decay = max(1, (int) ($policy->join_token_access_decay_minutes ?? config('join_tokens.access_decay_minutes', 1))); $key = sprintf('event:token:access:%s:%s', $joinToken->getKey(), $request->ip()); if (RateLimiter::tooManyAttempts($key, $limit)) { @@ -409,12 +414,13 @@ class EventPublicController extends BaseController private function enforceDownloadThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse { - $limit = (int) config('join_tokens.download_limit', 0); + $policy = $this->guestPolicy(); + $limit = (int) ($policy->join_token_download_limit ?? config('join_tokens.download_limit', 0)); if ($limit <= 0) { return null; } - $decay = max(1, (int) config('join_tokens.download_decay_minutes', 1)); + $decay = max(1, (int) ($policy->join_token_download_decay_minutes ?? config('join_tokens.download_decay_minutes', 1))); $key = sprintf('event:token:download:%s:%s', $joinToken->getKey(), $request->ip()); if (RateLimiter::tooManyAttempts($key, $limit)) { @@ -1431,6 +1437,7 @@ class EventPublicController extends BaseController $branding = $this->buildGalleryBranding($event); $expiresAt = optional($event->eventPackage)->gallery_expires_at; $settings = is_array($event->settings) ? $event->settings : []; + $policy = $this->guestPolicy(); return response()->json([ 'event' => [ @@ -1439,8 +1446,8 @@ class EventPublicController extends BaseController 'slug' => $event->slug, 'description' => $this->translateLocalized($event->description, $locale, ''), 'gallery_expires_at' => $expiresAt?->toIso8601String(), - 'guest_downloads_enabled' => (bool) ($settings['guest_downloads_enabled'] ?? true), - 'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? true), + 'guest_downloads_enabled' => (bool) ($settings['guest_downloads_enabled'] ?? $policy->guest_downloads_enabled), + 'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? $policy->guest_sharing_enabled), ], 'branding' => $branding, ]); @@ -1578,7 +1585,8 @@ class EventPublicController extends BaseController } $deviceId = trim((string) ($request->header('X-Device-Id') ?? $request->input('device_id', ''))); - $ttlHours = max(1, (int) config('share-links.ttl_hours', 48)); + $policy = $this->guestPolicy(); + $ttlHours = max(1, (int) ($policy->share_link_ttl_hours ?? config('share-links.ttl_hours', 48))); $existing = PhotoShareLink::query() ->where('photo_id', $photo->id) @@ -1892,6 +1900,7 @@ class EventPublicController extends BaseController $settings = $this->normalizeSettings($event->settings ?? []); $engagementMode = $settings['engagement_mode'] ?? 'tasks'; $event->loadMissing('photoboothSetting'); + $policy = $this->guestPolicy(); if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); @@ -1911,7 +1920,7 @@ class EventPublicController extends BaseController 'demo_read_only' => $demoReadOnly, 'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled), 'branding' => $branding, - 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'), + 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility), 'engagement_mode' => $engagementMode, ])->header('Cache-Control', 'no-store'); } @@ -2427,10 +2436,13 @@ class EventPublicController extends BaseController ]; } - private function notifyDeviceUploadLimit(Event $event, string $guestIdentifier, string $token): void + private function notifyDeviceUploadLimit(Event $event, string $guestIdentifier, string $token, int $limit): void { $title = 'Upload-Limit erreicht'; - $body = 'Du hast bereits 50 Fotos hochgeladen. Wir speichern deine Warteschlange – bitte lösche zuerst alte Uploads.'; + $body = sprintf( + 'Du hast bereits %d Fotos hochgeladen. Wir speichern deine Warteschlange – bitte lösche zuerst alte Uploads.', + $limit + ); $this->guestNotificationService->createNotification( $event, @@ -2473,6 +2485,11 @@ class EventPublicController extends BaseController ); } + private function guestPolicy(): GuestPolicySetting + { + return $this->guestPolicy ??= GuestPolicySetting::current(); + } + private function eventHasActiveNotification(Event $event, GuestNotificationType $type, string $title): bool { return GuestNotification::query() @@ -2895,7 +2912,8 @@ class EventPublicController extends BaseController 'eventPackages.package', 'storageAssignments.storageTarget', ])->findOrFail($eventId); - $uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', 'review'); + $policy = $this->guestPolicy(); + $uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility); $autoApproveUploads = $uploadVisibility === 'immediate'; $tenantModel = $eventModel->tenant; @@ -2930,10 +2948,10 @@ class EventPublicController extends BaseController $deviceId = $this->resolveDeviceIdentifier($request); - // Per-device cap per event (MVP: 50) + $deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50)); $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); - if ($deviceCount >= 50) { - $this->notifyDeviceUploadLimit($eventModel, $deviceId, $token); + if ($deviceLimit > 0 && $deviceCount >= $deviceLimit) { + $this->notifyDeviceUploadLimit($eventModel, $deviceId, $token, $deviceLimit); $this->recordTokenEvent( $joinToken, @@ -2957,7 +2975,7 @@ class EventPublicController extends BaseController 'scope' => 'photos', 'event_id' => $eventId, 'device_id' => $deviceId, - 'limit' => 50, + 'limit' => $deviceLimit, 'used' => $deviceCount, ] ); diff --git a/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php b/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php index bf2bb10..5cc33ff 100644 --- a/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php +++ b/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php @@ -9,6 +9,7 @@ use App\Http\Requests\Tenant\BroadcastGuestNotificationRequest; use App\Http\Resources\Tenant\GuestNotificationResource; use App\Models\Event; use App\Models\GuestNotification; +use App\Models\GuestPolicySetting; use App\Services\GuestNotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -55,6 +56,11 @@ class EventGuestNotificationController extends Controller $expiresAt = null; if (! empty($data['expires_in_minutes'])) { $expiresAt = now()->addMinutes((int) $data['expires_in_minutes']); + } else { + $policyTtl = GuestPolicySetting::current()->guest_notification_ttl_hours; + if ($policyTtl !== null && $policyTtl > 0) { + $expiresAt = now()->addHours((int) $policyTtl); + } } $payload = null; diff --git a/app/Models/GuestPolicySetting.php b/app/Models/GuestPolicySetting.php new file mode 100644 index 0000000..f5a204a --- /dev/null +++ b/app/Models/GuestPolicySetting.php @@ -0,0 +1,59 @@ + 'boolean', + 'guest_sharing_enabled' => 'boolean', + 'guest_notification_ttl_hours' => 'integer', + 'guest_upload_visibility' => 'string', + 'per_device_upload_limit' => 'integer', + 'join_token_failure_limit' => 'integer', + 'join_token_failure_decay_minutes' => 'integer', + 'join_token_access_limit' => 'integer', + 'join_token_access_decay_minutes' => 'integer', + 'join_token_download_limit' => 'integer', + 'join_token_download_decay_minutes' => 'integer', + 'share_link_ttl_hours' => 'integer', + ]; + + protected static function booted(): void + { + static::saved(fn () => static::flushCache()); + static::deleted(fn () => static::flushCache()); + } + + public static function current(): self + { + return Cache::remember('guest_policy.settings', now()->addMinutes(10), function () { + $defaults = [ + 'guest_downloads_enabled' => true, + 'guest_sharing_enabled' => true, + 'guest_upload_visibility' => 'review', + 'per_device_upload_limit' => 50, + 'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10), + 'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5), + 'join_token_access_limit' => (int) config('join_tokens.access_limit', 120), + 'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1), + 'join_token_download_limit' => (int) config('join_tokens.download_limit', 60), + 'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1), + 'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48), + 'guest_notification_ttl_hours' => null, + ]; + + return static::query()->firstOrCreate(['id' => 1], $defaults); + }); + } + + public static function flushCache(): void + { + Cache::forget('guest_policy.settings'); + } +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 7180ab9..b00683b 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -111,6 +111,7 @@ class SuperAdminPanelProvider extends PanelProvider ->pages([ Pages\Dashboard::class, \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class, + \App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class, ]) ->authGuard('super_admin'); diff --git a/database/migrations/2026_01_01_200857_create_guest_policy_settings_table.php b/database/migrations/2026_01_01_200857_create_guest_policy_settings_table.php new file mode 100644 index 0000000..adfefd8 --- /dev/null +++ b/database/migrations/2026_01_01_200857_create_guest_policy_settings_table.php @@ -0,0 +1,39 @@ +id(); + $table->boolean('guest_downloads_enabled')->default(true); + $table->boolean('guest_sharing_enabled')->default(true); + $table->string('guest_upload_visibility')->default('review'); + $table->unsignedInteger('per_device_upload_limit')->default(50); + $table->unsignedInteger('join_token_failure_limit')->default(10); + $table->unsignedInteger('join_token_failure_decay_minutes')->default(5); + $table->unsignedInteger('join_token_access_limit')->default(120); + $table->unsignedInteger('join_token_access_decay_minutes')->default(1); + $table->unsignedInteger('join_token_download_limit')->default(60); + $table->unsignedInteger('join_token_download_decay_minutes')->default(1); + $table->unsignedInteger('share_link_ttl_hours')->default(48); + $table->unsignedInteger('guest_notification_ttl_hours')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('guest_policy_settings'); + } +}; diff --git a/docs/ops/operations-manual.md b/docs/ops/operations-manual.md index 491bc33..f965e4c 100644 --- a/docs/ops/operations-manual.md +++ b/docs/ops/operations-manual.md @@ -51,6 +51,18 @@ Die Superadmin‑Konsole ist für operative Kontrolle und Eskalation gedacht – - Kein zusätzliches Tracking/PII‑Logging ohne Privacy‑Update. - Keine Infrastruktur‑Mutation ohne explizite Freigabe. +## 1.2 Superadmin‑Roadmap (geplante Seiten & Zweck) + +Diese Seiten sollen praktische Steuerung über Tenant‑Admins und die Guest‑Experience geben, ohne ins Tagesgeschäft abzurutschen. + +- **Moderation‑Queue (Guest‑Content):** Flagged Fotos/Feedback sammeln, bulk hide/delete/resolve, sauberes Audit. +- **Guest‑Policy‑Settings:** Default‑Toggles (Downloads/Sharing), Rate‑Limits, Retention‑Defaults, damit neue Events konsistent starten. +- **Ops‑Health‑Dashboard:** Queue‑Backlog, failed jobs, Storage‑Schwellen, Upload‑Pipeline‑Health auf einen Blick. +- **Compliance‑Tools:** DSGVO‑Export‑Requests und Retention‑Overrides pro Tenant/Event. +- **Superadmin‑Audit‑Log:** Jede Admin‑Aktion nachvollziehbar (ohne PII‑Payloads). +- **Tenant‑Announcements:** Zielgerichtete Hinweise/Release‑Notes an Tenant‑Admins, inkl. Zeitplanung. +- **Integrations‑Health:** Status‑Board für Paddle/RevenueCat/Webhooks inkl. Störungen. + ## 2. Deployments & Infrastruktur Diese Kapitel erklären, wie die Plattform in Docker/Dokploy betrieben wird. diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 7aa6f6c..b59879a 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -183,6 +183,42 @@ return [ 'deleted' => 'Gelöscht', ], ], + 'guest_policy' => [ + 'navigation' => [ + 'label' => 'Gast-Richtlinien', + ], + 'sections' => [ + 'toggles' => 'Gast-Funktionen', + 'rate_limits' => 'Rate-Limits', + 'retention' => 'Retention-Defaults', + ], + 'fields' => [ + 'guest_downloads_enabled' => 'Gast-Downloads erlauben', + 'guest_sharing_enabled' => 'Gast-Sharing erlauben', + 'guest_upload_visibility' => 'Gast-Upload-Sichtbarkeit', + 'upload_visibility_review' => 'Freigabe erforderlich', + 'upload_visibility_immediate' => 'Sofort veröffentlichen', + 'per_device_upload_limit' => 'Uploads pro Gerät (pro Event)', + 'join_token_failure_limit' => 'Join-Token Fehlerlimit', + 'join_token_failure_decay_minutes' => 'Join-Token Fehler-Decay (Minuten)', + 'join_token_access_limit' => 'Join-Token Zugriffslimit', + 'join_token_access_decay_minutes' => 'Join-Token Zugriff-Decay (Minuten)', + 'join_token_download_limit' => 'Join-Token Downloadlimit', + 'join_token_download_decay_minutes' => 'Join-Token Download-Decay (Minuten)', + 'share_link_ttl_hours' => 'Share-Link TTL (Stunden)', + 'guest_notification_ttl_hours' => 'Gast-Notification TTL (Stunden)', + ], + 'help' => [ + 'zero_disables' => '0 deaktiviert das Throttling.', + 'notification_ttl' => 'Leer lassen, um Benachrichtigungen ohne Ablauf zu speichern.', + ], + 'actions' => [ + 'save' => 'Änderungen speichern', + ], + 'notifications' => [ + 'saved' => 'Gast-Richtlinien aktualisiert.', + ], + ], 'events' => [ 'fields' => [ diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 30eb644..b0769cf 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -183,6 +183,42 @@ return [ 'deleted' => 'Deleted', ], ], + 'guest_policy' => [ + 'navigation' => [ + 'label' => 'Guest policy', + ], + 'sections' => [ + 'toggles' => 'Guest toggles', + 'rate_limits' => 'Rate limits', + 'retention' => 'Retention defaults', + ], + 'fields' => [ + 'guest_downloads_enabled' => 'Allow guest downloads', + 'guest_sharing_enabled' => 'Allow guest sharing', + 'guest_upload_visibility' => 'Guest upload visibility', + 'upload_visibility_review' => 'Review required', + 'upload_visibility_immediate' => 'Immediate publish', + 'per_device_upload_limit' => 'Uploads per device (per event)', + 'join_token_failure_limit' => 'Join token failure limit', + 'join_token_failure_decay_minutes' => 'Join token failure decay (minutes)', + 'join_token_access_limit' => 'Join token access limit', + 'join_token_access_decay_minutes' => 'Join token access decay (minutes)', + 'join_token_download_limit' => 'Join token download limit', + 'join_token_download_decay_minutes' => 'Join token download decay (minutes)', + 'share_link_ttl_hours' => 'Share link TTL (hours)', + 'guest_notification_ttl_hours' => 'Guest notification default TTL (hours)', + ], + 'help' => [ + 'zero_disables' => '0 disables throttling.', + 'notification_ttl' => 'Leave empty to keep notifications until explicitly expired.', + ], + 'actions' => [ + 'save' => 'Save changes', + ], + 'notifications' => [ + 'saved' => 'Guest policy updated.', + ], + ], 'events' => [ 'fields' => [ diff --git a/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php b/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php new file mode 100644 index 0000000..b0195aa --- /dev/null +++ b/resources/views/filament/super-admin/pages/guest-policy-settings-page.blade.php @@ -0,0 +1,12 @@ + +
+
+ {{ $this->form }} +
+ + {{ __('admin.guest_policy.actions.save') }} + +
+
+
+
diff --git a/tests/Feature/Api/EventGuestUploadLimitTest.php b/tests/Feature/Api/EventGuestUploadLimitTest.php index d038fe0..68774ba 100644 --- a/tests/Feature/Api/EventGuestUploadLimitTest.php +++ b/tests/Feature/Api/EventGuestUploadLimitTest.php @@ -7,6 +7,7 @@ use App\Jobs\Packages\SendEventPackagePhotoThresholdWarning; use App\Models\Emotion; use App\Models\Event; use App\Models\EventPackage; +use App\Models\GuestPolicySetting; use App\Models\MediaStorageTarget; use App\Models\Package; use App\Models\Photo; @@ -213,6 +214,43 @@ class EventGuestUploadLimitTest extends TestCase ]); } + public function test_guest_policy_overrides_device_upload_limit(): void + { + GuestPolicySetting::flushCache(); + GuestPolicySetting::query()->create([ + 'id' => 1, + 'per_device_upload_limit' => 2, + ]); + + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + + $mock = \Mockery::mock(PackageLimitEvaluator::class); + $mock->shouldReceive('assessPhotoUpload')->andReturn(null); + $mock->shouldReceive('resolveEventPackageForPhotoUpload')->andReturn(null); + $this->instance(PackageLimitEvaluator::class, $mock); + + Photo::factory()->count(2)->for($event)->create([ + 'guest_name' => 'device-limit', + ]); + + $emotion = Emotion::factory()->create(); + $emotion->eventTypes()->attach($event->event_type_id); + + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $response = $this->post("/api/v1/events/{$token}/upload", [ + 'photo' => UploadedFile::fake()->image('limit-device.jpg', 800, 600), + ], [ + 'X-Device-Id' => 'device-limit', + ]); + + $response->assertStatus(429); + $response->assertJsonPath('error.meta.limit', 2); + } + public function test_photo_limit_violation_creates_notification(): void { $tenant = Tenant::factory()->create(); diff --git a/tests/Feature/GuestJoinTokenFlowTest.php b/tests/Feature/GuestJoinTokenFlowTest.php index b656fd6..5722637 100644 --- a/tests/Feature/GuestJoinTokenFlowTest.php +++ b/tests/Feature/GuestJoinTokenFlowTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Models\Event; use App\Models\EventPackage; +use App\Models\GuestPolicySetting; use App\Models\MediaStorageTarget; use App\Models\Package; use App\Models\Photo; @@ -206,6 +207,25 @@ class GuestJoinTokenFlowTest extends TestCase ->assertJsonPath('error.code', 'invalid_token'); } + public function test_gallery_defaults_use_guest_policy_settings(): void + { + GuestPolicySetting::flushCache(); + GuestPolicySetting::query()->create([ + 'id' => 1, + 'guest_downloads_enabled' => false, + 'guest_sharing_enabled' => false, + ]); + + $event = $this->createPublishedEvent(); + $token = $this->tokenService->createToken($event); + + $response = $this->getJson("/api/v1/gallery/{$token->token}"); + + $response->assertOk() + ->assertJsonPath('event.guest_downloads_enabled', false) + ->assertJsonPath('event.guest_sharing_enabled', false); + } + public function test_guest_cannot_access_event_with_revoked_token(): void { $event = $this->createPublishedEvent(); diff --git a/tests/Feature/Tenant/GuestNotificationBroadcastTest.php b/tests/Feature/Tenant/GuestNotificationBroadcastTest.php index dbf54a0..897a401 100644 --- a/tests/Feature/Tenant/GuestNotificationBroadcastTest.php +++ b/tests/Feature/Tenant/GuestNotificationBroadcastTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature\Tenant; use App\Models\Event; use App\Models\GuestNotification; +use App\Models\GuestPolicySetting; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; class GuestNotificationBroadcastTest extends TenantTestCase @@ -49,6 +51,38 @@ class GuestNotificationBroadcastTest extends TenantTestCase ]); } + public function test_guest_notification_default_ttl_applies(): void + { + $now = now(); + Carbon::setTestNow($now); + + GuestPolicySetting::flushCache(); + GuestPolicySetting::query()->create([ + 'id' => 1, + 'guest_notification_ttl_hours' => 6, + ]); + + $event = Event::factory()->for($this->tenant)->create(); + + $payload = [ + 'title' => 'Upload-Reminder', + 'message' => 'Bitte lade deine letzten Fotos hoch.', + 'type' => 'broadcast', + ]; + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/guest-notifications", $payload); + + $response->assertCreated(); + + $notification = GuestNotification::query()->where('event_id', $event->id)->first(); + + $this->assertNotNull($notification); + $this->assertNotNull($notification->expires_at); + $this->assertEquals($now->copy()->addHours(6)->toDateTimeString(), $notification->expires_at?->toDateTimeString()); + + Carbon::setTestNow(); + } + public function test_admin_cannot_access_foreign_event(): void { $otherTenantEvent = Event::factory()->create([