diff --git a/app/Http/Middleware/ResponseSecurityHeaders.php b/app/Http/Middleware/ResponseSecurityHeaders.php index 13eea80..b6f15f4 100644 --- a/app/Http/Middleware/ResponseSecurityHeaders.php +++ b/app/Http/Middleware/ResponseSecurityHeaders.php @@ -13,11 +13,15 @@ class ResponseSecurityHeaders /** @var Response $response */ $response = $next($request); + $permissionsPolicy = $request->is('event', 'e/*', 'g/*', 'share/*') + ? 'camera=(self), microphone=(), geolocation=()' + : 'camera=(), microphone=(), geolocation=()'; + $headers = [ 'Referrer-Policy' => 'strict-origin-when-cross-origin', 'X-Content-Type-Options' => 'nosniff', 'X-Frame-Options' => 'SAMEORIGIN', - 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()', + 'Permissions-Policy' => $permissionsPolicy, ]; foreach ($headers as $name => $value) { diff --git a/database/seeders/_DemoLifecycleSeeder.php b/database/seeders/_DemoLifecycleSeeder.php deleted file mode 100644 index a26469f..0000000 --- a/database/seeders/_DemoLifecycleSeeder.php +++ /dev/null @@ -1,373 +0,0 @@ -ensurePackages(); - [$weddingType, $corporateType] = $this->ensureEventTypes(); - - $this->seedOnboardingTenant(); - $this->seedActiveTenant($standard, $premium, $weddingType, $corporateType); - $this->seedResellerTenant($reseller, $standard, $weddingType); - $this->seedDormantTenant(); - } - - private function ensurePackages(): array - { - $standard = Package::firstOrCreate( - ['slug' => 'standard'], - [ - 'type' => 'endcustomer', - 'name' => 'Standard', - 'name_translations' => ['de' => 'Standard', 'en' => 'Standard'], - 'price' => 79, - 'max_photos' => 1500, - 'max_guests' => 400, - 'gallery_days' => 60, - 'watermark_allowed' => false, - 'branding_allowed' => false, - 'features' => [ - 'basic_uploads' => true, - 'unlimited_sharing' => true, - 'custom_tasks' => true, - ], - ] - ); - - $premium = Package::firstOrCreate( - ['slug' => 'premium'], - [ - 'type' => 'endcustomer', - 'name' => 'Premium', - 'name_translations' => ['de' => 'Premium', 'en' => 'Premium'], - 'price' => 149, - 'max_photos' => 5000, - 'max_guests' => 1000, - 'gallery_days' => 180, - 'watermark_allowed' => false, - 'branding_allowed' => true, - 'features' => [ - 'basic_uploads' => true, - 'unlimited_sharing' => true, - 'custom_branding' => true, - 'custom_tasks' => true, - ], - ] - ); - - $reseller = Package::firstOrCreate( - ['slug' => 'studio-annual'], - [ - 'type' => 'reseller', - 'name' => 'Studio Jahrespaket', - 'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'], - 'price' => 1299, - 'max_events_per_year' => 24, - 'watermark_allowed' => false, - 'branding_allowed' => true, - 'features' => [ - 'custom_branding' => true, - 'unlimited_sharing' => true, - 'basic_uploads' => true, - ], - ] - ); - - return [$standard, $premium, $reseller]; - } - - private function ensureEventTypes(): array - { - $weddingType = EventType::firstOrCreate( - ['slug' => 'wedding'], - [ - 'name' => 'Wedding', - 'name_translations' => ['de' => 'Hochzeit', 'en' => 'Wedding'], - 'icon' => 'heart', - ] - ); - - $corporateType = EventType::firstOrCreate( - ['slug' => 'corporate'], - [ - 'name' => 'Corporate Event', - 'name_translations' => ['de' => 'Firmen-Event', 'en' => 'Corporate'], - 'icon' => 'presentation-chart', - ] - ); - - return [$weddingType, $corporateType]; - } - - private function seedOnboardingTenant(): void - { - $tenant = $this->createTenant('storycraft-weddings', [ - 'name' => 'Storycraft Weddings', - 'contact_email' => 'hello@storycraft-weddings.demo', - 'subscription_tier' => 'free', - 'subscription_status' => 'free', - 'subscription_expires_at' => null, - 'is_active' => true, - ]); - - $this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel'); - } - - private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void - { - $tenant = $this->createTenant('lumen-moments', [ - 'name' => 'Lumen Moments', - 'contact_email' => 'hello@lumen-moments.demo', - 'subscription_tier' => 'starter', - 'subscription_status' => 'active', - 'is_active' => true, - ]); - - $this->createTenantAdmin($tenant, 'hello@lumen-moments.demo'); - - $purchase = PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $premium->id, - 'provider' => 'stripe', - 'provider_id' => 'stripe_demo_pi', - 'price' => $premium->price, - 'type' => 'endcustomer_event', - 'purchased_at' => Carbon::now()->subDays(3), - 'metadata' => ['demo' => true], - ]); - - $publishedEvent = $this->createEventWithPackage( - tenant: $tenant, - package: $premium, - eventType: $weddingType, - attributes: [ - 'name' => ['de' => 'Sommerhochzeit Lea & Tim', 'en' => 'Summer Wedding Lea & Tim'], - 'slug' => 'summer-wedding-lea-tim', - 'status' => 'published', - 'is_active' => true, - 'date' => Carbon::now()->addWeeks(4), - ] - ); - - $draftEvent = $this->createEventWithPackage( - tenant: $tenant, - package: $standard, - eventType: $corporateType, - attributes: [ - 'name' => ['de' => 'Startup Social 2025', 'en' => 'Startup Social 2025'], - 'slug' => 'startup-social-2025', - 'status' => 'draft', - 'is_active' => false, - 'date' => Carbon::now()->addWeeks(12), - ] - ); - - $purchase->update(['event_id' => $publishedEvent->id]); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'event_id' => $draftEvent->id, - 'package_id' => $standard->id, - 'provider' => 'paypal', - 'provider_id' => 'paypal_demo_capture', - 'price' => $standard->price, - 'type' => 'endcustomer_event', - 'purchased_at' => Carbon::now()->subDays(1), - 'metadata' => ['demo' => true], - ]); - } - - private function seedResellerTenant(Package $reseller, Package $standard, EventType $weddingType): void - { - $tenant = $this->createTenant('viewfinder-studios', [ - 'name' => 'Viewfinder Studios', - 'contact_email' => 'team@viewfinder.demo', - 'subscription_tier' => 'reseller', - 'subscription_status' => 'active', - 'is_active' => true, - ]); - - $this->createTenantAdmin($tenant, 'team@viewfinder.demo'); - - $tenantPackage = TenantPackage::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $reseller->id, - 'price' => $reseller->price, - 'purchased_at' => Carbon::now()->subMonths(2), - 'expires_at' => Carbon::now()->addMonths(10), - 'used_events' => 6, - 'active' => true, - ]); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $reseller->id, - 'provider' => 'stripe', - 'provider_id' => 'stripe_demo_subscription', - 'price' => $reseller->price, - 'type' => 'reseller_subscription', - 'purchased_at' => $tenantPackage->purchased_at, - 'metadata' => ['demo' => true, 'plan' => 'studio-annual'], - ]); - - // Create a mix of events representing allowance consumption. - $statuses = ['published', 'published', 'draft', 'archived']; - - foreach ($statuses as $index => $status) { - $event = $this->createEventWithPackage( - tenant: $tenant, - package: $standard, - eventType: $weddingType, - attributes: [ - 'name' => ['de' => 'Studio Event #'.($index + 1), 'en' => 'Studio Event #'.($index + 1)], - 'slug' => 'studio-event-'.($index + 1), - 'status' => $status, - 'is_active' => $status === 'published', - 'date' => $status === 'archived' - ? Carbon::now()->subMonths(3) - : Carbon::now()->addWeeks($index * 3 + 1), - ] - ); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'event_id' => $event->id, - 'package_id' => $standard->id, - 'provider' => 'manual', - 'provider_id' => 'reseller_allowance', - 'price' => 0, - 'type' => 'endcustomer_event', - 'purchased_at' => Carbon::now()->subDays(10 - $index), - 'metadata' => ['allowance' => true], - ]); - } - } - - private function seedDormantTenant(): void - { - $tenant = $this->createTenant('pixel-and-co', [ - 'name' => 'Pixel & Co', - 'contact_email' => 'support@pixelco.demo', - 'subscription_status' => 'expired', - 'subscription_tier' => 'free', - 'subscription_expires_at' => Carbon::now()->subMonths(2), - 'is_active' => false, - 'is_suspended' => false, - ]); - - $this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member'); - } - - private function createTenantAdmin(Tenant $tenant, string $email, string $role = 'tenant_admin'): User - { - return User::updateOrCreate( - ['email' => $email], - [ - 'tenant_id' => $tenant->id, - 'role' => $role, - 'password' => Hash::make('Demo1234!'), - 'first_name' => Str::headline(Str::before($tenant->slug, '-')), - 'last_name' => 'Team', - ] - ); - } - - private function createEventWithPackage( - Tenant $tenant, - Package $package, - EventType $eventType, - array $attributes - ): Event { - $payload = array_merge([ - 'tenant_id' => $tenant->id, - 'event_type_id' => $eventType->id, - 'settings' => [ - 'features' => [ - 'photo_likes_enabled' => true, - 'event_checklist' => true, - ], - ], - ], $attributes); - - $event = Event::updateOrCreate(['slug' => $attributes['slug']], $payload); - - EventPackage::updateOrCreate( - [ - 'event_id' => $event->id, - 'package_id' => $package->id, - ], - [ - 'purchased_price' => $package->price, - 'purchased_at' => Carbon::now()->subDays(2), - 'used_photos' => 0, - 'used_guests' => 0, - 'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30), - ] - ); - - return $event; - } - - private function createTenant(string $slug, array $overrides = []): Tenant - { - $email = $overrides['contact_email'] ?? $slug.'@demo.fotospiel'; - - $defaults = [ - 'name' => Str::headline(str_replace('-', ' ', $slug)), - 'slug' => $slug, - 'contact_email' => $email, - 'event_credits_balance' => 0, - 'subscription_tier' => 'free', - 'subscription_status' => 'free', - 'subscription_expires_at' => Carbon::now()->addMonths(6), - 'is_active' => true, - 'is_suspended' => false, - 'settings_updated_at' => Carbon::now(), - 'settings' => $this->defaultSettings($email), - ]; - - $attributes = array_merge($defaults, $overrides); - - $tenant = Tenant::updateOrCreate(['slug' => $slug], $attributes); - - return $tenant; - } - - private function defaultSettings(string $contactEmail): array - { - return [ - 'branding' => [ - 'logo_url' => null, - 'primary_color' => '#3B82F6', - 'secondary_color' => '#1F2937', - 'font_family' => 'Inter, sans-serif', - ], - 'features' => [ - 'photo_likes_enabled' => true, - 'event_checklist' => true, - 'custom_domain' => false, - 'advanced_analytics' => false, - ], - 'custom_domain' => null, - 'contact_email' => $contactEmail, - 'event_default_type' => 'general', - ]; - } - -} diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 14b56c1..4c67403 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -445,17 +445,24 @@ export const messages: Record = { message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.', openGallery: 'Foto aus Galerie wählen', }, + cameraBlocked: { + title: 'Kamera durch Sicherheitsrichtlinie blockiert', + message: 'Die Kamera ist durch die Sicherheitsrichtlinie dieser Seite blockiert. Öffne den Event-Link im Browser oder lade ein Foto aus der Galerie hoch.', + hint: 'Tipp: Wenn du in einer In-App-Ansicht bist, öffne den Link in Safari/Chrome und lade die Seite neu.', + }, cameraDenied: { title: 'Kamera-Zugriff verweigert', explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.', reopenPrompt: 'Systemdialog erneut öffnen', chooseFile: 'Foto aus Galerie wählen', prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.', + hint: 'Tipp: Prüfe in den Browser-Einstellungen, ob Kamera-Zugriff erlaubt ist, und lade die Seite neu.', }, cameraError: { title: 'Kamera konnte nicht gestartet werden', explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.', tryAgain: 'Nochmals versuchen', + hint: 'Tipp: Schließe andere Apps mit Kamerazugriff und versuche es erneut.', }, readyOverlay: { title: 'Kamera bereit', @@ -591,6 +598,7 @@ export const messages: Record = { buttons: { startCamera: 'Kamera starten', tryAgain: 'Erneut versuchen', + recheckCamera: 'Zugriff erneut prüfen', }, }, settings: { @@ -1100,17 +1108,24 @@ export const messages: Record = { message: 'Your device does not support live camera preview in this browser. You can upload photos from your gallery instead.', openGallery: 'Choose photo from gallery', }, + cameraBlocked: { + title: 'Camera blocked by security policy', + message: 'Camera access is blocked by the site security policy. Open the event link in your browser or upload a photo from your gallery.', + hint: 'Tip: If you are in an in-app browser, open the link in Safari/Chrome and reload the page.', + }, cameraDenied: { title: 'Camera access denied', explanation: 'Allow camera access to capture photos.', reopenPrompt: 'Open system dialog again', chooseFile: 'Choose photo from gallery', prompt: 'We need access to your camera. Allow the request or pick a photo from your gallery.', + hint: 'Tip: Check your browser settings for camera permissions and reload the page.', }, cameraError: { title: 'Camera could not be started', explanation: 'We could not connect to the camera. Check permissions or restart your device.', tryAgain: 'Try again', + hint: 'Tip: Close other apps that might be using the camera and try again.', }, readyOverlay: { title: 'Camera ready', @@ -1246,6 +1261,7 @@ export const messages: Record = { buttons: { startCamera: 'Start camera', tryAgain: 'Try again', + recheckCamera: 'Recheck access', }, }, settings: { diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index a4c9e64..e71b03c 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -53,7 +53,7 @@ interface Task { difficulty?: 'easy' | 'medium' | 'hard'; } -type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported'; +type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported' | 'blocked'; type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading'; type CameraPreferences = { @@ -85,6 +85,21 @@ function getErrorName(error: unknown): string | undefined { return undefined; } +function isCameraBlockedByPolicy(): boolean { + if (typeof document === 'undefined') { + return false; + } + + const policy = (document as { permissionsPolicy?: { allowsFeature?: (feature: string) => boolean } }) + .permissionsPolicy; + + if (!policy?.allowsFeature) { + return false; + } + + return !policy.allowsFeature('camera'); +} + const DEFAULT_PREFS: CameraPreferences = { facingMode: 'environment', countdownSeconds: 3, @@ -449,6 +464,12 @@ const [canUpload, setCanUpload] = useState(true); if (mode === 'uploading') return; try { + if (isCameraBlockedByPolicy()) { + setPermissionState('blocked'); + setPermissionMessage(t('upload.cameraBlocked.message')); + return; + } + setPermissionState('prompt'); setPermissionMessage(null); @@ -475,6 +496,18 @@ const [canUpload, setCanUpload] = useState(true); } }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]); + const handleRecheckCamera = useCallback(() => { + if (isCameraBlockedByPolicy()) { + setPermissionState('blocked'); + setPermissionMessage(t('upload.cameraBlocked.message')); + return; + } + + setPermissionState('idle'); + setPermissionMessage(null); + void startCamera(); + }, [startCamera, t]); + useEffect(() => { if (loadingTask) return; startCamera(); @@ -1078,6 +1111,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ denied: t('upload.cameraDenied.title'), error: t('upload.cameraError.title'), unsupported: t('upload.cameraUnsupported.title'), + blocked: t('upload.cameraBlocked.title'), }; const fallbackMessages: Record = { @@ -1087,11 +1121,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ denied: t('upload.cameraDenied.explanation'), error: t('upload.cameraError.explanation'), unsupported: t('upload.cameraUnsupported.message'), + blocked: t('upload.cameraBlocked.message'), }; const title = titles[permissionState]; const description = permissionMessage ?? fallbackMessages[permissionState]; - const canRetryCamera = permissionState !== 'unsupported'; + const canRetryCamera = permissionState !== 'unsupported' && permissionState !== 'blocked'; + const canRecheckCamera = permissionState === 'blocked'; + const helpText = permissionState === 'blocked' + ? t('upload.cameraBlocked.hint') + : permissionState === 'denied' + ? t('upload.cameraDenied.hint') + : permissionState === 'error' + ? t('upload.cameraError.hint') + : null; return (
)} + {canRecheckCamera && ( + + )}
)} + {permissionState !== 'granted' && ( +
+ {renderPermissionNotice()} +
+ )} + {mode === 'countdown' && (
{countdownValue}
@@ -1449,7 +1514,6 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ )} - {permissionState !== 'granted' && renderPermissionNotice()} {renderPrimer()} detectEnvironment(fn () => $originalEnv); } } + + public function test_guest_pwa_allows_camera_via_permissions_policy(): void + { + $originalEnv = app()->environment(); + app()->detectEnvironment(fn () => 'production'); + config([ + 'app.debug' => false, + 'app.url' => 'https://test-y0k0.fotospiel.app', + 'security_headers.force_hsts' => true, + ]); + + Route::middleware('web')->get('/e/test-token', fn () => response('ok')); + + try { + $response = $this->withServerVariables([ + 'HTTPS' => 'on', + 'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app', + ])->get('/e/test-token'); + + $response->assertOk(); + $response->assertHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()'); + } finally { + app()->detectEnvironment(fn () => $originalEnv); + } + } }