fix(cms): validate media extensions and OG alt text

This commit is contained in:
2026-07-28 23:57:45 -03:00
parent 8da8d19504
commit 59bc624fa8
9 changed files with 142 additions and 4 deletions

View File

@@ -187,6 +187,7 @@ class ManageSiteSettings extends Page
->required() ->required()
->rows(3), ->rows(3),
PublicImageUploadRules::fileUpload('default_og_image_path', 'Imagem Open Graph padrão', 'content/og'), PublicImageUploadRules::fileUpload('default_og_image_path', 'Imagem Open Graph padrão', 'content/og'),
PublicImageUploadRules::altTextField('default_og_image_alt', 'default_og_image_path'),
]), ]),
Section::make('Analytics') Section::make('Analytics')
->schema([ ->schema([

View File

@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property array<string, string|null> $social_links * @property array<string, string|null> $social_links
* @property bool $analytics_enabled * @property bool $analytics_enabled
* @property string|null $default_og_image_path
* @property string|null $default_og_image_alt
*/ */
#[Fillable([ #[Fillable([
'brand_name', 'brand_name',
@@ -27,6 +29,7 @@ use Illuminate\Database\Eloquent\Model;
'default_meta_title', 'default_meta_title',
'default_meta_description', 'default_meta_description',
'default_og_image_path', 'default_og_image_path',
'default_og_image_alt',
'analytics_enabled', 'analytics_enabled',
'analytics_script', 'analytics_script',
])] ])]

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Support; namespace App\Support;
use Closure;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\File;
final class PublicImageUploadRules final class PublicImageUploadRules
@@ -27,6 +27,8 @@ final class PublicImageUploadRules
->directory($directory) ->directory($directory)
->acceptedFileTypes(self::ALLOWED_MIMES) ->acceptedFileTypes(self::ALLOWED_MIMES)
->maxSize(self::MAX_SIZE_KILOBYTES) ->maxSize(self::MAX_SIZE_KILOBYTES)
->rules(self::validationRules())
->validationMessages(self::validationMessages())
->getUploadedFileNameForStorageUsing( ->getUploadedFileNameForStorageUsing(
fn ($file): string => (string) str()->uuid().'.'.$file->getClientOriginalExtension(), fn ($file): string => (string) str()->uuid().'.'.$file->getClientOriginalExtension(),
); );
@@ -36,17 +38,35 @@ final class PublicImageUploadRules
{ {
return TextInput::make($name) return TextInput::make($name)
->label($label) ->label($label)
->required(fn (Closure $get): bool => filled($get($imageField))) ->required(fn (Get $get): bool => filled($get($imageField)))
->validationMessages([
'required' => 'O texto alternativo é obrigatório quando uma imagem é enviada.',
])
->maxLength(255); ->maxLength(255);
} }
/** /**
* @return list<File> * @return list<File|string>
*/ */
public static function validationRules(): array public static function validationRules(): array
{ {
return [ return [
File::types(self::ALLOWED_EXTENSIONS)->max(self::MAX_SIZE_KILOBYTES), File::types(self::ALLOWED_EXTENSIONS)->max(self::MAX_SIZE_KILOBYTES),
'extensions:'.implode(',', self::ALLOWED_EXTENSIONS),
];
}
/**
* @return array<string, string>
*/
public static function validationMessages(): array
{
return [
'file' => 'A imagem enviada é inválida.',
'mimes' => 'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
'mimetypes' => 'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
'extensions' => 'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
'max' => 'A imagem não pode ter mais de 10 MB.',
]; ];
} }

View File

@@ -25,6 +25,7 @@ return new class extends Migration
$table->string('default_meta_title'); $table->string('default_meta_title');
$table->text('default_meta_description'); $table->text('default_meta_description');
$table->string('default_og_image_path')->nullable(); $table->string('default_og_image_path')->nullable();
$table->string('default_og_image_alt')->nullable();
$table->boolean('analytics_enabled')->default(false); $table->boolean('analytics_enabled')->default(false);
$table->text('analytics_script')->nullable(); $table->text('analytics_script')->nullable();
$table->timestamps(); $table->timestamps();

