Add guest policy settings
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-ihd
|
||||
fotospiel-app-arp
|
||||
|
||||
170
app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php
Normal file
170
app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
59
app/Models/GuestPolicySetting.php
Normal file
59
app/Models/GuestPolicySetting.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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::create('guest_policy_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl bg-white p-6 shadow-sm">
|
||||
{{ $this->form }}
|
||||
<div class="mt-4 flex justify-end">
|
||||
<x-filament::button wire:click="save" color="primary">
|
||||
{{ __('admin.guest_policy.actions.save') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user