Add guest policy settings
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-01 20:25:39 +01:00
parent 25d464215e
commit c180b37760
15 changed files with 500 additions and 19 deletions

View File

@@ -0,0 +1,170 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Models\GuestPolicySetting;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class GuestPolicySettingsPage extends Page
{
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static ?string $cluster = RareAdminCluster::class;
protected string $view = 'filament.super-admin.pages.guest-policy-settings-page';
protected static null|string|\UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 25;
public static function getNavigationGroup(): \UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return __('admin.guest_policy.navigation.label');
}
public ?bool $guest_downloads_enabled = true;
public ?bool $guest_sharing_enabled = true;
public string $guest_upload_visibility = 'review';
public int $per_device_upload_limit = 50;
public int $join_token_failure_limit = 10;
public int $join_token_failure_decay_minutes = 5;
public int $join_token_access_limit = 120;
public int $join_token_access_decay_minutes = 1;
public int $join_token_download_limit = 60;
public int $join_token_download_decay_minutes = 1;
public int $share_link_ttl_hours = 48;
public ?int $guest_notification_ttl_hours = null;
public function mount(): void
{
$settings = GuestPolicySetting::current();
$this->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();
}
}

View File

@@ -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,
]
);

View File

@@ -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;

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class GuestPolicySetting extends Model
{
protected $guarded = [];
protected $casts = [
'guest_downloads_enabled' => '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');
}
}

View File

@@ -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');