View File

@@ -44,6 +44,7 @@ class ContentSeeder extends Seeder
'default_meta_title' => 'Amare Assessoria de Eventos', 'default_meta_title' => 'Amare Assessoria de Eventos',
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.', 'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'content/og/og-default.jpg'), 'default_og_image_path' => $this->copyFixture('og-default.jpg', 'content/og/og-default.jpg'),
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
'analytics_enabled' => false, 'analytics_enabled' => false,
'analytics_script' => null, 'analytics_script' => null,
]); ]);

View File

@@ -2,7 +2,7 @@
### Requirement: Site settings singleton is manageable by admin only ### Requirement: Site settings singleton is manageable by admin only
The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, hero copy (eyebrow, title, subtitle, CTA label), about summary, contact email/phone/city, social links (jsonb), default meta title/description, default OG image path, and optional analytics fields disabled by default. The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, hero copy (eyebrow, title, subtitle, CTA label), about summary, contact email/phone/city, social links (jsonb), default meta title/description, default OG image path and alt text, and optional analytics fields disabled by default.
#### Scenario: Admin updates site settings #### Scenario: Admin updates site settings
@@ -15,6 +15,12 @@ The system SHALL persist site-wide settings in a `site_settings` table as a type
- **WHEN** an assistant navigates to site settings in Filament - **WHEN** an assistant navigates to site settings in Filament
- **THEN** access MUST be denied with HTTP 403 - **THEN** access MUST be denied with HTTP 403
#### Scenario: Default OG image requires alt text
- **WHEN** an admin uploads a default OG image without alt text
- **THEN** validation MUST fail with a pt-BR error message
- **AND** alt text MUST remain optional when no default OG image is present
#### Scenario: Singleton avoids generic key-value store #### Scenario: Singleton avoids generic key-value store
- **WHEN** site settings are stored - **WHEN** site settings are stored

View File

@@ -9,6 +9,7 @@
- [x] 2.1 Criar `app/Support/PublicImageUploadRules.php` com allowlist MIME/extensão, limite 10 MB e regra de alt text obrigatório - [x] 2.1 Criar `app/Support/PublicImageUploadRules.php` com allowlist MIME/extensão, limite 10 MB e regra de alt text obrigatório
- [x] 2.2 Escrever teste unitário ou feature validando rejeição de MIME inválido e arquivo oversize - [x] 2.2 Escrever teste unitário ou feature validando rejeição de MIME inválido e arquivo oversize
- [x] 2.3 Documentar `php artisan storage:link` e disco `public` no README - [x] 2.3 Documentar `php artisan storage:link` e disco `public` no README
- [x] 2.4 Reforçar uploads Filament com validação compartilhada de MIME, extensão e limite de 10 MB, incluindo mensagens em pt-BR e testes regressivos
## 3. Site settings (site-settings / WEB-06) ## 3. Site settings (site-settings / WEB-06)
@@ -17,6 +18,7 @@
- [x] 3.3 Criar `SiteSettingPolicy` admin-only - [x] 3.3 Criar `SiteSettingPolicy` admin-only
- [x] 3.4 Criar Filament Page `ManageSiteSettings` no grupo **Conteúdo do site** com form tipado (hero, contato, meta, OG image, analytics desabilitados por padrão) - [x] 3.4 Criar Filament Page `ManageSiteSettings` no grupo **Conteúdo do site** com form tipado (hero, contato, meta, OG image, analytics desabilitados por padrão)
- [x] 3.5 Escrever feature tests: admin salva settings; assistant recebe 403 - [x] 3.5 Escrever feature tests: admin salva settings; assistant recebe 403
- [x] 3.6 Adicionar alt text condicional à imagem OG padrão na migration, model, formulário, seed determinístico e testes
## 4. Service catalog (service-catalog / WEB-02) ## 4. Service catalog (service-catalog / WEB-02)

