diff --git a/config/cachet.php b/config/cachet.php index ce620e1b..bcacd278 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -177,6 +177,24 @@ */ 'settings_cache' => env('CACHET_SETTINGS_CACHE', true), + /* + |-------------------------------------------------------------------------- + | Cachet Image Uploads + |-------------------------------------------------------------------------- + | + | Configure where custom images are stored, their maximum size in + | kilobytes, and the MIME types that may be uploaded. + | + */ + 'uploads' => [ + 'disk' => env('CACHET_UPLOAD_DISK', 'public'), + 'max_size' => (int) env('CACHET_UPLOAD_MAX_SIZE', 1024), + 'image_mime_types' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('CACHET_UPLOAD_IMAGE_MIME_TYPES', 'image/jpeg,image/png,image/gif,image/webp')), + ))), + ], + /* |-------------------------------------------------------------------------- | Cachet Migrations diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index 1d3d2afe..46540810 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -3,7 +3,7 @@
{{ $incident->name }} · {{ $incident->status->getLabel() }}
diff --git a/resources/views/mail/message.blade.php b/resources/views/mail/message.blade.php index f248ab75..a2a6318d 100644 --- a/resources/views/mail/message.blade.php +++ b/resources/views/mail/message.blade.php @@ -4,12 +4,10 @@
+{{ $update->status->getLabel() }} · {{ $update->created_at->toDayDateTimeString() }}
diff --git a/resources/views/mail/subscribers/new-incident.blade.php b/resources/views/mail/subscribers/new-incident.blade.php index e19d6915..fdeb3a90 100644 --- a/resources/views/mail/subscribers/new-incident.blade.php +++ b/resources/views/mail/subscribers/new-incident.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl]) +@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl]) # {{ $incident->name }}{{ $incident->status->getLabel() }} · {{ $incident->timestamp->toDayDateTimeString() }}
diff --git a/resources/views/mail/subscribers/new-schedule.blade.php b/resources/views/mail/subscribers/new-schedule.blade.php index 9d192fc3..f3275760 100644 --- a/resources/views/mail/subscribers/new-schedule.blade.php +++ b/resources/views/mail/subscribers/new-schedule.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl]) +@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl]) # {{ $schedule->name }}{{ __('cachet::subscriber.mail.new_schedule.scheduled_for') }} {{ $schedule->scheduled_at->toDayDateTimeString() }}@if ($schedule->completed_at) – {{ $schedule->completed_at->toDayDateTimeString() }}@endif
diff --git a/resources/views/mail/subscribers/schedule-completed.blade.php b/resources/views/mail/subscribers/schedule-completed.blade.php index ed9862b9..75cb28dc 100644 --- a/resources/views/mail/subscribers/schedule-completed.blade.php +++ b/resources/views/mail/subscribers/schedule-completed.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl]) +@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl]) # {{ $schedule->name }}{{ __('cachet::subscriber.mail.schedule_completed.completed_at') }} {{ $schedule->completed_at->toDayDateTimeString() }}
diff --git a/resources/views/mail/subscribers/schedule-rescheduled.blade.php b/resources/views/mail/subscribers/schedule-rescheduled.blade.php index a9f4691b..d0b2a6cf 100644 --- a/resources/views/mail/subscribers/schedule-rescheduled.blade.php +++ b/resources/views/mail/subscribers/schedule-rescheduled.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl]) +@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl]) @php($previousWindow = $previousScheduledAt?->toDayDateTimeString().($previousCompletedAt ? ' – '.$previousCompletedAt->toDayDateTimeString() : '')) @php($newWindow = $schedule->scheduled_at->toDayDateTimeString().($schedule->completed_at ? ' – '.$schedule->completed_at->toDayDateTimeString() : '')) # {{ $schedule->name }} diff --git a/resources/views/mail/subscribers/schedule-updated.blade.php b/resources/views/mail/subscribers/schedule-updated.blade.php index 312f916c..6eadc0c3 100644 --- a/resources/views/mail/subscribers/schedule-updated.blade.php +++ b/resources/views/mail/subscribers/schedule-updated.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl]) +@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl]) @php($window = $schedule->scheduled_at->toDayDateTimeString().($schedule->completed_at ? ' – '.$schedule->completed_at->toDayDateTimeString() : '')) # {{ $schedule->name }} diff --git a/resources/views/mail/subscribers/verify.blade.php b/resources/views/mail/subscribers/verify.blade.php index d02bf921..7b771e72 100644 --- a/resources/views/mail/subscribers/verify.blade.php +++ b/resources/views/mail/subscribers/verify.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl]) +@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl]) # {{ __('cachet::subscriber.mail.verify.heading') }} {{ __('cachet::subscriber.mail.verify.body', ['app' => $appName]) }} diff --git a/resources/views/mail/test.blade.php b/resources/views/mail/test.blade.php index 7239bbaa..7e99f922 100644 --- a/resources/views/mail/test.blade.php +++ b/resources/views/mail/test.blade.php @@ -1,4 +1,4 @@ -@component('cachet::mail.message') +@component('cachet::mail.message', ['mailMessage' => $message ?? null]) # {{ __('cachet::settings.manage_notifications.test_email_heading') }} {{ __('cachet::settings.manage_notifications.test_email_body', ['app' => $appName]) }} diff --git a/src/Filament/Pages/Settings/ManageTheme.php b/src/Filament/Pages/Settings/ManageTheme.php index ed1df706..170acb2a 100644 --- a/src/Filament/Pages/Settings/ManageTheme.php +++ b/src/Filament/Pages/Settings/ManageTheme.php @@ -40,11 +40,13 @@ public function form(Schema $schema): Schema ->components([ Section::make()->columns(2)->schema([ FileUpload::make('app_banner') - ->image() + ->acceptedFileTypes((array) config('cachet.uploads.image_mime_types')) + ->maxSize((int) config('cachet.uploads.max_size')) + ->preventFilePathTampering() ->imageEditor() ->label(__('cachet::settings.manage_theme.app_banner_label')) ->helperText(__('cachet::settings.manage_theme.app_banner_helper')) - ->disk('public') + ->disk((string) config('cachet.uploads.disk')) ->columnSpanFull(), ]), diff --git a/src/View/Composers/MailThemeComposer.php b/src/View/Composers/MailThemeComposer.php index 493fda61..790ffede 100644 --- a/src/View/Composers/MailThemeComposer.php +++ b/src/View/Composers/MailThemeComposer.php @@ -5,7 +5,10 @@ use Cachet\Data\Cachet\ThemeData; use Cachet\Settings\AppSettings; use Cachet\Settings\ThemeSettings; +use Illuminate\Mail\Attachment; +use Illuminate\Support\Facades\Storage; use Illuminate\View\View; +use Throwable; class MailThemeComposer { @@ -19,9 +22,13 @@ public function __construct( */ public function compose(View $view): void { + $appLogo = $this->appLogo($this->themeSettings->app_banner); + $view->with([ 'appName' => $this->appSettings->name ?? config('cachet.title'), - 'appBanner' => $this->themeSettings->app_banner, + 'appBanner' => $appLogo['banner'], + 'appLogoAttachment' => $appLogo['attachment'], + 'appLogoUrl' => $appLogo['url'], 'colors' => array_map( static::hex(...), (new ThemeData($this->themeSettings))->lightColors(), @@ -29,6 +36,53 @@ public function compose(View $view): void ]); } + /** + * @return array{banner: ?string, attachment: Attachment, url: string} + */ + private function appLogo(?string $appBanner): array + { + if (blank($appBanner)) { + return $this->defaultAppLogo(); + } + + try { + $disk = Storage::disk((string) config('cachet.uploads.disk', 'public')); + $contents = $disk->get($appBanner); + $mimeType = $disk->mimeType($appBanner); + + if (! is_string($contents) || ! is_string($mimeType) || ! str_contains($mimeType, '/')) { + return $this->defaultAppLogo(); + } + + $url = $disk->url($appBanner); + } catch (Throwable $exception) { + report($exception); + + return $this->defaultAppLogo(); + } + + return [ + 'banner' => $appBanner, + 'attachment' => Attachment::fromData( + static fn (): string => $contents, + basename($appBanner), + )->withMime($mimeType), + 'url' => $url, + ]; + } + + /** + * @return array{banner: null, attachment: Attachment, url: string} + */ + private function defaultAppLogo(): array + { + return [ + 'banner' => null, + 'attachment' => Attachment::fromPath(CACHET_PATH.'public/logo.png'), + 'url' => asset('vendor/cachethq/cachet/logo.png'), + ]; + } + /** * Convert an oklch() CSS color to a hex color that email clients understand. * diff --git a/tests/Feature/Filament/Settings/ManageThemeTest.php b/tests/Feature/Filament/Settings/ManageThemeTest.php new file mode 100644 index 00000000..2ec1624c --- /dev/null +++ b/tests/Feature/Filament/Settings/ManageThemeTest.php @@ -0,0 +1,63 @@ +create(['is_admin' => true])); + Storage::fake('public'); + + config()->set([ + 'cachet.uploads.disk' => 'public', + 'cachet.uploads.max_size' => 256, + 'cachet.uploads.image_mime_types' => ['image/jpeg', 'image/png'], + ]); +}); + +it('configures app logo upload validation', function () { + livewire(ManageTheme::class) + ->assertFormFieldExists('app_banner', function (FileUpload $field): bool { + return $field->getDiskName() === 'public' + && $field->getMaxSize() === 256 + && $field->getAcceptedFileTypes() === ['image/jpeg', 'image/png'] + && $field->shouldPreventFilePathTampering(); + }); +}); + +it('rejects app logos with disallowed image types', function () { + livewire(ManageTheme::class) + ->fillForm([ + 'app_banner' => UploadedFile::fake()->create('app-logo.gif', 32, 'image/gif'), + ]) + ->call('save') + ->assertHasFormErrors(['app_banner']); +}); + +it('rejects app logos larger than the configured maximum', function () { + livewire(ManageTheme::class) + ->fillForm([ + 'app_banner' => UploadedFile::fake()->create('app-logo.png', 257, 'image/png'), + ]) + ->call('save') + ->assertHasFormErrors(['app_banner']); +}); + +it('accepts app logos within the configured constraints', function () { + livewire(ManageTheme::class) + ->fillForm([ + 'app_banner' => UploadedFile::fake()->create('app-logo.png', 256, 'image/png'), + ]) + ->call('save') + ->assertHasNoFormErrors(); +}); diff --git a/tests/Feature/ProviderConfigTest.php b/tests/Feature/ProviderConfigTest.php index c31421bb..92086108 100644 --- a/tests/Feature/ProviderConfigTest.php +++ b/tests/Feature/ProviderConfigTest.php @@ -16,3 +16,15 @@ expect($commands->filter(fn (string $command) => str_contains($command, 'cachet:beacon')))->not->toBeEmpty(); }); + +it('configures safe image upload defaults', function () { + expect(config('cachet.uploads')) + ->disk->toBe('public') + ->max_size->toBe(1024) + ->image_mime_types->toBe([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]); +}); diff --git a/tests/Unit/Mail/TestMailTest.php b/tests/Unit/Mail/TestMailTest.php index de13917e..613580fe 100644 --- a/tests/Unit/Mail/TestMailTest.php +++ b/tests/Unit/Mail/TestMailTest.php @@ -7,6 +7,9 @@ use Cachet\Settings\AppSettings; use Cachet\Settings\ThemeSettings; use Cachet\View\Composers\MailThemeComposer; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; +use Symfony\Component\Mime\Email; it('renders the themed test email', function () { $html = (new TestMail)->render(); @@ -25,3 +28,51 @@ expect((new TestMail)->render())->toContain($accent); }); + +it('embeds the configured app logo', function () { + Storage::fake('public'); + Storage::disk('public')->put('app-logo.png', (string) file_get_contents(CACHET_PATH.'public/logo.png')); + + $theme = app(ThemeSettings::class); + $theme->app_banner = 'app-logo.png'; + $theme->save(); + + config()->set('mail.mailers.array', ['transport' => 'array']); + + $sentMessage = Mail::mailer('array')->to('subscriber@example.com')->send(new TestMail); + $email = $sentMessage?->getSymfonySentMessage()->getOriginalMessage(); + + expect($email)->toBeInstanceOf(Email::class) + ->and($email->getAttachments())->toHaveCount(1); + + $logo = $email->getAttachments()[0]; + + expect($logo->getDisposition())->toBe('inline') + ->and($logo->getFilename())->toBe('app-logo.png') + ->and($logo->getContentType())->toBe('image/png') + ->and($email->getHtmlBody())->toContain('src="cid:'.$logo->getContentId().'"'); +}); + +it('embeds the default app logo when the configured logo cannot be read', function () { + Storage::fake('public'); + + $theme = app(ThemeSettings::class); + $theme->app_banner = 'missing-app-logo.png'; + $theme->save(); + + config()->set('mail.mailers.array', ['transport' => 'array']); + + $sentMessage = Mail::mailer('array')->to('subscriber@example.com')->send(new TestMail); + $email = $sentMessage?->getSymfonySentMessage()->getOriginalMessage(); + + expect($email)->toBeInstanceOf(Email::class) + ->and($email->getAttachments())->toHaveCount(1); + + $logo = $email->getAttachments()[0]; + + expect($logo->getDisposition())->toBe('inline') + ->and($logo->getFilename())->toBe('logo.png') + ->and($logo->getContentType())->toBe('image/png') + ->and($email->getHtmlBody())->toContain('src="cid:'.$logo->getContentId().'"') + ->and($email->getHtmlBody())->toContain('class="header-name"'); +}); diff --git a/workbench/.env.example b/workbench/.env.example index 66a7234b..c7055782 100644 --- a/workbench/.env.example +++ b/workbench/.env.example @@ -18,6 +18,9 @@ DB_CONNECTION=sqlite BROADCAST_DRIVER=log CACHE_DRIVER=file FILESYSTEM_DISK=local +CACHET_UPLOAD_DISK=public +CACHET_UPLOAD_MAX_SIZE=1024 +CACHET_UPLOAD_IMAGE_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp QUEUE_CONNECTION=sync SESSION_DRIVER=file SESSION_LIFETIME=120 @@ -57,4 +60,3 @@ VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -