Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions config/cachet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion resources/views/components/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="container mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6 lg:px-8">
<a href="{{ route('cachet.status-page') }}" class="inline-flex items-center transition hover:opacity-80">
@if($appBanner)
<img src="{{ Storage::url($appBanner) }}" alt="{{ $siteName }}" class="h-8 w-auto" />
<img src="{{ Storage::disk(config('cachet.uploads.disk', 'public'))->url($appBanner) }}" alt="{{ $siteName }}" class="h-8 w-auto" />
@else
<x-cachet::logo class="h-8 w-auto" />
@endif
Expand Down
2 changes: 1 addition & 1 deletion resources/views/mail/incidents/long-running.blade.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@component('cachet::mail.message')
@component('cachet::mail.message', ['mailMessage' => $message ?? null])
# {{ __('cachet::incident.mail.long_running.heading') }}

<p class="sub"><strong>{{ $incident->name }}</strong> &middot; {{ $incident->status->getLabel() }}</p>
Expand Down
8 changes: 3 additions & 5 deletions resources/views/mail/message.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
<tr>
<td class="header">
<a href="{{ route('cachet.status-page') }}" style="display: inline-block;">
@if ($appBanner)
<img src="{{ \Illuminate\Support\Facades\Storage::url($appBanner) }}" class="logo-banner" alt="{{ $appName }}">
@else
<img src="{{ asset('vendor/cachethq/cachet/logo.png') }}" class="logo" alt="{{ $appName }}">
<img src="{{ $mailMessage ? $mailMessage->embed($appLogoAttachment) : $appLogoUrl }}" class="{{ $appBanner ? 'logo-banner' : 'logo' }}" alt="{{ $appName }}">
@unless ($appBanner)
<span class="header-name">{{ $appName }}</span>
@endif
@endunless
</a>
</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl])
@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl])
# {{ $incident->name }}

<p class="sub"><strong>{{ $update->status->getLabel() }}</strong> &middot; {{ $update->created_at->toDayDateTimeString() }}</p>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/mail/subscribers/new-incident.blade.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl])
@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl])
# {{ $incident->name }}

<p class="sub"><strong>{{ $incident->status->getLabel() }}</strong> &middot; {{ $incident->timestamp->toDayDateTimeString() }}</p>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/mail/subscribers/new-schedule.blade.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl])
@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl])
# {{ $schedule->name }}

<p class="sub"><strong>{{ __('cachet::subscriber.mail.new_schedule.scheduled_for') }}</strong> {{ $schedule->scheduled_at->toDayDateTimeString() }}@if ($schedule->completed_at) &ndash; {{ $schedule->completed_at->toDayDateTimeString() }}@endif</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@component('cachet::mail.message', ['unsubscribeUrl' => $unsubscribeUrl])
@component('cachet::mail.message', ['mailMessage' => $message ?? null, 'unsubscribeUrl' => $unsubscribeUrl])
# {{ $schedule->name }}

<p class="sub"><strong>{{ __('cachet::subscriber.mail.schedule_completed.completed_at') }}</strong> {{ $schedule->completed_at->toDayDateTimeString() }}</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }}

Expand Down
2 changes: 1 addition & 1 deletion resources/views/mail/subscribers/verify.blade.php
Original file line number Diff line number Diff line change
@@ -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]) }}
Expand Down
2 changes: 1 addition & 1 deletion resources/views/mail/test.blade.php
Original file line number Diff line number Diff line change
@@ -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]) }}
Expand Down
6 changes: 4 additions & 2 deletions src/Filament/Pages/Settings/ManageTheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]),

Expand Down
56 changes: 55 additions & 1 deletion src/View/Composers/MailThemeComposer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -19,16 +22,67 @@ 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(),
),
]);
}

/**
* @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.
*
Expand Down
63 changes: 63 additions & 0 deletions tests/Feature/Filament/Settings/ManageThemeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Tests\Feature\Filament\Settings;

use Cachet\Filament\Pages\Settings\ManageTheme;
use Filament\Facades\Filament;
use Filament\Forms\Components\FileUpload;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Workbench\App\User;

use function Pest\Laravel\actingAs;
use function Pest\Livewire\livewire;

beforeEach(function () {
Filament::setCurrentPanel(Filament::getPanel('cachet'));

actingAs(User::factory()->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();
});
12 changes: 12 additions & 0 deletions tests/Feature/ProviderConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
51 changes: 51 additions & 0 deletions tests/Unit/Mail/TestMailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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"');
});
4 changes: 3 additions & 1 deletion workbench/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"

Loading