Add homepage announcement module

This commit is contained in:
2026-05-01 11:43:08 +02:00
parent 961d21e91e
commit 874f8feb9c
16 changed files with 2968 additions and 2 deletions

View File

@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpsertHomepageAnnouncementRequest;
use App\Models\HomepageAnnouncement;
use App\Services\HomepageAnnouncementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class HomepageAnnouncementController extends Controller
{
private const BACKGROUND_WEBP_QUALITY = 84;
public function __construct(private readonly HomepageAnnouncementService $announcements)
{
}
public function index(Request $request): Response
{
$announcements = HomepageAnnouncement::query()
->latest('updated_at')
->paginate(20)
->withQueryString();
$announcements->getCollection()->transform(fn (HomepageAnnouncement $announcement): array => $this->serializeForIndex($announcement));
return Inertia::render('Admin/HomepageAnnouncements/Index', [
'announcements' => $announcements,
'createUrl' => route('admin.homepage-announcements.create'),
]);
}
public function create(): Response
{
$form = $this->blankForm();
return Inertia::render('Admin/HomepageAnnouncements/Form', [
'announcement' => $form,
'previewAnnouncement' => $this->announcements->previewPayload($form),
'options' => $this->options(),
'submitUrl' => route('admin.homepage-announcements.store'),
'previewUrl' => route('admin.homepage-announcements.preview'),
'indexUrl' => route('admin.homepage-announcements.index'),
'destroyUrl' => null,
]);
}
public function store(UpsertHomepageAnnouncementRequest $request): RedirectResponse
{
$actor = $this->currentActor($request);
$announcement = new HomepageAnnouncement();
$attributes = $this->persistedAttributes($request, $announcement);
$attributes['created_by'] = (int) $actor->id;
$attributes['updated_by'] = (int) $actor->id;
$announcement->forceFill($attributes)->save();
return redirect()
->route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $announcement])
->with('success', 'Homepage announcement created.');
}
public function edit(HomepageAnnouncement $homepageAnnouncement): Response
{
$form = $this->serializeForForm($homepageAnnouncement);
return Inertia::render('Admin/HomepageAnnouncements/Form', [
'announcement' => $form,
'previewAnnouncement' => $this->announcements->toHomepagePayload($homepageAnnouncement),
'options' => $this->options(),
'submitUrl' => route('admin.homepage-announcements.update', ['homepageAnnouncement' => $homepageAnnouncement]),
'previewUrl' => route('admin.homepage-announcements.preview'),
'indexUrl' => route('admin.homepage-announcements.index'),
'destroyUrl' => route('admin.homepage-announcements.destroy', ['homepageAnnouncement' => $homepageAnnouncement]),
]);
}
public function update(UpsertHomepageAnnouncementRequest $request, HomepageAnnouncement $homepageAnnouncement): RedirectResponse
{
$actor = $this->currentActor($request);
$attributes = $this->persistedAttributes($request, $homepageAnnouncement);
$attributes['updated_by'] = (int) $actor->id;
$homepageAnnouncement->forceFill($attributes)->save();
return redirect()
->route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $homepageAnnouncement])
->with('success', 'Homepage announcement updated.');
}
public function destroy(HomepageAnnouncement $homepageAnnouncement): RedirectResponse
{
$homepageAnnouncement->delete();
return redirect()
->route('admin.homepage-announcements.index')
->with('success', 'Homepage announcement deleted.');
}
public function preview(UpsertHomepageAnnouncementRequest $request): JsonResponse
{
$attributes = $this->persistedAttributes($request, null, false);
return response()->json([
'ok' => true,
'announcement' => $this->announcements->previewPayload($attributes),
]);
}
/**
* @return array<string, mixed>
*/
private function persistedAttributes(UpsertHomepageAnnouncementRequest $request, ?HomepageAnnouncement $announcement = null, bool $persistFile = true): array
{
$validated = $request->validated();
unset($validated['background_image_file'], $validated['remove_background_image']);
$validated = $this->announcements->sanitizeAttributes($validated);
$validated = $this->normalizeLinkAttributes($validated, 'primary');
$validated = $this->normalizeLinkAttributes($validated, 'secondary');
$currentBackground = $announcement?->background_image;
$backgroundImageFile = $this->backgroundImageUpload($request);
if ($request->boolean('remove_background_image')) {
if ($persistFile) {
$this->deleteStoredBackgroundIfLocal($currentBackground);
}
$validated['background_image'] = null;
} elseif ($persistFile && $backgroundImageFile instanceof UploadedFile) {
$this->deleteStoredBackgroundIfLocal($currentBackground);
$validated['background_image'] = $this->storeBackgroundImage($backgroundImageFile);
} elseif (! array_key_exists('background_image', $validated) && $announcement) {
$validated['background_image'] = $announcement->background_image;
}
$validated['background_type'] = filled($validated['background_image'] ?? null) ? 'image' : null;
return $validated;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
private function normalizeLinkAttributes(array $attributes, string $prefix): array
{
$type = (string) ($attributes[$prefix . '_link_type'] ?? HomepageAnnouncement::LINK_TYPE_NONE);
if ($type === HomepageAnnouncement::LINK_TYPE_NONE) {
$attributes[$prefix . '_link_label'] = null;
$attributes[$prefix . '_link_url'] = null;
$attributes[$prefix . '_link_target_type'] = null;
$attributes[$prefix . '_link_target_id'] = null;
return $attributes;
}
$attributes[$prefix . '_link_label'] = filled($attributes[$prefix . '_link_label'] ?? null)
? trim((string) $attributes[$prefix . '_link_label'])
: null;
$attributes[$prefix . '_link_url'] = filled($attributes[$prefix . '_link_url'] ?? null)
? trim((string) $attributes[$prefix . '_link_url'])
: null;
$attributes[$prefix . '_link_target_id'] = filled($attributes[$prefix . '_link_target_id'] ?? null)
? (int) $attributes[$prefix . '_link_target_id']
: null;
if (in_array($type, [HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, HomepageAnnouncement::LINK_TYPE_EXPLORE, HomepageAnnouncement::LINK_TYPE_UPLOAD], true)) {
$attributes[$prefix . '_link_target_type'] = null;
$attributes[$prefix . '_link_target_id'] = null;
return $attributes;
}
$attributes[$prefix . '_link_target_type'] = $type;
return $attributes;
}
private function deleteStoredBackgroundIfLocal(?string $path): void
{
$path = trim((string) $path);
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return;
}
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
private function backgroundImageUpload(UpsertHomepageAnnouncementRequest $request): ?UploadedFile
{
$file = $request->file('background_image_file');
if (! $file instanceof UploadedFile) {
return null;
}
$pathName = trim((string) $file->getPathname());
if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) {
return $file;
}
throw ValidationException::withMessages([
'background_image_file' => $this->backgroundUploadErrorMessage($file),
]);
}
private function storeBackgroundImage(UploadedFile $file): string
{
$pathName = trim((string) $file->getPathname());
if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) {
throw ValidationException::withMessages([
'background_image_file' => $this->backgroundUploadErrorMessage($file),
]);
}
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
throw ValidationException::withMessages([
'background_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload announcement backgrounds.',
]);
}
$binary = @file_get_contents($pathName);
if ($binary === false) {
throw ValidationException::withMessages([
'background_image_file' => 'The uploaded background image could not be opened for conversion. Please choose the file again and retry.',
]);
}
$image = @imagecreatefromstring($binary);
if (! $image instanceof \GdImage) {
throw ValidationException::withMessages([
'background_image_file' => 'The uploaded background image format could not be converted. Please use JPG, PNG, or WEBP.',
]);
}
try {
if (! imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
ob_start();
$converted = imagewebp($image, null, self::BACKGROUND_WEBP_QUALITY);
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw ValidationException::withMessages([
'background_image_file' => 'The uploaded background image could not be converted to WebP. Please try a different image.',
]);
}
$storedPath = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk('public')->put($storedPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($image);
}
return $storedPath;
}
private function backgroundUploadErrorMessage(UploadedFile $file): string
{
return match ($file->getError()) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded background image exceeds the server upload limit.',
UPLOAD_ERR_PARTIAL => 'The uploaded background image was only partially received. Please retry the upload.',
UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.',
UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded background image to temporary storage.',
UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the background image upload.',
default => 'The uploaded background image could not be read. Please choose the file again and retry.',
};
}
/**
* @return array<string, mixed>
*/
private function blankForm(): array
{
return [
'id' => null,
'title' => '',
'subtitle' => '',
'badge_text' => '',
'content_html' => '',
'type' => HomepageAnnouncement::TYPE_ANNOUNCEMENT,
'status' => HomepageAnnouncement::STATUS_DRAFT,
'is_active' => true,
'starts_at' => '',
'ends_at' => '',
'priority' => 0,
'is_dismissible' => true,
'dismiss_version' => 1,
'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA,
'theme_preset' => 'launch',
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
'text_color' => '',
'overlay_opacity' => 55,
'background_image' => '',
'background_image_url' => null,
'remove_background_image' => false,
'background_image_file' => null,
'primary_link_label' => '',
'primary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
'primary_link_url' => '',
'primary_link_target_id' => '',
'secondary_link_label' => '',
'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
'secondary_link_url' => '',
'secondary_link_target_id' => '',
];
}
/**
* @return array<string, mixed>
*/
private function serializeForForm(HomepageAnnouncement $announcement): array
{
return [
'id' => (int) $announcement->id,
'title' => (string) $announcement->title,
'subtitle' => (string) ($announcement->subtitle ?? ''),
'badge_text' => (string) ($announcement->badge_text ?? ''),
'content_html' => (string) ($announcement->content_html ?? ''),
'type' => (string) $announcement->type,
'status' => (string) $announcement->status,
'is_active' => (bool) $announcement->is_active,
'starts_at' => optional($announcement->starts_at)?->format('Y-m-d\TH:i') ?? '',
'ends_at' => optional($announcement->ends_at)?->format('Y-m-d\TH:i') ?? '',
'priority' => (int) $announcement->priority,
'is_dismissible' => (bool) $announcement->is_dismissible,
'dismiss_version' => (int) $announcement->dismiss_version,
'gradient_preset' => (string) ($announcement->gradient_preset ?? ''),
'theme_preset' => (string) ($announcement->theme_preset ?? ''),
'placement' => (string) $announcement->placement,
'text_color' => (string) ($announcement->text_color ?? ''),
'overlay_opacity' => (int) ($announcement->overlay_opacity ?? 55),
'background_image' => (string) ($announcement->background_image ?? ''),
'background_image_url' => $this->announcements->toHomepagePayload($announcement)['background_image_url'] ?? null,
'remove_background_image' => false,
'background_image_file' => null,
'primary_link_label' => (string) ($announcement->primary_link_label ?? ''),
'primary_link_type' => (string) ($announcement->primary_link_type ?? HomepageAnnouncement::LINK_TYPE_NONE),
'primary_link_url' => (string) ($announcement->primary_link_url ?? ''),
'primary_link_target_id' => $announcement->primary_link_target_id ? (string) $announcement->primary_link_target_id : '',
'secondary_link_label' => (string) ($announcement->secondary_link_label ?? ''),
'secondary_link_type' => (string) ($announcement->secondary_link_type ?? HomepageAnnouncement::LINK_TYPE_NONE),
'secondary_link_url' => (string) ($announcement->secondary_link_url ?? ''),
'secondary_link_target_id' => $announcement->secondary_link_target_id ? (string) $announcement->secondary_link_target_id : '',
];
}
/**
* @return array<string, mixed>
*/
private function serializeForIndex(HomepageAnnouncement $announcement): array
{
return [
'id' => (int) $announcement->id,
'title' => (string) $announcement->title,
'type' => (string) $announcement->type,
'status' => (string) $announcement->status,
'is_active' => (bool) $announcement->is_active,
'priority' => (int) $announcement->priority,
'dismiss_version' => (int) $announcement->dismiss_version,
'placement' => (string) $announcement->placement,
'starts_at' => optional($announcement->starts_at)?->toIso8601String(),
'ends_at' => optional($announcement->ends_at)?->toIso8601String(),
'updated_at' => optional($announcement->updated_at)?->toIso8601String(),
'edit_url' => route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $announcement]),
'destroy_url' => route('admin.homepage-announcements.destroy', ['homepageAnnouncement' => $announcement]),
'preview' => $this->announcements->toHomepagePayload($announcement),
];
}
/**
* @return array<string, array<int, array<string, string>>>
*/
private function options(): array
{
return [
'types' => [
['value' => HomepageAnnouncement::TYPE_ANNOUNCEMENT, 'label' => 'Announcement'],
['value' => HomepageAnnouncement::TYPE_LAUNCH, 'label' => 'Launch'],
['value' => HomepageAnnouncement::TYPE_NEWS, 'label' => 'News'],
['value' => HomepageAnnouncement::TYPE_WORLD, 'label' => 'World'],
['value' => HomepageAnnouncement::TYPE_EVENT, 'label' => 'Event'],
['value' => HomepageAnnouncement::TYPE_NOTICE, 'label' => 'Notice'],
['value' => HomepageAnnouncement::TYPE_MAINTENANCE, 'label' => 'Maintenance'],
],
'statuses' => [
['value' => HomepageAnnouncement::STATUS_DRAFT, 'label' => 'Draft'],
['value' => HomepageAnnouncement::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => HomepageAnnouncement::STATUS_ARCHIVED, 'label' => 'Archived'],
],
'placements' => [
['value' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, 'label' => 'Homepage after Featured Artwork'],
],
'linkTypes' => [
['value' => HomepageAnnouncement::LINK_TYPE_NONE, 'label' => 'None'],
['value' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, 'label' => 'Custom URL'],
['value' => HomepageAnnouncement::LINK_TYPE_NEWS, 'label' => 'News article'],
['value' => HomepageAnnouncement::LINK_TYPE_WORLD, 'label' => 'World'],
['value' => HomepageAnnouncement::LINK_TYPE_ARTWORK, 'label' => 'Artwork'],
['value' => HomepageAnnouncement::LINK_TYPE_COLLECTION, 'label' => 'Collection'],
['value' => HomepageAnnouncement::LINK_TYPE_GROUP, 'label' => 'Group'],
['value' => HomepageAnnouncement::LINK_TYPE_PROFILE, 'label' => 'Profile'],
['value' => HomepageAnnouncement::LINK_TYPE_EXPLORE, 'label' => 'Explore'],
['value' => HomepageAnnouncement::LINK_TYPE_UPLOAD, 'label' => 'Upload'],
],
'gradients' => [
['value' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, 'label' => 'Nova Aurora'],
['value' => HomepageAnnouncement::GRADIENT_DEEP_SPACE, 'label' => 'Deep Space'],
['value' => HomepageAnnouncement::GRADIENT_SUNRISE, 'label' => 'Sunrise'],
['value' => HomepageAnnouncement::GRADIENT_OCEAN_GLOW, 'label' => 'Ocean Glow'],
['value' => HomepageAnnouncement::GRADIENT_SPRING_VIBES, 'label' => 'Spring Vibes'],
['value' => HomepageAnnouncement::GRADIENT_FANTASY_REALMS, 'label' => 'Fantasy Realms'],
['value' => HomepageAnnouncement::GRADIENT_MINIMAL_LIGHT, 'label' => 'Minimal Light'],
['value' => HomepageAnnouncement::GRADIENT_DARK_GLASS, 'label' => 'Dark Glass'],
],
'themes' => [
['value' => 'launch', 'label' => 'Launch'],
['value' => 'announcement', 'label' => 'Announcement'],
['value' => 'notice', 'label' => 'Notice'],
['value' => 'maintenance', 'label' => 'Maintenance'],
],
];
}
private function currentActor(Request $request): object
{
return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use App\Models\HomepageAnnouncement;
use App\Services\HomepageAnnouncementSanitizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertHomepageAnnouncementRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) ($this->user('controlpanel') ?? $this->user());
}
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active'),
'is_dismissible' => $this->boolean('is_dismissible', true),
'remove_background_image' => $this->boolean('remove_background_image'),
'priority' => $this->filled('priority') ? (int) $this->input('priority') : 0,
'dismiss_version' => $this->filled('dismiss_version') ? (int) $this->input('dismiss_version') : 1,
'overlay_opacity' => $this->filled('overlay_opacity') ? (int) $this->input('overlay_opacity') : 55,
]);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:180'],
'subtitle' => ['nullable', 'string', 'max:255'],
'badge_text' => ['nullable', 'string', 'max:100'],
'content_html' => ['nullable', 'string'],
'type' => ['required', 'string', Rule::in(HomepageAnnouncement::types())],
'status' => ['required', 'string', Rule::in(HomepageAnnouncement::statuses())],
'is_active' => ['required', 'boolean'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
'priority' => ['required', 'integer', 'min:-9999', 'max:9999'],
'is_dismissible' => ['required', 'boolean'],
'dismiss_version' => ['required', 'integer', 'min:1', 'max:999999'],
'gradient_preset' => ['nullable', 'string', Rule::in(HomepageAnnouncement::gradientPresets())],
'theme_preset' => ['nullable', 'string', 'max:80'],
'placement' => ['required', 'string', Rule::in(HomepageAnnouncement::placements())],
'text_color' => ['nullable', 'string', 'max:32'],
'overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'],
'background_image' => ['nullable', 'string', 'max:2048'],
'background_image_file' => ['nullable', 'file', 'image', 'mimes:jpeg,jpg,png,webp', 'max:5120'],
'remove_background_image' => ['nullable', 'boolean'],
'primary_link_label' => ['nullable', 'string', 'max:80'],
'primary_link_type' => ['nullable', 'string', Rule::in(HomepageAnnouncement::linkTypes())],
'primary_link_url' => ['nullable', 'string', 'max:2048'],
'primary_link_target_id' => ['nullable', 'integer', 'min:1'],
'secondary_link_label' => ['nullable', 'string', 'max:80'],
'secondary_link_type' => ['nullable', 'string', Rule::in(HomepageAnnouncement::linkTypes())],
'secondary_link_url' => ['nullable', 'string', 'max:2048'],
'secondary_link_target_id' => ['nullable', 'integer', 'min:1'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$sanitizer = app(HomepageAnnouncementSanitizer::class);
foreach (['primary', 'secondary'] as $prefix) {
$type = (string) ($this->input($prefix . '_link_type') ?: HomepageAnnouncement::LINK_TYPE_NONE);
$label = trim((string) $this->input($prefix . '_link_label', ''));
$url = trim((string) $this->input($prefix . '_link_url', ''));
$targetId = (int) $this->input($prefix . '_link_target_id', 0);
if ($url !== '' && ! $sanitizer->isSafeCustomUrl($url)) {
$validator->errors()->add($prefix . '_link_url', 'Use a relative path starting with / or an https:// URL. Unsafe protocols are not allowed.');
}
if ($type !== HomepageAnnouncement::LINK_TYPE_NONE && $label === '') {
$validator->errors()->add($prefix . '_link_label', 'Provide a CTA label when this link is enabled.');
}
if ($type === HomepageAnnouncement::LINK_TYPE_CUSTOM_URL && $url === '') {
$validator->errors()->add($prefix . '_link_url', 'Provide a URL for custom links.');
}
if (! in_array($type, [HomepageAnnouncement::LINK_TYPE_NONE, HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, HomepageAnnouncement::LINK_TYPE_EXPLORE, HomepageAnnouncement::LINK_TYPE_UPLOAD], true)
&& $url === ''
&& $targetId < 1) {
$validator->errors()->add($prefix . '_link_target_id', 'Provide a target id or a fallback URL for this CTA type.');
}
}
});
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
class HomepageAnnouncement extends Model
{
use SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
public const TYPE_ANNOUNCEMENT = 'announcement';
public const TYPE_LAUNCH = 'launch';
public const TYPE_NEWS = 'news';
public const TYPE_WORLD = 'world';
public const TYPE_EVENT = 'event';
public const TYPE_NOTICE = 'notice';
public const TYPE_MAINTENANCE = 'maintenance';
public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured';
public const LINK_TYPE_NONE = 'none';
public const LINK_TYPE_CUSTOM_URL = 'custom_url';
public const LINK_TYPE_NEWS = 'news';
public const LINK_TYPE_WORLD = 'world';
public const LINK_TYPE_ARTWORK = 'artwork';
public const LINK_TYPE_COLLECTION = 'collection';
public const LINK_TYPE_GROUP = 'group';
public const LINK_TYPE_PROFILE = 'profile';
public const LINK_TYPE_EXPLORE = 'explore';
public const LINK_TYPE_UPLOAD = 'upload';
public const GRADIENT_NOVA_AURORA = 'nova_aurora';
public const GRADIENT_DEEP_SPACE = 'deep_space';
public const GRADIENT_SUNRISE = 'sunrise';
public const GRADIENT_OCEAN_GLOW = 'ocean_glow';
public const GRADIENT_SPRING_VIBES = 'spring_vibes';
public const GRADIENT_FANTASY_REALMS = 'fantasy_realms';
public const GRADIENT_MINIMAL_LIGHT = 'minimal_light';
public const GRADIENT_DARK_GLASS = 'dark_glass';
protected $table = 'homepage_announcements';
protected $fillable = [
'title',
'subtitle',
'badge_text',
'content_html',
'type',
'status',
'is_active',
'starts_at',
'ends_at',
'primary_link_label',
'primary_link_type',
'primary_link_url',
'primary_link_target_type',
'primary_link_target_id',
'secondary_link_label',
'secondary_link_type',
'secondary_link_url',
'secondary_link_target_type',
'secondary_link_target_id',
'background_type',
'background_image',
'gradient_preset',
'theme_preset',
'text_color',
'overlay_opacity',
'placement',
'priority',
'is_dismissible',
'dismiss_version',
'created_by',
'updated_by',
];
protected $casts = [
'is_active' => 'boolean',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'primary_link_target_id' => 'integer',
'secondary_link_target_id' => 'integer',
'overlay_opacity' => 'integer',
'priority' => 'integer',
'is_dismissible' => 'boolean',
'dismiss_version' => 'integer',
];
public static function statuses(): array
{
return [
self::STATUS_DRAFT,
self::STATUS_PUBLISHED,
self::STATUS_ARCHIVED,
];
}
public static function types(): array
{
return [
self::TYPE_ANNOUNCEMENT,
self::TYPE_LAUNCH,
self::TYPE_NEWS,
self::TYPE_WORLD,
self::TYPE_EVENT,
self::TYPE_NOTICE,
self::TYPE_MAINTENANCE,
];
}
public static function placements(): array
{
return [
self::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
];
}
public static function linkTypes(): array
{
return [
self::LINK_TYPE_NONE,
self::LINK_TYPE_CUSTOM_URL,
self::LINK_TYPE_NEWS,
self::LINK_TYPE_WORLD,
self::LINK_TYPE_ARTWORK,
self::LINK_TYPE_COLLECTION,
self::LINK_TYPE_GROUP,
self::LINK_TYPE_PROFILE,
self::LINK_TYPE_EXPLORE,
self::LINK_TYPE_UPLOAD,
];
}
public static function gradientPresets(): array
{
return [
self::GRADIENT_NOVA_AURORA,
self::GRADIENT_DEEP_SPACE,
self::GRADIENT_SUNRISE,
self::GRADIENT_OCEAN_GLOW,
self::GRADIENT_SPRING_VIBES,
self::GRADIENT_FANTASY_REALMS,
self::GRADIENT_MINIMAL_LIGHT,
self::GRADIENT_DARK_GLASS,
];
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', self::STATUS_PUBLISHED);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function scopeVisibleNow(Builder $query, ?Carbon $now = null): Builder
{
$now ??= now();
return $query
->where(function (Builder $builder) use ($now): void {
$builder->whereNull('starts_at')
->orWhere('starts_at', '<=', $now);
})
->where(function (Builder $builder) use ($now): void {
$builder->whereNull('ends_at')
->orWhere('ends_at', '>=', $now);
});
}
public function scopeForPlacement(Builder $query, string $placement): Builder
{
return $query->where('placement', $placement);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\HomepageAnnouncement;
use App\Services\HomepageAnnouncementService;
class HomepageAnnouncementObserver
{
public function saved(HomepageAnnouncement $announcement): void
{
app(HomepageAnnouncementService::class)->clearActiveCache();
}
public function deleted(HomepageAnnouncement $announcement): void
{
app(HomepageAnnouncementService::class)->clearActiveCache();
}
public function restored(HomepageAnnouncement $announcement): void
{
app(HomepageAnnouncementService::class)->clearActiveCache();
}
public function forceDeleted(HomepageAnnouncement $announcement): void
{
app(HomepageAnnouncementService::class)->clearActiveCache();
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DOMDocument;
use DOMElement;
use DOMNode;
class HomepageAnnouncementSanitizer
{
private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li', 'h2', 'h3', 'blockquote',
];
private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'target', 'rel'],
];
public function sanitizeHtml(?string $html): string
{
if ($html === null || trim($html) === '') {
return '';
}
$encodedHtml = mb_encode_numericentity(
$html,
[0x80, 0x10FFFF, 0, 0xFFFFFF],
'UTF-8'
);
$document = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="UTF-8"><html><body>' . $encodedHtml . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$body = $document->getElementsByTagName('body')->item(0);
if (! $body instanceof DOMNode) {
return '';
}
$this->cleanNode($body);
$innerHtml = '';
foreach ($body->childNodes as $child) {
$innerHtml .= $document->saveHTML($child);
}
return trim(html_entity_decode($innerHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
public function sanitizeCustomUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
if (! $this->isSafeCustomUrl($url)) {
return null;
}
return $url;
}
public function isSafeCustomUrl(?string $url): bool
{
$url = trim((string) $url);
if ($url === '') {
return true;
}
$lower = strtolower($url);
if (str_starts_with($lower, 'javascript:') || str_contains($lower, 'onerror=') || str_contains($lower, 'onclick=')) {
return false;
}
if (str_starts_with($url, '/')) {
return true;
}
return str_starts_with($lower, 'https://');
}
private function cleanNode(DOMNode $node): void
{
$toRemove = [];
$toUnwrap = [];
foreach ($node->childNodes as $child) {
if ($child->nodeType !== XML_ELEMENT_NODE) {
continue;
}
if (! $child instanceof DOMElement) {
continue;
}
$tag = strtolower($child->nodeName);
if (in_array($tag, ['script', 'style', 'iframe'], true)) {
$toRemove[] = $child;
continue;
}
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
$toUnwrap[] = $child;
continue;
}
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = [];
foreach ($child->attributes as $attribute) {
if (! in_array($attribute->nodeName, $allowedAttrs, true)) {
$attrsToRemove[] = $attribute->nodeName;
}
}
foreach ($attrsToRemove as $attributeName) {
$child->removeAttribute($attributeName);
}
if ($tag === 'a') {
$href = trim($child->getAttribute('href'));
if ($href === '' || ! $this->isSafeAnchorHref($href)) {
$toUnwrap[] = $child;
continue;
}
if (str_starts_with(strtolower($href), 'https://')) {
$child->setAttribute('rel', 'noopener noreferrer');
$child->setAttribute('target', '_blank');
} else {
$child->removeAttribute('target');
$child->removeAttribute('rel');
}
}
$this->cleanNode($child);
}
foreach ($toRemove as $element) {
$node->removeChild($element);
}
foreach ($toUnwrap as $element) {
while ($element->firstChild) {
$node->insertBefore($element->firstChild, $element);
}
$node->removeChild($element);
}
}
private function isSafeAnchorHref(string $href): bool
{
$lower = strtolower(trim($href));
if (str_starts_with($lower, 'javascript:') || str_starts_with($lower, 'data:')) {
return false;
}
if (str_starts_with($href, '/') || str_starts_with($href, '#')) {
return true;
}
return str_starts_with($lower, 'https://');
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\HomepageAnnouncement;
use App\Models\User;
use App\Models\World;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use cPad\Plugins\News\Models\NewsArticle;
class HomepageAnnouncementService
{
public const ACTIVE_CACHE_KEY = 'skinbase:homepage:announcement:active';
public function __construct(private readonly HomepageAnnouncementSanitizer $sanitizer)
{
}
public function getActiveForHomepage(): ?HomepageAnnouncement
{
return Cache::remember(self::ACTIVE_CACHE_KEY, now()->addMinutes(5), function (): ?HomepageAnnouncement {
return HomepageAnnouncement::query()
->published()
->active()
->visibleNow()
->forPlacement(HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED)
->orderByDesc('priority')
->orderByDesc('starts_at')
->first();
});
}
public function clearActiveCache(): void
{
Cache::forget(self::ACTIVE_CACHE_KEY);
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function sanitizeAttributes(array $attributes): array
{
foreach (['primary_link_url', 'secondary_link_url'] as $key) {
$attributes[$key] = $this->sanitizer->sanitizeCustomUrl($attributes[$key] ?? null);
}
$attributes['content_html'] = $this->sanitizer->sanitizeHtml($attributes['content_html'] ?? null);
return $attributes;
}
public function toHomepagePayload(?HomepageAnnouncement $announcement): ?array
{
if (! $announcement) {
return null;
}
return [
'id' => (int) $announcement->id,
'dismiss_version' => max(1, (int) $announcement->dismiss_version),
'title' => (string) $announcement->title,
'subtitle' => $announcement->subtitle,
'badge_text' => $announcement->badge_text,
'content_html' => $announcement->content_html,
'gradient_preset' => $announcement->gradient_preset,
'theme_preset' => $announcement->theme_preset,
'background_image_url' => $this->resolveBackgroundImageUrl($announcement->background_image),
'is_dismissible' => (bool) $announcement->is_dismissible,
'overlay_opacity' => (int) ($announcement->overlay_opacity ?? 55),
'primary_link' => $this->buildLinkPayload($announcement, 'primary'),
'secondary_link' => $this->buildLinkPayload($announcement, 'secondary'),
];
}
/**
* @param array<string, mixed> $attributes
*/
public function previewPayload(array $attributes): ?array
{
$announcement = new HomepageAnnouncement();
$announcement->forceFill($this->sanitizeAttributes($attributes));
$announcement->id = 0;
return $this->toHomepagePayload($announcement);
}
private function buildLinkPayload(HomepageAnnouncement $announcement, string $prefix): ?array
{
$label = trim((string) $announcement->getAttribute($prefix . '_link_label'));
if ($label === '') {
return null;
}
$url = $this->resolveLinkUrl($announcement, $prefix);
if ($url === null) {
return null;
}
return [
'label' => $label,
'url' => $url,
];
}
private function resolveLinkUrl(HomepageAnnouncement $announcement, string $prefix): ?string
{
$type = (string) ($announcement->getAttribute($prefix . '_link_type') ?? HomepageAnnouncement::LINK_TYPE_NONE);
$url = $this->sanitizer->sanitizeCustomUrl($announcement->getAttribute($prefix . '_link_url'));
$targetId = (int) ($announcement->getAttribute($prefix . '_link_target_id') ?? 0);
return match ($type) {
HomepageAnnouncement::LINK_TYPE_NONE => null,
HomepageAnnouncement::LINK_TYPE_CUSTOM_URL => $url,
HomepageAnnouncement::LINK_TYPE_EXPLORE => Route::has('explore.index') ? route('explore.index') : ($url ?? '/explore'),
HomepageAnnouncement::LINK_TYPE_UPLOAD => Route::has('upload') ? route('upload') : ($url ?? '/upload'),
HomepageAnnouncement::LINK_TYPE_NEWS => $this->newsUrl($targetId) ?? $url,
HomepageAnnouncement::LINK_TYPE_WORLD => $this->worldUrl($targetId) ?? $url,
HomepageAnnouncement::LINK_TYPE_COLLECTION => $this->collectionUrl($targetId) ?? $url,
HomepageAnnouncement::LINK_TYPE_GROUP => $this->groupUrl($targetId) ?? $url,
HomepageAnnouncement::LINK_TYPE_PROFILE => $this->profileUrl($targetId) ?? $url,
HomepageAnnouncement::LINK_TYPE_ARTWORK => $this->artworkUrl($targetId) ?? $url,
default => $url,
};
}
private function resolveBackgroundImageUrl(?string $backgroundImage): ?string
{
$backgroundImage = trim((string) $backgroundImage);
if ($backgroundImage === '') {
return null;
}
if (str_starts_with($backgroundImage, 'http://') || str_starts_with($backgroundImage, 'https://') || str_starts_with($backgroundImage, '/')) {
return $backgroundImage;
}
return Storage::disk('public')->url($backgroundImage);
}
private function artworkUrl(int $artworkId): ?string
{
if ($artworkId < 1) {
return null;
}
$artwork = Artwork::query()->find($artworkId);
if (! $artwork) {
return null;
}
return null;
}
private function newsUrl(int $articleId): ?string
{
if ($articleId < 1) {
return null;
}
$article = NewsArticle::query()->find($articleId);
if (! $article) {
return null;
}
return route('news.show', ['slug' => $article->slug]);
}
private function worldUrl(int $worldId): ?string
{
if ($worldId < 1) {
return null;
}
$world = World::query()->find($worldId);
if (! $world) {
return null;
}
if (method_exists($world, 'publicUrl')) {
return $world->publicUrl();
}
return route('worlds.show', ['world' => $world->slug]);
}
private function collectionUrl(int $collectionId): ?string
{
if ($collectionId < 1) {
return null;
}
$collection = Collection::query()->with('user')->find($collectionId);
if (! $collection || ! $collection->user?->username) {
return null;
}
return route('profile.collections.show', [
'username' => strtolower((string) $collection->user->username),
'slug' => $collection->slug,
]);
}
private function groupUrl(int $groupId): ?string
{
if ($groupId < 1) {
return null;
}
$group = Group::query()->find($groupId);
if (! $group) {
return null;
}
return route('groups.show', ['group' => $group]);
}
private function profileUrl(int $userId): ?string
{
if ($userId < 1) {
return null;
}
$user = User::query()->find($userId);
if (! $user?->username) {
return null;
}
return route('profile.show', ['username' => strtolower((string) $user->username)]);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Leaderboard;
use App\Models\Tag;
use App\Services\HomepageAnnouncementService;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
@@ -57,6 +58,7 @@ final class HomepageService
private readonly GroupDiscoveryService $groupDiscovery,
private readonly LeaderboardService $leaderboards,
private readonly WorldService $worlds,
private readonly HomepageAnnouncementService $homepageAnnouncements,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -68,9 +70,17 @@ final class HomepageService
*/
public function all(): array
{
return $this->guestPayloadCache()->remember(
// Use a stale-while-revalidate pattern: serve fresh cache for a short
// period and allow serving stale data while the cache is recalculated
// in the background. This reduces latency for the homepage on cache
// miss/expiration and keeps the heavy aggregation from blocking
// responses.
$ttl = $this->guestPayloadCacheTtl();
$freshSeconds = min(30, max(5, (int) config('homepage.fresh_seconds', 30)));
return $this->guestPayloadCache()->flexible(
$this->guestPayloadCacheKey(),
$this->guestPayloadCacheTtl(),
[$freshSeconds, $ttl],
fn (): array => $this->buildGuestPayload(),
);
}
@@ -119,6 +129,7 @@ final class HomepageService
{
return [
'hero' => $this->getHeroArtwork(),
'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'rising' => $this->getRising(),
@@ -171,6 +182,7 @@ final class HomepageService
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'for_you' => $this->getForYouPreview($user),