Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
class AvatarUrl
{
private static array $hashCache = [];
public static function forUser(int $userId, ?string $hash = null, int $size = 128): string
{
if ($userId <= 0) {
return self::default();
}
$avatarHash = $hash ?: self::resolveHash($userId);
if (!$avatarHash) {
return self::default();
}
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
$resolvedSize = self::resolveSize($size);
// Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash}
$p1 = substr($avatarHash, 0, 2);
$p2 = substr($avatarHash, 2, 2);
// Always use CDN-hosted avatar files.
return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $resolvedSize);
}
public static function default(): string
{
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
return sprintf('%s/default/avatar_default.webp', $base);
}
private static function resolveHash(int $userId): ?string
{
if (array_key_exists($userId, self::$hashCache)) {
return self::$hashCache[$userId];
}
try {
$value = DB::table('user_profiles')
->where('user_id', $userId)
->value('avatar_hash');
} catch (\Throwable $e) {
$value = null;
}
self::$hashCache[$userId] = $value ? (string) $value : null;
return self::$hashCache[$userId];
}
private static function resolveSize(int $requestedSize): int
{
$sizes = array_values(array_filter(
(array) config('avatars.sizes', [32, 64, 128, 256, 512]),
static fn ($size): bool => (int) $size > 0
));
if ($sizes === []) {
return max(1, $requestedSize);
}
sort($sizes);
foreach ($sizes as $size) {
if ($requestedSize <= (int) $size) {
return (int) $size;
}
}
return (int) end($sizes);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Support;
class CoverUrl
{
private const DEFAULT_FILES_CDN = 'https://files.skinbase.org';
public static function forUser(?string $hash, ?string $ext, ?int $version = null): ?string
{
$coverHash = trim((string) $hash);
$coverExt = strtolower(trim((string) $ext));
if ($coverHash === '' || $coverExt === '') {
return null;
}
$base = self::resolveBaseUrl();
$p1 = substr($coverHash, 0, 2);
$p2 = substr($coverHash, 2, 2);
$v = $version ?? time();
return sprintf('%s/covers/%s/%s/%s.%s?v=%s', $base, $p1, $p2, $coverHash, $coverExt, $v);
}
private static function resolveBaseUrl(): string
{
$configured = trim((string) config('cdn.files_url', self::DEFAULT_FILES_CDN));
// If a non-default CDN/files host is configured, always respect it.
if ($configured !== '' && $configured !== self::DEFAULT_FILES_CDN) {
return rtrim($configured, '/');
}
// Local/dev fallback: derive a web path from uploads.storage_root when it lives under public/.
$local = self::deriveLocalBaseFromStorageRoot();
if ($local !== null) {
return $local;
}
return rtrim($configured !== '' ? $configured : self::DEFAULT_FILES_CDN, '/');
}
private static function deriveLocalBaseFromStorageRoot(): ?string
{
$storageRoot = str_replace('\\', '/', rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR));
$publicRoot = str_replace('\\', '/', rtrim((string) public_path(), DIRECTORY_SEPARATOR));
$appUrl = rtrim((string) config('app.url'), '/');
if ($storageRoot === '' || $publicRoot === '' || $appUrl === '') {
return null;
}
if (! str_starts_with(strtolower($storageRoot), strtolower($publicRoot))) {
return null;
}
$suffix = trim((string) substr($storageRoot, strlen($publicRoot)), '/');
return $suffix === '' ? $appUrl : ($appUrl . '/' . $suffix);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Support;
use cPad\Plugins\Forum\Services\ForumContentRenderer;
class ForumPostContent
{
public static function render(?string $raw): string
{
return app(ForumContentRenderer::class)->render($raw);
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Support\Moderation;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\Collection;
use App\Models\CollectionComment;
use App\Models\CollectionSubmission;
use App\Models\ConversationParticipant;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\Message;
use App\Models\NovaCard;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\Report;
use App\Models\Story;
use App\Models\StoryComment;
use App\Models\User;
use App\Services\NovaCards\NovaCardPublishModerationService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ReportTargetResolver
{
public function __construct(
private readonly NovaCardPublishModerationService $moderation,
) {
}
/**
* @return array<int, string>
*/
public function supportedTargetTypes(): array
{
return [
'message',
'conversation',
'user',
'group',
'group_post',
'story',
'story_comment',
'artwork_comment',
'collection',
'collection_comment',
'collection_submission',
...$this->novaCardTargetTypes(),
];
}
/**
* @return array<int, string>
*/
public function novaCardTargetTypes(): array
{
return [
'nova_card',
'nova_card_comment',
'nova_card_challenge',
'nova_card_challenge_entry',
];
}
public function validateForReporter(User $user, string $targetType, int $targetId): void
{
switch ($targetType) {
case 'message':
$message = Message::query()->findOrFail($targetId);
$allowed = ConversationParticipant::query()
->where('conversation_id', $message->conversation_id)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this message.');
return;
case 'conversation':
$allowed = ConversationParticipant::query()
->where('conversation_id', $targetId)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
return;
case 'user':
User::query()->findOrFail($targetId);
return;
case 'group':
$group = Group::query()->findOrFail($targetId);
abort_unless($group->canBeViewedBy($user), 403, 'You are not allowed to report this group.');
return;
case 'group_post':
$post = GroupPost::query()->with('group')->findOrFail($targetId);
abort_unless(
$post->group !== null
&& $post->status === GroupPost::STATUS_PUBLISHED
&& $post->group->canBeViewedBy($user),
403,
'You are not allowed to report this group post.'
);
return;
case 'story':
Story::query()->findOrFail($targetId);
return;
case 'story_comment':
$storyComment = StoryComment::query()->with('story')->findOrFail($targetId);
abort_unless(
$storyComment->story !== null
&& Story::query()->published()->whereKey($storyComment->story_id)->exists(),
403,
'You are not allowed to report this comment.'
);
return;
case 'artwork_comment':
$artworkComment = ArtworkComment::query()->with('artwork')->findOrFail($targetId);
$artwork = $artworkComment->artwork;
abort_unless($artwork instanceof Artwork && (bool) $artwork->is_public && $artwork->published_at !== null, 403, 'You are not allowed to report this comment.');
return;
case 'collection':
$collection = Collection::query()->findOrFail($targetId);
abort_unless($collection->canBeViewedBy($user), 403, 'You are not allowed to report this collection.');
return;
case 'collection_comment':
$comment = CollectionComment::query()->with('collection')->findOrFail($targetId);
abort_unless($comment->collection && $comment->collection->canBeViewedBy($user), 403, 'You are not allowed to report this comment.');
return;
case 'collection_submission':
$submission = CollectionSubmission::query()->with('collection')->findOrFail($targetId);
abort_unless($submission->collection && $submission->collection->canBeViewedBy($user), 403, 'You are not allowed to report this submission.');
return;
case 'nova_card':
$card = NovaCard::query()->with('user')->findOrFail($targetId);
abort_unless($card->canBeViewedBy($user), 403, 'You are not allowed to report this card.');
return;
case 'nova_card_comment':
$comment = NovaCardComment::query()->with('card.user')->findOrFail($targetId);
abort_unless($comment->card !== null && $comment->card->canBeViewedBy($user), 403, 'You are not allowed to report this card comment.');
return;
case 'nova_card_challenge':
$challenge = NovaCardChallenge::query()->findOrFail($targetId);
abort_unless(in_array($challenge->status, [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED, NovaCardChallenge::STATUS_ARCHIVED], true), 404);
return;
case 'nova_card_challenge_entry':
$entry = NovaCardChallengeEntry::query()->with(['challenge', 'card.user'])->findOrFail($targetId);
abort_unless($entry->challenge !== null && $entry->card !== null, 404);
abort_unless(in_array($entry->challenge->status, [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED, NovaCardChallenge::STATUS_ARCHIVED], true), 404);
abort_unless($entry->card->canBeViewedBy($user), 403, 'You are not allowed to report this challenge entry.');
return;
default:
throw (new ModelNotFoundException())->setModel(Report::class, [$targetId]);
}
}
public function summarize(Report $report): array
{
$summary = [
'exists' => false,
'type' => (string) $report->target_type,
'id' => (int) $report->target_id,
'label' => 'Unavailable target',
'subtitle' => 'The original target could not be loaded.',
'public_url' => null,
'moderation_url' => null,
'moderation_target' => null,
];
try {
return match ($report->target_type) {
'group' => $this->summarizeGroup($report->target_id),
'group_post' => $this->summarizeGroupPost($report->target_id),
'nova_card' => $this->summarizeNovaCard($report->target_id),
'nova_card_comment' => $this->summarizeNovaCardComment($report->target_id),
'nova_card_challenge' => $this->summarizeNovaCardChallenge($report->target_id),
'nova_card_challenge_entry' => $this->summarizeNovaCardChallengeEntry($report->target_id),
default => $summary,
};
} catch (ModelNotFoundException) {
return $summary;
}
}
public function resolveModerationCard(Report $report): ?NovaCard
{
return match ($report->target_type) {
'nova_card' => NovaCard::query()->find($report->target_id),
'nova_card_comment' => NovaCardComment::query()->with('card')->find($report->target_id)?->card,
'nova_card_challenge_entry' => NovaCardChallengeEntry::query()->with('card')->find($report->target_id)?->card,
default => null,
};
}
private function summarizeGroup(int $targetId): array
{
$group = Group::query()->with('owner')->findOrFail($targetId);
return [
'exists' => true,
'type' => 'group',
'id' => (int) $group->id,
'label' => (string) $group->name,
'subtitle' => trim(sprintf('@%s%s', (string) $group->owner?->username, $group->headline ? ' • '.$group->headline : '')),
'public_url' => $group->publicUrl(),
'moderation_url' => null,
'moderation_target' => null,
];
}
private function summarizeGroupPost(int $targetId): array
{
$post = GroupPost::query()->with(['group.owner'])->findOrFail($targetId);
return [
'exists' => $post->group !== null,
'type' => 'group_post',
'id' => (int) $post->id,
'label' => (string) $post->title,
'subtitle' => trim(sprintf('%s • %s', (string) $post->group?->name, ucfirst((string) $post->type))),
'public_url' => $post->group ? route('groups.posts.show', ['group' => $post->group, 'post' => $post]) : null,
'moderation_url' => null,
'moderation_target' => null,
];
}
private function summarizeNovaCard(int $targetId): array
{
$card = NovaCard::query()
->with(['user', 'category'])
->findOrFail($targetId);
return [
'exists' => true,
'type' => 'nova_card',
'id' => (int) $card->id,
'label' => (string) $card->title,
'subtitle' => trim(sprintf('@%s%s', (string) $card->user?->username, $card->category ? ' • '.$card->category->name : '')),
'public_url' => $card->publicUrl(),
'moderation_url' => route('cp.cards.index'),
'moderation_target' => $this->summarizeModerationCard($card),
];
}
private function summarizeNovaCardChallenge(int $targetId): array
{
$challenge = NovaCardChallenge::query()->findOrFail($targetId);
return [
'exists' => true,
'type' => 'nova_card_challenge',
'id' => (int) $challenge->id,
'label' => (string) $challenge->title,
'subtitle' => sprintf('%s challenge • %d entries', ucfirst((string) $challenge->status), (int) $challenge->entries_count),
'public_url' => route('cards.challenges.show', ['slug' => $challenge->slug]),
'moderation_url' => route('cp.cards.challenges.index'),
'moderation_target' => null,
];
}
private function summarizeNovaCardComment(int $targetId): array
{
$comment = NovaCardComment::query()
->with(['card.user'])
->findOrFail($targetId);
return [
'exists' => $comment->card !== null,
'type' => 'nova_card_comment',
'id' => (int) $comment->id,
'label' => 'Comment on ' . ($comment->card?->title ?? 'Nova Card'),
'subtitle' => trim(sprintf('@%s • %s', (string) $comment->user?->username, str($comment->body)->limit(80))),
'public_url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : null,
'moderation_url' => route('cp.cards.index'),
'moderation_target' => $comment->card ? $this->summarizeModerationCard($comment->card, 'Card attached to this comment') : null,
];
}
private function summarizeNovaCardChallengeEntry(int $targetId): array
{
$entry = NovaCardChallengeEntry::query()
->with(['challenge', 'card.user'])
->findOrFail($targetId);
return [
'exists' => $entry->challenge !== null && $entry->card !== null,
'type' => 'nova_card_challenge_entry',
'id' => (int) $entry->id,
'label' => (string) ($entry->card?->title ?? 'Challenge entry'),
'subtitle' => trim(sprintf('Entry for %s%s', $entry->challenge?->title ? '"'.$entry->challenge->title.'"' : 'challenge', $entry->card?->user?->username ? ' • @'.$entry->card->user->username : '')),
'public_url' => $entry->card?->publicUrl(),
'moderation_url' => $entry->challenge ? route('cp.cards.challenges.index') : route('cp.cards.index'),
'moderation_target' => $entry->card ? $this->summarizeModerationCard($entry->card, 'Card attached to this challenge entry') : null,
];
}
private function summarizeModerationCard(NovaCard $card, ?string $context = null): array
{
$moderationReasons = $this->moderation->storedReasons($card);
$moderationOverride = $this->moderation->latestOverride($card);
return [
'kind' => 'nova_card',
'card_id' => (int) $card->id,
'title' => (string) $card->title,
'context' => $context,
'status' => (string) $card->status,
'moderation_status' => (string) $card->moderation_status,
'moderation_reasons' => $moderationReasons,
'moderation_reason_labels' => $this->moderation->labelsFor($moderationReasons),
'moderation_source' => $this->moderation->storedSource($card),
'moderation_override' => $moderationOverride,
'moderation_override_history' => $this->moderation->overrideHistory($card),
'available_actions' => [
['action' => 'approve_card', 'label' => 'Approve card'],
['action' => 'flag_card', 'label' => 'Flag card'],
['action' => 'reject_card', 'label' => 'Reject card'],
],
];
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Support\Seo;
use Illuminate\Support\Collection;
final class BreadcrumbTrail
{
/**
* @param iterable<mixed>|Collection<int, mixed>|null $breadcrumbs
* @return list<array{name: string, url: string}>
*/
public static function normalize(iterable|Collection|null $breadcrumbs): array
{
$items = collect($breadcrumbs instanceof Collection ? $breadcrumbs->all() : ($breadcrumbs ?? []))
->map(fn (mixed $crumb): ?array => self::mapCrumb($crumb))
->filter(fn (?array $crumb): bool => is_array($crumb) && $crumb['name'] !== '' && $crumb['url'] !== '')
->values();
$home = [
'name' => 'Home',
'url' => self::absoluteUrl('/'),
];
$normalized = [];
$seen = [];
foreach ($items as $crumb) {
$name = trim((string) ($crumb['name'] ?? ''));
$url = self::absoluteUrl((string) ($crumb['url'] ?? ''));
if ($name === '' || $url === '') {
continue;
}
if (self::isHome($name, $url)) {
$name = $home['name'];
$url = $home['url'];
}
$key = strtolower($name) . '|' . rtrim(strtolower($url), '/');
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$normalized[] = ['name' => $name, 'url' => $url];
}
if ($normalized === [] || ! self::isHome($normalized[0]['name'], $normalized[0]['url'])) {
array_unshift($normalized, $home);
}
return array_values($normalized);
}
/**
* @param mixed $crumb
* @return array{name: string, url: string}|null
*/
private static function mapCrumb(mixed $crumb): ?array
{
if (is_array($crumb)) {
return [
'name' => trim((string) ($crumb['name'] ?? '')),
'url' => trim((string) ($crumb['url'] ?? '')),
];
}
if (is_object($crumb)) {
return [
'name' => trim((string) ($crumb->name ?? '')),
'url' => trim((string) ($crumb->url ?? '')),
];
}
return null;
}
private static function isHome(string $name, string $url): bool
{
$normalizedUrl = rtrim(strtolower($url), '/');
$homeUrl = rtrim(strtolower(self::absoluteUrl('/')), '/');
return strtolower($name) === 'home' || $normalizedUrl === $homeUrl;
}
private static function absoluteUrl(string $url): string
{
$trimmed = trim($url);
if ($trimmed === '') {
return '';
}
if (preg_match('/^https?:\/\//i', $trimmed) === 1) {
return $trimmed;
}
return url($trimmed);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Support\Seo;
final class SeoData
{
/**
* @param array<string, mixed> $attributes
*/
public function __construct(private readonly array $attributes)
{
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->attributes;
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Support\Seo;
use Illuminate\Support\Collection;
final class SeoDataBuilder
{
private ?string $title = null;
private ?string $description = null;
private ?string $keywords = null;
private ?string $canonical = null;
private ?string $robots = null;
private ?string $prev = null;
private ?string $next = null;
/** @var list<array{name: string, url: string}> */
private array $breadcrumbs = [];
/** @var list<array<string, mixed>> */
private array $jsonLd = [];
/** @var array<string, mixed> */
private array $og = [];
/** @var array<string, mixed> */
private array $twitter = [];
public static function make(): self
{
return new self();
}
/**
* @param array<string, mixed> $attributes
*/
public static function fromArray(array $attributes): self
{
$builder = new self();
if (filled($attributes['title'] ?? null)) {
$builder->title((string) $attributes['title']);
}
if (filled($attributes['description'] ?? null)) {
$builder->description((string) $attributes['description']);
}
if (filled($attributes['keywords'] ?? null)) {
$builder->keywords($attributes['keywords']);
}
if (filled($attributes['canonical'] ?? null)) {
$builder->canonical((string) $attributes['canonical']);
}
if (filled($attributes['robots'] ?? null)) {
$builder->robots((string) $attributes['robots']);
}
if (filled($attributes['prev'] ?? null)) {
$builder->prev((string) $attributes['prev']);
}
if (filled($attributes['next'] ?? null)) {
$builder->next((string) $attributes['next']);
}
if (! empty($attributes['breadcrumbs'] ?? null)) {
$builder->breadcrumbs($attributes['breadcrumbs']);
}
foreach (self::normalizeSchemaPayload($attributes['json_ld'] ?? []) as $schema) {
$builder->addJsonLd($schema);
}
foreach (self::normalizeSchemaPayload($attributes['structured_data'] ?? []) as $schema) {
$builder->addJsonLd($schema);
}
foreach (self::normalizeSchemaPayload($attributes['faq_schema'] ?? []) as $schema) {
$builder->addJsonLd($schema);
}
$builder->og(
type: $attributes['og_type'] ?? null,
title: $attributes['og_title'] ?? null,
description: $attributes['og_description'] ?? null,
url: $attributes['og_url'] ?? null,
image: $attributes['og_image'] ?? null,
imageAlt: $attributes['og_image_alt'] ?? null,
);
$builder->twitter(
card: $attributes['twitter_card'] ?? null,
title: $attributes['twitter_title'] ?? null,
description: $attributes['twitter_description'] ?? null,
image: $attributes['twitter_image'] ?? null,
);
return $builder;
}
public function title(?string $title): self
{
$this->title = $this->clean($title);
return $this;
}
public function description(?string $description): self
{
$this->description = $this->clean($description);
return $this;
}
public function keywords(array|string|null $keywords): self
{
if (is_array($keywords)) {
$keywords = collect($keywords)
->map(fn (mixed $keyword): string => trim((string) $keyword))
->filter()
->unique()
->implode(', ');
}
$this->keywords = $this->clean($keywords);
return $this;
}
public function canonical(?string $canonical): self
{
$this->canonical = $this->absoluteUrl($canonical);
return $this;
}
public function robots(?string $robots): self
{
$this->robots = $this->clean($robots);
return $this;
}
public function indexable(bool $indexable, bool $follow = true): self
{
$this->robots = $indexable
? 'index,' . ($follow ? 'follow' : 'nofollow')
: 'noindex,' . ($follow ? 'follow' : 'nofollow');
return $this;
}
public function prev(?string $prev): self
{
$this->prev = $this->absoluteUrl($prev);
return $this;
}
public function next(?string $next): self
{
$this->next = $this->absoluteUrl($next);
return $this;
}
public function breadcrumbs(iterable|Collection|null $breadcrumbs): self
{
$this->breadcrumbs = BreadcrumbTrail::normalize($breadcrumbs);
return $this;
}
/**
* @param array<string, mixed>|null $schema
*/
public function addJsonLd(?array $schema): self
{
if (is_array($schema) && $schema !== []) {
$this->jsonLd[] = $schema;
}
return $this;
}
public function og(?string $type = null, ?string $title = null, ?string $description = null, ?string $url = null, ?string $image = null, ?string $imageAlt = null): self
{
$this->og = array_filter([
'type' => $this->clean($type),
'title' => $this->clean($title),
'description' => $this->clean($description),
'url' => $this->absoluteUrl($url),
'image' => $this->absoluteUrl($image),
'image_alt' => $this->clean($imageAlt),
], fn (mixed $value): bool => $value !== null && $value !== '');
return $this;
}
public function twitter(?string $card = null, ?string $title = null, ?string $description = null, ?string $image = null): self
{
$this->twitter = array_filter([
'card' => $this->clean($card),
'title' => $this->clean($title),
'description' => $this->clean($description),
'image' => $this->absoluteUrl($image),
], fn (mixed $value): bool => $value !== null && $value !== '');
return $this;
}
public function build(): SeoData
{
$title = $this->title ?: (string) config('seo.default_title', 'Skinbase');
$description = $this->description ?: (string) config('seo.default_description', 'Skinbase');
$robots = $this->robots ?: (string) config('seo.default_robots', 'index,follow');
$canonical = $this->canonical ?: url()->current();
$fallbackImage = $this->absoluteUrl((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
$image = $this->og['image'] ?? $this->twitter['image'] ?? $fallbackImage;
$jsonLd = collect($this->jsonLd)
->filter(fn (mixed $schema): bool => is_array($schema) && $schema !== [])
->values()
->all();
if ($this->breadcrumbs !== [] && ! $this->hasSchemaType($jsonLd, 'BreadcrumbList')) {
$jsonLd[] = [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => collect($this->breadcrumbs)
->values()
->map(fn (array $crumb, int $index): array => [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $crumb['name'],
'item' => $crumb['url'],
])
->all(),
];
}
$keywords = config('seo.keywords_enabled', true) ? $this->keywords : null;
$ogTitle = $this->og['title'] ?? $title;
$ogDescription = $this->og['description'] ?? $description;
$ogType = $this->og['type'] ?? 'website';
$ogUrl = $this->og['url'] ?? $canonical;
$twitterCard = $this->twitter['card'] ?? ($image !== '' ? (string) config('seo.twitter_card', 'summary_large_image') : 'summary');
return new SeoData(array_filter([
'title' => $title,
'description' => $description,
'keywords' => $keywords,
'canonical' => $canonical,
'robots' => $robots,
'prev' => $this->prev,
'next' => $this->next,
'breadcrumbs' => $this->breadcrumbs,
'og_type' => $ogType,
'og_title' => $ogTitle,
'og_description' => $ogDescription,
'og_url' => $ogUrl,
'og_image' => $image,
'og_image_alt' => $this->og['image_alt'] ?? null,
'og_site_name' => (string) config('seo.site_name', 'Skinbase'),
'twitter_card' => $twitterCard,
'twitter_title' => $this->twitter['title'] ?? $ogTitle,
'twitter_description' => $this->twitter['description'] ?? $ogDescription,
'twitter_image' => $this->twitter['image'] ?? $image,
'json_ld' => $jsonLd,
'is_indexable' => ! str_contains(strtolower($robots), 'noindex'),
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
}
/**
* @param list<array<string, mixed>> $schemas
*/
private function hasSchemaType(array $schemas, string $type): bool
{
foreach ($schemas as $schema) {
if (($schema['@type'] ?? null) === $type) {
return true;
}
}
return false;
}
/**
* @return list<array<string, mixed>>
*/
private static function normalizeSchemaPayload(mixed $payload): array
{
if ($payload === null || $payload === '') {
return [];
}
if (is_string($payload)) {
$decoded = json_decode($payload, true);
return json_last_error() === JSON_ERROR_NONE
? self::normalizeSchemaPayload($decoded)
: [];
}
if (! is_array($payload)) {
return [];
}
if ($payload === []) {
return [];
}
if (! array_is_list($payload)) {
return [$payload];
}
return collect($payload)
->map(static fn (mixed $schema): array => self::normalizeSchemaPayload($schema)[0] ?? [])
->filter(static fn (array $schema): bool => $schema !== [])
->values()
->all();
}
private function clean(?string $value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function absoluteUrl(?string $url): ?string
{
$url = $this->clean($url);
if ($url === null) {
return null;
}
if (preg_match('/^https?:\/\//i', $url) === 1) {
return $url;
}
return url($url);
}
}

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Support\Seo;
use App\Models\Artwork;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class SeoFactory
{
/**
* @param array<string, mixed> $meta
*/
public function homepage(array $meta): SeoData
{
$description = trim((string) ($meta['description'] ?? config('seo.default_description')));
return SeoDataBuilder::make()
->title((string) ($meta['title'] ?? config('seo.default_title')))
->description($description)
->keywords($meta['keywords'] ?? null)
->canonical((string) ($meta['canonical'] ?? url('/')))
->og(type: 'website', image: $meta['og_image'] ?? null)
->addJsonLd([
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => (string) config('seo.site_name', 'Skinbase'),
'url' => url('/'),
'description' => $description,
'potentialAction' => [
'@type' => 'SearchAction',
'target' => url('/search') . '?q={search_term_string}',
'query-input' => 'required name=search_term_string',
],
])
->build();
}
/**
* @param array<string, array<string, mixed>|null> $thumbs
*/
public function artwork(Artwork $artwork, array $thumbs, string $canonical): SeoData
{
$authorName = html_entity_decode((string) ($artwork->user?->name ?: $artwork->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$title = html_entity_decode((string) ($artwork->title ?: 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$description = trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
$description = Str::limit($description !== '' ? $description : $title, 160, '…');
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
return SeoDataBuilder::make()
->title(sprintf('%s by %s — %s', $title, $authorName, config('seo.site_name', 'Skinbase')))
->description($description)
->keywords($keywords)
->canonical($canonical)
->og(type: 'article', image: $image)
->addJsonLd(array_filter([
'@context' => 'https://schema.org',
'@type' => 'ImageObject',
'name' => $title,
'description' => $description,
'url' => $canonical,
'contentUrl' => $image,
'thumbnailUrl' => $thumbs['md']['url'] ?? $image,
'encodingFormat' => 'image/webp',
'width' => $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null,
'height' => $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null,
'author' => ['@type' => 'Person', 'name' => $authorName],
'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $artwork->license_url,
'keywords' => $keywords !== [] ? $keywords : null,
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
->addJsonLd(array_filter([
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => $title,
'description' => $description,
'url' => $canonical,
'author' => ['@type' => 'Person', 'name' => $authorName],
'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $artwork->license_url,
'keywords' => $keywords !== [] ? $keywords : null,
'image' => $image,
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
->build();
}
public function profilePage(string $title, string $canonical, string $description, ?string $image = null, iterable $breadcrumbs = []): SeoData
{
$profileName = trim(str_replace([' Gallery on Skinbase', ' on Skinbase'], '', $title));
return SeoDataBuilder::make()
->title($title)
->description($description)
->canonical($canonical)
->breadcrumbs($breadcrumbs)
->og(type: 'profile', image: $image)
->addJsonLd([
'@context' => 'https://schema.org',
'@type' => 'ProfilePage',
'name' => $title,
'description' => $description,
'url' => $canonical,
'mainEntity' => array_filter([
'@type' => 'Person',
'name' => $profileName,
'url' => $canonical,
'image' => $image,
], fn (mixed $value): bool => $value !== null && $value !== ''),
])
->build();
}
public function collectionListing(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
{
return SeoDataBuilder::make()
->title($title)
->description($description)
->canonical($canonical)
->indexable($indexable)
->og(type: 'website', image: $image)
->build();
}
public function collectionPage(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
{
return SeoDataBuilder::make()
->title($title)
->description($description)
->canonical($canonical)
->indexable($indexable)
->og(type: 'website', image: $image)
->build();
}
public function leaderboardPage(string $title, string $description, string $canonical): SeoData
{
return SeoDataBuilder::make()
->title($title)
->description($description)
->canonical($canonical)
->og(type: 'website')
->build();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function fromViewData(array $data): array
{
if (($data['seo'] ?? null) instanceof SeoData) {
return $data['seo']->toArray();
}
if (is_array($data['seo'] ?? null) && ($data['seo'] ?? []) !== []) {
return SeoDataBuilder::fromArray($data['seo'])->build()->toArray();
}
$attributes = [
'title' => $data['page_title'] ?? data_get($data, 'meta.title') ?? data_get($data, 'metaTitle'),
'description' => $data['page_meta_description'] ?? data_get($data, 'meta.description') ?? data_get($data, 'metaDescription'),
'keywords' => $data['page_meta_keywords'] ?? data_get($data, 'meta.keywords'),
'canonical' => $data['page_canonical'] ?? data_get($data, 'meta.canonical') ?? url()->current(),
'robots' => $data['page_robots'] ?? data_get($data, 'meta.robots'),
'prev' => $data['page_rel_prev'] ?? null,
'next' => $data['page_rel_next'] ?? null,
'breadcrumbs' => $data['breadcrumbs'] ?? [],
'structured_data' => Arr::wrap($data['structured_data'] ?? []),
'faq_schema' => Arr::wrap($data['faq_schema'] ?? []),
'og_type' => $data['seo_og_type'] ?? data_get($data, 'meta.og_type'),
'og_title' => $data['og_title'] ?? data_get($data, 'meta.og_title'),
'og_description' => $data['og_description'] ?? data_get($data, 'meta.og_description'),
'og_url' => $data['og_url'] ?? data_get($data, 'meta.og_url'),
'og_image' => $data['og_image']
?? $data['ogImage']
?? data_get($data, 'meta.og_image')
?? data_get($data, 'meta.ogImage')
?? data_get($data, 'props.hero.thumb_lg')
?? data_get($data, 'props.hero.thumb')
?? null,
'og_image_alt' => $data['og_image_alt'] ?? null,
];
$builder = SeoDataBuilder::fromArray($attributes);
if (($data['gallery_type'] ?? null) !== null) {
$builder->addJsonLd($this->gallerySchema($data));
}
return $builder->build()->toArray();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>|null
*/
private function gallerySchema(array $data): ?array
{
$artworks = $data['artworks'] ?? null;
if (! $artworks instanceof AbstractPaginator && ! $artworks instanceof Collection && ! is_array($artworks)) {
return null;
}
$items = $artworks instanceof AbstractPaginator
? collect($artworks->items())
: collect($artworks);
$itemListElement = $items
->take(12)
->values()
->map(function (mixed $artwork, int $index): ?array {
$name = trim((string) (data_get($artwork, 'name') ?? data_get($artwork, 'title') ?? ''));
$url = data_get($artwork, 'url');
if (! filled($url)) {
$slug = data_get($artwork, 'slug');
$id = data_get($artwork, 'id');
if (filled($slug) && filled($id)) {
$url = route('art.show', ['id' => $id, 'slug' => $slug]);
}
}
if ($name === '' && ! filled($url)) {
return null;
}
return array_filter([
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $name !== '' ? $name : null,
'url' => filled($url) ? url((string) $url) : null,
], fn (mixed $value): bool => $value !== null && $value !== '');
})
->filter()
->values()
->all();
if ($itemListElement === []) {
return null;
}
$count = $artworks instanceof AbstractPaginator ? $artworks->total() : count($itemListElement);
return [
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => (string) ($data['page_title'] ?? $data['hero_title'] ?? config('seo.default_title', 'Skinbase')),
'description' => (string) ($data['page_meta_description'] ?? $data['hero_description'] ?? config('seo.default_description')),
'url' => (string) ($data['page_canonical'] ?? url()->current()),
'mainEntity' => [
'@type' => 'ItemList',
'numberOfItems' => $count,
'itemListElement' => $itemListElement,
],
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class UsernamePolicy
{
public static function min(): int
{
return (int) config('usernames.min', 3);
}
public static function max(): int
{
return (int) config('usernames.max', 20);
}
public static function regex(): string
{
return (string) config('usernames.regex', '/^[a-zA-Z0-9_]{3,20}$/');
}
/**
* @return array<int, string>
*/
public static function reserved(): array
{
$pool = [
...(array) config('usernames.reserved', []),
...(array) config('skinbase.reserved_usernames', []),
];
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), $pool)));
}
public static function normalize(string $value): string
{
return strtolower(trim($value));
}
public static function sanitizeLegacy(string $value): string
{
$value = Str::ascii($value);
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9_-]+/', '_', $value) ?? '';
$value = trim($value, '_-');
if ($value === '') {
return 'user';
}
return substr($value, 0, self::max());
}
public static function isReserved(string $username): bool
{
return in_array(self::normalize($username), self::reserved(), true);
}
public static function similarReserved(string $username): ?string
{
$normalized = self::normalize($username);
$reduced = self::reduceForSimilarity($normalized);
$threshold = (int) config('usernames.similarity_threshold', 2);
foreach (self::reserved() as $reserved) {
if (levenshtein($reduced, self::reduceForSimilarity($reserved)) <= $threshold) {
return $reserved;
}
}
return null;
}
public static function hasApprovedOverride(string $username, ?int $userId = null): bool
{
if (! Schema::hasTable('username_approval_requests')) {
return false;
}
$normalized = self::normalize($username);
return DB::table('username_approval_requests')
->where('requested_username', $normalized)
->where('status', 'approved')
->when($userId !== null, fn ($q) => $q->where(function ($sub) use ($userId) {
$sub->where('user_id', $userId)->orWhereNull('user_id');
}))
->exists();
}
public static function uniqueCandidate(string $base, ?int $ignoreUserId = null): string
{
$base = self::sanitizeLegacy($base);
if ($base === '' || self::isReserved($base) || self::similarReserved($base) !== null) {
$base = 'user';
}
$max = self::max();
$candidate = substr($base, 0, $max);
$suffix = 1;
while (self::exists($candidate, $ignoreUserId) || self::isReserved($candidate) || self::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
return $candidate;
}
private static function exists(string $username, ?int $ignoreUserId = null): bool
{
$query = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)]);
if ($ignoreUserId !== null) {
$query->where('id', '!=', $ignoreUserId);
}
return $query->exists();
}
private static function reduceForSimilarity(string $value): string
{
return preg_replace('/[0-9_-]+/', '', strtolower($value)) ?? strtolower($value);
}
}