fixed sanitazer and academy
This commit is contained in:
@@ -42,6 +42,7 @@ class RegisteredUserController extends Controller
|
||||
|
||||
return view('auth.register', [
|
||||
'prefillEmail' => (string) $request->query('email', ''),
|
||||
'page_canonical' => route('register'),
|
||||
'turnstile' => [
|
||||
'enabled' => $this->turnstileVerifier->isEnabled(),
|
||||
'siteKey' => $this->turnstileVerifier->siteKey(),
|
||||
|
||||
@@ -79,6 +79,7 @@ class GroupController extends Controller
|
||||
{
|
||||
$this->authorize('view', $group);
|
||||
|
||||
$section = in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview';
|
||||
$viewer = $request->user();
|
||||
$group->loadMissing('owner.profile');
|
||||
$members = collect($this->memberships->mapMembers($group, $viewer))
|
||||
@@ -89,7 +90,8 @@ class GroupController extends Controller
|
||||
|
||||
return Inertia::render('Group/GroupShow', [
|
||||
'group' => $groupPayload,
|
||||
'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview',
|
||||
'section' => $section,
|
||||
'seo' => $this->seoPayload($group, $section),
|
||||
'featuredArtworks' => $this->groups->featuredArtworkCards($group),
|
||||
'artworks' => $this->groups->publicArtworkCards($group),
|
||||
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
|
||||
@@ -140,4 +142,19 @@ class GroupController extends Controller
|
||||
{
|
||||
return $this->show($request, $group, 'activity');
|
||||
}
|
||||
}
|
||||
|
||||
private function seoPayload(Group $group, string $section): array
|
||||
{
|
||||
$canonical = $section === 'overview'
|
||||
? route('groups.show', ['group' => $group])
|
||||
: route('groups.section', ['group' => $group, 'section' => $section]);
|
||||
$sectionLabel = $section === 'overview' ? '' : ' '.ucfirst($section);
|
||||
|
||||
return [
|
||||
'title' => trim($group->name.$sectionLabel.' - Skinbase'),
|
||||
'description' => $group->headline ?: $group->bio ?: 'Skinbase group',
|
||||
'canonical' => $canonical,
|
||||
'og_url' => $canonical,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,6 +767,17 @@ final class AcademyAdminController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
if ($resource === 'challenges') {
|
||||
return [
|
||||
'links' => array_filter([
|
||||
'preview' => $record->exists ? route('academy.challenges.show', ['slug' => $record->slug]) : null,
|
||||
]),
|
||||
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
|
||||
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
|
||||
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($resource !== 'lessons') {
|
||||
return [];
|
||||
}
|
||||
@@ -1200,6 +1211,7 @@ final class AcademyAdminController extends Controller
|
||||
'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'),
|
||||
'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'),
|
||||
'cover_image' => (string) ($record->cover_image ?? ''),
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
|
||||
'prize_text' => (string) ($record->prize_text ?? ''),
|
||||
'required_tags' => implode(', ', (array) ($record->required_tags ?? [])),
|
||||
'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])),
|
||||
|
||||
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\News\NewsService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -46,6 +48,8 @@ final class StudioNewsController extends Controller
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Studio/StudioNewsEditor', [
|
||||
'title' => 'Create article',
|
||||
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
|
||||
@@ -61,11 +65,14 @@ final class StudioNewsController extends Controller
|
||||
'storeUrl' => route('studio.news.store'),
|
||||
'coverUploadUrl' => route('api.studio.news.media.upload'),
|
||||
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
|
||||
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
|
||||
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
|
||||
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'entitySearchUrl' => route('studio.news.entity-search'),
|
||||
'categoriesUrl' => route('studio.news.categories'),
|
||||
'tagsUrl' => route('studio.news.tags'),
|
||||
'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null,
|
||||
'defaultAuthor' => $this->mapDefaultAuthor($user),
|
||||
'defaultPublishedAt' => now()->format('Y-m-d\TH:i'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -96,6 +103,8 @@ final class StudioNewsController extends Controller
|
||||
'relationTypeOptions' => $this->news->relationTypeOptions(),
|
||||
'coverUploadUrl' => route('api.studio.news.media.upload'),
|
||||
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
|
||||
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
|
||||
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
|
||||
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'updateUrl' => route('studio.news.update', ['article' => $article->id]),
|
||||
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
|
||||
@@ -250,6 +259,29 @@ final class StudioNewsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapDefaultAuthor(mixed $user): ?array
|
||||
{
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user->loadMissing('profile');
|
||||
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
'entity_type' => 'user',
|
||||
'entity_label' => 'User',
|
||||
'title' => (string) ($user->name ?: $user->username),
|
||||
'subtitle' => $user->username ? '@' . $user->username : null,
|
||||
'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120),
|
||||
'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null,
|
||||
'image' => null,
|
||||
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96),
|
||||
'context_label' => 'Profile',
|
||||
'meta' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function storeCategory(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
@@ -50,7 +50,8 @@ class TopAuthorsController extends Controller
|
||||
});
|
||||
|
||||
$page_title = 'Top Creators';
|
||||
$page_canonical = route('creators.top');
|
||||
|
||||
return view('web.authors.top', compact('page_title', 'authors', 'metric'));
|
||||
return view('web.authors.top', compact('page_title', 'page_canonical', 'authors', 'metric'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Trending Artworks',
|
||||
'page_canonical' => $this->canonicalRoute('discover.trending'),
|
||||
'section' => 'trending',
|
||||
'description' => 'The most-viewed artworks on Skinbase over the past 7 days.',
|
||||
'icon' => 'fa-fire',
|
||||
@@ -97,6 +98,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Rising Now',
|
||||
'page_canonical' => $this->canonicalRoute('discover.rising'),
|
||||
'section' => 'rising',
|
||||
'description' => 'Fastest growing artworks right now.',
|
||||
'icon' => 'fa-rocket',
|
||||
@@ -119,6 +121,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Fresh Uploads',
|
||||
'page_canonical' => $this->canonicalRoute('discover.fresh'),
|
||||
'section' => 'fresh',
|
||||
'description' => 'The latest artworks just uploaded to Skinbase.',
|
||||
'icon' => 'fa-bolt',
|
||||
@@ -138,6 +141,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Top Rated Artworks',
|
||||
'page_canonical' => $this->canonicalRoute('discover.top-rated'),
|
||||
'section' => 'top-rated',
|
||||
'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.',
|
||||
'icon' => 'fa-medal',
|
||||
@@ -157,6 +161,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Most Downloaded',
|
||||
'page_canonical' => $this->canonicalRoute('discover.most-downloaded'),
|
||||
'section' => 'most-downloaded',
|
||||
'description' => 'All-time most downloaded artworks on Skinbase.',
|
||||
'icon' => 'fa-download',
|
||||
@@ -178,9 +183,9 @@ final class DiscoverController extends Controller
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(published_at) = ?', [$today->day])
|
||||
->whereRaw('YEAR(published_at) < ?', [$today->year])
|
||||
->whereMonth('published_at', $today->month)
|
||||
->whereDay('published_at', $today->day)
|
||||
->whereYear('published_at', '<', $today->year)
|
||||
->orderMissingThumbnailsLast()
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
@@ -191,6 +196,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'On This Day',
|
||||
'page_canonical' => $this->canonicalRoute('discover.on-this-day'),
|
||||
'section' => 'on-this-day',
|
||||
'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.',
|
||||
'icon' => 'fa-calendar-day',
|
||||
@@ -246,6 +252,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.creators.rising', [
|
||||
'creators' => $creators,
|
||||
'page_title' => 'Rising Creators — Skinbase',
|
||||
'page_canonical' => $this->canonicalRoute('creators.rising'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -327,6 +334,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => collect(),
|
||||
'page_title' => 'Following Feed',
|
||||
'page_canonical' => $this->canonicalRoute('discover.following'),
|
||||
'section' => 'following',
|
||||
'description' => 'Follow some creators to see their work here.',
|
||||
'icon' => 'fa-user-group',
|
||||
@@ -366,6 +374,7 @@ final class DiscoverController extends Controller
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Following Feed',
|
||||
'page_canonical' => $this->canonicalRoute('discover.following'),
|
||||
'section' => 'following',
|
||||
'description' => 'The latest artworks from creators you follow.',
|
||||
'icon' => 'fa-user-group',
|
||||
@@ -388,6 +397,11 @@ final class DiscoverController extends Controller
|
||||
return ! $items || $items->isEmpty();
|
||||
}
|
||||
|
||||
private function canonicalRoute(string $routeName): string
|
||||
{
|
||||
return route($routeName);
|
||||
}
|
||||
|
||||
private function paginatorHasNoRisingMomentum($paginator): bool
|
||||
{
|
||||
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user