View File

@@ -7,7 +7,10 @@ namespace Tests\Feature\Marketing;
use App\Filament\Pages\ManageSiteSettings; use App\Filament\Pages\ManageSiteSettings;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\User; use App\Models\User;
use Database\Seeders\ContentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@@ -51,4 +54,76 @@ class SiteSettingsTest extends TestCase
Livewire::test(ManageSiteSettings::class) Livewire::test(ManageSiteSettings::class)
->assertForbidden(); ->assertForbidden();
} }
public function test_admin_can_upload_default_og_image_with_alt_text(): void
{
Storage::fake('public');
$admin = User::factory()->admin()->create();
SiteSetting::instance();
$this->actingAs($admin);
Livewire::test(ManageSiteSettings::class)
->set('data.default_og_image_path', [
UploadedFile::fake()->create('og-image.jpg', 512, 'image/jpeg'),
])
->set('data.default_og_image_alt', 'Casal celebrando com a equipe Amare')
->call('save')
->assertHasNoFormErrors();
$settings = SiteSetting::instance()->refresh();
$this->assertSame('Casal celebrando com a equipe Amare', $settings->default_og_image_alt);
$this->assertNotNull($settings->default_og_image_path);
Storage::disk('public')->assertExists($settings->default_og_image_path);
}
public function test_default_og_image_requires_alt_text(): void
{
Storage::fake('public');
$admin = User::factory()->admin()->create();
SiteSetting::instance();
$this->actingAs($admin);
Livewire::test(ManageSiteSettings::class)
->set('data.default_og_image_path', [
UploadedFile::fake()->create('og-image.jpg', 512, 'image/jpeg'),
])
->set('data.default_og_image_alt', null)
->call('save')
->assertHasFormErrors(['default_og_image_alt' => 'required']);
}
public function test_default_og_image_alt_is_optional_without_an_image(): void
{
$admin = User::factory()->admin()->create();
SiteSetting::instance();
$this->actingAs($admin);
Livewire::test(ManageSiteSettings::class)
->set('data.default_og_image_path', null)
->set('data.default_og_image_alt', null)
->call('save')
->assertHasNoFormErrors();
}
public function test_content_seeder_includes_default_og_image_alt_text(): void
{
Storage::fake('public');
$this->seed(ContentSeeder::class);
$settings = SiteSetting::query()->sole();
$this->assertSame(
'Identidade visual da Amare Assessoria de Eventos',
$settings->default_og_image_alt,
);
$this->assertSame('content/og/og-default.jpg', $settings->default_og_image_path);
Storage::disk('public')->assertExists('content/og/og-default.jpg');
}
} }

View File

@@ -39,6 +39,35 @@ class PublicImageUploadRulesTest extends TestCase
$this->assertTrue($validator->fails()); $this->assertTrue($validator->fails());
} }
public function test_rejects_image_with_disallowed_extension(): void
{
$file = UploadedFile::fake()->create('photo.gif', 512, 'image/jpeg');
$validator = Validator::make(
['image' => $file],
['image' => PublicImageUploadRules::validationRules()],
);
$this->assertTrue($validator->fails());
}
public function test_uses_portuguese_validation_message_for_invalid_extension(): void
{
$file = UploadedFile::fake()->create('photo.gif', 512, 'image/jpeg');
$component = PublicImageUploadRules::fileUpload('image', 'Imagem');
$validator = Validator::make(
['image' => $file],
['image' => PublicImageUploadRules::validationRules()],
$component->getValidationMessages(),
);
$this->assertSame(
'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
$validator->errors()->first('image'),
);
}
public function test_accepts_valid_image_within_limits(): void public function test_accepts_valid_image_within_limits(): void
{ {
$file = UploadedFile::fake()->create('photo.jpg', 512, 'image/jpeg'); $file = UploadedFile::fake()->create('photo.jpg', 512, 'image/jpeg');