fix(cms): validate media extensions and OG alt text
This commit is contained in:
@@ -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([
|
||||||
|
|||||||
@@ -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',
|
||||||
])]
|
])]
|
||||||
|
|||||||
@@ -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.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user