Add homepage announcement module
This commit is contained in:
175
app/Services/HomepageAnnouncementSanitizer.php
Normal file
175
app/Services/HomepageAnnouncementSanitizer.php
Normal 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://');
|
||||
}
|
||||
}
|
||||
238
app/Services/HomepageAnnouncementService.php
Normal file
238
app/Services/HomepageAnnouncementService.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user