fixed sanitazer and academy

This commit is contained in:
2026-06-05 16:53:20 +02:00
parent 15870ddb1f
commit f89ee937c0
29 changed files with 2444 additions and 1039 deletions

View File

@@ -37,11 +37,22 @@ class ContentSanitizer
'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Image and embed-related tags used by the rich editor
'figure', 'figcaption', 'img', 'picture', 'source', 'iframe',
// Basic structural/inline helpers sometimes produced by embeds
'div', 'span'
];
// Allowed attributes per tag
private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'srcset', 'sizes', 'alt', 'title', 'loading', 'decoding', 'width', 'height', 'style', 'class', 'data-width'],
'source' => ['srcset', 'src', 'type', 'media', 'sizes'],
'figure' => ['class', 'data-rich-image', 'data-platform', 'data-video-embed', 'data-social-embed', 'data-artwork-embed'],
'figcaption' => ['class'],
'iframe' => ['src', 'title', 'loading', 'frameborder', 'allow', 'allowfullscreen', 'referrerpolicy'],
'div' => ['class', 'data-href', 'data-show-text'],
'span' => ['class'],
];
private static ?MarkdownConverter $converter = null;
@@ -261,14 +272,82 @@ class ContentSanitizer
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = [];
foreach ($child->attributes as $attr) {
if (! in_array($attr->nodeName, $allowedAttrs, true)) {
$attrsToRemove[] = $attr->nodeName;
$name = $attr->nodeName;
// Allow data-* attributes and class on allowed tags
if (str_starts_with($name, 'data-') || $name === 'class') {
continue;
}
if (! in_array($name, $allowedAttrs, true)) {
$attrsToRemove[] = $name;
}
}
foreach ($attrsToRemove as $attrName) {
$child->removeAttribute($attrName);
}
// Validate URL-like attributes for image/source/iframe
if ($tag === 'img') {
$src = $child->getAttribute('src');
if ($src && ! static::isSafeUrl($src)) {
$toUnwrap[] = $child;
continue;
}
// Validate srcset: ensure each URL is safe; if not, remove the attribute
$srcset = $child->getAttribute('srcset');
if ($srcset) {
$parts = array_map('trim', explode(',', $srcset));
$valid = true;
foreach ($parts as $part) {
if ($part === '') {
continue;
}
// Each part: "url [descriptor]"
$pieces = preg_split('/\s+/', $part);
$url = $pieces[0] ?? '';
if ($url !== '' && ! static::isSafeUrl($url)) {
$valid = false;
break;
}
}
if (! $valid) {
$child->removeAttribute('srcset');
}
}
}
if ($tag === 'source') {
$src = $child->getAttribute('src') ?: $child->getAttribute('srcset');
if ($src) {
// For srcset allow comma-separated list; validate each
$values = array_map('trim', explode(',', $src));
$valid = true;
foreach ($values as $v) {
if ($v === '') continue;
$pieces = preg_split('/\s+/', $v);
$url = $pieces[0] ?? '';
if ($url !== '' && ! static::isSafeUrl($url)) {
$valid = false;
break;
}
}
if (! $valid) {
$toUnwrap[] = $child;
continue;
}
}
}
if ($tag === 'iframe') {
$src = $child->getAttribute('src');
if ($src && ! static::isSafeUrl($src)) {
$toUnwrap[] = $child;
continue;
}
}
// Force external links to be safe
if ($tag === 'a') {
if (! $allowLinks) {

View File

@@ -761,12 +761,12 @@ final class HomepageService
/**
* Latest 5 news posts from the forum news category.
*/
public function getNews(int $limit = 5): array
public function getNews(int $limit = 10): array
{
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$articles = NewsArticle::query()
->with('category')
->with(['category', 'author'])
->published()
->editorialOrder()
->limit($limit)
@@ -774,13 +774,23 @@ final class HomepageService
if ($articles->isNotEmpty()) {
return $articles->map(fn (NewsArticle $article) => [
'id' => $article->id,
'title' => $article->title,
'date' => $article->published_at,
'url' => route('news.show', ['slug' => $article->slug]),
'eyebrow' => $article->category?->name ?: $article->type_label,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 120),
])->values()->all();
'id' => $article->id,
'title' => $article->title,
'date' => $article->published_at,
'url' => route('news.show', ['slug' => $article->slug]),
'eyebrow' => $article->category?->name ?: $article->type_label,
'type' => $article->type ?? null,
'type_label' => $article->type_label ?? null,
'category' => $article->category ? ['name' => $article->category->name, 'slug' => $article->category->slug] : null,
'is_featured' => (bool) ($article->is_featured ?? false),
'is_pinned' => (bool) ($article->is_pinned ?? false),
'cover_url' => $article->cover_url ?? null,
'cover_mobile_url' => $article->cover_mobile_url ?? null,
'cover_srcset' => $article->cover_srcset ?? null,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 135),
'author' => $article->author ? ['name' => $article->author->name ?? $article->author->username, 'username' => $article->author->username ?? null] : null,
'views' => isset($article->views) ? (int) $article->views : 0,
])->values()->all();
}
$items = DB::table('forum_threads as t')

View File

@@ -61,12 +61,14 @@ final class RSSFeedBuilder
string $channelLink,
string $feedUrl,
Collection $items,
?string $canonicalUrl = null,
): Response {
$xml = view('rss.channel', [
'channelTitle' => trim($channelTitle) . ' — Skinbase',
'channelDescription' => $channelDescription,
'channelLink' => $channelLink,
'feedUrl' => $feedUrl,
'canonicalUrl' => $canonicalUrl ?: $feedUrl,
'items' => $items,
'buildDate' => now()->toRfc2822String(),
])->render();